Add ESLint, pre-commit hook & format all files (#319)

* Add ESLint config

* Add ESLint packages

* Add prettier config

* Fix ESLint package version

* Format all files

* Format static assets

* Format project root config

* Add pre-commit code formatting

Formats .css & .js files automatically.  If any .ts or .mts files are staged, the entire project is type-checked.

Packages:
- lint-staged
- husky
- prettier
This commit is contained in:
filtered
2024-11-21 13:50:58 +11:00
committed by GitHub
parent 69b1c86278
commit 5469bfdd52
35 changed files with 18490 additions and 14894 deletions

View File

@@ -1 +0,0 @@
/build

View File

@@ -1,56 +0,0 @@
module.exports = {
"env": {
"browser": true,
"es2021": true,
"node": true,
"jest/globals": true
},
"extends": "eslint:recommended",
"overrides": [
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["jest"],
"globals": {
"gl": true,
"GL": true,
"LS": true,
"Uint8Array": true,
"Uint32Array": true,
"Float32Array": true,
"LGraphCanvas": true,
"LGraph": true,
"LGraphNode": true,
"LiteGraph": true,
"LGraphTexture": true,
"Mesh": true,
"Shader": true,
"enableWebGLCanvas": true,
"vec2": true,
"vec3": true,
"vec4": true,
"DEG2RAD": true,
"isPowerOfTwo": true,
"cloneCanvas": true,
"createCanvas": true,
"hex2num": true,
"colorToString": true,
"showElement": true,
"quat": true,
"AudioSynth": true,
"SillyClient": true
},
"rules": {
"no-console": "off",
"no-empty": "warn",
"no-redeclare": "warn",
"no-inner-declarations": "warn",
"no-constant-condition": "warn",
"no-unused-vars": "warn",
"no-mixed-spaces-and-tabs": "warn",
"no-unreachable": "warn",
"curly": ["warn", "all"]
}
}

5
.husky/pre-commit Normal file
View File

@@ -0,0 +1,5 @@
if [[ "$OS" == "Windows_NT" ]]; then
npx.cmd lint-staged
else
npx lint-staged
fi

View File

@@ -1,5 +1,8 @@
{ {
"singleQuote": false, "singleQuote": false,
"semi": true, "semi": false,
"tabWidth": 2 "tabWidth": 2,
"trailingComma": "all",
"overrides": [{ "files": "*.ts", "options": { "requirePragma": true } }],
"arrowParens": "avoid"
} }

158
eslint.config.js Normal file
View File

@@ -0,0 +1,158 @@
import globals from "globals"
import eslint from "@eslint/js"
import tseslint from "typescript-eslint"
import stylistic from "@stylistic/eslint-plugin"
export default tseslint.config(
{ files: ["**/*.{js,mjs,ts,mts}"] },
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
stylistic.configs.customize({
quotes: "double",
braceStyle: "1tbs",
commaDangle: "always-multiline",
}),
{
languageOptions: {
globals: { ...globals.browser },
parserOptions: {
projectService: {
allowDefaultProject: [
"eslint.config.js",
"lint-staged.config.js",
"vite.config.mts",
],
},
tsconfigRootDir: import.meta.dirname,
},
},
},
{
ignores: ["./dist/**/*"],
},
{
rules: {
// TODO: Update when TypeScript has been cleaned
"prefer-spread": "off",
"no-empty": "off",
"no-prototype-builtins": "off",
"no-var": "warn",
"no-fallthrough": "off",
"no-empty-pattern": ["error", { allowObjectPatternsAsParameters: true }],
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-base-to-string": "off",
"@typescript-eslint/restrict-plus-operands": "off",
"@typescript-eslint/no-implied-eval": "off",
"@typescript-eslint/unbound-method": "off",
"@typescript-eslint/no-unsafe-enum-comparison": "off",
"@typescript-eslint/no-for-in-array": "off",
"@typescript-eslint/only-throw-error": "off",
"@typescript-eslint/no-duplicate-type-constituents": "off",
"@typescript-eslint/no-empty-object-type": "off",
// "@typescript-eslint/prefer-readonly-parameter-types": "error",
"@typescript-eslint/no-unused-vars": "off",
// "@typescript-eslint/no-unsafe-function-type": "off",
"@stylistic/max-len": [
"warn",
{ code: 100, comments: 130, ignoreStrings: true },
],
// "@stylistic/multiline-comment-style": ["warn", "starred-block"],
"@stylistic/curly-newline": [
"warn",
{ consistent: true, multiline: true },
],
"@stylistic/object-curly-newline": [
"warn",
{ consistent: true, multiline: true },
],
// "@stylistic/object-property-newline": ["warn", { allowAllPropertiesOnSameLine: true }],
// "@stylistic/object-property-newline": "warn",
"@stylistic/one-var-declaration-per-line": "warn",
"@stylistic/array-bracket-newline": ["warn", { multiline: true }],
"@stylistic/array-element-newline": [
"warn",
{ consistent: true, multiline: true },
],
"@stylistic/function-paren-newline": ["warn", "multiline-arguments"],
"@stylistic/newline-per-chained-call": "warn",
"@stylistic/array-bracket-spacing": "warn",
"@stylistic/arrow-parens": "warn",
"@stylistic/arrow-spacing": "warn",
"@stylistic/block-spacing": "warn",
"@stylistic/brace-style": "warn",
"@stylistic/comma-dangle": "warn",
"@stylistic/comma-spacing": "warn",
"@stylistic/comma-style": "warn",
"@stylistic/computed-property-spacing": "warn",
"@stylistic/dot-location": "warn",
"@stylistic/eol-last": "warn",
"@stylistic/indent": ["warn", 2, { VariableDeclarator: "first" }],
"@stylistic/indent-binary-ops": "warn",
"@stylistic/key-spacing": "warn",
"@stylistic/keyword-spacing": "warn",
"@stylistic/lines-between-class-members": "warn",
"@stylistic/max-statements-per-line": "warn",
"@stylistic/member-delimiter-style": "warn",
"@stylistic/multiline-ternary": "warn",
"@stylistic/new-parens": "warn",
"@stylistic/no-extra-parens": "warn",
"@stylistic/no-floating-decimal": "warn",
"@stylistic/no-mixed-operators": "warn",
"@stylistic/no-mixed-spaces-and-tabs": "warn",
"@stylistic/no-multi-spaces": "warn",
"@stylistic/no-multiple-empty-lines": "warn",
"@stylistic/no-tabs": "warn",
"@stylistic/no-trailing-spaces": "warn",
"@stylistic/no-whitespace-before-property": "warn",
"@stylistic/object-curly-spacing": "warn",
"@stylistic/operator-linebreak": [
"warn",
"after",
{ overrides: { "?": "before", ":": "before" } },
],
"@stylistic/padded-blocks": "warn",
"@stylistic/quote-props": "warn",
"@stylistic/quotes": "warn",
"@stylistic/rest-spread-spacing": "warn",
"@stylistic/semi": "warn",
"@stylistic/semi-spacing": "warn",
"@stylistic/semi-style": ["warn", "first"],
"@stylistic/space-before-blocks": "warn",
"@stylistic/space-before-function-paren": "warn",
"@stylistic/space-in-parens": "warn",
"@stylistic/space-infix-ops": "warn",
"@stylistic/space-unary-ops": "warn",
"@stylistic/spaced-comment": "warn",
"@stylistic/template-curly-spacing": "warn",
"@stylistic/template-tag-spacing": "warn",
"@stylistic/type-annotation-spacing": "warn",
"@stylistic/type-generic-spacing": "warn",
"@stylistic/type-named-tuple-spacing": "warn",
"@stylistic/wrap-iife": "warn",
"@stylistic/yield-star-spacing": "warn",
},
},
{
rules: {
"@typescript-eslint/no-unused-vars": "error",
},
files: ["test/**/*.ts"],
},
)

17
lint-staged.config.js Normal file
View File

@@ -0,0 +1,17 @@
export default {
"*.css": stagedFiles => `prettier --write ${stagedFiles.join(" ")}`,
"*.js": stagedFiles => prettierAndEslint(stagedFiles),
"*.{ts,mts}": stagedFiles => [
...prettierAndEslint(stagedFiles),
`tsc --noEmit`,
],
}
function prettierAndEslint(fileNames) {
return [
`prettier --write ${fileNames.join(" ")}`,
`eslint --fix ${fileNames.join(" ")}`,
]
}

2219
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -41,10 +41,19 @@
}, },
"homepage": "https://github.com/Comfy-Org/litegraph.js", "homepage": "https://github.com/Comfy-Org/litegraph.js",
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.14.0",
"@stylistic/eslint-plugin": "^2.10.1",
"@types/eslint__js": "^8.42.3",
"@types/node": "^22.1.0", "@types/node": "^22.1.0",
"eslint": "^9.14.0",
"globals": "^15.12.0",
"husky": "^9.1.7",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"lint-staged": "^15.2.10",
"prettier": "^3.3.3",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"typescript-eslint": "^8.14.0",
"vite": "^5.3.4", "vite": "^5.3.4",
"vite-plugin-dts": "^4.3.0", "vite-plugin-dts": "^4.3.0",
"vitest": "^2.1.4" "vitest": "^2.1.4"

File diff suppressed because it is too large Load Diff

View File

@@ -29,10 +29,12 @@ export class CanvasPointer {
static get maxClickDrift() { static get maxClickDrift() {
return this.#maxClickDrift return this.#maxClickDrift
} }
static set maxClickDrift(value) { static set maxClickDrift(value) {
this.#maxClickDrift = value this.#maxClickDrift = value
this.#maxClickDrift2 = value * value this.#maxClickDrift2 = value * value
} }
static #maxClickDrift = 6 static #maxClickDrift = 6
/** {@link maxClickDrift} squared. Used to calculate click drift without `sqrt`. */ /** {@link maxClickDrift} squared. Used to calculate click drift without `sqrt`. */
static #maxClickDrift2 = this.#maxClickDrift ** 2 static #maxClickDrift2 = this.#maxClickDrift ** 2
@@ -107,6 +109,7 @@ export class CanvasPointer {
get finally() { get finally() {
return this.#finally return this.#finally
} }
set finally(value) { set finally(value) {
try { try {
this.#finally?.() this.#finally?.()
@@ -114,6 +117,7 @@ export class CanvasPointer {
this.#finally = value this.#finally = value
} }
} }
#finally?: () => unknown #finally?: () => unknown
constructor(element: Element) { constructor(element: Element) {

View File

@@ -2,13 +2,13 @@ import type { IContextMenuOptions, IContextMenuValue } from "./interfaces"
import { LiteGraph } from "./litegraph" import { LiteGraph } from "./litegraph"
interface ContextMenuDivElement extends HTMLDivElement { interface ContextMenuDivElement extends HTMLDivElement {
value?: IContextMenuValue | string value?: IContextMenuValue | string
onclick_callback?: never onclick_callback?: never
closing_timer?: number closing_timer?: number
} }
export interface ContextMenu { export interface ContextMenu {
constructor: new (...args: ConstructorParameters<typeof ContextMenu>) => ContextMenu constructor: new (...args: ConstructorParameters<typeof ContextMenu>) => ContextMenu
} }
/** /**
@@ -24,354 +24,379 @@ export interface ContextMenu {
* - event: you can pass a MouseEvent, this way the ContextMenu appears in that position * - event: you can pass a MouseEvent, this way the ContextMenu appears in that position
*/ */
export class ContextMenu { export class ContextMenu {
options?: IContextMenuOptions options?: IContextMenuOptions
parentMenu?: ContextMenu parentMenu?: ContextMenu
root: ContextMenuDivElement root: ContextMenuDivElement
current_submenu?: ContextMenu current_submenu?: ContextMenu
lock?: boolean lock?: boolean
// TODO: Interface for values requires functionality change - currently accepts an array of strings, functions, objects, nulls, or undefined. // TODO: Interface for values requires functionality change - currently accepts
constructor(values: (IContextMenuValue | string)[], options: IContextMenuOptions) { // an array of strings, functions, objects, nulls, or undefined.
options ||= {} constructor(values: (IContextMenuValue | string)[], options: IContextMenuOptions) {
this.options = options options ||= {}
this.options = options
//to link a menu with its parent // to link a menu with its parent
const parent = options.parentMenu const parent = options.parentMenu
if (parent) { if (parent) {
if (!(parent instanceof ContextMenu)) { if (!(parent instanceof ContextMenu)) {
console.error("parentMenu must be of class ContextMenu, ignoring it") console.error("parentMenu must be of class ContextMenu, ignoring it")
options.parentMenu = null options.parentMenu = null
} else { } else {
this.parentMenu = parent this.parentMenu = parent
this.parentMenu.lock = true this.parentMenu.lock = true
this.parentMenu.current_submenu = this this.parentMenu.current_submenu = this
} }
if (parent.options?.className === "dark") { if (parent.options?.className === "dark") {
options.className = "dark" options.className = "dark"
} }
}
//use strings because comparing classes between windows doesnt work
const eventClass = options.event
? options.event.constructor.name
: null
if (eventClass !== "MouseEvent" &&
eventClass !== "CustomEvent" &&
eventClass !== "PointerEvent") {
console.error(`Event passed to ContextMenu is not of type MouseEvent or CustomEvent. Ignoring it. (${eventClass})`)
options.event = null
}
const root: ContextMenuDivElement = document.createElement("div")
let classes = "litegraph litecontextmenu litemenubar-panel"
if (options.className) classes += " " + options.className
root.className = classes
root.style.minWidth = "100"
root.style.minHeight = "100"
// TODO: Fix use of timer in place of events
root.style.pointerEvents = "none"
setTimeout(function () {
root.style.pointerEvents = "auto"
}, 100) //delay so the mouse up event is not caught by this element
//this prevents the default context browser menu to open in case this menu was created when pressing right button
LiteGraph.pointerListenerAdd(root, "up",
function (e: MouseEvent) {
//console.log("pointerevents: ContextMenu up root prevent");
e.preventDefault()
return true
},
true
)
root.addEventListener(
"contextmenu",
function (e: MouseEvent) {
//right button
if (e.button != 2) return false
e.preventDefault()
return false
},
true
)
LiteGraph.pointerListenerAdd(root, "down",
(e: MouseEvent) => {
//console.log("pointerevents: ContextMenu down");
if (e.button == 2) {
this.close()
e.preventDefault()
return true
}
},
true
)
function on_mouse_wheel(e: WheelEvent) {
const pos = parseInt(root.style.top)
root.style.top =
(pos + e.deltaY * options.scroll_speed).toFixed() + "px"
e.preventDefault()
return true
}
if (!options.scroll_speed) {
options.scroll_speed = 0.1
}
root.addEventListener("wheel", on_mouse_wheel, true)
this.root = root
//title
if (options.title) {
const element = document.createElement("div")
element.className = "litemenu-title"
element.innerHTML = options.title
root.appendChild(element)
}
//entries
for (let i = 0; i < values.length; i++) {
const value = values[i]
let name = Array.isArray(values) ? value : String(i)
if (typeof name !== "string") {
name = name != null
? name.content === undefined ? String(name) : name.content
: name as null | undefined
}
this.addItem(name, value, options)
}
LiteGraph.pointerListenerAdd(root, "enter", function () {
if (root.closing_timer) {
clearTimeout(root.closing_timer)
}
})
//insert before checking position
const ownerDocument = (options.event?.target as Node).ownerDocument
const root_document = ownerDocument || document
if (root_document.fullscreenElement)
root_document.fullscreenElement.appendChild(root)
else
root_document.body.appendChild(root)
//compute best position
let left = options.left || 0
let top = options.top || 0
if (options.event) {
left = options.event.clientX - 10
top = options.event.clientY - 10
if (options.title) top -= 20
if (parent) {
const rect = parent.root.getBoundingClientRect()
left = rect.left + rect.width
}
const body_rect = document.body.getBoundingClientRect()
const root_rect = root.getBoundingClientRect()
if (body_rect.height == 0)
console.error("document.body height is 0. That is dangerous, set html,body { height: 100%; }")
if (body_rect.width && left > body_rect.width - root_rect.width - 10)
left = body_rect.width - root_rect.width - 10
if (body_rect.height && top > body_rect.height - root_rect.height - 10)
top = body_rect.height - root_rect.height - 10
}
root.style.left = left + "px"
root.style.top = top + "px"
if (options.scale)
root.style.transform = `scale(${options.scale})`
} }
addItem(name: string, value: IContextMenuValue | string, options: IContextMenuOptions): HTMLElement { // use strings because comparing classes between windows doesnt work
options ||= {} const eventClass = options.event
? options.event.constructor.name
const element: ContextMenuDivElement = document.createElement("div") : null
element.className = "litemenu-entry submenu" if (
eventClass !== "MouseEvent" &&
let disabled = false eventClass !== "CustomEvent" &&
eventClass !== "PointerEvent"
if (value === null) { ) {
element.classList.add("separator") console.error(`Event passed to ContextMenu is not of type MouseEvent or CustomEvent. Ignoring it. (${eventClass})`)
} else { options.event = null
if (typeof value === "string") {
element.innerHTML = name
} else {
element.innerHTML = value?.title ?? name
if (value.disabled) {
disabled = true
element.classList.add("disabled")
element.setAttribute("aria-disabled", "true")
}
if (value.submenu || value.has_submenu) {
element.classList.add("has_submenu")
element.setAttribute("aria-haspopup", "true")
element.setAttribute("aria-expanded", "false")
}
if (value.className)
element.className += " " + value.className
}
element.value = value
element.setAttribute("role", "menuitem")
if (typeof value === "function") {
element.dataset["value"] = name
element.onclick_callback = value
} else {
element.dataset["value"] = String(value)
}
}
this.root.appendChild(element)
if (!disabled) element.addEventListener("click", inner_onclick)
if (!disabled && options.autoopen)
LiteGraph.pointerListenerAdd(element, "enter", inner_over)
const setAriaExpanded = () => {
const entries = this.root.querySelectorAll("div.litemenu-entry.has_submenu")
if (entries) {
for (let i = 0; i < entries.length; i++) {
entries[i].setAttribute("aria-expanded", "false")
}
}
element.setAttribute("aria-expanded", "true")
}
function inner_over(this: ContextMenuDivElement, e: MouseEvent) {
const value = this.value
if (!value || !(value as IContextMenuValue).has_submenu) return
//if it is a submenu, autoopen like the item was clicked
inner_onclick.call(this, e)
setAriaExpanded()
}
//menu option clicked
const that = this
function inner_onclick(this: ContextMenuDivElement, e: MouseEvent) {
const value = this.value
let close_parent = true
that.current_submenu?.close(e)
if ((value as IContextMenuValue)?.has_submenu || (value as IContextMenuValue)?.submenu) setAriaExpanded()
//global callback
if (options.callback) {
const r = options.callback.call(
this,
value,
options,
e,
that,
options.node
)
if (r === true) close_parent = false
}
//special cases
if (typeof value === "object") {
if (value.callback &&
!options.ignore_item_callbacks &&
value.disabled !== true) {
//item callback
const r = value.callback.call(
this,
value,
options,
e,
that,
options.extra
)
if (r === true) close_parent = false
}
if (value.submenu) {
if (!value.submenu.options)
throw "ContextMenu submenu needs options"
new that.constructor(value.submenu.options, {
callback: value.submenu.callback,
event: e,
parentMenu: that,
ignore_item_callbacks: value.submenu.ignore_item_callbacks,
title: value.submenu.title,
extra: value.submenu.extra,
autoopen: options.autoopen
})
close_parent = false
}
}
if (close_parent && !that.lock)
that.close()
}
return element
} }
close(e?: MouseEvent, ignore_parent_menu?: boolean): void { const root: ContextMenuDivElement = document.createElement("div")
this.root.parentNode?.removeChild(this.root) let classes = "litegraph litecontextmenu litemenubar-panel"
if (this.parentMenu && !ignore_parent_menu) { if (options.className) classes += " " + options.className
this.parentMenu.lock = false root.className = classes
this.parentMenu.current_submenu = null root.style.minWidth = "100"
if (e === undefined) { root.style.minHeight = "100"
this.parentMenu.close() // TODO: Fix use of timer in place of events
} else if (e && root.style.pointerEvents = "none"
!ContextMenu.isCursorOverElement(e, this.parentMenu.root)) { setTimeout(function () {
ContextMenu.trigger(this.parentMenu.root, LiteGraph.pointerevents_method + "leave", e) root.style.pointerEvents = "auto"
} }, 100) // delay so the mouse up event is not caught by this element
}
this.current_submenu?.close(e, true)
if (this.root.closing_timer) // this prevents the default context browser menu to open in case this menu was created when pressing right button
clearTimeout(this.root.closing_timer) LiteGraph.pointerListenerAdd(
} root,
"up",
//this code is used to trigger events easily (used in the context menu mouseleave function (e: MouseEvent) {
static trigger(element: HTMLDivElement, event_name: string, params: MouseEvent, origin?: unknown): CustomEvent { // console.log("pointerevents: ContextMenu up root prevent");
const evt = document.createEvent("CustomEvent") e.preventDefault()
evt.initCustomEvent(event_name, true, true, params) //canBubble, cancelable, detail return true
// @ts-expect-error },
evt.srcElement = origin true,
if (element.dispatchEvent) element.dispatchEvent(evt) )
// @ts-expect-error root.addEventListener(
else if (element.__events) element.__events.dispatchEvent(evt) "contextmenu",
//else nothing seems binded here so nothing to do function (e: MouseEvent) {
return evt // right button
} if (e.button != 2) return false
e.preventDefault()
//returns the top most menu
getTopMenu(): ContextMenu {
return this.options.parentMenu
? this.options.parentMenu.getTopMenu()
: this
}
getFirstEvent(): MouseEvent {
return this.options.parentMenu
? this.options.parentMenu.getFirstEvent()
: this.options.event
}
static isCursorOverElement(event: MouseEvent, element: HTMLDivElement): boolean {
const left = event.clientX
const top = event.clientY
const rect = element.getBoundingClientRect()
if (!rect) return false
if (top > rect.top &&
top < rect.top + rect.height &&
left > rect.left &&
left < rect.left + rect.width) {
return true
}
return false return false
},
true,
)
LiteGraph.pointerListenerAdd(
root,
"down",
(e: MouseEvent) => {
// console.log("pointerevents: ContextMenu down");
if (e.button == 2) {
this.close()
e.preventDefault()
return true
}
},
true,
)
function on_mouse_wheel(e: WheelEvent) {
const pos = parseInt(root.style.top)
root.style.top = (pos + e.deltaY * options.scroll_speed).toFixed() + "px"
e.preventDefault()
return true
} }
if (!options.scroll_speed) {
options.scroll_speed = 0.1
}
root.addEventListener("wheel", on_mouse_wheel, true)
this.root = root
// title
if (options.title) {
const element = document.createElement("div")
element.className = "litemenu-title"
element.innerHTML = options.title
root.appendChild(element)
}
// entries
for (let i = 0; i < values.length; i++) {
const value = values[i]
let name = Array.isArray(values) ? value : String(i)
if (typeof name !== "string") {
name = name != null
? name.content === undefined ? String(name) : name.content
: name as null | undefined
}
this.addItem(name, value, options)
}
LiteGraph.pointerListenerAdd(root, "enter", function () {
if (root.closing_timer) {
clearTimeout(root.closing_timer)
}
})
// insert before checking position
const ownerDocument = (options.event?.target as Node).ownerDocument
const root_document = ownerDocument || document
if (root_document.fullscreenElement)
root_document.fullscreenElement.appendChild(root)
else
root_document.body.appendChild(root)
// compute best position
let left = options.left || 0
let top = options.top || 0
if (options.event) {
left = options.event.clientX - 10
top = options.event.clientY - 10
if (options.title) top -= 20
if (parent) {
const rect = parent.root.getBoundingClientRect()
left = rect.left + rect.width
}
const body_rect = document.body.getBoundingClientRect()
const root_rect = root.getBoundingClientRect()
if (body_rect.height == 0)
console.error("document.body height is 0. That is dangerous, set html,body { height: 100%; }")
if (body_rect.width && left > body_rect.width - root_rect.width - 10)
left = body_rect.width - root_rect.width - 10
if (body_rect.height && top > body_rect.height - root_rect.height - 10)
top = body_rect.height - root_rect.height - 10
}
root.style.left = left + "px"
root.style.top = top + "px"
if (options.scale) root.style.transform = `scale(${options.scale})`
}
addItem(
name: string,
value: IContextMenuValue | string,
options: IContextMenuOptions,
): HTMLElement {
options ||= {}
const element: ContextMenuDivElement = document.createElement("div")
element.className = "litemenu-entry submenu"
let disabled = false
if (value === null) {
element.classList.add("separator")
} else {
if (typeof value === "string") {
element.innerHTML = name
} else {
element.innerHTML = value?.title ?? name
if (value.disabled) {
disabled = true
element.classList.add("disabled")
element.setAttribute("aria-disabled", "true")
}
if (value.submenu || value.has_submenu) {
element.classList.add("has_submenu")
element.setAttribute("aria-haspopup", "true")
element.setAttribute("aria-expanded", "false")
}
if (value.className) element.className += " " + value.className
}
element.value = value
element.setAttribute("role", "menuitem")
if (typeof value === "function") {
element.dataset["value"] = name
element.onclick_callback = value
} else {
element.dataset["value"] = String(value)
}
}
this.root.appendChild(element)
if (!disabled) element.addEventListener("click", inner_onclick)
if (!disabled && options.autoopen)
LiteGraph.pointerListenerAdd(element, "enter", inner_over)
const setAriaExpanded = () => {
const entries = this.root.querySelectorAll("div.litemenu-entry.has_submenu")
if (entries) {
for (let i = 0; i < entries.length; i++) {
entries[i].setAttribute("aria-expanded", "false")
}
}
element.setAttribute("aria-expanded", "true")
}
function inner_over(this: ContextMenuDivElement, e: MouseEvent) {
const value = this.value
if (!value || !(value as IContextMenuValue).has_submenu) return
// if it is a submenu, autoopen like the item was clicked
inner_onclick.call(this, e)
setAriaExpanded()
}
// menu option clicked
const that = this
function inner_onclick(this: ContextMenuDivElement, e: MouseEvent) {
const value = this.value
let close_parent = true
that.current_submenu?.close(e)
if (
(value as IContextMenuValue)?.has_submenu ||
(value as IContextMenuValue)?.submenu
) {
setAriaExpanded()
}
// global callback
if (options.callback) {
const r = options.callback.call(
this,
value,
options,
e,
that,
options.node,
)
if (r === true) close_parent = false
}
// special cases
if (typeof value === "object") {
if (
value.callback &&
!options.ignore_item_callbacks &&
value.disabled !== true
) {
// item callback
const r = value.callback.call(
this,
value,
options,
e,
that,
options.extra,
)
if (r === true) close_parent = false
}
if (value.submenu) {
if (!value.submenu.options) throw "ContextMenu submenu needs options"
new that.constructor(value.submenu.options, {
callback: value.submenu.callback,
event: e,
parentMenu: that,
ignore_item_callbacks: value.submenu.ignore_item_callbacks,
title: value.submenu.title,
extra: value.submenu.extra,
autoopen: options.autoopen,
})
close_parent = false
}
}
if (close_parent && !that.lock) that.close()
}
return element
}
close(e?: MouseEvent, ignore_parent_menu?: boolean): void {
this.root.parentNode?.removeChild(this.root)
if (this.parentMenu && !ignore_parent_menu) {
this.parentMenu.lock = false
this.parentMenu.current_submenu = null
if (e === undefined) {
this.parentMenu.close()
} else if (e && !ContextMenu.isCursorOverElement(e, this.parentMenu.root)) {
ContextMenu.trigger(
this.parentMenu.root,
LiteGraph.pointerevents_method + "leave",
e,
)
}
}
this.current_submenu?.close(e, true)
if (this.root.closing_timer) clearTimeout(this.root.closing_timer)
}
// this code is used to trigger events easily (used in the context menu mouseleave
static trigger(
element: HTMLDivElement,
event_name: string,
params: MouseEvent,
origin?: unknown,
): CustomEvent {
const evt = document.createEvent("CustomEvent")
evt.initCustomEvent(event_name, true, true, params) // canBubble, cancelable, detail
// @ts-expect-error
evt.srcElement = origin
if (element.dispatchEvent) element.dispatchEvent(evt)
// @ts-expect-error
else if (element.__events) element.__events.dispatchEvent(evt)
// else nothing seems binded here so nothing to do
return evt
}
// returns the top most menu
getTopMenu(): ContextMenu {
return this.options.parentMenu
? this.options.parentMenu.getTopMenu()
: this
}
getFirstEvent(): MouseEvent {
return this.options.parentMenu
? this.options.parentMenu.getFirstEvent()
: this.options.event
}
static isCursorOverElement(
event: MouseEvent,
element: HTMLDivElement,
): boolean {
const left = event.clientX
const top = event.clientY
const rect = element.getBoundingClientRect()
if (!rect) return false
if (
top > rect.top &&
top < rect.top + rect.height &&
left > rect.left &&
left < rect.left + rect.width
) {
return true
}
return false
}
} }

View File

@@ -2,172 +2,190 @@ import type { Point, Rect } from "./interfaces"
import { clamp, LGraphCanvas } from "./litegraph" import { clamp, LGraphCanvas } from "./litegraph"
import { distance } from "./measure" import { distance } from "./measure"
//used by some widgets to render a curve editor // used by some widgets to render a curve editor
export class CurveEditor { export class CurveEditor {
points: Point[] points: Point[]
selected: number selected: number
nearest: number nearest: number
size: Rect size: Rect
must_update: boolean must_update: boolean
margin: number margin: number
_nearest: number _nearest: number
constructor(points: Point[]) { constructor(points: Point[]) {
this.points = points this.points = points
this.selected = -1 this.selected = -1
this.nearest = -1 this.nearest = -1
this.size = null //stores last size used this.size = null // stores last size used
this.must_update = true this.must_update = true
this.margin = 5 this.margin = 5
}
static sampleCurve(f: number, points: Point[]): number {
if (!points) return
for (let i = 0; i < points.length - 1; ++i) {
const p = points[i]
const pn = points[i + 1]
if (pn[0] < f) continue
const r = pn[0] - p[0]
if (Math.abs(r) < 0.00001) return p[1]
const local_f = (f - p[0]) / r
return p[1] * (1.0 - local_f) + pn[1] * local_f
} }
return 0
}
static sampleCurve(f: number, points: Point[]): number { draw(
if (!points) ctx: CanvasRenderingContext2D,
return size: Rect,
for (let i = 0; i < points.length - 1; ++i) { graphcanvas?: LGraphCanvas,
const p = points[i] background_color?: string,
const pn = points[i + 1] line_color?: string,
if (pn[0] < f) inactive = false,
continue ): void {
const r = (pn[0] - p[0]) const points = this.points
if (Math.abs(r) < 0.00001) if (!points) return
return p[1]
const local_f = (f - p[0]) / r this.size = size
return p[1] * (1.0 - local_f) + pn[1] * local_f const w = size[0] - this.margin * 2
} const h = size[1] - this.margin * 2
return 0
line_color = line_color || "#666"
ctx.save()
ctx.translate(this.margin, this.margin)
if (background_color) {
ctx.fillStyle = "#111"
ctx.fillRect(0, 0, w, h)
ctx.fillStyle = "#222"
ctx.fillRect(w * 0.5, 0, 1, h)
ctx.strokeStyle = "#333"
ctx.strokeRect(0, 0, w, h)
} }
ctx.strokeStyle = line_color
draw(ctx: CanvasRenderingContext2D, size: Rect, graphcanvas?: LGraphCanvas, background_color?: string, line_color?: string, inactive = false): void { if (inactive) ctx.globalAlpha = 0.5
const points = this.points ctx.beginPath()
if (!points) for (let i = 0; i < points.length; ++i) {
return const p = points[i]
this.size = size ctx.lineTo(p[0] * w, (1.0 - p[1]) * h)
const w = size[0] - this.margin * 2 }
const h = size[1] - this.margin * 2 ctx.stroke()
ctx.globalAlpha = 1
line_color = line_color || "#666" if (!inactive)
for (let i = 0; i < points.length; ++i) {
ctx.save() const p = points[i]
ctx.translate(this.margin, this.margin) ctx.fillStyle = this.selected == i
? "#FFF"
if (background_color) { : this.nearest == i ? "#DDD" : "#AAA"
ctx.fillStyle = "#111"
ctx.fillRect(0, 0, w, h)
ctx.fillStyle = "#222"
ctx.fillRect(w * 0.5, 0, 1, h)
ctx.strokeStyle = "#333"
ctx.strokeRect(0, 0, w, h)
}
ctx.strokeStyle = line_color
if (inactive)
ctx.globalAlpha = 0.5
ctx.beginPath() ctx.beginPath()
for (let i = 0; i < points.length; ++i) { ctx.arc(p[0] * w, (1.0 - p[1]) * h, 2, 0, Math.PI * 2)
const p = points[i] ctx.fill()
ctx.lineTo(p[0] * w, (1.0 - p[1]) * h) }
} ctx.restore()
ctx.stroke() }
ctx.globalAlpha = 1
if (!inactive) // localpos is mouse in curve editor space
for (let i = 0; i < points.length; ++i) { onMouseDown(localpos: Point, graphcanvas: LGraphCanvas): boolean {
const p = points[i] const points = this.points
ctx.fillStyle = this.selected == i ? "#FFF" : (this.nearest == i ? "#DDD" : "#AAA") if (!points) return
ctx.beginPath() if (localpos[1] < 0) return
ctx.arc(p[0] * w, (1.0 - p[1]) * h, 2, 0, Math.PI * 2)
ctx.fill() // this.captureInput(true);
} const w = this.size[0] - this.margin * 2
ctx.restore() const h = this.size[1] - this.margin * 2
const x = localpos[0] - this.margin
const y = localpos[1] - this.margin
const pos: Point = [x, y]
const max_dist = 30 / graphcanvas.ds.scale
// search closer one
this.selected = this.getCloserPoint(pos, max_dist)
// create one
if (this.selected == -1) {
const point: Point = [x / w, 1 - y / h]
points.push(point)
points.sort(function (a, b) {
return a[0] - b[0]
})
this.selected = points.indexOf(point)
this.must_update = true
} }
if (this.selected != -1) return true
}
//localpos is mouse in curve editor space onMouseMove(localpos: Point, graphcanvas: LGraphCanvas): void {
onMouseDown(localpos: Point, graphcanvas: LGraphCanvas): boolean { const points = this.points
const points = this.points if (!points) return
if (!points)
return
if (localpos[1] < 0)
return
//this.captureInput(true); const s = this.selected
const w = this.size[0] - this.margin * 2 if (s < 0) return
const h = this.size[1] - this.margin * 2
const x = localpos[0] - this.margin
const y = localpos[1] - this.margin
const pos: Point = [x, y]
const max_dist = 30 / graphcanvas.ds.scale
//search closer one
this.selected = this.getCloserPoint(pos, max_dist)
//create one
if (this.selected == -1) {
const point: Point = [x / w, 1 - y / h]
points.push(point)
points.sort(function (a, b) { return a[0] - b[0] })
this.selected = points.indexOf(point)
this.must_update = true
}
if (this.selected != -1)
return true
}
onMouseMove(localpos: Point, graphcanvas: LGraphCanvas): void { const x = (localpos[0] - this.margin) / (this.size[0] - this.margin * 2)
const points = this.points const y = (localpos[1] - this.margin) / (this.size[1] - this.margin * 2)
if (!points) const curvepos: Point = [
return localpos[0] - this.margin,
const s = this.selected localpos[1] - this.margin,
if (s < 0) ]
return const max_dist = 30 / graphcanvas.ds.scale
const x = (localpos[0] - this.margin) / (this.size[0] - this.margin * 2) this._nearest = this.getCloserPoint(curvepos, max_dist)
const y = (localpos[1] - this.margin) / (this.size[1] - this.margin * 2) const point = points[s]
const curvepos: Point = [(localpos[0] - this.margin), (localpos[1] - this.margin)] if (point) {
const max_dist = 30 / graphcanvas.ds.scale const is_edge_point = s == 0 || s == points.length - 1
this._nearest = this.getCloserPoint(curvepos, max_dist) if (
const point = points[s] !is_edge_point &&
if (point) { (localpos[0] < -10 ||
const is_edge_point = s == 0 || s == points.length - 1 localpos[0] > this.size[0] + 10 ||
if (!is_edge_point && (localpos[0] < -10 || localpos[0] > this.size[0] + 10 || localpos[1] < -10 || localpos[1] > this.size[1] + 10)) { localpos[1] < -10 ||
points.splice(s, 1) localpos[1] > this.size[1] + 10)
this.selected = -1 ) {
return points.splice(s, 1)
}
if (!is_edge_point) //not edges
point[0] = clamp(x, 0, 1)
else
point[0] = s == 0 ? 0 : 1
point[1] = 1.0 - clamp(y, 0, 1)
points.sort(function (a, b) { return a[0] - b[0] })
this.selected = points.indexOf(point)
this.must_update = true
}
}
// Former params: localpos, graphcanvas
onMouseUp(): boolean {
this.selected = -1 this.selected = -1
return false return
}
// not edges
if (!is_edge_point) point[0] = clamp(x, 0, 1)
else point[0] = s == 0 ? 0 : 1
point[1] = 1.0 - clamp(y, 0, 1)
points.sort(function (a, b) {
return a[0] - b[0]
})
this.selected = points.indexOf(point)
this.must_update = true
} }
}
getCloserPoint(pos: Point, max_dist: number): number { // Former params: localpos, graphcanvas
const points = this.points onMouseUp(): boolean {
if (!points) this.selected = -1
return -1 return false
max_dist = max_dist || 30 }
const w = (this.size[0] - this.margin * 2)
const h = (this.size[1] - this.margin * 2) getCloserPoint(pos: Point, max_dist: number): number {
const num = points.length const points = this.points
const p2: Point = [0, 0] if (!points) return -1
let min_dist = 1000000
let closest = -1 max_dist = max_dist || 30
for (let i = 0; i < num; ++i) { const w = this.size[0] - this.margin * 2
const p = points[i] const h = this.size[1] - this.margin * 2
p2[0] = p[0] * w const num = points.length
p2[1] = (1.0 - p[1]) * h const p2: Point = [0, 0]
const dist = distance(pos, p2) let min_dist = 1000000
if (dist > min_dist || dist > max_dist) let closest = -1
continue
closest = i for (let i = 0; i < num; ++i) {
min_dist = dist const p = points[i]
} p2[0] = p[0] * w
return closest p2[1] = (1.0 - p[1]) * h
const dist = distance(pos, p2)
if (dist > min_dist || dist > max_dist) continue
closest = i
min_dist = dist
} }
return closest
}
} }

View File

@@ -4,252 +4,247 @@ import { LiteGraph } from "./litegraph"
import { isInRect } from "./measure" import { isInRect } from "./measure"
export interface DragAndScaleState { export interface DragAndScaleState {
offset: Point offset: Point
scale: number scale: number
} }
export class DragAndScale { export class DragAndScale {
/** /**
* The state of this DragAndScale instance. * The state of this DragAndScale instance.
* *
* Implemented as a POCO that can be proxied without side-effects. * Implemented as a POCO that can be proxied without side-effects.
*/ */
state: DragAndScaleState state: DragAndScaleState
/** Maximum scale (zoom in) */ /** Maximum scale (zoom in) */
max_scale: number max_scale: number
/** Minimum scale (zoom out) */ /** Minimum scale (zoom out) */
min_scale: number min_scale: number
enabled: boolean enabled: boolean
last_mouse: Point last_mouse: Point
element?: HTMLCanvasElement element?: HTMLCanvasElement
visible_area: Rect32 visible_area: Rect32
_binded_mouse_callback _binded_mouse_callback
dragging?: boolean dragging?: boolean
viewport?: Rect viewport?: Rect
onredraw?(das: DragAndScale): void onredraw?(das: DragAndScale): void
/** @deprecated */ /** @deprecated */
onmouse?(e: unknown): boolean onmouse?(e: unknown): boolean
get offset(): Point { get offset(): Point {
return this.state.offset return this.state.offset
}
set offset(value: Point) {
this.state.offset = value
}
get scale(): number {
return this.state.scale
}
set scale(value: number) {
this.state.scale = value
}
constructor(element?: HTMLCanvasElement, skip_events?: boolean) {
this.state = {
offset: new Float32Array([0, 0]),
scale: 1,
} }
set offset(value: Point) { this.max_scale = 10
this.state.offset = value this.min_scale = 0.1
this.onredraw = null
this.enabled = true
this.last_mouse = [0, 0]
this.element = null
this.visible_area = new Float32Array(4)
if (element) {
this.element = element
if (!skip_events) {
this.bindEvents(element)
}
}
}
/** @deprecated Has not been kept up to date */
bindEvents(element: Node): void {
this.last_mouse = new Float32Array(2)
this._binded_mouse_callback = this.onMouse.bind(this)
LiteGraph.pointerListenerAdd(element, "down", this._binded_mouse_callback)
LiteGraph.pointerListenerAdd(element, "move", this._binded_mouse_callback)
LiteGraph.pointerListenerAdd(element, "up", this._binded_mouse_callback)
element.addEventListener("mousewheel", this._binded_mouse_callback, false)
element.addEventListener("wheel", this._binded_mouse_callback, false)
}
computeVisibleArea(viewport: Rect): void {
if (!this.element) {
this.visible_area[0] = this.visible_area[1] = this.visible_area[2] = this.visible_area[3] = 0
return
}
let width = this.element.width
let height = this.element.height
let startx = -this.offset[0]
let starty = -this.offset[1]
if (viewport) {
startx += viewport[0] / this.scale
starty += viewport[1] / this.scale
width = viewport[2]
height = viewport[3]
}
const endx = startx + width / this.scale
const endy = starty + height / this.scale
this.visible_area[0] = startx
this.visible_area[1] = starty
this.visible_area[2] = endx - startx
this.visible_area[3] = endy - starty
}
/** @deprecated Has not been kept up to date */
onMouse(e: CanvasMouseEvent) {
if (!this.enabled) {
return
} }
get scale(): number { const canvas = this.element
return this.state.scale const rect = canvas.getBoundingClientRect()
} const x = e.clientX - rect.left
set scale(value: number) { const y = e.clientY - rect.top
this.state.scale = value // FIXME: "canvasx" / y are not referenced anywhere - wrong case
// @ts-expect-error Incorrect case
e.canvasx = x
// @ts-expect-error Incorrect case
e.canvasy = y
e.dragging = this.dragging
const is_inside = !this.viewport || isInRect(x, y, this.viewport)
let ignore = false
if (this.onmouse) {
ignore = this.onmouse(e)
} }
constructor(element?: HTMLCanvasElement, skip_events?: boolean) { if (e.type == LiteGraph.pointerevents_method + "down" && is_inside) {
this.state = { this.dragging = true
offset: new Float32Array([0, 0]), LiteGraph.pointerListenerRemove(canvas, "move", this._binded_mouse_callback)
scale: 1 LiteGraph.pointerListenerAdd(document, "move", this._binded_mouse_callback)
LiteGraph.pointerListenerAdd(document, "up", this._binded_mouse_callback)
} else if (e.type == LiteGraph.pointerevents_method + "move") {
if (!ignore) {
const deltax = x - this.last_mouse[0]
const deltay = y - this.last_mouse[1]
if (this.dragging) {
this.mouseDrag(deltax, deltay)
} }
this.max_scale = 10 }
this.min_scale = 0.1 } else if (e.type == LiteGraph.pointerevents_method + "up") {
this.onredraw = null this.dragging = false
this.enabled = true LiteGraph.pointerListenerRemove(document, "move", this._binded_mouse_callback)
this.last_mouse = [0, 0] LiteGraph.pointerListenerRemove(document, "up", this._binded_mouse_callback)
this.element = null LiteGraph.pointerListenerAdd(canvas, "move", this._binded_mouse_callback)
this.visible_area = new Float32Array(4) } else if (
is_inside &&
(e.type == "mousewheel" || e.type == "wheel" || e.type == "DOMMouseScroll")
) {
// @ts-expect-error Deprecated
e.eventType = "mousewheel"
// @ts-expect-error Deprecated
if (e.type == "wheel") e.wheel = -e.deltaY
// @ts-expect-error Deprecated
else e.wheel = e.wheelDeltaY != null ? e.wheelDeltaY : e.detail * -60
if (element) { // from stack overflow
this.element = element // @ts-expect-error Deprecated
if (!skip_events) { e.delta = e.wheelDelta
this.bindEvents(element) // @ts-expect-error Deprecated
} ? e.wheelDelta / 40
} : e.deltaY
? -e.deltaY / 3
: 0
// @ts-expect-error Deprecated
this.changeDeltaScale(1.0 + e.delta * 0.05)
} }
/** @deprecated Has not been kept up to date */ this.last_mouse[0] = x
bindEvents(element: Node): void { this.last_mouse[1] = y
this.last_mouse = new Float32Array(2)
this._binded_mouse_callback = this.onMouse.bind(this) if (is_inside) {
e.preventDefault()
e.stopPropagation()
return false
}
}
LiteGraph.pointerListenerAdd(element, "down", this._binded_mouse_callback) toCanvasContext(ctx: CanvasRenderingContext2D): void {
LiteGraph.pointerListenerAdd(element, "move", this._binded_mouse_callback) ctx.scale(this.scale, this.scale)
LiteGraph.pointerListenerAdd(element, "up", this._binded_mouse_callback) ctx.translate(this.offset[0], this.offset[1])
}
element.addEventListener( convertOffsetToCanvas(pos: Point): Point {
"mousewheel", return [
this._binded_mouse_callback, (pos[0] + this.offset[0]) * this.scale,
false (pos[1] + this.offset[1]) * this.scale,
) ]
element.addEventListener("wheel", this._binded_mouse_callback, false) }
convertCanvasToOffset(pos: Point, out?: Point): Point {
out = out || [0, 0]
out[0] = pos[0] / this.scale - this.offset[0]
out[1] = pos[1] / this.scale - this.offset[1]
return out
}
/** @deprecated Has not been kept up to date */
mouseDrag(x: number, y: number): void {
this.offset[0] += x / this.scale
this.offset[1] += y / this.scale
this.onredraw?.(this)
}
changeScale(value: number, zooming_center?: Point): void {
if (value < this.min_scale) {
value = this.min_scale
} else if (value > this.max_scale) {
value = this.max_scale
} }
computeVisibleArea(viewport: Rect): void { if (value == this.scale) return
if (!this.element) { if (!this.element) return
this.visible_area[0] = this.visible_area[1] = this.visible_area[2] = this.visible_area[3] = 0
return
}
let width = this.element.width
let height = this.element.height
let startx = -this.offset[0]
let starty = -this.offset[1]
if (viewport) {
startx += viewport[0] / this.scale
starty += viewport[1] / this.scale
width = viewport[2]
height = viewport[3]
}
const endx = startx + width / this.scale
const endy = starty + height / this.scale
this.visible_area[0] = startx
this.visible_area[1] = starty
this.visible_area[2] = endx - startx
this.visible_area[3] = endy - starty
}
/** @deprecated Has not been kept up to date */ const rect = this.element.getBoundingClientRect()
onMouse(e: CanvasMouseEvent) { if (!rect) return
if (!this.enabled) {
return
}
const canvas = this.element zooming_center = zooming_center || [rect.width * 0.5, rect.height * 0.5]
const rect = canvas.getBoundingClientRect() const center = this.convertCanvasToOffset(zooming_center)
const x = e.clientX - rect.left this.scale = value
const y = e.clientY - rect.top if (Math.abs(this.scale - 1) < 0.01) this.scale = 1
// FIXME: "canvasx" / y are not referenced anywhere - wrong case
// @ts-expect-error Incorrect case
e.canvasx = x
// @ts-expect-error Incorrect case
e.canvasy = y
e.dragging = this.dragging
const is_inside = !this.viewport || isInRect(x, y, this.viewport) const new_center = this.convertCanvasToOffset(zooming_center)
const delta_offset = [
new_center[0] - center[0],
new_center[1] - center[1],
]
let ignore = false this.offset[0] += delta_offset[0]
if (this.onmouse) { this.offset[1] += delta_offset[1]
ignore = this.onmouse(e)
}
if (e.type == LiteGraph.pointerevents_method + "down" && is_inside) { this.onredraw?.(this)
this.dragging = true }
LiteGraph.pointerListenerRemove(canvas, "move", this._binded_mouse_callback)
LiteGraph.pointerListenerAdd(document, "move", this._binded_mouse_callback)
LiteGraph.pointerListenerAdd(document, "up", this._binded_mouse_callback)
} else if (e.type == LiteGraph.pointerevents_method + "move") {
if (!ignore) {
const deltax = x - this.last_mouse[0]
const deltay = y - this.last_mouse[1]
if (this.dragging) {
this.mouseDrag(deltax, deltay)
}
}
} else if (e.type == LiteGraph.pointerevents_method + "up") {
this.dragging = false
LiteGraph.pointerListenerRemove(document, "move", this._binded_mouse_callback)
LiteGraph.pointerListenerRemove(document, "up", this._binded_mouse_callback)
LiteGraph.pointerListenerAdd(canvas, "move", this._binded_mouse_callback)
} else if (is_inside &&
(e.type == "mousewheel" ||
e.type == "wheel" ||
e.type == "DOMMouseScroll")) {
// @ts-expect-error Deprecated
e.eventType = "mousewheel"
// @ts-expect-error Deprecated
if (e.type == "wheel") e.wheel = -e.deltaY
// @ts-expect-error Deprecated
else e.wheel = e.wheelDeltaY != null ? e.wheelDeltaY : e.detail * -60
//from stack overflow changeDeltaScale(value: number, zooming_center?: Point): void {
// @ts-expect-error Deprecated this.changeScale(this.scale * value, zooming_center)
e.delta = e.wheelDelta }
// @ts-expect-error Deprecated
? e.wheelDelta / 40
: e.deltaY
? -e.deltaY / 3
: 0
// @ts-expect-error Deprecated
this.changeDeltaScale(1.0 + e.delta * 0.05)
}
this.last_mouse[0] = x reset(): void {
this.last_mouse[1] = y this.scale = 1
this.offset[0] = 0
if (is_inside) { this.offset[1] = 0
e.preventDefault() }
e.stopPropagation()
return false
}
}
toCanvasContext(ctx: CanvasRenderingContext2D): void {
ctx.scale(this.scale, this.scale)
ctx.translate(this.offset[0], this.offset[1])
}
convertOffsetToCanvas(pos: Point): Point {
return [
(pos[0] + this.offset[0]) * this.scale,
(pos[1] + this.offset[1]) * this.scale
]
}
convertCanvasToOffset(pos: Point, out?: Point): Point {
out = out || [0, 0]
out[0] = pos[0] / this.scale - this.offset[0]
out[1] = pos[1] / this.scale - this.offset[1]
return out
}
/** @deprecated Has not been kept up to date */
mouseDrag(x: number, y: number): void {
this.offset[0] += x / this.scale
this.offset[1] += y / this.scale
this.onredraw?.(this)
}
changeScale(value: number, zooming_center?: Point): void {
if (value < this.min_scale) {
value = this.min_scale
} else if (value > this.max_scale) {
value = this.max_scale
}
if (value == this.scale) return
if (!this.element) return
const rect = this.element.getBoundingClientRect()
if (!rect) return
zooming_center = zooming_center || [
rect.width * 0.5,
rect.height * 0.5
]
const center = this.convertCanvasToOffset(zooming_center)
this.scale = value
if (Math.abs(this.scale - 1) < 0.01) this.scale = 1
const new_center = this.convertCanvasToOffset(zooming_center)
const delta_offset = [
new_center[0] - center[0],
new_center[1] - center[1]
]
this.offset[0] += delta_offset[0]
this.offset[1] += delta_offset[1]
this.onredraw?.(this)
}
changeDeltaScale(value: number, zooming_center?: Point): void {
this.changeScale(this.scale * value, zooming_center)
}
reset(): void {
this.scale = 1
this.offset[0] = 0
this.offset[1] = 0
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -4,23 +4,23 @@ export enum BadgePosition {
} }
export interface LGraphBadgeOptions { export interface LGraphBadgeOptions {
text: string; text: string
fgColor?: string; fgColor?: string
bgColor?: string; bgColor?: string
fontSize?: number; fontSize?: number
padding?: number; padding?: number
height?: number; height?: number
cornerRadius?: number; cornerRadius?: number
} }
export class LGraphBadge { export class LGraphBadge {
text: string; text: string
fgColor: string; fgColor: string
bgColor: string; bgColor: string
fontSize: number; fontSize: number
padding: number; padding: number
height: number; height: number
cornerRadius: number; cornerRadius: number
constructor({ constructor({
text, text,
@@ -31,27 +31,27 @@ export class LGraphBadge {
height = 20, height = 20,
cornerRadius = 5, cornerRadius = 5,
}: LGraphBadgeOptions) { }: LGraphBadgeOptions) {
this.text = text; this.text = text
this.fgColor = fgColor; this.fgColor = fgColor
this.bgColor = bgColor; this.bgColor = bgColor
this.fontSize = fontSize; this.fontSize = fontSize
this.padding = padding; this.padding = padding
this.height = height; this.height = height
this.cornerRadius = cornerRadius; this.cornerRadius = cornerRadius
} }
get visible() { get visible() {
return this.text.length > 0; return this.text.length > 0
} }
getWidth(ctx: CanvasRenderingContext2D) { getWidth(ctx: CanvasRenderingContext2D) {
if (!this.visible) return 0; if (!this.visible) return 0
ctx.save(); ctx.save()
ctx.font = `${this.fontSize}px sans-serif`; ctx.font = `${this.fontSize}px sans-serif`
const textWidth = ctx.measureText(this.text).width; const textWidth = ctx.measureText(this.text).width
ctx.restore(); ctx.restore()
return textWidth + this.padding * 2; return textWidth + this.padding * 2
} }
draw( draw(
@@ -59,32 +59,32 @@ export class LGraphBadge {
x: number, x: number,
y: number, y: number,
): void { ): void {
if (!this.visible) return; if (!this.visible) return
ctx.save(); ctx.save()
ctx.font = `${this.fontSize}px sans-serif`; ctx.font = `${this.fontSize}px sans-serif`
const badgeWidth = this.getWidth(ctx); const badgeWidth = this.getWidth(ctx)
const badgeX = 0; const badgeX = 0
// Draw badge background // Draw badge background
ctx.fillStyle = this.bgColor; ctx.fillStyle = this.bgColor
ctx.beginPath(); ctx.beginPath()
if (ctx.roundRect) { if (ctx.roundRect) {
ctx.roundRect(x + badgeX, y, badgeWidth, this.height, this.cornerRadius); ctx.roundRect(x + badgeX, y, badgeWidth, this.height, this.cornerRadius)
} else { } else {
// Fallback for browsers that don't support roundRect // Fallback for browsers that don't support roundRect
ctx.rect(x + badgeX, y, badgeWidth, this.height); ctx.rect(x + badgeX, y, badgeWidth, this.height)
} }
ctx.fill(); ctx.fill()
// Draw badge text // Draw badge text
ctx.fillStyle = this.fgColor; ctx.fillStyle = this.fgColor
ctx.fillText( ctx.fillText(
this.text, this.text,
x + badgeX + this.padding, x + badgeX + this.padding,
y + this.height - this.padding y + this.height - this.padding,
); )
ctx.restore(); ctx.restore()
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,306 +1,328 @@
import type { IContextMenuValue, IPinnable, Point, Positionable, Size } from "./interfaces" import type {
IContextMenuValue,
IPinnable,
Point,
Positionable,
Size,
} from "./interfaces"
import type { LGraph } from "./LGraph" import type { LGraph } from "./LGraph"
import type { ISerialisedGroup } from "./types/serialisation" import type { ISerialisedGroup } from "./types/serialisation"
import { LiteGraph } from "./litegraph" import { LiteGraph } from "./litegraph"
import { LGraphCanvas } from "./LGraphCanvas" import { LGraphCanvas } from "./LGraphCanvas"
import { containsCentre, containsRect, isInRectangle, isPointInRect, createBounds, snapPoint } from "./measure" import {
containsCentre,
containsRect,
isInRectangle,
isPointInRect,
createBounds,
snapPoint,
} from "./measure"
import { LGraphNode } from "./LGraphNode" import { LGraphNode } from "./LGraphNode"
import { RenderShape, TitleMode } from "./types/globalEnums" import { RenderShape, TitleMode } from "./types/globalEnums"
export interface IGraphGroupFlags extends Record<string, unknown> { export interface IGraphGroupFlags extends Record<string, unknown> {
pinned?: true pinned?: true
} }
export class LGraphGroup implements Positionable, IPinnable { export class LGraphGroup implements Positionable, IPinnable {
static minWidth = 140 static minWidth = 140
static minHeight = 80 static minHeight = 80
static resizeLength = 10 static resizeLength = 10
static padding = 4 static padding = 4
static defaultColour = '#335' static defaultColour = "#335"
id: number id: number
color: string color: string
title: string title: string
font?: string font?: string
font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24 font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24
_bounding: Float32Array = new Float32Array([10, 10, LGraphGroup.minWidth, LGraphGroup.minHeight]) _bounding: Float32Array = new Float32Array([
_pos: Point = this._bounding.subarray(0, 2) 10,
_size: Size = this._bounding.subarray(2, 4) 10,
/** @deprecated See {@link _children} */ LGraphGroup.minWidth,
_nodes: LGraphNode[] = [] LGraphGroup.minHeight,
_children: Set<Positionable> = new Set() ])
graph: LGraph | null = null _pos: Point = this._bounding.subarray(0, 2)
flags: IGraphGroupFlags = {} _size: Size = this._bounding.subarray(2, 4)
selected?: boolean /** @deprecated See {@link _children} */
_nodes: LGraphNode[] = []
_children: Set<Positionable> = new Set()
graph: LGraph | null = null
flags: IGraphGroupFlags = {}
selected?: boolean
constructor(title?: string, id?: number) { constructor(title?: string, id?: number) {
// TODO: Object instantiation pattern requires too much boilerplate and null checking. ID should be passed in via constructor. // TODO: Object instantiation pattern requires too much boilerplate and null checking. ID should be passed in via constructor.
this.id = id ?? -1 this.id = id ?? -1
this.title = title || "Group" this.title = title || "Group"
this.color = LGraphCanvas.node_colors.pale_blue this.color = LGraphCanvas.node_colors.pale_blue
? LGraphCanvas.node_colors.pale_blue.groupcolor ? LGraphCanvas.node_colors.pale_blue.groupcolor
: "#AAA" : "#AAA"
}
/** Position of the group, as x,y co-ordinates in graph space */
get pos() {
return this._pos
}
set pos(v) {
if (!v || v.length < 2) return
this._pos[0] = v[0]
this._pos[1] = v[1]
}
/** Size of the group, as width,height in graph units */
get size() {
return this._size
}
set size(v) {
if (!v || v.length < 2) return
this._size[0] = Math.max(LGraphGroup.minWidth, v[0])
this._size[1] = Math.max(LGraphGroup.minHeight, v[1])
}
get boundingRect() {
return this._bounding
}
get nodes() {
return this._nodes
}
get titleHeight() {
return this.font_size * 1.4
}
get children(): ReadonlySet<Positionable> {
return this._children
}
get pinned() {
return !!this.flags.pinned
}
/**
* Prevents the group being accidentally moved or resized by mouse interaction.
* Toggles pinned state if no value is provided.
**/
pin(value?: boolean): void {
const newState = value === undefined ? !this.pinned : value
if (newState) this.flags.pinned = true
else delete this.flags.pinned
}
unpin(): void {
this.pin(false)
}
configure(o: ISerialisedGroup): void {
this.id = o.id
this.title = o.title
this._bounding.set(o.bounding)
this.color = o.color
this.flags = o.flags || this.flags
if (o.font_size) this.font_size = o.font_size
}
serialize(): ISerialisedGroup {
const b = this._bounding
return {
id: this.id,
title: this.title,
bounding: [...b],
color: this.color,
font_size: this.font_size,
flags: this.flags,
}
}
/**
* Draws the group on the canvas
* @param {LGraphCanvas} graphCanvas
* @param {CanvasRenderingContext2D} ctx
*/
draw(graphCanvas: LGraphCanvas, ctx: CanvasRenderingContext2D): void {
const { padding, resizeLength, defaultColour } = LGraphGroup
const font_size = this.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE
const [x, y] = this._pos
const [width, height] = this._size
// Titlebar
ctx.globalAlpha = 0.25 * graphCanvas.editor_alpha
ctx.fillStyle = this.color || defaultColour
ctx.strokeStyle = this.color || defaultColour
ctx.beginPath()
ctx.rect(x + 0.5, y + 0.5, width, font_size * 1.4)
ctx.fill()
// Group background, border
ctx.fillStyle = this.color
ctx.strokeStyle = this.color
ctx.beginPath()
ctx.rect(x + 0.5, y + 0.5, width, height)
ctx.fill()
ctx.globalAlpha = graphCanvas.editor_alpha
ctx.stroke()
// Resize marker
ctx.beginPath()
ctx.moveTo(x + width, y + height)
ctx.lineTo(x + width - resizeLength, y + height)
ctx.lineTo(x + width, y + height - resizeLength)
ctx.fill()
// Title
ctx.font = font_size + "px Arial"
ctx.textAlign = "left"
ctx.fillText(this.title + (this.pinned ? "📌" : ""), x + padding, y + font_size)
if (LiteGraph.highlight_selected_group && this.selected) {
graphCanvas.drawSelectionBounding(ctx, this._bounding, {
shape: RenderShape.BOX,
title_height: this.titleHeight,
title_mode: TitleMode.NORMAL_TITLE,
fgcolor: this.color,
padding,
})
}
}
resize(width: number, height: number): boolean {
if (this.pinned) return false
this._size[0] = Math.max(LGraphGroup.minWidth, width)
this._size[1] = Math.max(LGraphGroup.minHeight, height)
return true
}
move(deltaX: number, deltaY: number, skipChildren: boolean = false): void {
if (this.pinned) return
this._pos[0] += deltaX
this._pos[1] += deltaY
if (skipChildren === true) return
for (const item of this._children) {
item.move(deltaX, deltaY)
}
}
/** @inheritdoc */
snapToGrid(snapTo: number): boolean {
return this.pinned ? false : snapPoint(this.pos, snapTo)
}
recomputeInsideNodes(): void {
const { nodes, reroutes, groups } = this.graph
const children = this._children
this._nodes.length = 0
children.clear()
// Move nodes we overlap the centre point of
for (const node of nodes) {
if (containsCentre(this._bounding, node.boundingRect)) {
this._nodes.push(node)
children.add(node)
}
} }
/** Position of the group, as x,y co-ordinates in graph space */ // Move reroutes we overlap the centre point of
get pos() { for (const reroute of reroutes.values()) {
return this._pos if (isPointInRect(reroute.pos, this._bounding))
} children.add(reroute)
set pos(v) {
if (!v || v.length < 2) return
this._pos[0] = v[0]
this._pos[1] = v[1]
} }
/** Size of the group, as width,height in graph units */ // Move groups we wholly contain
get size() { for (const group of groups) {
return this._size if (containsRect(this._bounding, group._bounding))
} children.add(group)
set size(v) {
if (!v || v.length < 2) return
this._size[0] = Math.max(LGraphGroup.minWidth, v[0])
this._size[1] = Math.max(LGraphGroup.minHeight, v[1])
} }
get boundingRect() { groups.sort((a, b) => {
return this._bounding if (a === this) {
} return children.has(b) ? -1 : 0
} else if (b === this) {
return children.has(a) ? 1 : 0
}
})
}
get nodes() { /**
return this._nodes * Resizes and moves the group to neatly fit all given {@link objects}.
} * @param objects All objects that should be inside the group
* @param padding Value in graph units to add to all sides of the group. Default: 10
*/
resizeTo(objects: Iterable<Positionable>, padding: number = 10): void {
const boundingBox = createBounds(objects, padding)
if (boundingBox === null) return
get titleHeight() { this.pos[0] = boundingBox[0]
return this.font_size * 1.4 this.pos[1] = boundingBox[1] - this.titleHeight
} this.size[0] = boundingBox[2]
this.size[1] = boundingBox[3] + this.titleHeight
}
get children(): ReadonlySet<Positionable> { /**
return this._children * Add nodes to the group and adjust the group's position and size accordingly
} * @param {LGraphNode[]} nodes - The nodes to add to the group
* @param {number} [padding=10] - The padding around the group
* @returns {void}
*/
addNodes(nodes: LGraphNode[], padding: number = 10): void {
if (!this._nodes && nodes.length === 0) return
this.resizeTo([...this.children, ...this._nodes, ...nodes], padding)
}
get pinned() { getMenuOptions(): IContextMenuValue[] {
return !!this.flags.pinned return [
} {
content: this.pinned ? "Unpin" : "Pin",
callback: () => {
if (this.pinned) this.unpin()
else this.pin()
this.setDirtyCanvas(false, true)
},
},
null,
{ content: "Title", callback: LGraphCanvas.onShowPropertyEditor },
{
content: "Color",
has_submenu: true,
callback: LGraphCanvas.onMenuNodeColors,
},
{
content: "Font size",
property: "font_size",
type: "Number",
callback: LGraphCanvas.onShowPropertyEditor,
},
null,
{ content: "Remove", callback: LGraphCanvas.onMenuNodeRemove },
]
}
/** isPointInTitlebar(x: number, y: number): boolean {
* Prevents the group being accidentally moved or resized by mouse interaction. const b = this.boundingRect
* Toggles pinned state if no value is provided. return isInRectangle(x, y, b[0], b[1], b[2], this.titleHeight)
**/ }
pin(value?: boolean): void {
const newState = value === undefined ? !this.pinned : value
if (newState) this.flags.pinned = true isInResize(x: number, y: number): boolean {
else delete this.flags.pinned const b = this.boundingRect
} const right = b[0] + b[2]
const bottom = b[1] + b[3]
unpin(): void { return (
this.pin(false) x < right &&
} y < bottom &&
x - right + (y - bottom) > -LGraphGroup.resizeLength
)
}
configure(o: ISerialisedGroup): void { isPointInside = LGraphNode.prototype.isPointInside
this.id = o.id setDirtyCanvas = LGraphNode.prototype.setDirtyCanvas
this.title = o.title
this._bounding.set(o.bounding)
this.color = o.color
this.flags = o.flags || this.flags
if (o.font_size) this.font_size = o.font_size
}
serialize(): ISerialisedGroup {
const b = this._bounding
return {
id: this.id,
title: this.title,
bounding: [...b],
color: this.color,
font_size: this.font_size,
flags: this.flags,
}
}
/**
* Draws the group on the canvas
* @param {LGraphCanvas} graphCanvas
* @param {CanvasRenderingContext2D} ctx
*/
draw(graphCanvas: LGraphCanvas, ctx: CanvasRenderingContext2D): void {
const { padding, resizeLength, defaultColour } = LGraphGroup
const font_size = this.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE
const [x, y] = this._pos
const [width, height] = this._size
// Titlebar
ctx.globalAlpha = 0.25 * graphCanvas.editor_alpha
ctx.fillStyle = this.color || defaultColour
ctx.strokeStyle = this.color || defaultColour
ctx.beginPath()
ctx.rect(x + 0.5, y + 0.5, width, font_size * 1.4)
ctx.fill()
// Group background, border
ctx.fillStyle = this.color
ctx.strokeStyle = this.color
ctx.beginPath()
ctx.rect(x + 0.5, y + 0.5, width, height)
ctx.fill()
ctx.globalAlpha = graphCanvas.editor_alpha
ctx.stroke()
// Resize marker
ctx.beginPath()
ctx.moveTo(x + width, y + height)
ctx.lineTo(x + width - resizeLength, y + height)
ctx.lineTo(x + width, y + height - resizeLength)
ctx.fill()
// Title
ctx.font = font_size + "px Arial"
ctx.textAlign = "left"
ctx.fillText(this.title + (this.pinned ? "📌" : ""), x + padding, y + font_size)
if (LiteGraph.highlight_selected_group && this.selected) {
graphCanvas.drawSelectionBounding(ctx, this._bounding, {
shape: RenderShape.BOX,
title_height: this.titleHeight,
title_mode: TitleMode.NORMAL_TITLE,
fgcolor: this.color,
padding,
})
}
}
resize(width: number, height: number): boolean {
if (this.pinned) return false
this._size[0] = Math.max(LGraphGroup.minWidth, width)
this._size[1] = Math.max(LGraphGroup.minHeight, height)
return true
}
move(deltaX: number, deltaY: number, skipChildren: boolean = false): void {
if (this.pinned) return
this._pos[0] += deltaX
this._pos[1] += deltaY
if (skipChildren === true) return
for (const item of this._children) {
item.move(deltaX, deltaY)
}
}
/** @inheritdoc */
snapToGrid(snapTo: number): boolean {
return this.pinned ? false : snapPoint(this.pos, snapTo)
}
recomputeInsideNodes(): void {
const { nodes, reroutes, groups } = this.graph
const children = this._children
this._nodes.length = 0
children.clear()
// Move nodes we overlap the centre point of
for (const node of nodes) {
if (containsCentre(this._bounding, node.boundingRect)) {
this._nodes.push(node)
children.add(node)
}
}
// Move reroutes we overlap the centre point of
for (const reroute of reroutes.values()) {
if (isPointInRect(reroute.pos, this._bounding))
children.add(reroute)
}
// Move groups we wholly contain
for (const group of groups) {
if (containsRect(this._bounding, group._bounding))
children.add(group)
}
groups.sort((a, b) => {
if (a === this) {
return children.has(b) ? -1 : 0
} else if (b === this) {
return children.has(a) ? 1 : 0
}
})
}
/**
* Resizes and moves the group to neatly fit all given {@link objects}.
* @param objects All objects that should be inside the group
* @param padding Value in graph units to add to all sides of the group. Default: 10
*/
resizeTo(objects: Iterable<Positionable>, padding: number = 10): void {
const boundingBox = createBounds(objects, padding)
if (boundingBox === null) return
this.pos[0] = boundingBox[0]
this.pos[1] = boundingBox[1] - this.titleHeight
this.size[0] = boundingBox[2]
this.size[1] = boundingBox[3] + this.titleHeight
}
/**
* Add nodes to the group and adjust the group's position and size accordingly
* @param {LGraphNode[]} nodes - The nodes to add to the group
* @param {number} [padding=10] - The padding around the group
* @returns {void}
*/
addNodes(nodes: LGraphNode[], padding: number = 10): void {
if (!this._nodes && nodes.length === 0) return
this.resizeTo([...this.children, ...this._nodes, ...nodes], padding)
}
getMenuOptions(): IContextMenuValue[] {
return [
{
content: this.pinned ? "Unpin" : "Pin",
callback: () => {
if (this.pinned) this.unpin()
else this.pin()
this.setDirtyCanvas(false, true)
},
},
null,
{ content: "Title", callback: LGraphCanvas.onShowPropertyEditor },
{
content: "Color",
has_submenu: true,
callback: LGraphCanvas.onMenuNodeColors
},
{
content: "Font size",
property: "font_size",
type: "Number",
callback: LGraphCanvas.onShowPropertyEditor
},
null,
{ content: "Remove", callback: LGraphCanvas.onMenuNodeRemove }
]
}
isPointInTitlebar(x: number, y: number): boolean {
const b = this.boundingRect
return isInRectangle(x, y, b[0], b[1], b[2], this.titleHeight)
}
isInResize(x: number, y: number): boolean {
const b = this.boundingRect
const right = b[0] + b[2]
const bottom = b[1] + b[3]
return x < right
&& y < bottom
&& (x - right) + (y - bottom) > -LGraphGroup.resizeLength
}
isPointInside = LGraphNode.prototype.isPointInside
setDirtyCanvas = LGraphNode.prototype.setDirtyCanvas
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,152 +1,193 @@
import type { CanvasColour, LinkNetwork, ISlotType, LinkSegment } from "./interfaces" import type {
CanvasColour,
LinkNetwork,
ISlotType,
LinkSegment,
} from "./interfaces"
import type { NodeId } from "./LGraphNode" import type { NodeId } from "./LGraphNode"
import type { Reroute, RerouteId } from "./Reroute" import type { Reroute, RerouteId } from "./Reroute"
import type { Serialisable, SerialisableLLink } from "./types/serialisation" import type { Serialisable, SerialisableLLink } from "./types/serialisation"
export type LinkId = number export type LinkId = number
export type SerialisedLLinkArray = [id: LinkId, origin_id: NodeId, origin_slot: number, target_id: NodeId, target_slot: number, type: ISlotType] export type SerialisedLLinkArray = [
id: LinkId,
origin_id: NodeId,
origin_slot: number,
target_id: NodeId,
target_slot: number,
type: ISlotType,
]
//this is the class in charge of storing link information // this is the class in charge of storing link information
export class LLink implements LinkSegment, Serialisable<SerialisableLLink> { export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
/** Link ID */ /** Link ID */
id: LinkId id: LinkId
parentId?: RerouteId parentId?: RerouteId
type: ISlotType type: ISlotType
/** Output node ID */ /** Output node ID */
origin_id: NodeId origin_id: NodeId
/** Output slot index */ /** Output slot index */
origin_slot: number origin_slot: number
/** Input node ID */ /** Input node ID */
target_id: NodeId target_id: NodeId
/** Input slot index */ /** Input slot index */
target_slot: number target_slot: number
data?: number | string | boolean | { toToolTip?(): string } data?: number | string | boolean | { toToolTip?(): string }
_data?: unknown _data?: unknown
/** Centre point of the link, calculated during render only - can be inaccurate */ /** Centre point of the link, calculated during render only - can be inaccurate */
_pos: Float32Array _pos: Float32Array
/** @todo Clean up - never implemented in comfy. */ /** @todo Clean up - never implemented in comfy. */
_last_time?: number _last_time?: number
/** The last canvas 2D path that was used to render this link */ /** The last canvas 2D path that was used to render this link */
path?: Path2D path?: Path2D
/** @inheritdoc */ /** @inheritdoc */
_centreAngle?: number _centreAngle?: number
#color?: CanvasColour #color?: CanvasColour
/** Custom colour for this link only */ /** Custom colour for this link only */
public get color(): CanvasColour { return this.#color } public get color(): CanvasColour {
public set color(value: CanvasColour) { return this.#color
this.#color = value === "" ? null : value }
public set color(value: CanvasColour) {
this.#color = value === "" ? null : value
}
constructor(
id: LinkId,
type: ISlotType,
origin_id: NodeId,
origin_slot: number,
target_id: NodeId,
target_slot: number,
parentId?: RerouteId,
) {
this.id = id
this.type = type
this.origin_id = origin_id
this.origin_slot = origin_slot
this.target_id = target_id
this.target_slot = target_slot
this.parentId = parentId
this._data = null
this._pos = new Float32Array(2) // center
}
/** @deprecated Use {@link LLink.create} */
static createFromArray(data: SerialisedLLinkArray): LLink {
return new LLink(data[0], data[5], data[1], data[2], data[3], data[4])
}
/**
* LLink static factory: creates a new LLink from the provided data.
* @param data Serialised LLink data to create the link from
* @returns A new LLink
*/
static create(data: SerialisableLLink): LLink {
return new LLink(
data.id,
data.type,
data.origin_id,
data.origin_slot,
data.target_id,
data.target_slot,
data.parentId,
)
}
/**
* Gets all reroutes from the output slot to this segment. If this segment is a reroute, it will be the last element.
* @returns An ordered array of all reroutes from the node output to
* this reroute or the reroute before it. Otherwise, an empty array.
*/
static getReroutes(
network: LinkNetwork,
linkSegment: LinkSegment,
): Reroute[] {
return network.reroutes.get(linkSegment.parentId)
?.getReroutes() ?? []
}
/**
* Finds the reroute in the chain after the provided reroute ID.
* @param network The network this link belongs to
* @param linkSegment The starting point of the search (input side).
* Typically the LLink object itself, but can be any link segment.
* @param rerouteId The matching reroute will have this set as its {@link parentId}.
* @returns The reroute that was found, `undefined` if no reroute was found, or `null` if an infinite loop was detected.
*/
static findNextReroute(
network: LinkNetwork,
linkSegment: LinkSegment,
rerouteId: RerouteId,
): Reroute | null | undefined {
return network.reroutes.get(linkSegment.parentId)
?.findNextReroute(rerouteId)
}
configure(o: LLink | SerialisedLLinkArray) {
if (Array.isArray(o)) {
this.id = o[0]
this.origin_id = o[1]
this.origin_slot = o[2]
this.target_id = o[3]
this.target_slot = o[4]
this.type = o[5]
} else {
this.id = o.id
this.type = o.type
this.origin_id = o.origin_id
this.origin_slot = o.origin_slot
this.target_id = o.target_id
this.target_slot = o.target_slot
this.parentId = o.parentId
} }
}
constructor(id: LinkId, type: ISlotType, origin_id: NodeId, origin_slot: number, target_id: NodeId, target_slot: number, parentId?: RerouteId) { /**
this.id = id * Disconnects a link and removes it from the graph, cleaning up any reroutes that are no longer used
this.type = type * @param network The container (LGraph) where reroutes should be updated
this.origin_id = origin_id * @param keepReroutes If `true`, reroutes will not be garbage collected.
this.origin_slot = origin_slot */
this.target_id = target_id disconnect(network: LinkNetwork, keepReroutes?: boolean): void {
this.target_slot = target_slot const reroutes = LLink.getReroutes(network, this)
this.parentId = parentId
this._data = null for (const reroute of reroutes) {
this._pos = new Float32Array(2) //center reroute.linkIds.delete(this.id)
if (!keepReroutes && !reroute.linkIds.size)
network.reroutes.delete(reroute.id)
} }
network.links.delete(this.id)
}
/** @deprecated Use {@link LLink.create} */ /**
static createFromArray(data: SerialisedLLinkArray): LLink { * @deprecated Prefer {@link LLink.asSerialisable} (returns an object, not an array)
return new LLink(data[0], data[5], data[1], data[2], data[3], data[4]) * @returns An array representing this LLink
} */
serialize(): SerialisedLLinkArray {
/** return [
* LLink static factory: creates a new LLink from the provided data. this.id,
* @param data Serialised LLink data to create the link from this.origin_id,
* @returns A new LLink this.origin_slot,
*/ this.target_id,
static create(data: SerialisableLLink): LLink { this.target_slot,
return new LLink(data.id, data.type, data.origin_id, data.origin_slot, data.target_id, data.target_slot, data.parentId) this.type,
} ]
}
/**
* Gets all reroutes from the output slot to this segment. If this segment is a reroute, it will be the last element. asSerialisable(): SerialisableLLink {
* @returns An ordered array of all reroutes from the node output to this reroute or the reroute before it. Otherwise, an empty array. const copy: SerialisableLLink = {
*/ id: this.id,
static getReroutes(network: LinkNetwork, linkSegment: LinkSegment): Reroute[] { origin_id: this.origin_id,
return network.reroutes.get(linkSegment.parentId) origin_slot: this.origin_slot,
?.getReroutes() ?? [] target_id: this.target_id,
} target_slot: this.target_slot,
type: this.type,
/**
* Finds the reroute in the chain after the provided reroute ID.
* @param network The network this link belongs to
* @param linkSegment The starting point of the search (input side). Typically the LLink object itself, but can be any link segment.
* @param rerouteId The matching reroute will have this set as its {@link parentId}.
* @returns The reroute that was found, `undefined` if no reroute was found, or `null` if an infinite loop was detected.
*/
static findNextReroute(network: LinkNetwork, linkSegment: LinkSegment, rerouteId: RerouteId): Reroute | null | undefined {
return network.reroutes.get(linkSegment.parentId)
?.findNextReroute(rerouteId)
}
configure(o: LLink | SerialisedLLinkArray) {
if (Array.isArray(o)) {
this.id = o[0]
this.origin_id = o[1]
this.origin_slot = o[2]
this.target_id = o[3]
this.target_slot = o[4]
this.type = o[5]
} else {
this.id = o.id
this.type = o.type
this.origin_id = o.origin_id
this.origin_slot = o.origin_slot
this.target_id = o.target_id
this.target_slot = o.target_slot
this.parentId = o.parentId
}
}
/**
* Disconnects a link and removes it from the graph, cleaning up any reroutes that are no longer used
* @param network The container (LGraph) where reroutes should be updated
* @param keepReroutes If `true`, reroutes will not be garbage collected.
*/
disconnect(network: LinkNetwork, keepReroutes?: boolean): void {
const reroutes = LLink.getReroutes(network, this)
for (const reroute of reroutes) {
reroute.linkIds.delete(this.id)
if (!keepReroutes && !reroute.linkIds.size) network.reroutes.delete(reroute.id)
}
network.links.delete(this.id)
}
/**
* @deprecated Prefer {@link LLink.asSerialisable} (returns an object, not an array)
* @returns An array representing this LLink
*/
serialize(): SerialisedLLinkArray {
return [
this.id,
this.origin_id,
this.origin_slot,
this.target_id,
this.target_slot,
this.type
]
}
asSerialisable(): SerialisableLLink {
const copy: SerialisableLLink = {
id: this.id,
origin_id: this.origin_id,
origin_slot: this.origin_slot,
target_id: this.target_id,
target_slot: this.target_slot,
type: this.type
}
if (this.parentId) copy.parentId = this.parentId
return copy
} }
if (this.parentId) copy.parentId = this.parentId
return copy
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,61 @@
/** Temporary workaround until downstream consumers migrate to Map. A brittle wrapper with many flaws, but should be fine for simple maps using int indexes. */ /**
* Temporary workaround until downstream consumers migrate to Map.
* A brittle wrapper with many flaws, but should be fine for simple maps using int indexes.
*/
export class MapProxyHandler<V> implements ProxyHandler<Map<number | string, V>> { export class MapProxyHandler<V> implements ProxyHandler<Map<number | string, V>> {
getOwnPropertyDescriptor(target: Map<number | string, V>, p: string | symbol): PropertyDescriptor | undefined { getOwnPropertyDescriptor(
const value = this.get(target, p) target: Map<number | string, V>,
if (value) return { p: string | symbol,
configurable: true, ): PropertyDescriptor | undefined {
enumerable: true, const value = this.get(target, p)
value if (value) return {
} configurable: true,
enumerable: true,
value,
} }
}
has(target: Map<number | string, V>, p: string | symbol): boolean { has(target: Map<number | string, V>, p: string | symbol): boolean {
if (typeof p === "symbol") return false if (typeof p === "symbol") return false
const int = parseInt(p, 10) const int = parseInt(p, 10)
return target.has(!isNaN(int) ? int : p) return target.has(!isNaN(int) ? int : p)
} }
ownKeys(target: Map<number | string, V>): ArrayLike<string | symbol> { ownKeys(target: Map<number | string, V>): ArrayLike<string | symbol> {
return [...target.keys()].map(x => String(x)) return [...target.keys()].map(x => String(x))
} }
get(target: Map<number | string, V>, p: string | symbol): any { get(target: Map<number | string, V>, p: string | symbol): any {
// Workaround does not support link IDs of "values", "entries", "constructor", etc. // Workaround does not support link IDs of "values", "entries", "constructor", etc.
if (p in target) return Reflect.get(target, p, target) if (p in target) return Reflect.get(target, p, target)
if (typeof p === "symbol") return if (typeof p === "symbol") return
const int = parseInt(p, 10) const int = parseInt(p, 10)
return target.get(!isNaN(int) ? int : p) return target.get(!isNaN(int) ? int : p)
} }
set(target: Map<number | string, V>, p: string | symbol, newValue: any): boolean { set(target: Map<number | string, V>, p: string | symbol, newValue: any): boolean {
if (typeof p === "symbol") return false if (typeof p === "symbol") return false
const int = parseInt(p, 10) const int = parseInt(p, 10)
target.set(!isNaN(int) ? int : p, newValue) target.set(!isNaN(int) ? int : p, newValue)
return true return true
} }
deleteProperty(target: Map<number | string, V>, p: string | symbol): boolean { deleteProperty(target: Map<number | string, V>, p: string | symbol): boolean {
return target.delete(p as number | string) return target.delete(p as number | string)
} }
static bindAllMethods(map: Map<any, any>): void { static bindAllMethods(map: Map<any, any>): void {
map.clear = map.clear.bind(map) map.clear = map.clear.bind(map)
map.delete = map.delete.bind(map) map.delete = map.delete.bind(map)
map.forEach = map.forEach.bind(map) map.forEach = map.forEach.bind(map)
map.get = map.get.bind(map) map.get = map.get.bind(map)
map.has = map.has.bind(map) map.has = map.has.bind(map)
map.set = map.set.bind(map) map.set = map.set.bind(map)
map.entries = map.entries.bind(map) map.entries = map.entries.bind(map)
map.keys = map.keys.bind(map) map.keys = map.keys.bind(map)
map.values = map.values.bind(map) map.values = map.values.bind(map)
} }
} }

View File

@@ -1,4 +1,11 @@
import type { CanvasColour, LinkSegment, LinkNetwork, Point, Positionable, ReadOnlyRect } from "./interfaces" import type {
CanvasColour,
LinkSegment,
LinkNetwork,
Point,
Positionable,
ReadOnlyRect,
} from "./interfaces"
import { LLink, type LinkId } from "./LLink" import { LLink, type LinkId } from "./LLink"
import type { SerialisableReroute, Serialisable } from "./types/serialisation" import type { SerialisableReroute, Serialisable } from "./types/serialisation"
import { distance } from "./measure" import { distance } from "./measure"
@@ -8,285 +15,302 @@ export type RerouteId = number
/** /**
* Represents an additional point on the graph that a link path will travel through. Used for visual organisation only. * Represents an additional point on the graph that a link path will travel through. Used for visual organisation only.
* *
* Requires no disposal or clean up. * Requires no disposal or clean up.
* Stores only primitive values (IDs) to reference other items in its network, and a `WeakRef` to a {@link LinkNetwork} to resolve them. * Stores only primitive values (IDs) to reference other items in its network,
* and a `WeakRef` to a {@link LinkNetwork} to resolve them.
*/ */
export class Reroute implements Positionable, LinkSegment, Serialisable<SerialisableReroute> { export class Reroute implements Positionable, LinkSegment, Serialisable<SerialisableReroute> {
static radius: number = 10 static radius: number = 10
#malloc = new Float32Array(8) #malloc = new Float32Array(8)
/** The network this reroute belongs to. Contains all valid links and reroutes. */ /** The network this reroute belongs to. Contains all valid links and reroutes. */
#network: WeakRef<LinkNetwork> #network: WeakRef<LinkNetwork>
#parentId?: RerouteId #parentId?: RerouteId
/** @inheritdoc */ /** @inheritdoc */
public get parentId(): RerouteId { public get parentId(): RerouteId {
return this.#parentId return this.#parentId
}
/** Ignores attempts to create an infinite loop. @inheritdoc */
public set parentId(value: RerouteId) {
if (value === this.id) return
if (this.getReroutes() === null) return
this.#parentId = value
}
#pos = this.#malloc.subarray(0, 2)
/** @inheritdoc */
get pos(): Point {
return this.#pos
}
set pos(value: Point) {
if (!(value?.length >= 2))
throw new TypeError("Reroute.pos is an x,y point, and expects an indexable with at least two values.")
this.#pos[0] = value[0]
this.#pos[1] = value[1]
}
/** @inheritdoc */
get boundingRect(): ReadOnlyRect {
const { radius } = Reroute
const [x, y] = this.#pos
return [x - radius, y - radius, 2 * radius, 2 * radius]
}
/** @inheritdoc */
selected?: boolean
/** The ID ({@link LLink.id}) of every link using this reroute */
linkIds: Set<LinkId>
/** The averaged angle of every link through this reroute. */
otherAngle: number = 0
/** Cached cos */
cos: number = 0
sin: number = 0
/** Bezier curve control point for the "target" (input) side of the link */
controlPoint: Point = this.#malloc.subarray(4, 6)
/** @inheritdoc */
path?: Path2D
/** @inheritdoc */
_centreAngle?: number
/** @inheritdoc */
_pos: Float32Array = this.#malloc.subarray(6, 8)
/** Colour of the first link that rendered this reroute */
_colour?: CanvasColour
/**
* Used to ensure reroute angles are only executed once per frame.
* @todo Calculate on change instead.
*/
#lastRenderTime: number = -Infinity
#buffer: Point = this.#malloc.subarray(2, 4)
/** @inheritdoc */
get origin_id(): NodeId | undefined {
// if (!this.linkIds.size) return this.#network.deref()?.reroutes.get(this.parentId)
return this.#network.deref()
?.links.get(this.linkIds.values().next().value)
?.origin_id
}
/** @inheritdoc */
get origin_slot(): number | undefined {
return this.#network.deref()
?.links.get(this.linkIds.values().next().value)
?.origin_slot
}
/**
* Initialises a new link reroute object.
* @param id Unique identifier for this reroute
* @param network The network of links this reroute belongs to. Internally converted to a WeakRef.
* @param pos Position in graph coordinates
* @param linkIds Link IDs ({@link LLink.id}) of all links that use this reroute
*/
constructor(
public readonly id: RerouteId,
network: LinkNetwork,
pos?: Point,
parentId?: RerouteId,
linkIds?: Iterable<LinkId>,
) {
this.#network = new WeakRef(network)
this.update(parentId, pos, linkIds)
this.linkIds ??= new Set()
}
/**
* Applies a new parentId to the reroute, and optinoally a new position and linkId.
* Primarily used for deserialisation.
* @param parentId The ID of the reroute prior to this reroute, or
* `undefined` if it is the first reroute connected to a nodes output
* @param pos The position of this reroute
* @param linkIds All link IDs that pass through this reroute
*/
update(
parentId: RerouteId | undefined,
pos?: Point,
linkIds?: Iterable<LinkId>,
): void {
this.parentId = parentId
if (pos) this.pos = pos
if (linkIds) this.linkIds = new Set(linkIds)
}
/**
* Validates the linkIds this reroute has. Removes broken links.
* @param links Collection of valid links
* @returns true if any links remain after validation
*/
validateLinks(links: Map<LinkId, LLink>): boolean {
const { linkIds } = this
for (const linkId of linkIds) {
if (!links.get(linkId)) linkIds.delete(linkId)
} }
/** Ignores attempts to create an infinite loop. @inheritdoc */ return linkIds.size > 0
public set parentId(value: RerouteId) { }
if (value === this.id) return
if (this.getReroutes() === null) return /**
this.#parentId = value * Retrieves an ordered array of all reroutes from the node output.
* @param visited Internal. A set of reroutes that this function
* has already visited whilst recursing up the chain.
* @returns An ordered array of all reroutes from the node output to this reroute, inclusive.
* `null` if an infinite loop is detected.
* `undefined` if the reroute chain or {@link LinkNetwork} are invalid.
*/
getReroutes(visited = new Set<Reroute>()): Reroute[] | null | undefined {
// No parentId - last in the chain
if (this.#parentId === undefined) return [this]
// Invalid chain - looped
if (visited.has(this)) return null
visited.add(this)
const parent = this.#network.deref()?.reroutes.get(this.#parentId)
// Invalid parent (or network) - drop silently to recover
if (!parent) {
this.#parentId = undefined
return [this]
} }
#pos = this.#malloc.subarray(0, 2) const reroutes = parent.getReroutes(visited)
/** @inheritdoc */ reroutes?.push(this)
get pos(): Point { return reroutes
return this.#pos }
/**
* Internal. Called by {@link LLink.findNextReroute}. Not intended for use by itself.
* @param withParentId The rerouteId to look for
* @param visited A set of reroutes that have already been visited
* @returns The reroute that was found, `undefined` if no reroute was found, or `null` if an infinite loop was detected.
*/
findNextReroute(
withParentId: RerouteId,
visited = new Set<Reroute>(),
): Reroute | null | undefined {
if (this.#parentId === withParentId) return this
if (visited.has(this)) return null
visited.add(this)
return this.#network
.deref()
?.reroutes.get(this.#parentId)
?.findNextReroute(withParentId, visited)
}
/** @inheritdoc */
move(deltaX: number, deltaY: number) {
this.#pos[0] += deltaX
this.#pos[1] += deltaY
}
/** @inheritdoc */
snapToGrid(snapTo: number): boolean {
if (!snapTo) return false
const { pos } = this
pos[0] = snapTo * Math.round(pos[0] / snapTo)
pos[1] = snapTo * Math.round(pos[1] / snapTo)
return true
}
calculateAngle(lastRenderTime: number, network: LinkNetwork, linkStart: Point): void {
// Ensure we run once per render
if (!(lastRenderTime > this.#lastRenderTime)) return
this.#lastRenderTime = lastRenderTime
const { links } = network
const { linkIds, id } = this
const angles: number[] = []
let sum = 0
for (const linkId of linkIds) {
const link = links.get(linkId)
// Remove the linkId or just ignore?
if (!link) continue
const pos = LLink.findNextReroute(network, link, id)?.pos ??
network.getNodeById(link.target_id)
?.getConnectionPos(true, link.target_slot, this.#buffer)
if (!pos) continue
// TODO: Store points/angles, check if changed, skip calcs.
const angle = Math.atan2(pos[1] - this.#pos[1], pos[0] - this.#pos[0])
angles.push(angle)
sum += angle
} }
set pos(value: Point) { if (!angles.length) return
if (!(value?.length >= 2)) throw new TypeError("Reroute.pos is an x,y point, and expects an indexable with at least two values.")
this.#pos[0] = value[0] sum /= angles.length
this.#pos[1] = value[1]
const originToReroute = Math.atan2(
this.#pos[1] - linkStart[1],
this.#pos[0] - linkStart[0],
)
let diff = (originToReroute - sum) * 0.5
if (Math.abs(diff) > Math.PI * 0.5) diff += Math.PI
const dist = Math.min(80, distance(linkStart, this.#pos) * 0.25)
// Store results
const originDiff = originToReroute - diff
const cos = Math.cos(originDiff)
const sin = Math.sin(originDiff)
this.otherAngle = originDiff
this.cos = cos
this.sin = sin
this.controlPoint[0] = dist * -cos
this.controlPoint[1] = dist * -sin
return
}
/**
* Renders the reroute on the canvas.
* @param ctx Canvas context to draw on
* @param colour Reroute colour (typically link colour)
*
* @remarks Leaves {@link ctx}.fillStyle, strokeStyle, and lineWidth dirty (perf.).
*/
draw(ctx: CanvasRenderingContext2D): void {
const { pos } = this
ctx.fillStyle = this._colour
ctx.beginPath()
ctx.arc(pos[0], pos[1], Reroute.radius, 0, 2 * Math.PI)
ctx.fill()
ctx.lineWidth = 1
ctx.strokeStyle = "rgb(0,0,0,0.5)"
ctx.stroke()
ctx.fillStyle = "#ffffff55"
ctx.strokeStyle = "rgb(0,0,0,0.3)"
ctx.beginPath()
ctx.arc(pos[0], pos[1], 8, 0, 2 * Math.PI)
ctx.fill()
ctx.stroke()
if (this.selected) {
ctx.strokeStyle = "#fff"
ctx.beginPath()
ctx.arc(pos[0], pos[1], 12, 0, 2 * Math.PI)
ctx.stroke()
} }
}
/** @inheritdoc */ /** @inheritdoc */
get boundingRect(): ReadOnlyRect { asSerialisable(): SerialisableReroute {
const { radius } = Reroute return {
const [x, y] = this.#pos id: this.id,
return [x - radius, y - radius, 2 * radius, 2 * radius] parentId: this.parentId,
} pos: [this.pos[0], this.pos[1]],
linkIds: [...this.linkIds],
/** @inheritdoc */
selected?: boolean
/** The ID ({@link LLink.id}) of every link using this reroute */
linkIds: Set<LinkId>
/** The averaged angle of every link through this reroute. */
otherAngle: number = 0
/** Cached cos */
cos: number = 0
sin: number = 0
/** Bezier curve control point for the "target" (input) side of the link */
controlPoint: Point = this.#malloc.subarray(4, 6)
/** @inheritdoc */
path?: Path2D
/** @inheritdoc */
_centreAngle?: number
/** @inheritdoc */
_pos: Float32Array = this.#malloc.subarray(6, 8)
/** Colour of the first link that rendered this reroute */
_colour?: CanvasColour
/**
* Used to ensure reroute angles are only executed once per frame.
* @todo Calculate on change instead.
*/
#lastRenderTime: number = -Infinity
#buffer: Point = this.#malloc.subarray(2, 4)
/** @inheritdoc */
get origin_id(): NodeId | undefined {
// if (!this.linkIds.size) return this.#network.deref()?.reroutes.get(this.parentId)
return this.#network.deref()
?.links.get(this.linkIds.values().next().value)
?.origin_id
}
/** @inheritdoc */
get origin_slot(): number | undefined {
return this.#network.deref()
?.links.get(this.linkIds.values().next().value)
?.origin_slot
}
/**
* Initialises a new link reroute object.
* @param id Unique identifier for this reroute
* @param network The network of links this reroute belongs to. Internally converted to a WeakRef.
* @param pos Position in graph coordinates
* @param linkIds Link IDs ({@link LLink.id}) of all links that use this reroute
*/
constructor(
public readonly id: RerouteId,
network: LinkNetwork,
pos?: Point,
parentId?: RerouteId,
linkIds?: Iterable<LinkId>
) {
this.#network = new WeakRef(network)
this.update(parentId, pos, linkIds)
this.linkIds ??= new Set()
}
/**
* Applies a new parentId to the reroute, and optinoally a new position and linkId.
* Primarily used for deserialisation.
* @param parentId The ID of the reroute prior to this reroute, or `undefined` if it is the first reroute connected to a nodes output
* @param pos The position of this reroute
* @param linkIds All link IDs that pass through this reroute
*/
update(parentId: RerouteId | undefined, pos?: Point, linkIds?: Iterable<LinkId>): void {
this.parentId = parentId
if (pos) this.pos = pos
if (linkIds) this.linkIds = new Set(linkIds)
}
/**
* Validates the linkIds this reroute has. Removes broken links.
* @param links Collection of valid links
* @returns true if any links remain after validation
*/
validateLinks(links: Map<LinkId, LLink>): boolean {
const { linkIds } = this
for (const linkId of linkIds) {
if (!links.get(linkId)) linkIds.delete(linkId)
}
return linkIds.size > 0
}
/**
* Retrieves an ordered array of all reroutes from the node output.
* @param visited Internal. A set of reroutes that this function has already visited whilst recursing up the chain.
* @returns An ordered array of all reroutes from the node output to this reroute, inclusive.
* `null` if an infinite loop is detected.
* `undefined` if the reroute chain or {@link LinkNetwork} are invalid.
*/
getReroutes(visited = new Set<Reroute>()): Reroute[] | null | undefined {
// No parentId - last in the chain
if (this.#parentId === undefined) return [this]
// Invalid chain - looped
if (visited.has(this)) return null
visited.add(this)
const parent = this.#network.deref()?.reroutes.get(this.#parentId)
// Invalid parent (or network) - drop silently to recover
if (!parent) {
this.#parentId = undefined
return [this]
}
const reroutes = parent.getReroutes(visited)
reroutes?.push(this)
return reroutes
}
/**
* Internal. Called by {@link LLink.findNextReroute}. Not intended for use by itself.
* @param withParentId The rerouteId to look for
* @param visited A set of reroutes that have already been visited
* @returns The reroute that was found, `undefined` if no reroute was found, or `null` if an infinite loop was detected.
*/
findNextReroute(withParentId: RerouteId, visited = new Set<Reroute>()): Reroute | null | undefined {
if (this.#parentId === withParentId) return this
if (visited.has(this)) return null
visited.add(this)
return this.#network.deref()
?.reroutes.get(this.#parentId)
?.findNextReroute(withParentId, visited)
}
/** @inheritdoc */
move(deltaX: number, deltaY: number) {
this.#pos[0] += deltaX
this.#pos[1] += deltaY
}
/** @inheritdoc */
snapToGrid(snapTo: number): boolean {
if (!snapTo) return false
const { pos } = this
pos[0] = snapTo * Math.round(pos[0] / snapTo)
pos[1] = snapTo * Math.round(pos[1] / snapTo)
return true
}
calculateAngle(lastRenderTime: number, network: LinkNetwork, linkStart: Point): void {
// Ensure we run once per render
if (!(lastRenderTime > this.#lastRenderTime)) return
this.#lastRenderTime = lastRenderTime
const { links } = network
const { linkIds, id } = this
const angles: number[] = []
let sum = 0
for (const linkId of linkIds) {
const link = links.get(linkId)
// Remove the linkId or just ignore?
if (!link) continue
const pos = LLink.findNextReroute(network, link, id)?.pos ??
network.getNodeById(link.target_id)
?.getConnectionPos(true, link.target_slot, this.#buffer)
if (!pos) continue
// TODO: Store points/angles, check if changed, skip calcs.
const angle = Math.atan2(pos[1] - this.#pos[1], pos[0] - this.#pos[0])
angles.push(angle)
sum += angle
}
if (!angles.length) return
sum /= angles.length
const originToReroute = Math.atan2(this.#pos[1] - linkStart[1], this.#pos[0] - linkStart[0])
let diff = (originToReroute - sum) * 0.5
if (Math.abs(diff) > Math.PI * 0.5) diff += Math.PI
const dist = Math.min(80, distance(linkStart, this.#pos) * 0.25)
// Store results
const originDiff = originToReroute - diff
const cos = Math.cos(originDiff)
const sin = Math.sin(originDiff)
this.otherAngle = originDiff
this.cos = cos
this.sin = sin
this.controlPoint[0] = dist * -cos
this.controlPoint[1] = dist * -sin
return
}
/**
* Renders the reroute on the canvas.
* @param ctx Canvas context to draw on
* @param colour Reroute colour (typically link colour)
*
* @remarks Leaves {@link ctx}.fillStyle, strokeStyle, and lineWidth dirty (perf.).
*/
draw(ctx: CanvasRenderingContext2D): void {
const { pos } = this
ctx.fillStyle = this._colour
ctx.beginPath()
ctx.arc(pos[0], pos[1], Reroute.radius, 0, 2 * Math.PI)
ctx.fill()
ctx.lineWidth = 1
ctx.strokeStyle = "rgb(0,0,0,0.5)"
ctx.stroke()
ctx.fillStyle = "#ffffff55"
ctx.strokeStyle = "rgb(0,0,0,0.3)"
ctx.beginPath()
ctx.arc(pos[0], pos[1], 8, 0, 2 * Math.PI)
ctx.fill()
ctx.stroke()
if (this.selected) {
ctx.strokeStyle = "#fff"
ctx.beginPath()
ctx.arc(pos[0], pos[1], 12, 0, 2 * Math.PI)
ctx.stroke()
}
}
/** @inheritdoc */
asSerialisable(): SerialisableReroute {
return {
id: this.id,
parentId: this.parentId,
pos: [this.pos[0], this.pos[1]],
linkIds: [...this.linkIds]
}
} }
}
} }

View File

@@ -1,4 +1,4 @@
import type { Vector2 } from "./litegraph"; import type { Vector2 } from "./litegraph"
import type { INodeSlot } from "./interfaces" import type { INodeSlot } from "./interfaces"
import { LinkDirection, RenderShape } from "./types/globalEnums" import { LinkDirection, RenderShape } from "./types/globalEnums"
@@ -42,44 +42,44 @@ export function drawSlot(
do_stroke = false, do_stroke = false,
highlight = false, highlight = false,
}: { }: {
label_color?: string; label_color?: string
label_position?: LabelPosition; label_position?: LabelPosition
horizontal?: boolean; horizontal?: boolean
low_quality?: boolean; low_quality?: boolean
render_text?: boolean; render_text?: boolean
do_stroke?: boolean; do_stroke?: boolean
highlight?: boolean; highlight?: boolean
} = {} } = {},
) { ) {
// Save the current fillStyle and strokeStyle // Save the current fillStyle and strokeStyle
const originalFillStyle = ctx.fillStyle; const originalFillStyle = ctx.fillStyle
const originalStrokeStyle = ctx.strokeStyle; const originalStrokeStyle = ctx.strokeStyle
const originalLineWidth = ctx.lineWidth; const originalLineWidth = ctx.lineWidth
const slot_type = slot.type as SlotType; const slot_type = slot.type as SlotType
const slot_shape = ( const slot_shape = (
slot_type === SlotType.Array ? SlotShape.Grid : slot.shape slot_type === SlotType.Array ? SlotShape.Grid : slot.shape
) as SlotShape; ) as SlotShape
ctx.beginPath(); ctx.beginPath()
let doStroke = do_stroke; let doStroke = do_stroke
let doFill = true; let doFill = true
if (slot_type === SlotType.Event || slot_shape === SlotShape.Box) { if (slot_type === SlotType.Event || slot_shape === SlotShape.Box) {
if (horizontal) { if (horizontal) {
ctx.rect(pos[0] - 5 + 0.5, pos[1] - 8 + 0.5, 10, 14); ctx.rect(pos[0] - 5 + 0.5, pos[1] - 8 + 0.5, 10, 14)
} else { } else {
ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10); ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10)
} }
} else if (slot_shape === SlotShape.Arrow) { } else if (slot_shape === SlotShape.Arrow) {
ctx.moveTo(pos[0] + 8, pos[1] + 0.5); ctx.moveTo(pos[0] + 8, pos[1] + 0.5)
ctx.lineTo(pos[0] - 4, pos[1] + 6 + 0.5); ctx.lineTo(pos[0] - 4, pos[1] + 6 + 0.5)
ctx.lineTo(pos[0] - 4, pos[1] - 6 + 0.5); ctx.lineTo(pos[0] - 4, pos[1] - 6 + 0.5)
ctx.closePath(); ctx.closePath()
} else if (slot_shape === SlotShape.Grid) { } else if (slot_shape === SlotShape.Grid) {
const gridSize = 3; const gridSize = 3
const cellSize = 2; const cellSize = 2
const spacing = 3; const spacing = 3
for (let x = 0; x < gridSize; x++) { for (let x = 0; x < gridSize; x++) {
for (let y = 0; y < gridSize; y++) { for (let y = 0; y < gridSize; y++) {
@@ -87,59 +87,59 @@ export function drawSlot(
pos[0] - 4 + x * spacing, pos[0] - 4 + x * spacing,
pos[1] - 4 + y * spacing, pos[1] - 4 + y * spacing,
cellSize, cellSize,
cellSize cellSize,
); )
} }
} }
doStroke = false; doStroke = false
} else { } else {
// Default rendering for circle, hollow circle. // Default rendering for circle, hollow circle.
if (low_quality) { if (low_quality) {
ctx.rect(pos[0] - 4, pos[1] - 4, 8, 8); ctx.rect(pos[0] - 4, pos[1] - 4, 8, 8)
} else { } else {
let radius: number; let radius: number
if (slot_shape === SlotShape.HollowCircle) { if (slot_shape === SlotShape.HollowCircle) {
doFill = false; doFill = false
doStroke = true; doStroke = true
ctx.lineWidth = 3; ctx.lineWidth = 3
ctx.strokeStyle = ctx.fillStyle; ctx.strokeStyle = ctx.fillStyle
radius = highlight ? 4 : 3; radius = highlight ? 4 : 3
} else { } else {
// Normal circle // Normal circle
radius = highlight ? 5 : 4; radius = highlight ? 5 : 4
} }
ctx.arc(pos[0], pos[1], radius, 0, Math.PI * 2); ctx.arc(pos[0], pos[1], radius, 0, Math.PI * 2)
} }
} }
if (doFill) ctx.fill(); if (doFill) ctx.fill()
if (!low_quality && doStroke) ctx.stroke(); if (!low_quality && doStroke) ctx.stroke()
// render slot label // render slot label
if (render_text) { if (render_text) {
const text = slot.label != null ? slot.label : slot.name; const text = slot.label != null ? slot.label : slot.name
if (text) { if (text) {
// TODO: Finish impl. Highlight text on mouseover unless we're connecting links. // TODO: Finish impl. Highlight text on mouseover unless we're connecting links.
ctx.fillStyle = label_color; ctx.fillStyle = label_color
if (label_position === LabelPosition.Right) { if (label_position === LabelPosition.Right) {
if (horizontal || slot.dir == LinkDirection.UP) { if (horizontal || slot.dir == LinkDirection.UP) {
ctx.fillText(text, pos[0], pos[1] - 10); ctx.fillText(text, pos[0], pos[1] - 10)
} else { } else {
ctx.fillText(text, pos[0] + 10, pos[1] + 5); ctx.fillText(text, pos[0] + 10, pos[1] + 5)
} }
} else { } else {
if (horizontal || slot.dir == LinkDirection.DOWN) { if (horizontal || slot.dir == LinkDirection.DOWN) {
ctx.fillText(text, pos[0], pos[1] - 8); ctx.fillText(text, pos[0], pos[1] - 8)
} else { } else {
ctx.fillText(text, pos[0] - 10, pos[1] + 5); ctx.fillText(text, pos[0] - 10, pos[1] + 5)
} }
} }
} }
} }
// Restore the original fillStyle and strokeStyle // Restore the original fillStyle and strokeStyle
ctx.fillStyle = originalFillStyle; ctx.fillStyle = originalFillStyle
ctx.strokeStyle = originalStrokeStyle; ctx.strokeStyle = originalStrokeStyle
ctx.lineWidth = originalLineWidth; ctx.lineWidth = originalLineWidth
} }

View File

@@ -8,15 +8,15 @@ export type Dictionary<T> = { [key: string]: T }
/** Allows all properties to be null. The same as `Partial<T>`, but adds null instead of undefined. */ /** Allows all properties to be null. The same as `Partial<T>`, but adds null instead of undefined. */
export type NullableProperties<T> = { export type NullableProperties<T> = {
[P in keyof T]: T[P] | null [P in keyof T]: T[P] | null
} }
export type CanvasColour = string | CanvasGradient | CanvasPattern export type CanvasColour = string | CanvasGradient | CanvasPattern
/** An object containing a set of child objects */ /** An object containing a set of child objects */
export interface Parent<TChild> { export interface Parent<TChild> {
/** All objects owned by the parent object. */ /** All objects owned by the parent object. */
readonly children?: ReadonlySet<TChild> readonly children?: ReadonlySet<TChild>
} }
/** /**
@@ -25,42 +25,42 @@ export interface Parent<TChild> {
* May contain other {@link Positionable} objects. * May contain other {@link Positionable} objects.
*/ */
export interface Positionable extends Parent<Positionable> { export interface Positionable extends Parent<Positionable> {
id: NodeId | RerouteId | number id: NodeId | RerouteId | number
/** Position in graph coordinates. Default: 0,0 */ /** Position in graph coordinates. Default: 0,0 */
pos: Point pos: Point
/** true if this object is part of the selection, otherwise false. */ /** true if this object is part of the selection, otherwise false. */
selected?: boolean selected?: boolean
/** See {@link IPinnable.pinned} */ /** See {@link IPinnable.pinned} */
readonly pinned?: boolean readonly pinned?: boolean
/** /**
* Adds a delta to the current position. * Adds a delta to the current position.
* @param deltaX X value to add to current position * @param deltaX X value to add to current position
* @param deltaY Y value to add to current position * @param deltaY Y value to add to current position
* @param skipChildren If true, any child objects like group contents will not be moved * @param skipChildren If true, any child objects like group contents will not be moved
*/ */
move(deltaX: number, deltaY: number, skipChildren?: boolean): void move(deltaX: number, deltaY: number, skipChildren?: boolean): void
/** /**
* Snaps this item to a grid. * Snaps this item to a grid.
* *
* Position values are rounded to the nearest multiple of {@link snapTo}. * Position values are rounded to the nearest multiple of {@link snapTo}.
* @param snapTo The size of the grid to align to * @param snapTo The size of the grid to align to
* @returns `true` if it moved, or `false` if the snap was rejected (e.g. `pinned`) * @returns `true` if it moved, or `false` if the snap was rejected (e.g. `pinned`)
*/ */
snapToGrid(snapTo: number): boolean snapToGrid(snapTo: number): boolean
/**
* Cached position & size as `x, y, width, height`.
* @readonly See {@link move}
*/
readonly boundingRect: ReadOnlyRect
/** Called whenever the item is selected */ /**
onSelected?(): void * Cached position & size as `x, y, width, height`.
/** Called whenever the item is deselected */ * @readonly See {@link move}
onDeselected?(): void */
readonly boundingRect: ReadOnlyRect
/** Called whenever the item is selected */
onSelected?(): void
/** Called whenever the item is deselected */
onDeselected?(): void
} }
/** /**
@@ -69,52 +69,55 @@ export interface Positionable extends Parent<Positionable> {
* Prevents the object being accidentally moved or resized by mouse interaction. * Prevents the object being accidentally moved or resized by mouse interaction.
*/ */
export interface IPinnable { export interface IPinnable {
pinned: boolean pinned: boolean
pin(value?: boolean): void pin(value?: boolean): void
unpin(): void unpin(): void
} }
/** /**
* Contains a list of links, reroutes, and nodes. * Contains a list of links, reroutes, and nodes.
*/ */
export interface LinkNetwork { export interface LinkNetwork {
links: Map<LinkId, LLink> links: Map<LinkId, LLink>
reroutes: Map<RerouteId, Reroute> reroutes: Map<RerouteId, Reroute>
getNodeById(id: NodeId): LGraphNode | null getNodeById(id: NodeId): LGraphNode | null
} }
/** Contains a cached 2D canvas path and a centre point, with an optional forward angle. */ /** Contains a cached 2D canvas path and a centre point, with an optional forward angle. */
export interface LinkSegment { export interface LinkSegment {
/** Link / reroute ID */ /** Link / reroute ID */
readonly id: LinkId | RerouteId readonly id: LinkId | RerouteId
/** The {@link id} of the reroute that this segment starts from (output side), otherwise `undefined`. */ /** The {@link id} of the reroute that this segment starts from (output side), otherwise `undefined`. */
readonly parentId?: RerouteId readonly parentId?: RerouteId
/** The last canvas 2D path that was used to render this segment */ /** The last canvas 2D path that was used to render this segment */
path?: Path2D path?: Path2D
/** Centre point of the {@link path}. Calculated during render only - can be inaccurate */ /** Centre point of the {@link path}. Calculated during render only - can be inaccurate */
readonly _pos: Float32Array readonly _pos: Float32Array
/** Y-forward along the {@link path} from its centre point, in radians. `undefined` if using circles for link centres. Calculated during render only - can be inaccurate. */ /** Y-forward along the {@link path} from its centre point, in radians.
_centreAngle?: number * `undefined` if using circles for link centres.
* Calculated during render only - can be inaccurate.
*/
_centreAngle?: number
/** Output node ID */ /** Output node ID */
readonly origin_id: NodeId readonly origin_id: NodeId
/** Output slot index */ /** Output slot index */
readonly origin_slot: number readonly origin_slot: number
} }
export interface IInputOrOutput { export interface IInputOrOutput {
// If an input, this will be defined // If an input, this will be defined
input?: INodeInputSlot input?: INodeInputSlot
// If an output, this will be defined // If an output, this will be defined
output?: INodeOutputSlot output?: INodeOutputSlot
} }
export interface IFoundSlot extends IInputOrOutput { export interface IFoundSlot extends IInputOrOutput {
// Slot index // Slot index
slot: number slot: number
// Centre point of the rendered slot connection // Centre point of the rendered slot connection
link_pos: Point link_pos: Point
} }
/** A point represented as `[x, y]` co-ordinates */ /** A point represented as `[x, y]` co-ordinates */
@@ -133,13 +136,31 @@ export type Rect = ArRect | Float32Array | Float64Array
export type Rect32 = Float32Array export type Rect32 = Float32Array
/** A point represented as `[x, y]` co-ordinates that will not be modified */ /** A point represented as `[x, y]` co-ordinates that will not be modified */
export type ReadOnlyPoint = readonly [x: number, y: number] | ReadOnlyTypedArray<Float32Array> | ReadOnlyTypedArray<Float64Array> export type ReadOnlyPoint =
/** A rectangle starting at top-left coordinates `[x, y, width, height]` that will not be modified */ | readonly [x: number, y: number]
export type ReadOnlyRect = readonly [x: number, y: number, width: number, height: number] | ReadOnlyTypedArray<Float32Array> | ReadOnlyTypedArray<Float64Array> | ReadOnlyTypedArray<Float32Array>
| ReadOnlyTypedArray<Float64Array>
/** A rectangle starting at top-left coordinates `[x, y, width, height]` that will not be modified */
export type ReadOnlyRect =
| readonly [x: number, y: number, width: number, height: number]
| ReadOnlyTypedArray<Float32Array>
| ReadOnlyTypedArray<Float64Array>
type TypedArrays =
| Int8Array
| Uint8Array
| Uint8ClampedArray
| Int16Array
| Uint16Array
| Int32Array
| Uint32Array
| Float32Array
| Float64Array
type TypedArrays = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array
type TypedBigIntArrays = BigInt64Array | BigUint64Array type TypedBigIntArrays = BigInt64Array | BigUint64Array
type ReadOnlyTypedArray<T extends TypedArrays | TypedBigIntArrays> = Omit<T, "fill" | "copyWithin" | "reverse" | "set" | "sort" | "subarray"> type ReadOnlyTypedArray<T extends TypedArrays | TypedBigIntArrays> =
Omit<T, "fill" | "copyWithin" | "reverse" | "set" | "sort" | "subarray">
/** Union of property names that are of type Match */ /** Union of property names that are of type Match */
export type KeysOfType<T, Match> = { [P in keyof T]: T[P] extends Match ? P : never }[keyof T] export type KeysOfType<T, Match> = { [P in keyof T]: T[P] extends Match ? P : never }[keyof T]
@@ -151,99 +172,105 @@ export type PickByType<T, Match> = { [P in keyof T]: Extract<T[P], Match> }
export type MethodNames<T> = KeysOfType<T, ((...args: any) => any) | undefined> export type MethodNames<T> = KeysOfType<T, ((...args: any) => any) | undefined>
export interface IBoundaryNodes { export interface IBoundaryNodes {
top: LGraphNode top: LGraphNode
right: LGraphNode right: LGraphNode
bottom: LGraphNode bottom: LGraphNode
left: LGraphNode left: LGraphNode
} }
export type Direction = "top" | "bottom" | "left" | "right" export type Direction = "top" | "bottom" | "left" | "right"
export interface IOptionalSlotData<TSlot extends INodeInputSlot | INodeOutputSlot> { export interface IOptionalSlotData<TSlot extends INodeInputSlot | INodeOutputSlot> {
content: string content: string
value: TSlot value: TSlot
className?: string className?: string
} }
export type ISlotType = number | string export type ISlotType = number | string
export interface INodeSlot { export interface INodeSlot {
name: string name: string
type: ISlotType type: ISlotType
dir?: LinkDirection dir?: LinkDirection
removable?: boolean removable?: boolean
shape?: RenderShape shape?: RenderShape
not_subgraph_input?: boolean not_subgraph_input?: boolean
color_off?: CanvasColour color_off?: CanvasColour
color_on?: CanvasColour color_on?: CanvasColour
label?: string label?: string
locked?: boolean locked?: boolean
nameLocked?: boolean nameLocked?: boolean
pos?: Point pos?: Point
widget?: unknown widget?: unknown
} }
export interface INodeFlags { export interface INodeFlags {
skip_repeated_outputs?: boolean skip_repeated_outputs?: boolean
allow_interaction?: boolean allow_interaction?: boolean
pinned?: boolean pinned?: boolean
collapsed?: boolean collapsed?: boolean
/** Configuration setting for {@link LGraphNode.connectInputToOutput} */ /** Configuration setting for {@link LGraphNode.connectInputToOutput} */
keepAllLinksOnBypass?: boolean keepAllLinksOnBypass?: boolean
} }
export interface INodeInputSlot extends INodeSlot { export interface INodeInputSlot extends INodeSlot {
link: LinkId | null link: LinkId | null
not_subgraph_input?: boolean not_subgraph_input?: boolean
} }
export interface INodeOutputSlot extends INodeSlot { export interface INodeOutputSlot extends INodeSlot {
links: LinkId[] | null links: LinkId[] | null
_data?: unknown _data?: unknown
slot_index?: number slot_index?: number
not_subgraph_output?: boolean not_subgraph_output?: boolean
} }
/** Links */ /** Links */
export interface ConnectingLink extends IInputOrOutput { export interface ConnectingLink extends IInputOrOutput {
node: LGraphNode node: LGraphNode
slot: number slot: number
pos: Point pos: Point
direction?: LinkDirection direction?: LinkDirection
afterRerouteId?: RerouteId afterRerouteId?: RerouteId
} }
interface IContextMenuBase { interface IContextMenuBase {
title?: string title?: string
className?: string className?: string
callback?(value?: unknown, options?: unknown, event?: MouseEvent, previous_menu?: ContextMenu, node?: LGraphNode): void | boolean callback?(
value?: unknown,
options?: unknown,
event?: MouseEvent,
previous_menu?: ContextMenu,
node?: LGraphNode,
): void | boolean
} }
/** ContextMenu */ /** ContextMenu */
export interface IContextMenuOptions extends IContextMenuBase { export interface IContextMenuOptions extends IContextMenuBase {
ignore_item_callbacks?: boolean ignore_item_callbacks?: boolean
parentMenu?: ContextMenu parentMenu?: ContextMenu
event?: MouseEvent event?: MouseEvent
extra?: unknown extra?: unknown
scroll_speed?: number scroll_speed?: number
left?: number left?: number
top?: number top?: number
scale?: number scale?: number
node?: LGraphNode node?: LGraphNode
autoopen?: boolean autoopen?: boolean
} }
export interface IContextMenuValue extends IContextMenuBase { export interface IContextMenuValue extends IContextMenuBase {
value?: string value?: string
content: string content: string
has_submenu?: boolean has_submenu?: boolean
disabled?: boolean disabled?: boolean
submenu?: IContextMenuSubmenu submenu?: IContextMenuSubmenu
property?: string property?: string
type?: string type?: string
slot?: IFoundSlot slot?: IFoundSlot
} }
export interface IContextMenuSubmenu extends IContextMenuOptions { export interface IContextMenuSubmenu extends IContextMenuOptions {
options: ConstructorParameters<typeof ContextMenu>[0] options: ConstructorParameters<typeof ContextMenu>[0]
} }

View File

@@ -1,5 +1,25 @@
import type { Point, ConnectingLink } from "./interfaces" import type { Point, ConnectingLink } from "./interfaces"
import type { INodeSlot, INodeInputSlot, INodeOutputSlot, CanvasColour, Direction, IBoundaryNodes, IContextMenuOptions, IContextMenuValue, IFoundSlot, IInputOrOutput, INodeFlags, IOptionalSlotData, ISlotType, KeysOfType, MethodNames, PickByType, Rect, Rect32, Size } from "./interfaces" import type {
INodeSlot,
INodeInputSlot,
INodeOutputSlot,
CanvasColour,
Direction,
IBoundaryNodes,
IContextMenuOptions,
IContextMenuValue,
IFoundSlot,
IInputOrOutput,
INodeFlags,
IOptionalSlotData,
ISlotType,
KeysOfType,
MethodNames,
PickByType,
Rect,
Rect32,
Size,
} from "./interfaces"
import type { SlotShape, LabelPosition, SlotDirection, SlotType } from "./draw" import type { SlotShape, LabelPosition, SlotDirection, SlotType } from "./draw"
import type { IWidget } from "./types/widgets" import type { IWidget } from "./types/widgets"
import type { RenderShape, TitleMode } from "./types/globalEnums" import type { RenderShape, TitleMode } from "./types/globalEnums"
@@ -18,19 +38,53 @@ import { CurveEditor } from "./CurveEditor"
import { LGraphBadge, BadgePosition } from "./LGraphBadge" import { LGraphBadge, BadgePosition } from "./LGraphBadge"
export const LiteGraph = new LiteGraphGlobal() export const LiteGraph = new LiteGraphGlobal()
export { LGraph, LGraphCanvas, LGraphCanvasState, DragAndScale, LGraphNode, LGraphGroup, LLink, ContextMenu, CurveEditor } export {
export { INodeSlot, INodeInputSlot, INodeOutputSlot, ConnectingLink, CanvasColour, Direction, IBoundaryNodes, IContextMenuOptions, IContextMenuValue, IFoundSlot, IInputOrOutput, INodeFlags, IOptionalSlotData, ISlotType, KeysOfType, MethodNames, PickByType, Rect, Rect32, Size } LGraph,
LGraphCanvas,
LGraphCanvasState,
DragAndScale,
LGraphNode,
LGraphGroup,
LLink,
ContextMenu,
CurveEditor,
}
export {
INodeSlot,
INodeInputSlot,
INodeOutputSlot,
ConnectingLink,
CanvasColour,
Direction,
IBoundaryNodes,
IContextMenuOptions,
IContextMenuValue,
IFoundSlot,
IInputOrOutput,
INodeFlags,
IOptionalSlotData,
ISlotType,
KeysOfType,
MethodNames,
PickByType,
Rect,
Rect32,
Size,
}
export { IWidget } export { IWidget }
export { LGraphBadge, BadgePosition } export { LGraphBadge, BadgePosition }
export { SlotShape, LabelPosition, SlotDirection, SlotType } export { SlotShape, LabelPosition, SlotDirection, SlotType }
export { EaseFunction, LinkMarkerShape } from "./types/globalEnums" export { EaseFunction, LinkMarkerShape } from "./types/globalEnums"
export type { SerialisableGraph, SerialisableLLink } from "./types/serialisation" export type {
SerialisableGraph,
SerialisableLLink,
} from "./types/serialisation"
export { CanvasPointer } from "./CanvasPointer" export { CanvasPointer } from "./CanvasPointer"
export { createBounds } from "./measure" export { createBounds } from "./measure"
export function clamp(v: number, a: number, b: number): number { export function clamp(v: number, a: number, b: number): number {
return a > v ? a : b < v ? b : v return a > v ? a : b < v ? b : v
}; }
// Load legacy polyfills // Load legacy polyfills
loadPolyfills() loadPolyfills()
@@ -44,68 +98,72 @@ export type Vector2 = Point
export type Vector4 = [number, number, number, number] export type Vector4 = [number, number, number, number]
export interface IContextMenuItem { export interface IContextMenuItem {
content: string content: string
callback?: ContextMenuEventListener callback?: ContextMenuEventListener
/** Used as innerHTML for extra child element */ /** Used as innerHTML for extra child element */
title?: string title?: string
disabled?: boolean disabled?: boolean
has_submenu?: boolean has_submenu?: boolean
submenu?: { submenu?: {
options: IContextMenuItem[] options: IContextMenuItem[]
} & IContextMenuOptions } & IContextMenuOptions
className?: string className?: string
} }
export type ContextMenuEventListener = ( export type ContextMenuEventListener = (
value: IContextMenuItem, value: IContextMenuItem,
options: IContextMenuOptions, options: IContextMenuOptions,
event: MouseEvent, event: MouseEvent,
parentMenu: ContextMenu | undefined, parentMenu: ContextMenu | undefined,
node: LGraphNode node: LGraphNode,
) => boolean | void ) => boolean | void
export interface LinkReleaseContext { export interface LinkReleaseContext {
node_to?: LGraphNode node_to?: LGraphNode
node_from?: LGraphNode node_from?: LGraphNode
slot_from: INodeSlot slot_from: INodeSlot
type_filter_in?: string type_filter_in?: string
type_filter_out?: string type_filter_out?: string
} }
export interface LinkReleaseContextExtended { export interface LinkReleaseContextExtended {
links: ConnectingLink[] links: ConnectingLink[]
} }
/** @deprecated Confirm no downstream consumers, then remove. */ /** @deprecated Confirm no downstream consumers, then remove. */
export type LiteGraphCanvasEventType = "empty-release" | "empty-double-click" | "group-double-click" export type LiteGraphCanvasEventType =
| "empty-release"
| "empty-double-click"
| "group-double-click"
export interface LiteGraphCanvasEvent extends CustomEvent<CanvasEventDetail> { } export interface LiteGraphCanvasEvent extends CustomEvent<CanvasEventDetail> {}
export interface LiteGraphCanvasGroupEvent extends CustomEvent<{ export interface LiteGraphCanvasGroupEvent
extends CustomEvent<{
subType: "group-double-click" subType: "group-double-click"
originalEvent: MouseEvent originalEvent: MouseEvent
group: LGraphGroup group: LGraphGroup
}> { } }> {}
/** https://github.com/jagenjo/litegraph.js/blob/master/guides/README.md#lgraphnode */ /** https://github.com/jagenjo/litegraph.js/blob/master/guides/README.md#lgraphnode */
export interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> { export interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {
title?: string title?: string
type?: string type?: string
size?: Size size?: Size
min_height?: number min_height?: number
slot_start_y?: number slot_start_y?: number
widgets_info?: any widgets_info?: any
collapsable?: boolean collapsable?: boolean
color?: string color?: string
bgcolor?: string bgcolor?: string
shape?: RenderShape shape?: RenderShape
title_mode?: TitleMode title_mode?: TitleMode
title_color?: string title_color?: string
title_text_color?: string title_text_color?: string
keepAllLinksOnBypass: boolean keepAllLinksOnBypass: boolean
nodeData: any nodeData: any
new(): T new (): T
} }
// End backwards compat // End backwards compat

View File

@@ -1,4 +1,10 @@
import type { Point, Positionable, ReadOnlyPoint, ReadOnlyRect, Rect } from "./interfaces" import type {
Point,
Positionable,
ReadOnlyPoint,
ReadOnlyRect,
Rect,
} from "./interfaces"
import { LinkDirection } from "./types/globalEnums" import { LinkDirection } from "./types/globalEnums"
/** /**
@@ -8,9 +14,9 @@ import { LinkDirection } from "./types/globalEnums"
* @returns Distance between point {@link a} & {@link b} * @returns Distance between point {@link a} & {@link b}
*/ */
export function distance(a: ReadOnlyPoint, b: ReadOnlyPoint): number { export function distance(a: ReadOnlyPoint, b: ReadOnlyPoint): number {
return Math.sqrt( return Math.sqrt(
(b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1]) (b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1]),
) )
} }
/** /**
@@ -23,12 +29,12 @@ export function distance(a: ReadOnlyPoint, b: ReadOnlyPoint): number {
* @returns Distance2 (squared) between point [{@link x1}, {@link y1}] & [{@link x2}, {@link y2}] * @returns Distance2 (squared) between point [{@link x1}, {@link y1}] & [{@link x2}, {@link y2}]
*/ */
export function dist2(x1: number, y1: number, x2: number, y2: number): number { export function dist2(x1: number, y1: number, x2: number, y2: number): number {
return ((x2 - x1) * (x2 - x1)) + ((y2 - y1) * (y2 - y1)) return ((x2 - x1) * (x2 - x1)) + ((y2 - y1) * (y2 - y1))
} }
/** /**
* Determines whether a point is inside a rectangle. * Determines whether a point is inside a rectangle.
* *
* Otherwise identical to {@link isInsideRectangle}, it also returns `true` if `x` equals `left` or `y` equals `top`. * Otherwise identical to {@link isInsideRectangle}, it also returns `true` if `x` equals `left` or `y` equals `top`.
* @param x Point x * @param x Point x
* @param y Point y * @param y Point y
@@ -38,11 +44,18 @@ export function dist2(x1: number, y1: number, x2: number, y2: number): number {
* @param height Rect height * @param height Rect height
* @returns `true` if the point is inside the rect, otherwise `false` * @returns `true` if the point is inside the rect, otherwise `false`
*/ */
export function isInRectangle(x: number, y: number, left: number, top: number, width: number, height: number): boolean { export function isInRectangle(
return x >= left x: number,
&& x < left + width y: number,
&& y >= top left: number,
&& y < top + height top: number,
width: number,
height: number,
): boolean {
return x >= left &&
x < left + width &&
y >= top &&
y < top + height
} }
/** /**
@@ -52,10 +65,10 @@ export function isInRectangle(x: number, y: number, left: number, top: number, w
* @returns `true` if the point is inside the rect, otherwise `false` * @returns `true` if the point is inside the rect, otherwise `false`
*/ */
export function isPointInRect(point: ReadOnlyPoint, rect: ReadOnlyRect): boolean { export function isPointInRect(point: ReadOnlyPoint, rect: ReadOnlyRect): boolean {
return point[0] >= rect[0] return point[0] >= rect[0] &&
&& point[0] < rect[0] + rect[2] point[0] < rect[0] + rect[2] &&
&& point[1] >= rect[1] point[1] >= rect[1] &&
&& point[1] < rect[1] + rect[3] point[1] < rect[1] + rect[3]
} }
/** /**
@@ -66,20 +79,20 @@ export function isPointInRect(point: ReadOnlyPoint, rect: ReadOnlyRect): boolean
* @returns `true` if the point is inside the rect, otherwise `false` * @returns `true` if the point is inside the rect, otherwise `false`
*/ */
export function isInRect(x: number, y: number, rect: ReadOnlyRect): boolean { export function isInRect(x: number, y: number, rect: ReadOnlyRect): boolean {
return x >= rect[0] return x >= rect[0] &&
&& x < rect[0] + rect[2] x < rect[0] + rect[2] &&
&& y >= rect[1] y >= rect[1] &&
&& y < rect[1] + rect[3] y < rect[1] + rect[3]
} }
/** /**
* Determines whether a point (`x, y`) is inside a rectangle. * Determines whether a point (`x, y`) is inside a rectangle.
* *
* This is the original litegraph implementation. It returns `false` if `x` is equal to `left`, or `y` is equal to `top`. * This is the original litegraph implementation. It returns `false` if `x` is equal to `left`, or `y` is equal to `top`.
* @deprecated * @deprecated
* Use {@link isInRectangle} to match inclusive of top left. * Use {@link isInRectangle} to match inclusive of top left.
* This function returns a false negative when an integer point (e.g. pixel) is on the leftmost or uppermost edge of a rectangle. * This function returns a false negative when an integer point (e.g. pixel) is on the leftmost or uppermost edge of a rectangle.
* *
* @param x Point x * @param x Point x
* @param y Point y * @param y Point y
* @param left Rect x * @param left Rect x
@@ -88,11 +101,18 @@ export function isInRect(x: number, y: number, rect: ReadOnlyRect): boolean {
* @param height Rect height * @param height Rect height
* @returns `true` if the point is inside the rect, otherwise `false` * @returns `true` if the point is inside the rect, otherwise `false`
*/ */
export function isInsideRectangle(x: number, y: number, left: number, top: number, width: number, height: number): boolean { export function isInsideRectangle(
return left < x x: number,
&& left + width > x y: number,
&& top < y left: number,
&& top + height > y top: number,
width: number,
height: number,
): boolean {
return left < x &&
left + width > x &&
top < y &&
top + height > y
} }
/** /**
@@ -103,8 +123,8 @@ export function isInsideRectangle(x: number, y: number, left: number, top: numbe
* @returns `true` if the point is roughly inside the octagon centred on 0,0 with specified radius * @returns `true` if the point is roughly inside the octagon centred on 0,0 with specified radius
*/ */
export function isSortaInsideOctagon(x: number, y: number, radius: number): boolean { export function isSortaInsideOctagon(x: number, y: number, radius: number): boolean {
const sum = Math.min(radius, Math.abs(x)) + Math.min(radius, Math.abs(y)) const sum = Math.min(radius, Math.abs(x)) + Math.min(radius, Math.abs(y))
return sum < radius * 0.75 return sum < radius * 0.75
} }
/** /**
@@ -114,17 +134,17 @@ export function isSortaInsideOctagon(x: number, y: number, radius: number): bool
* @returns `true` if rectangles overlap, otherwise `false` * @returns `true` if rectangles overlap, otherwise `false`
*/ */
export function overlapBounding(a: ReadOnlyRect, b: ReadOnlyRect): boolean { export function overlapBounding(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
const aRight = a[0] + a[2] const aRight = a[0] + a[2]
const aBottom = a[1] + a[3] const aBottom = a[1] + a[3]
const bRight = b[0] + b[2] const bRight = b[0] + b[2]
const bBottom = b[1] + b[3] const bBottom = b[1] + b[3]
return a[0] > bRight return a[0] > bRight ||
|| a[1] > bBottom a[1] > bBottom ||
|| aRight < b[0] aRight < b[0] ||
|| aBottom < b[1] aBottom < b[1]
? false ? false
: true : true
} }
/** /**
@@ -134,9 +154,9 @@ export function overlapBounding(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
* @returns `true` if {@link a} contains most of {@link b}, otherwise `false` * @returns `true` if {@link a} contains most of {@link b}, otherwise `false`
*/ */
export function containsCentre(a: ReadOnlyRect, b: ReadOnlyRect): boolean { export function containsCentre(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
const centreX = b[0] + (b[2] * 0.5) const centreX = b[0] + (b[2] * 0.5)
const centreY = b[1] + (b[3] * 0.5) const centreY = b[1] + (b[3] * 0.5)
return isInRect(centreX, centreY, a) return isInRect(centreX, centreY, a)
} }
/** /**
@@ -146,21 +166,21 @@ export function containsCentre(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
* @returns `true` if {@link a} wholly contains {@link b}, otherwise `false` * @returns `true` if {@link a} wholly contains {@link b}, otherwise `false`
*/ */
export function containsRect(a: ReadOnlyRect, b: ReadOnlyRect): boolean { export function containsRect(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
const aRight = a[0] + a[2] const aRight = a[0] + a[2]
const aBottom = a[1] + a[3] const aBottom = a[1] + a[3]
const bRight = b[0] + b[2] const bRight = b[0] + b[2]
const bBottom = b[1] + b[3] const bBottom = b[1] + b[3]
const identical = a[0] === b[0] const identical = a[0] === b[0] &&
&& a[1] === b[1] a[1] === b[1] &&
&& aRight === bRight aRight === bRight &&
&& aBottom === bBottom aBottom === bBottom
return !identical return !identical &&
&& a[0] <= b[0] a[0] <= b[0] &&
&& a[1] <= b[1] a[1] <= b[1] &&
&& aRight >= bRight aRight >= bRight &&
&& aBottom >= bBottom aBottom >= bBottom
} }
/** /**
@@ -169,99 +189,117 @@ export function containsRect(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
* @param direction Direction to add the offset to * @param direction Direction to add the offset to
* @param out The {@link Point} to add the offset to * @param out The {@link Point} to add the offset to
*/ */
export function addDirectionalOffset(amount: number, direction: LinkDirection, out: Point): void { export function addDirectionalOffset(
switch (direction) { amount: number,
case LinkDirection.LEFT: direction: LinkDirection,
out[0] -= amount out: Point,
return ): void {
case LinkDirection.RIGHT: switch (direction) {
out[0] += amount case LinkDirection.LEFT:
return out[0] -= amount
case LinkDirection.UP: return
out[1] -= amount case LinkDirection.RIGHT:
return out[0] += amount
case LinkDirection.DOWN: return
out[1] += amount case LinkDirection.UP:
return out[1] -= amount
// LinkDirection.CENTER: Nothing to do. return
} case LinkDirection.DOWN:
out[1] += amount
return
// LinkDirection.CENTER: Nothing to do.
}
} }
/** /**
* Rotates an offset in 90° increments. * Rotates an offset in 90° increments.
* *
* Swaps/flips axis values of a 2D vector offset - effectively rotating {@link offset} by 90° * Swaps/flips axis values of a 2D vector offset - effectively rotating
* {@link offset} by 90°
* @param offset The zero-based offset to rotate * @param offset The zero-based offset to rotate
* @param from Direction to rotate from * @param from Direction to rotate from
* @param to Direction to rotate to * @param to Direction to rotate to
*/ */
export function rotateLink(offset: Point, from: LinkDirection, to: LinkDirection): void { export function rotateLink(
let x: number offset: Point,
let y: number from: LinkDirection,
to: LinkDirection,
): void {
let x: number
let y: number
// Normalise to left // Normalise to left
switch (from) { switch (from) {
case to: case to:
case LinkDirection.CENTER: case LinkDirection.CENTER:
case LinkDirection.NONE: case LinkDirection.NONE:
// Nothing to do // Nothing to do
return return
case LinkDirection.LEFT: case LinkDirection.LEFT:
x = offset[0] x = offset[0]
y = offset[1] y = offset[1]
break break
case LinkDirection.RIGHT: case LinkDirection.RIGHT:
x = -offset[0] x = -offset[0]
y = -offset[1] y = -offset[1]
break break
case LinkDirection.UP: case LinkDirection.UP:
x = -offset[1] x = -offset[1]
y = offset[0] y = offset[0]
break break
case LinkDirection.DOWN: case LinkDirection.DOWN:
x = offset[1] x = offset[1]
y = -offset[0] y = -offset[0]
break break
} }
// Apply new direction // Apply new direction
switch (to) { switch (to) {
case LinkDirection.CENTER: case LinkDirection.CENTER:
case LinkDirection.NONE: case LinkDirection.NONE:
// Nothing to do // Nothing to do
return return
case LinkDirection.LEFT: case LinkDirection.LEFT:
offset[0] = x offset[0] = x
offset[1] = y offset[1] = y
break break
case LinkDirection.RIGHT: case LinkDirection.RIGHT:
offset[0] = -x offset[0] = -x
offset[1] = -y offset[1] = -y
break break
case LinkDirection.UP: case LinkDirection.UP:
offset[0] = y offset[0] = y
offset[1] = -x offset[1] = -x
break break
case LinkDirection.DOWN: case LinkDirection.DOWN:
offset[0] = -y offset[0] = -y
offset[1] = x offset[1] = x
break break
} }
} }
/** /**
* Check if a point is to to the left or right of a line. * Check if a point is to to the left or right of a line.
* Project a line from lineStart -> lineEnd. Determine if point is to the left or right of that projection. * Project a line from lineStart -> lineEnd. Determine if point is to the left
* or right of that projection.
* {@link https://www.geeksforgeeks.org/orientation-3-ordered-points/} * {@link https://www.geeksforgeeks.org/orientation-3-ordered-points/}
* @param lineStart The start point of the line * @param lineStart The start point of the line
* @param lineEnd The end point of the line * @param lineEnd The end point of the line
* @param point The point to check * @param point The point to check
* @returns 0 if all three points are in a straight line, a negative value if point is to the left of the projected line, or positive if the point is to the right * @returns 0 if all three points are in a straight line, a negative value if
* point is to the left of the projected line, or positive if the point is to
* the right
*/ */
export function getOrientation(lineStart: ReadOnlyPoint, lineEnd: ReadOnlyPoint, x: number, y: number): number { export function getOrientation(
return ((lineEnd[1] - lineStart[1]) * (x - lineEnd[0])) - ((lineEnd[0] - lineStart[0]) * (y - lineEnd[1])) lineStart: ReadOnlyPoint,
lineEnd: ReadOnlyPoint,
x: number,
y: number,
): number {
return ((lineEnd[1] - lineStart[1]) * (x - lineEnd[0])) -
((lineEnd[0] - lineStart[0]) * (y - lineEnd[1]))
} }
/** /**
@@ -274,42 +312,45 @@ export function getOrientation(lineStart: ReadOnlyPoint, lineEnd: ReadOnlyPoint,
* @param t Time: factor of distance to travel along the curve (e.g 0.25 is 25% along the curve) * @param t Time: factor of distance to travel along the curve (e.g 0.25 is 25% along the curve)
*/ */
export function findPointOnCurve( export function findPointOnCurve(
out: Point, out: Point,
a: ReadOnlyPoint, a: ReadOnlyPoint,
b: ReadOnlyPoint, b: ReadOnlyPoint,
controlA: ReadOnlyPoint, controlA: ReadOnlyPoint,
controlB: ReadOnlyPoint, controlB: ReadOnlyPoint,
t: number = 0.5, t: number = 0.5,
): void { ): void {
const iT = 1 - t const iT = 1 - t
const c1 = iT * iT * iT const c1 = iT * iT * iT
const c2 = 3 * (iT * iT) * t const c2 = 3 * (iT * iT) * t
const c3 = 3 * iT * (t * t) const c3 = 3 * iT * (t * t)
const c4 = t * t * t const c4 = t * t * t
out[0] = (c1 * a[0]) + (c2 * controlA[0]) + (c3 * controlB[0]) + (c4 * b[0]) out[0] = (c1 * a[0]) + (c2 * controlA[0]) + (c3 * controlB[0]) + (c4 * b[0])
out[1] = (c1 * a[1]) + (c2 * controlA[1]) + (c3 * controlB[1]) + (c4 * b[1]) out[1] = (c1 * a[1]) + (c2 * controlA[1]) + (c3 * controlB[1]) + (c4 * b[1])
} }
export function createBounds(objects: Iterable<Positionable>, padding: number = 10): ReadOnlyRect | null { export function createBounds(
const bounds = new Float32Array([Infinity, Infinity, -Infinity, -Infinity]) objects: Iterable<Positionable>,
padding: number = 10,
): ReadOnlyRect | null {
const bounds = new Float32Array([Infinity, Infinity, -Infinity, -Infinity])
for (const obj of objects) { for (const obj of objects) {
const rect = obj.boundingRect const rect = obj.boundingRect
bounds[0] = Math.min(bounds[0], rect[0]) bounds[0] = Math.min(bounds[0], rect[0])
bounds[1] = Math.min(bounds[1], rect[1]) bounds[1] = Math.min(bounds[1], rect[1])
bounds[2] = Math.max(bounds[2], rect[0] + rect[2]) bounds[2] = Math.max(bounds[2], rect[0] + rect[2])
bounds[3] = Math.max(bounds[3], rect[1] + rect[3]) bounds[3] = Math.max(bounds[3], rect[1] + rect[3])
} }
if (!bounds.every(x => isFinite(x))) return null if (!bounds.every(x => isFinite(x))) return null
return [ return [
bounds[0] - padding, bounds[0] - padding,
bounds[1] - padding, bounds[1] - padding,
bounds[2] - bounds[0] + (2 * padding), bounds[2] - bounds[0] + (2 * padding),
bounds[3] - bounds[1] + (2 * padding) bounds[3] - bounds[1] + (2 * padding),
] ]
} }
/** /**
@@ -320,9 +361,9 @@ export function createBounds(objects: Iterable<Positionable>, padding: number =
* @remarks `NaN` propagates through this function and does not affect return value. * @remarks `NaN` propagates through this function and does not affect return value.
*/ */
export function snapPoint(pos: Point | Rect, snapTo: number): boolean { export function snapPoint(pos: Point | Rect, snapTo: number): boolean {
if (!snapTo) return false if (!snapTo) return false
pos[0] = snapTo * Math.round(pos[0] / snapTo) pos[0] = snapTo * Math.round(pos[0] / snapTo)
pos[1] = snapTo * Math.round(pos[1] / snapTo) pos[1] = snapTo * Math.round(pos[1] / snapTo)
return true return true
} }

View File

@@ -1,85 +1,85 @@
// API *************************************************
//API ************************************************* // like rect but rounded corners
//like rect but rounded corners
export function loadPolyfills() { export function loadPolyfills() {
if (typeof (window) != "undefined" && window.CanvasRenderingContext2D && !window.CanvasRenderingContext2D.prototype.roundRect) { if (
typeof window != "undefined" &&
window.CanvasRenderingContext2D &&
!window.CanvasRenderingContext2D.prototype.roundRect
) {
// @ts-expect-error Slightly broken polyfill - radius_low not impl. anywhere // @ts-expect-error Slightly broken polyfill - radius_low not impl. anywhere
window.CanvasRenderingContext2D.prototype.roundRect = function ( window.CanvasRenderingContext2D.prototype.roundRect = function (
x, x,
y, y,
w, w,
h, h,
radius, radius,
radius_low radius_low,
) { ) {
let top_left_radius = 0; let top_left_radius = 0
let top_right_radius = 0; let top_right_radius = 0
let bottom_left_radius = 0; let bottom_left_radius = 0
let bottom_right_radius = 0; let bottom_right_radius = 0
if (radius === 0) { if (radius === 0) {
this.rect(x, y, w, h); this.rect(x, y, w, h)
return; return
}
if (radius_low === undefined) radius_low = radius
// make it compatible with official one
if (radius != null && radius.constructor === Array) {
if (radius.length == 1)
top_left_radius = top_right_radius = bottom_left_radius = bottom_right_radius = radius[0]
else if (radius.length == 2) {
top_left_radius = bottom_right_radius = radius[0]
top_right_radius = bottom_left_radius = radius[1]
} else if (radius.length == 4) {
top_left_radius = radius[0]
top_right_radius = radius[1]
bottom_left_radius = radius[2]
bottom_right_radius = radius[3]
} else {
return
} }
} else {
// old using numbers
top_left_radius = radius || 0
top_right_radius = radius || 0
bottom_left_radius = radius_low || 0
bottom_right_radius = radius_low || 0
}
if (radius_low === undefined) // top right
radius_low = radius; this.moveTo(x + top_left_radius, y)
this.lineTo(x + w - top_right_radius, y)
this.quadraticCurveTo(x + w, y, x + w, y + top_right_radius)
//make it compatible with official one // bottom right
if (radius != null && radius.constructor === Array) { this.lineTo(x + w, y + h - bottom_right_radius)
if (radius.length == 1) this.quadraticCurveTo(
top_left_radius = top_right_radius = bottom_left_radius = bottom_right_radius = radius[0]; x + w,
else if (radius.length == 2) { y + h,
top_left_radius = bottom_right_radius = radius[0]; x + w - bottom_right_radius,
top_right_radius = bottom_left_radius = radius[1]; y + h,
} )
else if (radius.length == 4) {
top_left_radius = radius[0];
top_right_radius = radius[1];
bottom_left_radius = radius[2];
bottom_right_radius = radius[3];
}
else
return;
}
else //old using numbers
{
top_left_radius = radius || 0;
top_right_radius = radius || 0;
bottom_left_radius = radius_low || 0;
bottom_right_radius = radius_low || 0;
}
//top right // bottom left
this.moveTo(x + top_left_radius, y); this.lineTo(x + bottom_right_radius, y + h)
this.lineTo(x + w - top_right_radius, y); this.quadraticCurveTo(x, y + h, x, y + h - bottom_left_radius)
this.quadraticCurveTo(x + w, y, x + w, y + top_right_radius);
//bottom right // top left
this.lineTo(x + w, y + h - bottom_right_radius); this.lineTo(x, y + bottom_left_radius)
this.quadraticCurveTo( this.quadraticCurveTo(x, y, x + top_left_radius, y)
x + w, }
y + h, } // if
x + w - bottom_right_radius,
y + h
);
//bottom left if (typeof window != "undefined" && !window["requestAnimationFrame"]) {
this.lineTo(x + bottom_right_radius, y + h);
this.quadraticCurveTo(x, y + h, x, y + h - bottom_left_radius);
//top left
this.lineTo(x, y + bottom_left_radius);
this.quadraticCurveTo(x, y, x + top_left_radius, y);
};
}//if
if (typeof window != "undefined" && !window["requestAnimationFrame"]) {
window.requestAnimationFrame = window.requestAnimationFrame =
// @ts-expect-error Legacy code // @ts-expect-error Legacy code
window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame ||
function (callback) { function (callback) {
window.setTimeout(callback, 1000 / 60); window.setTimeout(callback, 1000 / 60)
}; }
}
} }
}

View File

@@ -4,7 +4,7 @@
* @returns String(value) or null * @returns String(value) or null
*/ */
export function stringOrNull(value: unknown): string | null { export function stringOrNull(value: unknown): string | null {
return value == null ? null : String(value) return value == null ? null : String(value)
} }
/** /**
@@ -13,5 +13,5 @@ export function stringOrNull(value: unknown): string | null {
* @returns String(value) or "" * @returns String(value) or ""
*/ */
export function stringOrEmpty(value: unknown): string { export function stringOrEmpty(value: unknown): string {
return value == null ? "" : String(value) return value == null ? "" : String(value)
} }

View File

@@ -9,77 +9,84 @@ import type { LGraphGroup } from "../LGraphGroup"
/** For Canvas*Event - adds graph space co-ordinates (property names are shipped) */ /** For Canvas*Event - adds graph space co-ordinates (property names are shipped) */
export interface ICanvasPosition { export interface ICanvasPosition {
/** X co-ordinate of the event, in graph space (NOT canvas space) */ /** X co-ordinate of the event, in graph space (NOT canvas space) */
canvasX: number canvasX: number
/** Y co-ordinate of the event, in graph space (NOT canvas space) */ /** Y co-ordinate of the event, in graph space (NOT canvas space) */
canvasY: number canvasY: number
} }
/** For Canvas*Event */ /** For Canvas*Event */
export interface IDeltaPosition { export interface IDeltaPosition {
deltaX: number deltaX: number
deltaY: number deltaY: number
} }
interface LegacyMouseEvent { interface LegacyMouseEvent {
/** @deprecated Part of DragAndScale mouse API - incomplete / not maintained */ /** @deprecated Part of DragAndScale mouse API - incomplete / not maintained */
dragging?: boolean dragging?: boolean
click_time?: number click_time?: number
} }
/** PointerEvent with canvasX/Y and deltaX/Y properties */ /** PointerEvent with canvasX/Y and deltaX/Y properties */
export interface CanvasPointerEvent extends PointerEvent, CanvasMouseEvent { } export interface CanvasPointerEvent extends PointerEvent, CanvasMouseEvent {}
/** MouseEvent with canvasX/Y and deltaX/Y properties */ /** MouseEvent with canvasX/Y and deltaX/Y properties */
export interface CanvasMouseEvent extends MouseEvent, Readonly<ICanvasPosition>, Readonly<IDeltaPosition>, LegacyMouseEvent { } export interface CanvasMouseEvent extends
MouseEvent,
Readonly<ICanvasPosition>,
Readonly<IDeltaPosition>,
LegacyMouseEvent {}
/** DragEvent with canvasX/Y and deltaX/Y properties */ /** DragEvent with canvasX/Y and deltaX/Y properties */
export interface CanvasDragEvent extends DragEvent, ICanvasPosition, IDeltaPosition { } export interface CanvasDragEvent extends
DragEvent,
ICanvasPosition,
IDeltaPosition {}
export type CanvasEventDetail = export type CanvasEventDetail =
GenericEventDetail | GenericEventDetail
| DragggingCanvasEventDetail | DragggingCanvasEventDetail
| ReadOnlyEventDetail | ReadOnlyEventDetail
| GroupDoubleClickEventDetail | GroupDoubleClickEventDetail
| EmptyDoubleClickEventDetail | EmptyDoubleClickEventDetail
| ConnectingWidgetLinkEventDetail | ConnectingWidgetLinkEventDetail
| EmptyReleaseEventDetail | EmptyReleaseEventDetail
export interface GenericEventDetail { export interface GenericEventDetail {
subType: "before-change" | "after-change" subType: "before-change" | "after-change"
} }
export interface OriginalEvent { export interface OriginalEvent {
originalEvent: CanvasPointerEvent, originalEvent: CanvasPointerEvent
} }
export interface EmptyReleaseEventDetail extends OriginalEvent { export interface EmptyReleaseEventDetail extends OriginalEvent {
subType: "empty-release", subType: "empty-release"
linkReleaseContext: LinkReleaseContextExtended, linkReleaseContext: LinkReleaseContextExtended
} }
export interface ConnectingWidgetLinkEventDetail { export interface ConnectingWidgetLinkEventDetail {
subType: "connectingWidgetLink" subType: "connectingWidgetLink"
link: ConnectingLink link: ConnectingLink
node: LGraphNode node: LGraphNode
widget: IWidget widget: IWidget
} }
export interface EmptyDoubleClickEventDetail extends OriginalEvent { export interface EmptyDoubleClickEventDetail extends OriginalEvent {
subType: "empty-double-click" subType: "empty-double-click"
} }
export interface GroupDoubleClickEventDetail extends OriginalEvent { export interface GroupDoubleClickEventDetail extends OriginalEvent {
subType: "group-double-click" subType: "group-double-click"
group: LGraphGroup group: LGraphGroup
} }
export interface DragggingCanvasEventDetail { export interface DragggingCanvasEventDetail {
subType: "dragging-canvas" subType: "dragging-canvas"
draggingCanvas: boolean draggingCanvas: boolean
} }
export interface ReadOnlyEventDetail { export interface ReadOnlyEventDetail {
subType: "read-only" subType: "read-only"
readOnly: boolean readOnly: boolean
} }

View File

@@ -1,92 +1,92 @@
/** Node slot type - input or output */ /** Node slot type - input or output */
export enum NodeSlotType { export enum NodeSlotType {
INPUT = 1, INPUT = 1,
OUTPUT = 2, OUTPUT = 2,
} }
/** Shape that an object will render as - used by nodes and slots */ /** Shape that an object will render as - used by nodes and slots */
export enum RenderShape { export enum RenderShape {
/** Rectangle with square corners */ /** Rectangle with square corners */
BOX = 1, BOX = 1,
/** Rounded rectangle */ /** Rounded rectangle */
ROUND = 2, ROUND = 2,
/** Circle is circle */ /** Circle is circle */
CIRCLE = 3, CIRCLE = 3,
/** Two rounded corners: top left & bottom right */ /** Two rounded corners: top left & bottom right */
CARD = 4, CARD = 4,
/** Slot shape: Arrow */ /** Slot shape: Arrow */
ARROW = 5, ARROW = 5,
/** Slot shape: Grid */ /** Slot shape: Grid */
GRID = 6, GRID = 6,
/** Slot shape: Hollow circle */ /** Slot shape: Hollow circle */
HollowCircle = 7, HollowCircle = 7,
} }
/** Bit flags used to indicate what the pointer is currently hovering over. */ /** Bit flags used to indicate what the pointer is currently hovering over. */
export enum CanvasItem { export enum CanvasItem {
/** No items / none */ /** No items / none */
Nothing = 0, Nothing = 0,
/** At least one node */ /** At least one node */
Node = 1 << 0, Node = 1 << 0,
/** At least one group */ /** At least one group */
Group = 1 << 1, Group = 1 << 1,
/** A reroute (not its path) */ /** A reroute (not its path) */
Reroute = 1 << 2, Reroute = 1 << 2,
/** The path of a link */ /** The path of a link */
Link = 1 << 3, Link = 1 << 3,
/** A resize in the bottom-right corner */ /** A resize in the bottom-right corner */
ResizeSe = 1 << 4, ResizeSe = 1 << 4,
} }
/** The direction that a link point will flow towards - e.g. horizontal outputs are right by default */ /** The direction that a link point will flow towards - e.g. horizontal outputs are right by default */
export enum LinkDirection { export enum LinkDirection {
NONE = 0, NONE = 0,
UP = 1, UP = 1,
DOWN = 2, DOWN = 2,
LEFT = 3, LEFT = 3,
RIGHT = 4, RIGHT = 4,
CENTER = 5, CENTER = 5,
} }
/** The path calculation that links follow */ /** The path calculation that links follow */
export enum LinkRenderType { export enum LinkRenderType {
HIDDEN_LINK = -1, HIDDEN_LINK = -1,
/** Juts out from the input & output a little @see LinkDirection, then a straight line between them */ /** Juts out from the input & output a little @see LinkDirection, then a straight line between them */
STRAIGHT_LINK = 0, STRAIGHT_LINK = 0,
/** 90° angles, clean and box-like */ /** 90° angles, clean and box-like */
LINEAR_LINK = 1, LINEAR_LINK = 1,
/** Smooth curved links - default */ /** Smooth curved links - default */
SPLINE_LINK = 2, SPLINE_LINK = 2,
} }
/** The marker in the middle of a link */ /** The marker in the middle of a link */
export enum LinkMarkerShape { export enum LinkMarkerShape {
/** Do not display markers */ /** Do not display markers */
None = 0, None = 0,
/** Circles (default) */ /** Circles (default) */
Circle = 1, Circle = 1,
/** Directional arrows */ /** Directional arrows */
Arrow = 2, Arrow = 2,
} }
export enum TitleMode { export enum TitleMode {
NORMAL_TITLE = 0, NORMAL_TITLE = 0,
NO_TITLE = 1, NO_TITLE = 1,
TRANSPARENT_TITLE = 2, TRANSPARENT_TITLE = 2,
AUTOHIDE_TITLE = 3, AUTOHIDE_TITLE = 3,
} }
export enum LGraphEventMode { export enum LGraphEventMode {
ALWAYS = 0, ALWAYS = 0,
ON_EVENT = 1, ON_EVENT = 1,
NEVER = 2, NEVER = 2,
ON_TRIGGER = 3, ON_TRIGGER = 3,
BYPASS = 4, BYPASS = 4,
} }
export enum EaseFunction { export enum EaseFunction {
LINEAR = "linear", LINEAR = "linear",
EASE_IN_QUAD = "easeInQuad", EASE_IN_QUAD = "easeInQuad",
EASE_OUT_QUAD = "easeOutQuad", EASE_OUT_QUAD = "easeOutQuad",
EASE_IN_OUT_QUAD = "easeInOutQuad", EASE_IN_OUT_QUAD = "easeInOutQuad",
} }

View File

@@ -1,4 +1,12 @@
import type { ISlotType, Dictionary, INodeFlags, INodeInputSlot, INodeOutputSlot, Point, Size } from "../interfaces" import type {
ISlotType,
Dictionary,
INodeFlags,
INodeInputSlot,
INodeOutputSlot,
Point,
Size,
} from "../interfaces"
import type { LGraph, LGraphState } from "../LGraph" import type { LGraph, LGraphState } from "../LGraph"
import type { IGraphGroupFlags, LGraphGroup } from "../LGraphGroup" import type { IGraphGroupFlags, LGraphGroup } from "../LGraphGroup"
import type { LGraphNode, NodeId } from "../LGraphNode" import type { LGraphNode, NodeId } from "../LGraphNode"
@@ -12,45 +20,45 @@ import type { RenderShape } from "./globalEnums"
* An object that implements custom pre-serialization logic via {@link Serialisable.asSerialisable}. * An object that implements custom pre-serialization logic via {@link Serialisable.asSerialisable}.
*/ */
export interface Serialisable<SerialisableObject> { export interface Serialisable<SerialisableObject> {
/** /**
* Prepares this object for serialization. * Prepares this object for serialization.
* Creates a partial shallow copy of itself, with only the properties that should be serialised. * Creates a partial shallow copy of itself, with only the properties that should be serialised.
* @returns An object that can immediately be serialized to JSON. * @returns An object that can immediately be serialized to JSON.
*/ */
asSerialisable(): SerialisableObject asSerialisable(): SerialisableObject
} }
export interface SerialisableGraph { export interface SerialisableGraph {
/** Schema version. @remarks Version bump should add to const union, which is used to narrow type during deserialise. */ /** Schema version. @remarks Version bump should add to const union, which is used to narrow type during deserialise. */
version: 0 | 1 version: 0 | 1
config: LGraph["config"] config: LGraph["config"]
state: LGraphState state: LGraphState
groups?: ISerialisedGroup[] groups?: ISerialisedGroup[]
nodes?: ISerialisedNode[] nodes?: ISerialisedNode[]
links?: SerialisableLLink[] links?: SerialisableLLink[]
reroutes?: SerialisableReroute[] reroutes?: SerialisableReroute[]
extra?: Record<any, any> extra?: Record<any, any>
} }
/** Serialised LGraphNode */ /** Serialised LGraphNode */
export interface ISerialisedNode { export interface ISerialisedNode {
title?: string title?: string
id: NodeId id: NodeId
type?: string type?: string
pos?: Point pos?: Point
size?: Size size?: Size
flags?: INodeFlags flags?: INodeFlags
order?: number order?: number
mode?: number mode?: number
outputs?: INodeOutputSlot[] outputs?: INodeOutputSlot[]
inputs?: INodeInputSlot[] inputs?: INodeInputSlot[]
properties?: Dictionary<unknown> properties?: Dictionary<unknown>
shape?: RenderShape shape?: RenderShape
boxcolor?: string boxcolor?: string
color?: string color?: string
bgcolor?: string bgcolor?: string
showAdvanced?: boolean showAdvanced?: boolean
widgets_values?: TWidgetValue[] widgets_values?: TWidgetValue[]
} }
/** /**
@@ -58,66 +66,72 @@ export interface ISerialisedNode {
* Maintained for backwards compat * Maintained for backwards compat
*/ */
export type ISerialisedGraph< export type ISerialisedGraph<
TNode = ReturnType<LGraphNode["serialize"]>, TNode = ReturnType<LGraphNode["serialize"]>,
TLink = ReturnType<LLink["serialize"]>, TLink = ReturnType<LLink["serialize"]>,
TGroup = ReturnType<LGraphGroup["serialize"]> TGroup = ReturnType<LGraphGroup["serialize"]>,
> = { > = {
last_node_id: NodeId last_node_id: NodeId
last_link_id: number last_link_id: number
nodes: TNode[] nodes: TNode[]
links: TLink[] links: TLink[]
groups: TGroup[] groups: TGroup[]
config: LGraph["config"] config: LGraph["config"]
version: typeof LiteGraph.VERSION version: typeof LiteGraph.VERSION
extra?: Record<any, any> extra?: Record<any, any>
} }
/** Serialised LGraphGroup */ /** Serialised LGraphGroup */
export interface ISerialisedGroup { export interface ISerialisedGroup {
id: number id: number
title: string title: string
bounding: number[] bounding: number[]
color: string color: string
font_size: number font_size: number
flags?: IGraphGroupFlags flags?: IGraphGroupFlags
} }
export type TClipboardLink = [targetRelativeIndex: number, originSlot: number, nodeRelativeIndex: number, targetSlot: number, targetNodeId: NodeId] export type TClipboardLink = [
targetRelativeIndex: number,
originSlot: number,
nodeRelativeIndex: number,
targetSlot: number,
targetNodeId: NodeId,
]
/** Items copied from the canvas */ /** Items copied from the canvas */
export interface ClipboardItems { export interface ClipboardItems {
nodes?: ISerialisedNode[] nodes?: ISerialisedNode[]
groups?: ISerialisedGroup[] groups?: ISerialisedGroup[]
reroutes?: SerialisableReroute[] reroutes?: SerialisableReroute[]
links?: SerialisableLLink[] links?: SerialisableLLink[]
} }
/** */ /** */
export interface IClipboardContents { export interface IClipboardContents {
nodes?: ISerialisedNode[] nodes?: ISerialisedNode[]
links?: TClipboardLink[] links?: TClipboardLink[]
} }
export interface SerialisableReroute { export interface SerialisableReroute {
id: RerouteId id: RerouteId
parentId?: RerouteId parentId?: RerouteId
pos: Point pos: Point
linkIds: LinkId[] linkIds: LinkId[]
} }
export interface SerialisableLLink { export interface SerialisableLLink {
/** Link ID */ /** Link ID */
id: LinkId id: LinkId
/** Output node ID */ /** Output node ID */
origin_id: NodeId origin_id: NodeId
/** Output slot index */ /** Output slot index */
origin_slot: number origin_slot: number
/** Input node ID */ /** Input node ID */
target_id: NodeId target_id: NodeId
/** Input slot index */ /** Input slot index */
target_slot: number target_slot: number
/** Data type of the link */ /** Data type of the link */
type: ISlotType type: ISlotType
/** ID of the last reroute (from input to output) that this link passes through, otherwise `undefined` */ /** ID of the last reroute (from input to output) that this link passes through, otherwise `undefined` */
parentId?: RerouteId parentId?: RerouteId
} }

View File

@@ -3,26 +3,26 @@ import type { LGraphCanvas, LGraphNode } from "../litegraph"
import type { CanvasMouseEvent } from "./events" import type { CanvasMouseEvent } from "./events"
export interface IWidgetOptions<TValue = unknown> extends Record<string, unknown> { export interface IWidgetOptions<TValue = unknown> extends Record<string, unknown> {
on?: string on?: string
off?: string off?: string
max?: number max?: number
min?: number min?: number
slider_color?: CanvasColour slider_color?: CanvasColour
marker_color?: CanvasColour marker_color?: CanvasColour
precision?: number precision?: number
read_only?: boolean read_only?: boolean
step?: number step?: number
y?: number y?: number
multiline?: boolean multiline?: boolean
// TODO: Confirm this // TODO: Confirm this
property?: string property?: string
hasOwnProperty?(arg0: string): any hasOwnProperty?(arg0: string): any
// values?(widget?: IWidget, node?: LGraphNode): any // values?(widget?: IWidget, node?: LGraphNode): any
values?: TValue[] values?: TValue[]
callback?: IWidget["callback"] callback?: IWidget["callback"]
onHide?(widget: IWidget): void onHide?(widget: IWidget): void
} }
/** /**
@@ -34,52 +34,61 @@ export interface IWidgetOptions<TValue = unknown> extends Record<string, unknown
* Recommend declaration merging any properties that use IWidget (e.g. {@link LGraphNode.widgets}) with a new type alias. * Recommend declaration merging any properties that use IWidget (e.g. {@link LGraphNode.widgets}) with a new type alias.
* @see ICustomWidget * @see ICustomWidget
*/ */
export type IWidget = IBooleanWidget | INumericWidget | IStringWidget | IMultilineStringWidget | IComboWidget | ICustomWidget export type IWidget =
| IBooleanWidget
| INumericWidget
| IStringWidget
| IMultilineStringWidget
| IComboWidget
| ICustomWidget
export interface IBooleanWidget extends IBaseWidget { export interface IBooleanWidget extends IBaseWidget {
type?: "toggle" type?: "toggle"
value: boolean value: boolean
} }
/** Any widget that uses a numeric backing */ /** Any widget that uses a numeric backing */
export interface INumericWidget extends IBaseWidget { export interface INumericWidget extends IBaseWidget {
type?: "slider" | "number" type?: "slider" | "number"
value: number value: number
} }
/** A combo-box widget (dropdown, select, etc) */ /** A combo-box widget (dropdown, select, etc) */
export interface IComboWidget extends IBaseWidget { export interface IComboWidget extends IBaseWidget {
type?: "combo" type?: "combo"
value: string | number value: string | number
options: IWidgetOptions<string> options: IWidgetOptions<string>
} }
export type IStringWidgetType = IStringWidget["type"] | IMultilineStringWidget["type"] export type IStringWidgetType = IStringWidget["type"] | IMultilineStringWidget["type"]
/** A widget with a string value */ /** A widget with a string value */
export interface IStringWidget extends IBaseWidget { export interface IStringWidget extends IBaseWidget {
type?: "string" | "text" | "button" type?: "string" | "text" | "button"
value: string value: string
} }
/** A widget with a string value and a multiline text input */ /** A widget with a string value and a multiline text input */
export interface IMultilineStringWidget<TElement extends HTMLElement = HTMLTextAreaElement> extends IBaseWidget { export interface IMultilineStringWidget<TElement extends HTMLElement = HTMLTextAreaElement> extends
type?: "multiline" IBaseWidget {
value: string
/** HTML textarea element */ type?: "multiline"
element?: TElement value: string
/** HTML textarea element */
element?: TElement
} }
/** A custom widget - accepts any value and has no built-in special handling */ /** A custom widget - accepts any value and has no built-in special handling */
export interface ICustomWidget<TElement extends HTMLElement = HTMLElement> extends IBaseWidget<TElement> { export interface ICustomWidget<TElement extends HTMLElement = HTMLElement> extends
type?: "custom" IBaseWidget<TElement> {
value: string | object
element?: TElement type?: "custom"
value: string | object
element?: TElement
} }
/** /**
* Valid widget types. TS cannot provide easily extensible type safety for this at present. * Valid widget types. TS cannot provide easily extensible type safety for this at present.
* Override linkedWidgets[] * Override linkedWidgets[]
@@ -93,35 +102,47 @@ export type TWidgetValue = IWidget["value"]
* @see IWidget * @see IWidget
*/ */
export interface IBaseWidget<TElement extends HTMLElement = HTMLElement> { export interface IBaseWidget<TElement extends HTMLElement = HTMLElement> {
linkedWidgets?: IWidget[] linkedWidgets?: IWidget[]
options: IWidgetOptions options: IWidgetOptions
marker?: number marker?: number
label?: string label?: string
clicked?: boolean clicked?: boolean
name?: string name?: string
/** Widget type (see {@link TWidgetType}) */ /** Widget type (see {@link TWidgetType}) */
type?: TWidgetType type?: TWidgetType
value?: TWidgetValue value?: TWidgetValue
y?: number y?: number
last_y?: number last_y?: number
width?: number width?: number
disabled?: boolean disabled?: boolean
hidden?: boolean
advanced?: boolean
tooltip?: string hidden?: boolean
advanced?: boolean
/** HTML widget element */ tooltip?: string
element?: TElement
// TODO: Confirm this format /** HTML widget element */
callback?(value: any, canvas?: LGraphCanvas, node?: LGraphNode, pos?: Point, e?: CanvasMouseEvent): void element?: TElement
onRemove?(): void
beforeQueued?(): void
mouse?(event: CanvasMouseEvent, arg1: number[], node: LGraphNode): boolean // TODO: Confirm this format
draw?(ctx: CanvasRenderingContext2D, node: LGraphNode, widget_width: number, y: number, H: number): void callback?(
computeSize?(width: number): Size value: any,
canvas?: LGraphCanvas,
node?: LGraphNode,
pos?: Point,
e?: CanvasMouseEvent,
): void
onRemove?(): void
beforeQueued?(): void
mouse?(event: CanvasMouseEvent, arg1: number[], node: LGraphNode): boolean
draw?(
ctx: CanvasRenderingContext2D,
node: LGraphNode,
widget_width: number,
y: number,
H: number,
): void
computeSize?(width: number): Size
} }

View File

@@ -4,34 +4,35 @@ import type { LGraphNode } from "../LGraphNode"
/** /**
* Finds the nodes that are farthest in all four directions, representing the boundary of the nodes. * Finds the nodes that are farthest in all four directions, representing the boundary of the nodes.
* @param nodes The nodes to check the edges of * @param nodes The nodes to check the edges of
* @returns An object listing the furthest node (edge) in all four directions. `null` if no nodes were supplied or the first node was falsy. * @returns An object listing the furthest node (edge) in all four directions.
* `null` if no nodes were supplied or the first node was falsy.
*/ */
export function getBoundaryNodes(nodes: LGraphNode[]): IBoundaryNodes | null { export function getBoundaryNodes(nodes: LGraphNode[]): IBoundaryNodes | null {
const valid = nodes?.find(x => x) const valid = nodes?.find(x => x)
if (!valid) return null if (!valid) return null
let top = valid let top = valid
let right = valid let right = valid
let bottom = valid let bottom = valid
let left = valid let left = valid
for (const node of nodes) { for (const node of nodes) {
if (!node) continue if (!node) continue
const [x, y] = node.pos const [x, y] = node.pos
const [width, height] = node.size const [width, height] = node.size
if (y < top.pos[1]) top = node if (y < top.pos[1]) top = node
if (x + width > right.pos[0] + right.size[0]) right = node if (x + width > right.pos[0] + right.size[0]) right = node
if (y + height > bottom.pos[1] + bottom.size[1]) bottom = node if (y + height > bottom.pos[1] + bottom.size[1]) bottom = node
if (x < left.pos[0]) left = node if (x < left.pos[0]) left = node
} }
return { return {
top, top,
right, right,
bottom, bottom,
left left,
} }
} }
/** /**
@@ -40,30 +41,30 @@ export function getBoundaryNodes(nodes: LGraphNode[]): IBoundaryNodes | null {
* @param horizontal If true, distributes along the horizontal plane. Otherwise, the vertical plane. * @param horizontal If true, distributes along the horizontal plane. Otherwise, the vertical plane.
*/ */
export function distributeNodes(nodes: LGraphNode[], horizontal?: boolean): void { export function distributeNodes(nodes: LGraphNode[], horizontal?: boolean): void {
const nodeCount = nodes?.length const nodeCount = nodes?.length
if (!(nodeCount > 1)) return if (!(nodeCount > 1)) return
const index = horizontal ? 0 : 1 const index = horizontal ? 0 : 1
let total = 0 let total = 0
let highest = -Infinity let highest = -Infinity
for (const node of nodes) { for (const node of nodes) {
total += node.size[index] total += node.size[index]
const high = node.pos[index] + node.size[index] const high = node.pos[index] + node.size[index]
if (high > highest) highest = high if (high > highest) highest = high
} }
const sorted = [...nodes].sort((a, b) => a.pos[index] - b.pos[index]) const sorted = [...nodes].sort((a, b) => a.pos[index] - b.pos[index])
const lowest = sorted[0].pos[index] const lowest = sorted[0].pos[index]
const gap = ((highest - lowest) - total) / (nodeCount - 1) const gap = (highest - lowest - total) / (nodeCount - 1)
let startAt = lowest let startAt = lowest
for (let i = 0; i < nodeCount; i++) { for (let i = 0; i < nodeCount; i++) {
const node = sorted[i] const node = sorted[i]
node.pos[index] = startAt + (gap * i) node.pos[index] = startAt + gap * i
startAt += node.size[index] startAt += node.size[index]
} }
} }
/** /**
@@ -72,34 +73,33 @@ export function distributeNodes(nodes: LGraphNode[], horizontal?: boolean): void
* @param direction The edge to align nodes on * @param direction The edge to align nodes on
* @param align_to The node to align all other nodes to. If undefined, the farthest node will be used. * @param align_to The node to align all other nodes to. If undefined, the farthest node will be used.
*/ */
export function alignNodes(nodes: LGraphNode[], direction: Direction, align_to?: LGraphNode): void { export function alignNodes(
if (!nodes) return nodes: LGraphNode[],
direction: Direction,
align_to?: LGraphNode,
): void {
if (!nodes) return
const boundary = align_to === undefined const boundary = align_to === undefined
? getBoundaryNodes(nodes) ? getBoundaryNodes(nodes)
: { : { top: align_to, right: align_to, bottom: align_to, left: align_to }
top: align_to,
right: align_to,
bottom: align_to,
left: align_to
}
if (boundary === null) return if (boundary === null) return
for (const node of nodes) { for (const node of nodes) {
switch (direction) { switch (direction) {
case "right": case "right":
node.pos[0] = boundary.right.pos[0] + boundary.right.size[0] - node.size[0] node.pos[0] = boundary.right.pos[0] + boundary.right.size[0] - node.size[0]
break break
case "left": case "left":
node.pos[0] = boundary.left.pos[0] node.pos[0] = boundary.left.pos[0]
break break
case "top": case "top":
node.pos[1] = boundary.top.pos[1] node.pos[1] = boundary.top.pos[1]
break break
case "bottom": case "bottom":
node.pos[1] = boundary.bottom.pos[1] + boundary.bottom.size[1] - node.size[1] node.pos[1] = boundary.bottom.pos[1] + boundary.bottom.size[1] - node.size[1]
break break
}
} }
}
} }

View File

@@ -3,21 +3,21 @@ import { LGraphNode } from "@/LGraphNode"
/** /**
* Creates a flat set of all positionable items by recursively iterating through all child items. * Creates a flat set of all positionable items by recursively iterating through all child items.
* *
* Does not include or recurse into pinned items. * Does not include or recurse into pinned items.
* @param items The original set of items to iterate through * @param items The original set of items to iterate through
* @returns All unpinned items in the original set, and recursively, their children * @returns All unpinned items in the original set, and recursively, their children
*/ */
export function getAllNestedItems(items: ReadonlySet<Positionable>): Set<Positionable> { export function getAllNestedItems(items: ReadonlySet<Positionable>): Set<Positionable> {
const allItems = new Set<Positionable>() const allItems = new Set<Positionable>()
items?.forEach(x => addRecursively(x, allItems)) items?.forEach(x => addRecursively(x, allItems))
return allItems return allItems
function addRecursively(item: Positionable, flatSet: Set<Positionable>): void { function addRecursively(item: Positionable, flatSet: Set<Positionable>): void {
if (flatSet.has(item) || item.pinned) return if (flatSet.has(item) || item.pinned) return
flatSet.add(item) flatSet.add(item)
item.children?.forEach(x => addRecursively(x, flatSet)) item.children?.forEach(x => addRecursively(x, flatSet))
} }
} }
/** /**
@@ -26,7 +26,7 @@ export function getAllNestedItems(items: ReadonlySet<Positionable>): Set<Positio
* @returns The first node found in {@link items}, otherwise `undefined` * @returns The first node found in {@link items}, otherwise `undefined`
*/ */
export function findFirstNode(items: Iterable<Positionable>): LGraphNode | undefined { export function findFirstNode(items: Iterable<Positionable>): LGraphNode | undefined {
for (const item of items) { for (const item of items) {
if (item instanceof LGraphNode) return item if (item instanceof LGraphNode) return item
} }
} }

View File

@@ -1,18 +1,18 @@
/// <reference types='vitest' /> /// <reference types='vitest' />
import { defineConfig } from 'vite' import { defineConfig } from "vite"
import path from 'path' import path from "path"
import dts from 'vite-plugin-dts' import dts from "vite-plugin-dts"
export default defineConfig({ export default defineConfig({
build: { build: {
lib: { lib: {
entry: path.resolve(__dirname, 'src/litegraph'), entry: path.resolve(__dirname, "src/litegraph"),
name: 'litegraph.js', name: "litegraph.js",
fileName: (format) => `litegraph.${format}.js`, fileName: format => `litegraph.${format}.js`,
formats: ['es', 'umd'] formats: ["es", "umd"],
}, },
sourcemap: true, sourcemap: true,
target: ['es2022'], target: ["es2022"],
}, },
esbuild: { esbuild: {
minifyIdentifiers: false, minifyIdentifiers: false,
@@ -20,18 +20,18 @@ export default defineConfig({
}, },
plugins: [ plugins: [
dts({ dts({
entryRoot: 'src', entryRoot: "src",
insertTypesEntry: true, insertTypesEntry: true,
include: ['src/**/*.ts'], include: ["src/**/*.ts"],
outDir: 'dist', outDir: "dist",
aliasesExclude: ['@'], aliasesExclude: ["@"],
}), }),
], ],
resolve: { resolve: {
alias: { '@': '/src' }, alias: { "@": "/src" },
}, },
test: { test: {
alias: { '@/': path.resolve(__dirname, './src/') }, alias: { "@/": path.resolve(__dirname, "./src/") },
environment: 'jsdom', environment: "jsdom",
}, },
}) })