mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-13 00:50:01 +00:00
Add 'src/lib/litegraph/' from commit '1b58bf4966e9cdaa04bfaa40f5650b6c6680ab97'
git-subtree-dir: src/lib/litegraph git-subtree-mainline:1eadf80fecgit-subtree-split:1b58bf4966
This commit is contained in:
21
src/lib/litegraph/.cursor/rules/unit-test.mdc
Normal file
21
src/lib/litegraph/.cursor/rules/unit-test.mdc
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
description: Creating unit tests
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Creating unit tests
|
||||
|
||||
- This project uses `vitest` for unit testing
|
||||
- Tests are stored in the `test/` directory
|
||||
- Tests should be cross-platform compatible; able to run on Windows, macOS, and linux
|
||||
- e.g. the use of `path.resolve`, or `path.join` and `path.sep` to ensure that tests work the same on all platforms
|
||||
- Tests should be mocked properly
|
||||
- Mocks should be cleanly written and easy to understand
|
||||
- Mocks should be re-usable where possible
|
||||
|
||||
## Unit test style
|
||||
|
||||
- Prefer the use of `test.extend` over loose variables
|
||||
- To achieve this, import `test as baseTest` from `vitest`
|
||||
- Never use `it`; `test` should be used in place of this
|
||||
62
src/lib/litegraph/.github/workflows/dev-release.yml
vendored
Normal file
62
src/lib/litegraph/.github/workflows/dev-release.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: Create Dev Release
|
||||
description: Create a nightly-style npm package for a development / experimental branch. Do not use "latest" tag. This will not have a GitHub release / tag by default.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "npm tag (`ni pacakge@tag`)"
|
||||
required: true
|
||||
default: "subgraph"
|
||||
gh-release:
|
||||
description: "Draft a GitHub release"
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
if: inputs.gh-release == true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Get current version
|
||||
id: current_version
|
||||
run: |
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ steps.current_version.outputs.version }}
|
||||
draft: true
|
||||
prerelease: true
|
||||
generate_release_notes: true
|
||||
make_latest: "false"
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
if: inputs.tag != 'latest'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: lts/*
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm publish --access public --tag ${{ inputs.tag }}
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
62
src/lib/litegraph/.github/workflows/github-release.yml
vendored
Normal file
62
src/lib/litegraph/.github/workflows/github-release.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: Create GitHub Release
|
||||
description: Automatically creates a release when a PR is merged that both that changes package.json and has the Release label.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- "package.json"
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event.pull_request.merged == true &&
|
||||
contains(github.event.pull_request.labels.*.name, 'Release')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Get current version
|
||||
id: current_version
|
||||
run: |
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ steps.current_version.outputs.version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
make_latest: "true"
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event.pull_request.merged == true &&
|
||||
contains(github.event.pull_request.labels.*.name, 'Release')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: lts/*
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
60
src/lib/litegraph/.github/workflows/release-version.yml
vendored
Normal file
60
src/lib/litegraph/.github/workflows/release-version.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Release a New Version
|
||||
description: Creates a PR to increment the version. When the PR is merged, it will automatically create a release.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_type:
|
||||
description: Version increment type
|
||||
required: true
|
||||
default: patch
|
||||
type: choice
|
||||
options: [patch, minor, major, prepatch, preminor, premajor, prerelease]
|
||||
pre_release:
|
||||
description: Pre-release ID (suffix)
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "npm"
|
||||
|
||||
- name: Bump version
|
||||
id: bump-version
|
||||
run: |
|
||||
npm version ${{ github.event.inputs.version_type }} --preid ${{ github.event.inputs.pre_release }} --no-git-tag-version
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Format PR string
|
||||
id: capitalised
|
||||
run: |
|
||||
CAPITALISED_TYPE=${{ github.event.inputs.version_type }}
|
||||
echo "capitalised=${CAPITALISED_TYPE@u}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: "[Release] Increment version to ${{ steps.bump-version.outputs.NEW_VERSION }}"
|
||||
title: ${{ steps.bump-version.outputs.NEW_VERSION }}
|
||||
body: |
|
||||
${{ steps.capitalised.outputs.capitalised }} version increment to ${{ steps.bump-version.outputs.NEW_VERSION }}
|
||||
branch: version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
|
||||
base: master
|
||||
labels: |
|
||||
Release
|
||||
115
src/lib/litegraph/.github/workflows/test-comfyui-frontend.yml
vendored
Normal file
115
src/lib/litegraph/.github/workflows/test-comfyui-frontend.yml
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
# This action should be kept in sync with
|
||||
# https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/.github/workflows/test-ui.yaml
|
||||
|
||||
name: Test ComfyUI Frontend
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
frontend-branch:
|
||||
description: "Frontend branch"
|
||||
required: true
|
||||
default: "main"
|
||||
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths-ignore:
|
||||
- ".cursor/**"
|
||||
- ".husky/**"
|
||||
- ".vscode/**"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
- "test/**"
|
||||
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
paths-ignore:
|
||||
- ".cursor/**"
|
||||
- ".husky/**"
|
||||
- ".vscode/**"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
- "test/**"
|
||||
|
||||
jobs:
|
||||
test-comfyui-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout litegraph
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: "litegraph"
|
||||
|
||||
- name: Checkout ComfyUI
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: "comfyanonymous/ComfyUI"
|
||||
path: "ComfyUI"
|
||||
ref: master
|
||||
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: "Comfy-Org/ComfyUI_frontend"
|
||||
path: "ComfyUI_frontend"
|
||||
ref: ${{ inputs.frontend-branch }}
|
||||
|
||||
- name: Checkout ComfyUI_devtools
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: "Comfy-Org/ComfyUI_devtools"
|
||||
path: "ComfyUI/custom_nodes/ComfyUI_devtools"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install requirements
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements.txt
|
||||
pip install wait-for-it
|
||||
working-directory: ComfyUI
|
||||
|
||||
- name: Build litegraph
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
working-directory: litegraph
|
||||
|
||||
- name: Install updated litegraph in ComfyUI_frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm install ../litegraph
|
||||
npm run build
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- name: Start ComfyUI server
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../ComfyUI_frontend/dist &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
working-directory: ComfyUI
|
||||
|
||||
- name: Run UI tests
|
||||
run: |
|
||||
npm run test:component
|
||||
npm run test:unit
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- name: Run Playwright tests (chromium)
|
||||
run: npx playwright test --project=chromium
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: ComfyUI_frontend/playwright-report/
|
||||
retention-days: 30
|
||||
35
src/lib/litegraph/.github/workflows/test-lint-format.yml
vendored
Normal file
35
src/lib/litegraph/.github/workflows/test-lint-format.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Unit Test, Lint, and Format
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, "dev*"]
|
||||
pull_request:
|
||||
branches: [main, master, "dev*"]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Run lint
|
||||
run: |
|
||||
npm run lint:ci
|
||||
|
||||
- name: Run format
|
||||
run: |
|
||||
npm run format
|
||||
|
||||
- name: Run vitest tests
|
||||
run: |
|
||||
npm test -- --reporter=verbose
|
||||
33
src/lib/litegraph/.gitignore
vendored
Executable file
33
src/lib/litegraph/.gitignore
vendored
Executable file
@@ -0,0 +1,33 @@
|
||||
temp/
|
||||
temp/*
|
||||
coverage/
|
||||
|
||||
# Editors
|
||||
*.bak
|
||||
.project
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
5
src/lib/litegraph/.husky/pre-commit
Normal file
5
src/lib/litegraph/.husky/pre-commit
Normal file
@@ -0,0 +1,5 @@
|
||||
if [[ "$OS" == "Windows_NT" ]]; then
|
||||
npx.cmd lint-staged
|
||||
else
|
||||
npx lint-staged
|
||||
fi
|
||||
8
src/lib/litegraph/.prettierrc
Executable file
8
src/lib/litegraph/.prettierrc
Executable file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"singleQuote": false,
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"overrides": [{ "files": "*.ts", "options": { "requirePragma": true } }],
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
203
src/lib/litegraph/API.md
Normal file
203
src/lib/litegraph/API.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# API
|
||||
|
||||
This document is intended to provide a brief introduction to new Litegraph APIs.
|
||||
|
||||
<detail open>
|
||||
|
||||
<summary>
|
||||
|
||||
# CanvasPointer API
|
||||
|
||||
</summary>
|
||||
|
||||
CanvasPointer replaces much of the original pointer handling code. It provides a standard click, double-click, and drag UX for users.
|
||||
|
||||
<detail open>
|
||||
|
||||
<summary>
|
||||
|
||||
## Default behaviour changes
|
||||
|
||||
</summary>
|
||||
|
||||
- Dragging multiple items no longer requires that the shift key be held down
|
||||
- Clicking an item when multiple nodes / etc are selected will still deselect everything else
|
||||
- Clicking a connected link on an input no longer disconnects and reconnects it
|
||||
- Double-clicking requires that both clicks occur nearby
|
||||
- Provides much room for extension, configuration, and changes
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Intermittent issue where clicking a node slightly displaces it
|
||||
- Alt-clicking to add a reroute creates two undo steps
|
||||
|
||||
### Selecting multiple items
|
||||
|
||||
- `Ctrl + drag` - Begin multi-select
|
||||
- `Ctrl + Shift + drag` - Add to selection
|
||||
- `Ctrl + drag`, `Shift` - Alternate add to selection
|
||||
- `Ctrl + drag`, `Alt` - Remove from selection
|
||||
|
||||
### Click "drift"
|
||||
|
||||
A small amount of buffering is performed between down/up events to prevent accidental micro-drag events. If either of the two controls are exceeded, the event will be considered a drag event, not a click.
|
||||
|
||||
- `buffterTime` is the maximum time that tiny movements can be ignored (Default: 150ms)
|
||||
- `maxClickDrift` controls how far a click can drift from its down event before it is considered a drag (Default: 6)
|
||||
|
||||
### Double-click
|
||||
|
||||
When double clicking, the double click callback is executed shortly after one normal click callback (if present). At present, dragging from the second click simply invalidates the event - nothing will happen.
|
||||
|
||||
- `doubleClickTime` is the maximum time between two `down` events for them to be considered a double click (Default: 300ms)
|
||||
- Distance between the two events must be less than `3 * maxClickDrift`
|
||||
|
||||
### Configuration
|
||||
|
||||
All above configuration is via class static.
|
||||
|
||||
```ts
|
||||
CanvasPointer.bufferTime = 150
|
||||
CanvasPointer.maxClickDrift = 6
|
||||
CanvasPointer.doubleClickTime = 300
|
||||
```
|
||||
|
||||
</detail>
|
||||
|
||||
<detail open>
|
||||
|
||||
<summary>
|
||||
|
||||
## Implementing
|
||||
|
||||
</summary>
|
||||
|
||||
Clicking, double-clicking, and dragging can now all be configured during the initial `pointerdown` event, and the correct callback(s) will be executed.
|
||||
|
||||
A click event can be as simple as:
|
||||
|
||||
```ts
|
||||
if (node.isClickedInSpot(e.canvasX, e.canvasY))
|
||||
this.pointer.onClick = () => node.gotClickInSpot()
|
||||
```
|
||||
|
||||
Full usage can be seen in the old `processMouseDown` handler, which is still in place (several monkey patches in the wild).
|
||||
|
||||
### Registering a click or drag event
|
||||
|
||||
Example usage:
|
||||
|
||||
```typescript
|
||||
const { pointer } = this
|
||||
// Click / double click - executed on pointerup
|
||||
pointer.onClick = e => node.executeClick(e)
|
||||
pointer.onDoubleClick = node.gotDoubleClick
|
||||
|
||||
// Drag events - executed on pointermove
|
||||
pointer.onDragStart = e => {
|
||||
node.isBeingDragged = true
|
||||
canvas.startedDragging(e)
|
||||
}
|
||||
pointer.onDrag = () => {}
|
||||
// finally() is preferred where possible, as it is guaranteed to run
|
||||
pointer.onDragEnd = () => {}
|
||||
|
||||
// Always run, regardless of outcome
|
||||
pointer.finally = () => (node.isBeingDragged = false)
|
||||
```
|
||||
|
||||
## Widgets
|
||||
|
||||
Adds `onPointerDown` callback to node widgets. A few benefits of the new API:
|
||||
|
||||
- Simplified usage
|
||||
- Exposes callbacks like "double click", removing the need to time / measure multiple pointer events
|
||||
- Unified UX - same API as used in the rest of Litegraph
|
||||
- Honours the user's click speed and pointer accuracy settings
|
||||
|
||||
#### Usage
|
||||
|
||||
```ts
|
||||
// Callbacks for each pointer action can be configured ahead of time
|
||||
widget.onPointerDown = function (pointer, node, canvas) {
|
||||
const e = pointer.eDown
|
||||
const offsetFromNode = [e.canvasX - node.pos[0], e.canvasY - node.pos[1]]
|
||||
|
||||
// Click events - no overlap with drag events
|
||||
pointer.onClick = upEvent => {
|
||||
// Provides access to the whole lifecycle of events in every callback
|
||||
console.log(pointer.eDown)
|
||||
console.log(pointer.eMove ?? "Pointer didn't move")
|
||||
console.log(pointer.eUp)
|
||||
}
|
||||
pointer.onDoubleClick = upEvent => this.customFunction(upEvent)
|
||||
|
||||
// Runs once before the first onDrag event
|
||||
pointer.onDragStart = () => {}
|
||||
// Receives every movement event
|
||||
pointer.onDrag = moveEvent => {}
|
||||
// The pointerup event of a drag
|
||||
pointer.onDragEnd = upEvent => {}
|
||||
|
||||
// Semantics of a "finally" block (try/catch). Once set, the block always executes.
|
||||
pointer.finally = () => {}
|
||||
|
||||
// Return true to cancel regular Litegraph handling of this click / drag
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
</detail>
|
||||
|
||||
### TypeScript & JSDoc
|
||||
|
||||
In-IDE typing is available for use in at least mainstream editors. It can be added to JS projects using the @comfyorg/litegraph npm package.
|
||||
|
||||
```ts
|
||||
/** @import { IWidget } from './path/to/@comfyorg/litegraph/litegraph.d.ts' */
|
||||
/** @type IWidget */
|
||||
const widget = node.widgets[0]
|
||||
widget.onPointerDown = function (pointer, node, canvas) {}
|
||||
```
|
||||
|
||||
#### VS Code
|
||||
|
||||

|
||||
|
||||
## Hovering over
|
||||
|
||||
Adds API for downstream consumers to handle custom cursors. A bitwise enum of items,
|
||||
|
||||
```typescript
|
||||
type LGraphCanvasState = {
|
||||
/** If `true`, pointer move events will set the canvas cursor style. */
|
||||
shouldSetCursor: boolean,
|
||||
/** Bit flags indicating what is currently below the pointer. */
|
||||
hoveringOver: CanvasItem,
|
||||
...
|
||||
}
|
||||
|
||||
// Disable litegraph cursors
|
||||
canvas.state.shouldSetCursor = false
|
||||
|
||||
// Checking state - bit operators
|
||||
if (canvas.state.hoveringOver & CanvasItem.ResizeSe) element.style.cursor = 'se-resize'
|
||||
```
|
||||
|
||||
</detail>
|
||||
|
||||
# Removed public interfaces
|
||||
|
||||
All are unused and incomplete. Have bugs beyond just typescript typing, and are (currently) not worth maintaining. If any of these features are desired down the track, they can be reimplemented.
|
||||
|
||||
- Live mode
|
||||
- Subgraph
|
||||
- `dragged_node`
|
||||
|
||||
## LiteGraph
|
||||
|
||||
These features have not been maintained, and would require refactoring / rewrites. As code search revealed them to be unused, they are being removed.
|
||||
|
||||
- addNodeMethod
|
||||
- compareObjects
|
||||
- auto_sort_node_types (option)
|
||||
62
src/lib/litegraph/CLAUDE.md
Normal file
62
src/lib/litegraph/CLAUDE.md
Normal file
@@ -0,0 +1,62 @@
|
||||
- This codebase has extensive eslint autofix rules and IDEs are configured to use eslint as the format on save tool. Run ESLint instead of manually figuring out whitespace fixes or other trivial style concerns. Review the results and correct any remaining eslint errors.
|
||||
- Take advantage of `TypedArray` `subarray` when appropriate.
|
||||
- The `size` and `pos` properties of `Rectangle` share the same array buffer (`subarray`); they may be used to set the rectangles size and position.
|
||||
- Prefer single line `if` syntax over adding curly braces, when the statement has a very concise expression and concise, single line statement.
|
||||
- Do not replace `&&=` or `||=` with `=` when there is no reason to do so. If you do find a reason to remove either `&&=` or `||=`, leave a comment explaining why the removal occurred.
|
||||
- You are allowed to research code on https://developer.mozilla.org/ and https://stackoverflow.com without asking.
|
||||
- When adding features, always write vitest unit tests using cursor rules in @.cursor
|
||||
- When writing methods, prefer returning idiomatic JavaScript `undefined` over `null`.
|
||||
|
||||
# Bash commands
|
||||
|
||||
- `npm run typecheck` Run the typechecker
|
||||
- `npm run build` Build the project
|
||||
- `npm run lint:fix` Run ESLint
|
||||
|
||||
# Code style
|
||||
|
||||
- Always prefer best practices when writing code.
|
||||
- Write using concise, legible, and easily maintainable code.
|
||||
- Avoid repetition where possible, but not at the expense of code legibility.
|
||||
- Type assertions are an absolute last resort. In almost all cases, they are a crutch that leads to brittle code.
|
||||
|
||||
# Workflow
|
||||
|
||||
- Be sure to typecheck when you’re done making a series of code changes
|
||||
- Prefer running single tests, and not the whole test suite, for performance
|
||||
|
||||
# Testing Guidelines
|
||||
|
||||
## Avoiding Circular Dependencies in Tests
|
||||
|
||||
**CRITICAL**: When writing tests for subgraph-related code, always import from the barrel export to avoid circular dependency issues:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use barrel import
|
||||
import { LGraph, Subgraph, SubgraphNode } from "@/litegraph"
|
||||
|
||||
// ❌ WRONG - Direct imports cause circular dependency
|
||||
import { LGraph } from "@/LGraph"
|
||||
import { Subgraph } from "@/subgraph/Subgraph"
|
||||
import { SubgraphNode } from "@/subgraph/SubgraphNode"
|
||||
```
|
||||
|
||||
**Root cause**: `LGraph` and `Subgraph` have a circular dependency:
|
||||
- `LGraph.ts` imports `Subgraph` (creates instances with `new Subgraph()`)
|
||||
- `Subgraph.ts` extends `LGraph`
|
||||
|
||||
The barrel export (`@/litegraph`) handles this properly, but direct imports cause module loading failures.
|
||||
|
||||
## Test Setup for Subgraphs
|
||||
|
||||
Use the provided test helpers for consistent setup:
|
||||
|
||||
```typescript
|
||||
import { createTestSubgraph, createTestSubgraphNode } from "./fixtures/subgraphHelpers"
|
||||
|
||||
function createTestSetup() {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
return { subgraph, subgraphNode }
|
||||
}
|
||||
```
|
||||
9
src/lib/litegraph/CONTRIBUTING.md
Executable file
9
src/lib/litegraph/CONTRIBUTING.md
Executable file
@@ -0,0 +1,9 @@
|
||||
# Contribution Rules
|
||||
There are some simple rules that everyone should follow:
|
||||
|
||||
### Do not commit files from build folder
|
||||
> I usually have horrible merge conflicts when I upload the build version that take me too much time to solve, but I want to keep the build version in the repo, so I guess it would be better if only one of us does the built, which would be me.
|
||||
> https://github.com/jagenjo/litegraph.js/pull/155#issuecomment-656602861
|
||||
Those files will be updated by owner.
|
||||
|
||||
|
||||
19
src/lib/litegraph/LICENSE
Executable file
19
src/lib/litegraph/LICENSE
Executable file
@@ -0,0 +1,19 @@
|
||||
Copyright (C) 2013 by Javi Agenjo
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
173
src/lib/litegraph/README.md
Executable file
173
src/lib/litegraph/README.md
Executable file
@@ -0,0 +1,173 @@
|
||||
# @ComfyOrg/litegraph
|
||||
|
||||
This is the litegraph version used in [ComfyUI_frontend](https://github.com/Comfy-Org/ComfyUI_frontend).
|
||||
|
||||
It is a fork of the original `litegraph.js`. Some APIs may by unchanged, however it is largely incompatible with the original.
|
||||
|
||||
Some early highlights:
|
||||
|
||||
- Accumulated comfyUI custom changes (2024-01 ~ 2024-05) (https://github.com/Comfy-Org/litegraph.js/pull/1)
|
||||
- Type schema change for ComfyUI_frontend TS migration (https://github.com/Comfy-Org/litegraph.js/pull/3)
|
||||
- Zoom fix (https://github.com/Comfy-Org/litegraph.js/pull/7)
|
||||
- Emit search box triggering custom events (<https://github.com/Comfy-Org/litegraph.js/pull/10>)
|
||||
- Truncate overflowing combo widget text (<https://github.com/Comfy-Org/litegraph.js/pull/17>)
|
||||
- Sort node based on ID on graph serialization (<https://github.com/Comfy-Org/litegraph.js/pull/21>)
|
||||
- Fix empty input not used when connecting links (<https://github.com/Comfy-Org/litegraph.js/pull/24>)
|
||||
- Batch output connection move/disconnect (<https://github.com/Comfy-Org/litegraph.js/pull/39>)
|
||||
- And now with hundreds more...
|
||||
|
||||
# Install
|
||||
|
||||
`npm i @comfyorg/litegraph`
|
||||
|
||||
# litegraph.js
|
||||
|
||||
A TypeScript library to create graphs in the browser similar to Unreal Blueprints.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Description of the original litegraph.js</summary>
|
||||
|
||||
A library in Javascript to create graphs in the browser similar to Unreal Blueprints. Nodes can be programmed easily and it includes an editor to construct and tests the graphs.
|
||||
|
||||
It can be integrated easily in any existing web applications and graphs can be run without the need of the editor.
|
||||
|
||||
</details>
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Renders on Canvas2D (zoom in/out and panning, easy to render complex interfaces, can be used inside a WebGLTexture)
|
||||
- Easy to use editor (searchbox, keyboard shortcuts, multiple selection, context menu, ...)
|
||||
- Optimized to support hundreds of nodes per graph (on editor but also on execution)
|
||||
- Customizable theme (colors, shapes, background)
|
||||
- Callbacks to personalize every action/drawing/event of nodes
|
||||
- Graphs can be executed in NodeJS
|
||||
- Highly customizable nodes (color, shape, widgets, custom rendering)
|
||||
- Easy to integrate in any JS application (one single file, no dependencies)
|
||||
- Typescript support
|
||||
|
||||
## Installation
|
||||
|
||||
You can install it using npm
|
||||
|
||||
```bash
|
||||
npm install @comfyorg/litegraph
|
||||
```
|
||||
|
||||
## How to code a new Node type
|
||||
|
||||
Here is an example of how to build a node that sums two inputs:
|
||||
|
||||
```ts
|
||||
import { LiteGraph, LGraphNode } from "./litegraph"
|
||||
|
||||
class MyAddNode extends LGraphNode {
|
||||
// Name to show
|
||||
title = "Sum"
|
||||
|
||||
constructor() {
|
||||
this.addInput("A", "number")
|
||||
this.addInput("B", "number")
|
||||
this.addOutput("A+B", "number")
|
||||
this.properties.precision = 1
|
||||
}
|
||||
|
||||
// Function to call when the node is executed
|
||||
onExecute() {
|
||||
var A = this.getInputData(0)
|
||||
if (A === undefined) A = 0
|
||||
var B = this.getInputData(1)
|
||||
if (B === undefined) B = 0
|
||||
this.setOutputData(0, A + B)
|
||||
}
|
||||
}
|
||||
|
||||
// Register the node type
|
||||
LiteGraph.registerNodeType("basic/sum", MyAddNode)
|
||||
```
|
||||
|
||||
## Server side
|
||||
|
||||
It also works server-side using NodeJS although some nodes do not work in server (audio, graphics, input, etc).
|
||||
|
||||
```ts
|
||||
import { LiteGraph, LGraph } from "./litegraph.js"
|
||||
|
||||
const graph = new LGraph()
|
||||
|
||||
const firstNode = LiteGraph.createNode("basic/sum")
|
||||
graph.add(firstNode)
|
||||
|
||||
const secondNode = LiteGraph.createNode("basic/sum")
|
||||
graph.add(secondNode)
|
||||
|
||||
firstNode.connect(0, secondNode, 1)
|
||||
|
||||
graph.start()
|
||||
```
|
||||
|
||||
## Projects using it
|
||||
|
||||
### [ComfyUI](https://github.com/comfyanonymous/ComfyUI)
|
||||
|
||||

|
||||
|
||||
### Projects using the original litegraph.js
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
### [webglstudio.org](http://webglstudio.org)
|
||||
|
||||

|
||||
|
||||
### [MOI Elephant](http://moiscript.weebly.com/elephant-systegraveme-nodal.html)
|
||||
|
||||

|
||||
|
||||
### Mynodes
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## Feedback
|
||||
|
||||
Please [open an issue](https://github.com/Comfy-Org/litegraph.js/issues/) on the GitHub repo.
|
||||
|
||||
# Development
|
||||
|
||||
Litegraph has no runtime dependencies. The build tooling has been tested on Node.JS 20.18.x
|
||||
|
||||
## Releasing
|
||||
|
||||
Use GitHub actions to release normal versions.
|
||||
|
||||
1. Run the `Release a New Version` action, selecting the version incrment type
|
||||
1. Merge the resultion PR
|
||||
1. A GitHub release is automatically published on merge
|
||||
|
||||
### Pre-release
|
||||
|
||||
The action directly translates `Version increment type` to the npm version command. `Pre-release ID (suffix)` is the option for the `--preid` argument.
|
||||
|
||||
e.g. Use `prerelease` increment type to automatically bump the patch version and create a pre-release version. Subsequent runs of prerelease will update the prerelease version only.
|
||||
Use `patch` when ready to remove the pre-release suffix.
|
||||
|
||||
## Contributors
|
||||
|
||||
You can find the [current list of contributors](https://github.com/Comfy-Org/litegraph.js/graphs/contributors) on GitHub.
|
||||
|
||||
### Contributors (pre-fork)
|
||||
|
||||
- atlasan
|
||||
- kriffe
|
||||
- rappestad
|
||||
- InventivetalentDev
|
||||
- NateScarlet
|
||||
- coderofsalvation
|
||||
- ilyabesk
|
||||
- gausszhou
|
||||
310
src/lib/litegraph/eslint.config.js
Normal file
310
src/lib/litegraph/eslint.config.js
Normal file
@@ -0,0 +1,310 @@
|
||||
import eslint from "@eslint/js"
|
||||
import stylistic from "@stylistic/eslint-plugin"
|
||||
import eslintPluginAntfu from "eslint-plugin-antfu"
|
||||
import jsdoc from "eslint-plugin-jsdoc"
|
||||
import eslintPluginSimpleImportSort from "eslint-plugin-simple-import-sort"
|
||||
import eslintPluginUnicorn from "eslint-plugin-unicorn"
|
||||
import unusedImports from "eslint-plugin-unused-imports"
|
||||
import globals from "globals"
|
||||
import tseslint from "typescript-eslint"
|
||||
|
||||
const rules = Object.fromEntries(
|
||||
Object.entries(eslintPluginAntfu.rules).map(([id]) => [`antfu/${id}`, "off"]),
|
||||
)
|
||||
const antfuLint = {
|
||||
name: "antfu/without-if-newline-or-imports",
|
||||
plugins: { antfu: eslintPluginAntfu },
|
||||
rules,
|
||||
}
|
||||
|
||||
const unicornRecommended = eslintPluginUnicorn.configs.recommended
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: [".*/**", "dist/**", "scripts/**"] },
|
||||
{ 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,
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
jsdoc: {
|
||||
mode: "typescript",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["./dist/**/*"],
|
||||
},
|
||||
|
||||
// Unicorn
|
||||
unicornRecommended,
|
||||
{
|
||||
rules: {
|
||||
// Temporarily disabled
|
||||
// See https://github.com/Comfy-Org/litegraph.js/issues/629
|
||||
"unicorn/no-lonely-if": "off",
|
||||
"unicorn/no-this-assignment": "off",
|
||||
"unicorn/no-useless-switch-case": "off",
|
||||
"unicorn/no-zero-fractions": "off",
|
||||
"unicorn/prefer-blob-reading-methods": "off",
|
||||
"unicorn/prefer-default-parameters": "off",
|
||||
"unicorn/prefer-math-min-max": "off",
|
||||
"unicorn/prefer-query-selector": "off",
|
||||
"unicorn/prefer-spread": "off",
|
||||
"unicorn/prefer-structured-clone": "off",
|
||||
"unicorn/prefer-switch": "off",
|
||||
"unicorn/prefer-ternary": "off",
|
||||
|
||||
// Disable rules
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
"unicorn/explicit-length-check": "off",
|
||||
"unicorn/filename-case": "off",
|
||||
"unicorn/no-negated-condition": "off",
|
||||
"unicorn/no-new-array": "off",
|
||||
"unicorn/no-null": "off",
|
||||
"unicorn/prefer-global-this": "off",
|
||||
"unicorn/prefer-number-properties": "off",
|
||||
"unicorn/prefer-string-raw": "off",
|
||||
"unicorn/prefer-string-slice": "off",
|
||||
"unicorn/prevent-abbreviations": "off",
|
||||
"unicorn/require-number-to-fixed-digits-argument": "off",
|
||||
"unicorn/switch-case-braces": "off",
|
||||
|
||||
// Node rules: dev dependency config, etc.
|
||||
"unicorn/prefer-module": "error",
|
||||
"unicorn/prefer-node-protocol": "error",
|
||||
},
|
||||
},
|
||||
|
||||
// JSDoc
|
||||
jsdoc.configs["flat/contents-typescript-error"],
|
||||
jsdoc.configs["flat/logical-typescript-error"],
|
||||
jsdoc.configs["flat/stylistic-typescript-error"],
|
||||
{
|
||||
rules: {
|
||||
"jsdoc/check-param-names": [
|
||||
"error",
|
||||
{
|
||||
disableMissingParamChecks: true,
|
||||
disableExtraPropertyReporting: true,
|
||||
checkRestProperty: false,
|
||||
checkDestructured: false,
|
||||
},
|
||||
],
|
||||
"jsdoc/check-tag-names": ["error", { definedTags: ["remarks"] }],
|
||||
"jsdoc/multiline-blocks": "error",
|
||||
// Disabling
|
||||
"jsdoc/empty-tags": "off",
|
||||
"jsdoc/lines-before-block": "off",
|
||||
"jsdoc/match-description": "off",
|
||||
"jsdoc/no-undefined-types": "off",
|
||||
"jsdoc/text-escaping": "off",
|
||||
"jsdoc/valid-types": "off",
|
||||
"jsdoc/informative-docs": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// Base, TypeScript, and Stylistic
|
||||
{
|
||||
rules: {
|
||||
"prefer-template": "error",
|
||||
|
||||
// TODO: Update when TypeScript has been cleaned
|
||||
// https://github.com/Comfy-Org/litegraph.js/issues/657
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "off",
|
||||
"prefer-spread": "off",
|
||||
"no-empty": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"no-var": "error",
|
||||
"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-unsafe-function-type": "off",
|
||||
|
||||
"@stylistic/max-len": [
|
||||
"off",
|
||||
{
|
||||
code: 100,
|
||||
comments: 130,
|
||||
ignoreStrings: true,
|
||||
ignoreTemplateLiterals: true,
|
||||
ignoreComments: true,
|
||||
},
|
||||
],
|
||||
|
||||
// "@stylistic/multiline-comment-style": ["error", "starred-block"],
|
||||
"@stylistic/curly-newline": [
|
||||
"error",
|
||||
{ consistent: true, multiline: true },
|
||||
],
|
||||
// "@stylistic/object-property-newline": ["error", { allowAllPropertiesOnSameLine: true }],
|
||||
// "@stylistic/object-property-newline": "error",
|
||||
"@stylistic/one-var-declaration-per-line": "error",
|
||||
|
||||
"@stylistic/array-bracket-newline": ["error", { multiline: true }],
|
||||
"@stylistic/array-element-newline": [
|
||||
"error",
|
||||
{ consistent: true, multiline: true },
|
||||
],
|
||||
|
||||
"@stylistic/function-paren-newline": ["error", "multiline-arguments"],
|
||||
|
||||
"@stylistic/array-bracket-spacing": "error",
|
||||
"@stylistic/arrow-parens": "error",
|
||||
"@stylistic/arrow-spacing": "error",
|
||||
"@stylistic/block-spacing": "error",
|
||||
"@stylistic/brace-style": "error",
|
||||
"@stylistic/comma-dangle": "error",
|
||||
"@stylistic/comma-spacing": "error",
|
||||
"@stylistic/comma-style": "error",
|
||||
"@stylistic/computed-property-spacing": "error",
|
||||
"@stylistic/dot-location": "error",
|
||||
"@stylistic/eol-last": "error",
|
||||
"@stylistic/indent": ["error", 2, { VariableDeclarator: "first" }],
|
||||
"@stylistic/indent-binary-ops": "error",
|
||||
"@stylistic/key-spacing": "error",
|
||||
"@stylistic/keyword-spacing": "error",
|
||||
"@stylistic/lines-between-class-members": "error",
|
||||
"@stylistic/max-statements-per-line": "error",
|
||||
"@stylistic/member-delimiter-style": "error",
|
||||
"@stylistic/multiline-ternary": "error",
|
||||
"@stylistic/new-parens": "error",
|
||||
"@stylistic/no-extra-parens": "error",
|
||||
"@stylistic/no-floating-decimal": "error",
|
||||
"@stylistic/no-mixed-operators": "error",
|
||||
"@stylistic/no-mixed-spaces-and-tabs": "error",
|
||||
"@stylistic/no-multi-spaces": "error",
|
||||
"@stylistic/no-multiple-empty-lines": "error",
|
||||
"@stylistic/no-tabs": "error",
|
||||
"@stylistic/no-trailing-spaces": "error",
|
||||
"@stylistic/no-whitespace-before-property": "error",
|
||||
"@stylistic/object-curly-spacing": "error",
|
||||
"@stylistic/operator-linebreak": [
|
||||
"error",
|
||||
"after",
|
||||
{ overrides: { "?": "before", ":": "before" } },
|
||||
],
|
||||
"@stylistic/padded-blocks": "error",
|
||||
"@stylistic/quote-props": "error",
|
||||
"@stylistic/quotes": "error",
|
||||
"@stylistic/rest-spread-spacing": "error",
|
||||
"@stylistic/semi": "error",
|
||||
"@stylistic/semi-spacing": "error",
|
||||
"@stylistic/semi-style": ["error", "first"],
|
||||
"@stylistic/space-before-blocks": "error",
|
||||
"@stylistic/space-before-function-paren": "error",
|
||||
"@stylistic/space-in-parens": "error",
|
||||
"@stylistic/space-infix-ops": "error",
|
||||
"@stylistic/space-unary-ops": "error",
|
||||
"@stylistic/spaced-comment": "error",
|
||||
"@stylistic/template-curly-spacing": "error",
|
||||
"@stylistic/template-tag-spacing": "error",
|
||||
"@stylistic/type-annotation-spacing": "error",
|
||||
"@stylistic/type-generic-spacing": "error",
|
||||
"@stylistic/type-named-tuple-spacing": "error",
|
||||
"@stylistic/wrap-iife": "error",
|
||||
"@stylistic/yield-star-spacing": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
},
|
||||
files: ["test/**/*.ts"],
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
"unused-imports": unusedImports,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
vars: "all",
|
||||
varsIgnorePattern: "^_",
|
||||
args: "after-used",
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Antfu
|
||||
antfuLint,
|
||||
{
|
||||
rules: {
|
||||
"antfu/consistent-chaining": "error",
|
||||
"antfu/consistent-list-newline": "error",
|
||||
"antfu/curly": "error",
|
||||
"antfu/import-dedupe": "error",
|
||||
"antfu/no-import-dist": "error",
|
||||
"antfu/no-ts-export-equal": "error",
|
||||
"antfu/top-level-function": "error",
|
||||
},
|
||||
},
|
||||
|
||||
// Sort imports
|
||||
{
|
||||
plugins: {
|
||||
"simple-import-sort": eslintPluginSimpleImportSort,
|
||||
},
|
||||
rules: {
|
||||
"simple-import-sort/imports": [
|
||||
"error",
|
||||
{
|
||||
// The default grouping, but with type imports first as a separate group.
|
||||
groups: [
|
||||
["^.*\\u0000$"],
|
||||
["^\\u0000"],
|
||||
["^node:"],
|
||||
["^@?\\w"],
|
||||
["^"],
|
||||
["^\\."],
|
||||
],
|
||||
},
|
||||
],
|
||||
"simple-import-sort/exports": "error",
|
||||
},
|
||||
},
|
||||
)
|
||||
BIN
src/lib/litegraph/imgs/elephant.gif
Executable file
BIN
src/lib/litegraph/imgs/elephant.gif
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
BIN
src/lib/litegraph/imgs/mynodes.png
Executable file
BIN
src/lib/litegraph/imgs/mynodes.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
BIN
src/lib/litegraph/imgs/node_graph_example.png
Executable file
BIN
src/lib/litegraph/imgs/node_graph_example.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
BIN
src/lib/litegraph/imgs/webglstudio.gif
Executable file
BIN
src/lib/litegraph/imgs/webglstudio.gif
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 12 MiB |
17
src/lib/litegraph/lint-staged.config.js
Normal file
17
src/lib/litegraph/lint-staged.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
export default {
|
||||
"*.css": stagedFiles => `prettier --write ${stagedFiles.join(" ")}`,
|
||||
|
||||
"*.js": stagedFiles => prettierAndEslint(stagedFiles),
|
||||
|
||||
"*.{ts,mts}": stagedFiles => [
|
||||
...prettierAndEslint(stagedFiles),
|
||||
"tsc",
|
||||
],
|
||||
}
|
||||
|
||||
function prettierAndEslint(fileNames) {
|
||||
return [
|
||||
`prettier --write ${fileNames.join(" ")}`,
|
||||
`eslint --fix ${fileNames.join(" ")}`,
|
||||
]
|
||||
}
|
||||
6046
src/lib/litegraph/package-lock.json
generated
Normal file
6046
src/lib/litegraph/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
72
src/lib/litegraph/package.json
Normal file
72
src/lib/litegraph/package.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "@comfyorg/litegraph",
|
||||
"version": "0.17.0",
|
||||
"type": "module",
|
||||
"description": "A graph node editor similar to PD or UDK Blueprints. It works in an HTML5 Canvas and allows to export graphs to be included in applications.",
|
||||
"main": "./dist/litegraph.umd.js",
|
||||
"module": "./dist/litegraph.es.js",
|
||||
"types": "./dist/litegraph.d.ts",
|
||||
"style": "./dist/css/litegraph.css",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/litegraph.d.ts",
|
||||
"import": "./dist/litegraph.es.js",
|
||||
"require": "./dist/litegraph.umd.js"
|
||||
},
|
||||
"./style.css": "./dist/css/litegraph.css",
|
||||
"./dist/*": "./dist/*.d.ts"
|
||||
},
|
||||
"directories": {
|
||||
"doc": "doc"
|
||||
},
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"build": "tsc && vite build",
|
||||
"dev": "vite",
|
||||
"preview": "vite preview",
|
||||
"watch": "vite build --watch",
|
||||
"test": "vitest",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix",
|
||||
"lint:ci": "eslint src",
|
||||
"format": "prettier --check './src/*.{js,ts,tsx,vue,mts}'",
|
||||
"format:fix": "prettier --write './src/*.{js,ts,tsx,vue,mts}'",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:Comfy-Org/litegraph.js.git"
|
||||
},
|
||||
"author": "comfyorg",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"bugs": {
|
||||
"url": "https://github.com/Comfy-Org/litegraph.js/issues"
|
||||
},
|
||||
"homepage": "https://github.com/Comfy-Org/litegraph.js",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@stylistic/eslint-plugin": "^2.13.0",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/node": "^22.13.9",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-antfu": "^3.1.0",
|
||||
"eslint-plugin-jsdoc": "^50.6.3",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-unicorn": "^57.0.0",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"globals": "^15.12.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^25.0.1",
|
||||
"lint-staged": "^15.2.10",
|
||||
"prettier": "^3.3.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.26.0",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vitest": "^3.1.3"
|
||||
}
|
||||
}
|
||||
638
src/lib/litegraph/public/css/litegraph.css
Normal file
638
src/lib/litegraph/public/css/litegraph.css
Normal file
@@ -0,0 +1,638 @@
|
||||
/* this CSS contains only the basic CSS needed to run the app and use it */
|
||||
|
||||
.lgraphcanvas {
|
||||
/*cursor: crosshair;*/
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
outline: none;
|
||||
font-family: Tahoma, sans-serif;
|
||||
}
|
||||
|
||||
.lgraphcanvas * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu {
|
||||
font-family: Tahoma, sans-serif;
|
||||
position: fixed;
|
||||
top: 100px;
|
||||
left: 100px;
|
||||
min-width: 100px;
|
||||
color: #aaf;
|
||||
padding: 0;
|
||||
box-shadow: 0 0 10px black !important;
|
||||
background-color: #2e2e2e !important;
|
||||
z-index: 10;
|
||||
max-height: -webkit-fill-available;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Enable scrolling overflow in Firefox */
|
||||
@supports not (max-height: -webkit-fill-available) {
|
||||
.litegraph.litecontextmenu {
|
||||
max-height: 80vh;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu.dark {
|
||||
background-color: #000 !important;
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu .litemenu-title img {
|
||||
margin-top: 2px;
|
||||
margin-left: 2px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu .litemenu-entry {
|
||||
margin: 2px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu .litemenu-entry.submenu {
|
||||
background-color: #2e2e2e !important;
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu.dark .litemenu-entry.submenu {
|
||||
background-color: #000 !important;
|
||||
}
|
||||
|
||||
.litegraph .litemenubar ul {
|
||||
font-family: Tahoma, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.litegraph .litemenubar li {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
display: inline-block;
|
||||
min-width: 50px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.litegraph .litemenubar li:hover {
|
||||
background-color: #777;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.litegraph .litegraph .litemenubar-panel {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
min-width: 100px;
|
||||
background-color: #444;
|
||||
box-shadow: 0 0 3px black;
|
||||
padding: 4px;
|
||||
border-bottom: 2px solid #aaf;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.litegraph .litemenu-entry,
|
||||
.litemenu-title {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
padding: 0 0 0 4px;
|
||||
margin: 2px;
|
||||
padding-left: 2px;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.litegraph .litemenu-entry .icon {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: 2px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.litegraph .litemenu-entry.checked .icon {
|
||||
background-color: #aaf;
|
||||
}
|
||||
|
||||
.litegraph .litemenu-entry .more {
|
||||
float: right;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.litegraph .litemenu-entry.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.litegraph .litemenu-entry.separator {
|
||||
display: block;
|
||||
border-top: 1px solid #333;
|
||||
border-bottom: 1px solid #666;
|
||||
width: 100%;
|
||||
height: 0px;
|
||||
margin: 3px 0 2px 0;
|
||||
background-color: transparent;
|
||||
padding: 0 !important;
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
.litegraph .litemenu-entry.has_submenu {
|
||||
border-right: 2px solid cyan;
|
||||
}
|
||||
|
||||
.litegraph .litemenu-title {
|
||||
color: #dde;
|
||||
background-color: #111;
|
||||
margin: 0;
|
||||
padding: 2px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.litegraph .litemenu-entry:hover:not(.disabled):not(.separator) {
|
||||
background-color: #444 !important;
|
||||
color: #eee;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.litegraph .litemenu-entry .property_name {
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
min-width: 80px;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.litegraph .litemenu-entry .property_value {
|
||||
display: inline-block;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
text-align: right;
|
||||
min-width: 80px;
|
||||
min-height: 1.2em;
|
||||
vertical-align: middle;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.litegraph.litesearchbox {
|
||||
font-family: Tahoma, sans-serif;
|
||||
position: absolute;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.litegraph.litesearchbox input,
|
||||
.litegraph.litesearchbox select {
|
||||
margin-top: 3px;
|
||||
min-width: 60px;
|
||||
min-height: 1.5em;
|
||||
background-color: black;
|
||||
border: 0;
|
||||
color: white;
|
||||
padding-left: 10px;
|
||||
margin-right: 5px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.litegraph.litesearchbox .name {
|
||||
display: inline-block;
|
||||
min-width: 60px;
|
||||
min-height: 1.5em;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.litegraph.litesearchbox .helper {
|
||||
overflow: auto;
|
||||
max-height: 200px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.litegraph.lite-search-item {
|
||||
font-family: Tahoma, sans-serif;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.litegraph.lite-search-item.not_in_filter {
|
||||
/*background-color: rgba(50, 50, 50, 0.5);*/
|
||||
/*color: #999;*/
|
||||
color: #b99;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.litegraph.lite-search-item.generic_type {
|
||||
/*background-color: rgba(50, 50, 50, 0.5);*/
|
||||
/*color: #DD9;*/
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.litegraph.lite-search-item:hover,
|
||||
.litegraph.lite-search-item.selected {
|
||||
cursor: pointer;
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.litegraph.lite-search-item-type {
|
||||
display: inline-block;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
margin-left: 5px;
|
||||
font-size: 14px;
|
||||
padding: 2px 5px;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
opacity: 0.8;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* DIALOGs ******/
|
||||
|
||||
.litegraph .dialog {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-top: -150px;
|
||||
margin-left: -200px;
|
||||
|
||||
background-color: #2a2a2a;
|
||||
|
||||
min-width: 400px;
|
||||
min-height: 200px;
|
||||
box-shadow: 0 0 4px #111;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.litegraph .dialog.settings {
|
||||
left: 10px;
|
||||
top: 10px;
|
||||
height: calc(100% - 20px);
|
||||
margin: auto;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.litegraph .dialog.centered {
|
||||
top: 50px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
min-width: 600px;
|
||||
min-height: 300px;
|
||||
height: calc(100% - 100px);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.litegraph .dialog .close {
|
||||
float: right;
|
||||
margin: 4px;
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.litegraph .dialog .close:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.litegraph .dialog .dialog-header {
|
||||
color: #aaa;
|
||||
border-bottom: 1px solid #161616;
|
||||
}
|
||||
|
||||
.litegraph .dialog .dialog-header {
|
||||
height: 40px;
|
||||
}
|
||||
.litegraph .dialog .dialog-footer {
|
||||
height: 50px;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.litegraph .dialog .dialog-header .dialog-title {
|
||||
font: 20px "Arial";
|
||||
margin: 4px;
|
||||
padding: 4px 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.litegraph .dialog .dialog-content,
|
||||
.litegraph .dialog .dialog-alt-content {
|
||||
height: calc(100% - 90px);
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
display: inline-block;
|
||||
color: #aaa;
|
||||
/*background-color: black;*/
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.litegraph .dialog .dialog-content h3 {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.litegraph .dialog .dialog-content .connections {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.litegraph .dialog .dialog-content .connections .connections_side {
|
||||
width: calc(50% - 5px);
|
||||
min-height: 100px;
|
||||
background-color: black;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.litegraph .dialog .node_type {
|
||||
font-size: 1.2em;
|
||||
display: block;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.litegraph .dialog .node_desc {
|
||||
opacity: 0.5;
|
||||
display: block;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.litegraph .dialog .separator {
|
||||
display: block;
|
||||
width: calc(100% - 4px);
|
||||
height: 1px;
|
||||
border-top: 1px solid #000;
|
||||
border-bottom: 1px solid #333;
|
||||
margin: 10px 2px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.litegraph .dialog .property {
|
||||
margin-bottom: 2px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.litegraph .dialog .property:hover {
|
||||
background: #545454;
|
||||
}
|
||||
|
||||
.litegraph .dialog .property_name {
|
||||
color: #737373;
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
width: 160px;
|
||||
padding-left: 4px;
|
||||
overflow: hidden;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.litegraph .dialog .property:hover .property_name {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.litegraph .dialog .property_value {
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
color: #aaa;
|
||||
background-color: #1a1a1a;
|
||||
/*width: calc( 100% - 122px );*/
|
||||
max-width: calc(100% - 162px);
|
||||
min-width: 200px;
|
||||
max-height: 300px;
|
||||
min-height: 20px;
|
||||
padding: 4px;
|
||||
padding-right: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.litegraph .dialog .property_value:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.litegraph .dialog .property.boolean .property_value {
|
||||
padding-right: 30px;
|
||||
color: #a88;
|
||||
/*width: auto;
|
||||
float: right;*/
|
||||
}
|
||||
|
||||
.litegraph .dialog .property.boolean.bool-on .property_name {
|
||||
color: #8a8;
|
||||
}
|
||||
.litegraph .dialog .property.boolean.bool-on .property_value {
|
||||
color: #8a8;
|
||||
}
|
||||
|
||||
.litegraph .dialog .btn {
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 4px 20px;
|
||||
margin-left: 0px;
|
||||
background-color: #060606;
|
||||
color: #8e8e8e;
|
||||
}
|
||||
|
||||
.litegraph .dialog .btn:hover {
|
||||
background-color: #111;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.litegraph .dialog .btn.delete:hover {
|
||||
background-color: #f33;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.litegraph .bullet_icon {
|
||||
margin-left: 10px;
|
||||
border-radius: 10px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #666;
|
||||
display: inline-block;
|
||||
margin-top: 2px;
|
||||
margin-right: 4px;
|
||||
transition: background-color 0.1s ease 0s;
|
||||
-moz-transition: background-color 0.1s ease 0s;
|
||||
}
|
||||
|
||||
.litegraph .bullet_icon:hover {
|
||||
background-color: #698;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* OLD */
|
||||
|
||||
.graphcontextmenu {
|
||||
padding: 4px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.graphcontextmenu-title {
|
||||
color: #dde;
|
||||
background-color: #222;
|
||||
margin: 0;
|
||||
padding: 2px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.graphmenu-entry {
|
||||
box-sizing: border-box;
|
||||
margin: 2px;
|
||||
padding-left: 20px;
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition: all linear 0.3s;
|
||||
}
|
||||
|
||||
.graphmenu-entry.event,
|
||||
.litemenu-entry.event {
|
||||
border-left: 8px solid orange;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.graphmenu-entry.disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.graphmenu-entry.submenu {
|
||||
border-right: 2px solid #eee;
|
||||
}
|
||||
|
||||
.graphmenu-entry:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.graphmenu-entry.separator {
|
||||
background-color: #111;
|
||||
border-bottom: 1px solid #666;
|
||||
height: 1px;
|
||||
width: calc(100% - 20px);
|
||||
-moz-width: calc(100% - 20px);
|
||||
-webkit-width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
.graphmenu-entry .property_name {
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
min-width: 80px;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.graphmenu-entry .property_value,
|
||||
.litemenu-entry .property_value {
|
||||
display: inline-block;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
text-align: right;
|
||||
min-width: 80px;
|
||||
min-height: 1.2em;
|
||||
vertical-align: middle;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.graphdialog {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
min-height: 2em;
|
||||
background-color: #333;
|
||||
font-size: 1.2em;
|
||||
box-shadow: 0 0 10px black !important;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.graphdialog.rounded {
|
||||
border-radius: 12px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.graphdialog .name {
|
||||
display: inline-block;
|
||||
min-width: 60px;
|
||||
min-height: 1.5em;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.graphdialog input,
|
||||
.graphdialog textarea,
|
||||
.graphdialog select {
|
||||
margin: 3px;
|
||||
min-width: 60px;
|
||||
min-height: 1.5em;
|
||||
background-color: black;
|
||||
border: 0;
|
||||
color: white;
|
||||
padding-left: 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.graphdialog textarea {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.graphdialog button {
|
||||
margin-top: 3px;
|
||||
vertical-align: top;
|
||||
background-color: #999;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.graphdialog button.rounded,
|
||||
.graphdialog input.rounded {
|
||||
border-radius: 0 12px 12px 0;
|
||||
}
|
||||
|
||||
.graphdialog .helper {
|
||||
overflow: auto;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.graphdialog .help-item {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.graphdialog .help-item:hover,
|
||||
.graphdialog .help-item.selected {
|
||||
cursor: pointer;
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.litegraph .dialog {
|
||||
min-height: 0;
|
||||
}
|
||||
.litegraph .dialog .dialog-content {
|
||||
display: block;
|
||||
}
|
||||
.litegraph .graphdialog {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 20px;
|
||||
padding: 4px 10px;
|
||||
position: fixed;
|
||||
}
|
||||
.litegraph .graphdialog .name {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
font-size: 16px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.litegraph .graphdialog .value {
|
||||
font-size: 16px;
|
||||
min-height: 0;
|
||||
margin: 0 10px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
.litegraph .graphdialog input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.litegraph .graphdialog button {
|
||||
padding: 4px 18px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
290
src/lib/litegraph/src/CanvasPointer.ts
Normal file
290
src/lib/litegraph/src/CanvasPointer.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import type { CompassCorners } from "./interfaces"
|
||||
import type { CanvasPointerEvent } from "./types/events"
|
||||
|
||||
import { dist2 } from "./measure"
|
||||
|
||||
/**
|
||||
* Allows click and drag actions to be declared ahead of time during a pointerdown event.
|
||||
*
|
||||
* By default, it retains the most recent event of each type until it is reset (on pointerup).
|
||||
* - {@link eDown}
|
||||
* - {@link eMove}
|
||||
* - {@link eUp}
|
||||
*
|
||||
* Depending on whether the user clicks or drags the pointer, only the appropriate callbacks are called:
|
||||
* - {@link onClick}
|
||||
* - {@link onDoubleClick}
|
||||
* - {@link onDragStart}
|
||||
* - {@link onDrag}
|
||||
* - {@link onDragEnd}
|
||||
* - {@link finally}
|
||||
* @see
|
||||
* - {@link LGraphCanvas.processMouseDown}
|
||||
* - {@link LGraphCanvas.processMouseMove}
|
||||
* - {@link LGraphCanvas.processMouseUp}
|
||||
*/
|
||||
export class CanvasPointer {
|
||||
/** Maximum time in milliseconds to ignore click drift */
|
||||
static bufferTime = 150
|
||||
|
||||
/** Maximum gap between pointerup and pointerdown events to be considered as a double click */
|
||||
static doubleClickTime = 300
|
||||
|
||||
/** Maximum offset from click location */
|
||||
static get maxClickDrift() {
|
||||
return this.#maxClickDrift
|
||||
}
|
||||
|
||||
static set maxClickDrift(value) {
|
||||
this.#maxClickDrift = value
|
||||
this.#maxClickDrift2 = value * value
|
||||
}
|
||||
|
||||
static #maxClickDrift = 6
|
||||
/** {@link maxClickDrift} squared. Used to calculate click drift without `sqrt`. */
|
||||
static #maxClickDrift2 = this.#maxClickDrift ** 2
|
||||
|
||||
/** The element this PointerState should capture input against when dragging. */
|
||||
element: Element
|
||||
/** Pointer ID used by drag capture. */
|
||||
pointerId?: number
|
||||
|
||||
/** Set to true when if the pointer moves far enough after a down event, before the corresponding up event is fired. */
|
||||
dragStarted: boolean = false
|
||||
|
||||
/** The {@link eUp} from the last successful click */
|
||||
eLastDown?: CanvasPointerEvent
|
||||
|
||||
/** Used downstream for touch event support. */
|
||||
isDouble: boolean = false
|
||||
/** Used downstream for touch event support. */
|
||||
isDown: boolean = false
|
||||
|
||||
/** The resize handle currently being hovered or dragged */
|
||||
resizeDirection?: CompassCorners
|
||||
|
||||
/**
|
||||
* If `true`, {@link eDown}, {@link eMove}, and {@link eUp} will be set to
|
||||
* `undefined` when {@link reset} is called.
|
||||
*
|
||||
* Default: `true`
|
||||
*/
|
||||
clearEventsOnReset: boolean = true
|
||||
|
||||
/** The last pointerdown event for the primary button */
|
||||
eDown?: CanvasPointerEvent
|
||||
/** The last pointermove event for the primary button */
|
||||
eMove?: CanvasPointerEvent
|
||||
/** The last pointerup event for the primary button */
|
||||
eUp?: CanvasPointerEvent
|
||||
|
||||
/**
|
||||
* If set, as soon as the mouse moves outside the click drift threshold, this action is run once.
|
||||
* @param pointer [DEPRECATED] This parameter will be removed in a future release.
|
||||
* @param eMove The pointermove event of this ongoing drag action.
|
||||
*
|
||||
* It is possible for no `pointermove` events to occur, but still be far from
|
||||
* the original `pointerdown` event. In this case, {@link eMove} will be null, and
|
||||
* {@link onDragEnd} will be called immediately after {@link onDragStart}.
|
||||
*/
|
||||
onDragStart?(pointer: this, eMove?: CanvasPointerEvent): unknown
|
||||
|
||||
/**
|
||||
* Called on pointermove whilst dragging.
|
||||
* @param eMove The pointermove event of this ongoing drag action
|
||||
*/
|
||||
onDrag?(eMove: CanvasPointerEvent): unknown
|
||||
|
||||
/**
|
||||
* Called on pointerup after dragging (i.e. not called if clicked).
|
||||
* @param upEvent The pointerup or pointermove event that triggered this callback
|
||||
*/
|
||||
onDragEnd?(upEvent: CanvasPointerEvent): unknown
|
||||
|
||||
/**
|
||||
* Callback that will be run once, the next time a pointerup event appears to be a normal click.
|
||||
* @param upEvent The pointerup or pointermove event that triggered this callback
|
||||
*/
|
||||
onClick?(upEvent: CanvasPointerEvent): unknown
|
||||
|
||||
/**
|
||||
* Callback that will be run once, the next time a pointerup event appears to be a normal click.
|
||||
* @param upEvent The pointerup or pointermove event that triggered this callback
|
||||
*/
|
||||
onDoubleClick?(upEvent: CanvasPointerEvent): unknown
|
||||
|
||||
/**
|
||||
* Run-once callback, called at the end of any click or drag, whether or not it was successful in any way.
|
||||
*
|
||||
* The setter of this callback will call the existing value before replacing it.
|
||||
* Therefore, simply setting this value twice will execute the first callback.
|
||||
*/
|
||||
get finally() {
|
||||
return this.#finally
|
||||
}
|
||||
|
||||
set finally(value) {
|
||||
try {
|
||||
this.#finally?.()
|
||||
} finally {
|
||||
this.#finally = value
|
||||
}
|
||||
}
|
||||
|
||||
#finally?: () => unknown
|
||||
|
||||
constructor(element: Element) {
|
||||
this.element = element
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for `pointerdown` events. To be used as the event handler (or called by it).
|
||||
* @param e The `pointerdown` event
|
||||
*/
|
||||
down(e: CanvasPointerEvent): void {
|
||||
this.reset()
|
||||
this.eDown = e
|
||||
this.pointerId = e.pointerId
|
||||
this.element.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for `pointermove` events. To be used as the event handler (or called by it).
|
||||
* @param e The `pointermove` event
|
||||
*/
|
||||
move(e: CanvasPointerEvent): void {
|
||||
const { eDown } = this
|
||||
if (!eDown) return
|
||||
|
||||
// No buttons down, but eDown exists - clean up & leave
|
||||
if (!e.buttons) {
|
||||
this.reset()
|
||||
return
|
||||
}
|
||||
|
||||
// Primary button released - treat as pointerup.
|
||||
if (!(e.buttons & eDown.buttons)) {
|
||||
this.#completeClick(e)
|
||||
this.reset()
|
||||
return
|
||||
}
|
||||
this.eMove = e
|
||||
this.onDrag?.(e)
|
||||
|
||||
// Dragging, but no callback to run
|
||||
if (this.dragStarted) return
|
||||
|
||||
const longerThanBufferTime = e.timeStamp - eDown.timeStamp > CanvasPointer.bufferTime
|
||||
if (longerThanBufferTime || !this.#hasSamePosition(e, eDown)) {
|
||||
this.#setDragStarted(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for `pointerup` events. To be used as the event handler (or called by it).
|
||||
* @param e The `pointerup` event
|
||||
*/
|
||||
up(e: CanvasPointerEvent): boolean {
|
||||
if (e.button !== this.eDown?.button) return false
|
||||
|
||||
this.#completeClick(e)
|
||||
const { dragStarted } = this
|
||||
this.reset()
|
||||
return !dragStarted
|
||||
}
|
||||
|
||||
#completeClick(e: CanvasPointerEvent): void {
|
||||
const { eDown } = this
|
||||
if (!eDown) return
|
||||
|
||||
this.eUp = e
|
||||
|
||||
if (this.dragStarted) {
|
||||
// A move event already started drag
|
||||
this.onDragEnd?.(e)
|
||||
} else if (!this.#hasSamePosition(e, eDown)) {
|
||||
// Teleport without a move event (e.g. tab out, move, tab back)
|
||||
this.#setDragStarted()
|
||||
this.onDragEnd?.(e)
|
||||
} else if (this.onDoubleClick && this.#isDoubleClick()) {
|
||||
// Double-click event
|
||||
this.onDoubleClick(e)
|
||||
this.eLastDown = undefined
|
||||
} else {
|
||||
// Normal click event
|
||||
this.onClick?.(e)
|
||||
this.eLastDown = eDown
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if two events occurred near each other - not further apart than the maximum click drift.
|
||||
* @param a The first event to compare
|
||||
* @param b The second event to compare
|
||||
* @param tolerance2 The maximum distance (squared) before the positions are considered different
|
||||
* @returns `true` if the two events were no more than {@link maxClickDrift} apart, otherwise `false`
|
||||
*/
|
||||
#hasSamePosition(
|
||||
a: PointerEvent,
|
||||
b: PointerEvent,
|
||||
tolerance2 = CanvasPointer.#maxClickDrift2,
|
||||
): boolean {
|
||||
const drift = dist2(a.clientX, a.clientY, b.clientX, b.clientY)
|
||||
return drift <= tolerance2
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the pointer is currently past the max click drift threshold.
|
||||
* @returns `true` if the latest pointer event is past the the click drift threshold
|
||||
*/
|
||||
#isDoubleClick(): boolean {
|
||||
const { eDown, eLastDown } = this
|
||||
if (!eDown || !eLastDown) return false
|
||||
|
||||
// Use thrice the drift distance for double-click gap
|
||||
const tolerance2 = (3 * CanvasPointer.#maxClickDrift) ** 2
|
||||
const diff = eDown.timeStamp - eLastDown.timeStamp
|
||||
return diff > 0 &&
|
||||
diff < CanvasPointer.doubleClickTime &&
|
||||
this.#hasSamePosition(eDown, eLastDown, tolerance2)
|
||||
}
|
||||
|
||||
#setDragStarted(eMove?: CanvasPointerEvent): void {
|
||||
this.dragStarted = true
|
||||
this.onDragStart?.(this, eMove)
|
||||
delete this.onDragStart
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the state of this {@link CanvasPointer} instance.
|
||||
*
|
||||
* The {@link finally} callback is first executed, then all callbacks and intra-click
|
||||
* state is cleared.
|
||||
*/
|
||||
reset(): void {
|
||||
// The setter executes the callback before clearing it
|
||||
this.finally = undefined
|
||||
delete this.onClick
|
||||
delete this.onDoubleClick
|
||||
delete this.onDragStart
|
||||
delete this.onDrag
|
||||
delete this.onDragEnd
|
||||
|
||||
this.isDown = false
|
||||
this.isDouble = false
|
||||
this.dragStarted = false
|
||||
this.resizeDirection = undefined
|
||||
|
||||
if (this.clearEventsOnReset) {
|
||||
this.eDown = undefined
|
||||
this.eMove = undefined
|
||||
this.eUp = undefined
|
||||
}
|
||||
|
||||
const { element, pointerId } = this
|
||||
this.pointerId = undefined
|
||||
if (typeof pointerId === "number" && element.hasPointerCapture(pointerId)) {
|
||||
element.releasePointerCapture(pointerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
387
src/lib/litegraph/src/ContextMenu.ts
Normal file
387
src/lib/litegraph/src/ContextMenu.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import type { ContextMenuDivElement, IContextMenuOptions, IContextMenuValue } from "./interfaces"
|
||||
|
||||
import { LiteGraph } from "./litegraph"
|
||||
|
||||
// TODO: Replace this pattern with something more modern.
|
||||
export interface ContextMenu<TValue = unknown> {
|
||||
constructor: new (...args: ConstructorParameters<typeof ContextMenu<TValue>>) => ContextMenu<TValue>
|
||||
}
|
||||
|
||||
/**
|
||||
* ContextMenu from LiteGUI
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export class ContextMenu<TValue = unknown> {
|
||||
options: IContextMenuOptions<TValue>
|
||||
parentMenu?: ContextMenu<TValue>
|
||||
root: ContextMenuDivElement<TValue>
|
||||
current_submenu?: ContextMenu<TValue>
|
||||
lock?: boolean
|
||||
|
||||
controller: AbortController = new AbortController()
|
||||
|
||||
/**
|
||||
* @todo Interface for values requires functionality change - currently accepts
|
||||
* an array of strings, functions, objects, nulls, or undefined.
|
||||
* @param values (allows object { title: "Nice text", callback: function ... })
|
||||
* @param options [optional] Some options:\
|
||||
* - title: title to show on top of the menu
|
||||
* - callback: function to call when an option is clicked, it receives the item information
|
||||
* - ignore_item_callbacks: ignores the callback inside the item, it just calls the options.callback
|
||||
* - event: you can pass a MouseEvent, this way the ContextMenu appears in that position
|
||||
*/
|
||||
constructor(values: readonly (string | IContextMenuValue<TValue> | null)[], options: IContextMenuOptions<TValue>) {
|
||||
options ||= {}
|
||||
this.options = options
|
||||
|
||||
// to link a menu with its parent
|
||||
const parent = options.parentMenu
|
||||
if (parent) {
|
||||
if (!(parent instanceof ContextMenu)) {
|
||||
console.error("parentMenu must be of class ContextMenu, ignoring it")
|
||||
options.parentMenu = undefined
|
||||
} else {
|
||||
this.parentMenu = parent
|
||||
this.parentMenu.lock = true
|
||||
this.parentMenu.current_submenu = this
|
||||
}
|
||||
if (parent.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 = undefined
|
||||
}
|
||||
|
||||
const root: ContextMenuDivElement<TValue> = 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"
|
||||
|
||||
// Close the context menu when a click occurs outside this context menu or its submenus
|
||||
const { signal } = this.controller
|
||||
const eventOptions = { capture: true, signal }
|
||||
|
||||
if (!this.parentMenu) {
|
||||
document.addEventListener("pointerdown", (e) => {
|
||||
if (e.target instanceof Node && !this.containsNode(e.target)) {
|
||||
this.close()
|
||||
}
|
||||
}, eventOptions)
|
||||
}
|
||||
|
||||
// this prevents the default context browser menu to open in case this menu was created when pressing right button
|
||||
root.addEventListener("pointerup", e => e.preventDefault(), eventOptions)
|
||||
|
||||
// Right button
|
||||
root.addEventListener(
|
||||
"contextmenu",
|
||||
(e) => {
|
||||
if (e.button === 2) e.preventDefault()
|
||||
},
|
||||
eventOptions,
|
||||
)
|
||||
|
||||
root.addEventListener(
|
||||
"pointerdown",
|
||||
(e) => {
|
||||
if (e.button == 2) {
|
||||
this.close()
|
||||
e.preventDefault()
|
||||
}
|
||||
},
|
||||
eventOptions,
|
||||
)
|
||||
|
||||
this.root = root
|
||||
|
||||
// title
|
||||
if (options.title) {
|
||||
const element = document.createElement("div")
|
||||
element.className = "litemenu-title"
|
||||
element.innerHTML = options.title
|
||||
root.append(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
|
||||
}
|
||||
|
||||
this.addItem(name, value, options)
|
||||
}
|
||||
|
||||
// insert before checking position
|
||||
const ownerDocument = (options.event?.target as Node | null | undefined)?.ownerDocument
|
||||
const root_document = ownerDocument || document
|
||||
|
||||
if (root_document.fullscreenElement)
|
||||
root_document.fullscreenElement.append(root)
|
||||
else
|
||||
root_document.body.append(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 (LiteGraph.context_menu_scaling && options.scale) {
|
||||
root.style.transform = `scale(${Math.round(options.scale * 4) * 0.25})`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if {@link node} is inside this context menu or any of its submenus
|
||||
* @param node The {@link Node} to check
|
||||
* @param visited A set of visited menus to avoid circular references
|
||||
* @returns `true` if {@link node} is inside this context menu or any of its submenus
|
||||
*/
|
||||
containsNode(node: Node, visited: Set<this> = new Set()): boolean {
|
||||
if (visited.has(this)) return false
|
||||
visited.add(this)
|
||||
|
||||
return this.current_submenu?.containsNode(node, visited) || this.root.contains(node)
|
||||
}
|
||||
|
||||
addItem(
|
||||
name: string | null,
|
||||
value: string | IContextMenuValue<TValue> | null,
|
||||
options: IContextMenuOptions<TValue>,
|
||||
): HTMLElement {
|
||||
options ||= {}
|
||||
|
||||
const element: ContextMenuDivElement<TValue> = document.createElement("div")
|
||||
element.className = "litemenu-entry submenu"
|
||||
|
||||
let disabled = false
|
||||
|
||||
if (value === null) {
|
||||
element.classList.add("separator")
|
||||
} else {
|
||||
const innerHtml = name === null ? "" : String(name)
|
||||
if (typeof value === "string") {
|
||||
element.innerHTML = innerHtml
|
||||
} else {
|
||||
element.innerHTML = value?.title ?? innerHtml
|
||||
|
||||
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"] = String(name)
|
||||
element.onclick_callback = value
|
||||
} else {
|
||||
element.dataset["value"] = String(value)
|
||||
}
|
||||
}
|
||||
|
||||
this.root.append(element)
|
||||
if (!disabled) element.addEventListener("click", inner_onclick)
|
||||
if (!disabled && options.autoopen)
|
||||
element.addEventListener("pointerenter", inner_over)
|
||||
|
||||
const setAriaExpanded = () => {
|
||||
const entries = this.root.querySelectorAll("div.litemenu-entry.has_submenu")
|
||||
if (entries) {
|
||||
for (const entry of entries) {
|
||||
entry.setAttribute("aria-expanded", "false")
|
||||
}
|
||||
}
|
||||
element.setAttribute("aria-expanded", "true")
|
||||
}
|
||||
|
||||
function inner_over(this: ContextMenuDivElement<TValue>, 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<TValue>, 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.controller.abort()
|
||||
this.root.remove()
|
||||
if (this.parentMenu && !ignore_parent_menu) {
|
||||
this.parentMenu.lock = false
|
||||
this.parentMenu.current_submenu = undefined
|
||||
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)
|
||||
}
|
||||
|
||||
/** @deprecated Likely unused, however code search was inconclusive (too many results to check by hand). */
|
||||
// this code is used to trigger events easily (used in the context menu mouseleave
|
||||
static trigger(
|
||||
element: HTMLDivElement,
|
||||
event_name: string,
|
||||
params: MouseEvent,
|
||||
): CustomEvent {
|
||||
const evt = document.createEvent("CustomEvent")
|
||||
evt.initCustomEvent(event_name, true, true, params)
|
||||
if (element.dispatchEvent) element.dispatchEvent(evt)
|
||||
// else nothing seems bound here so nothing to do
|
||||
return evt
|
||||
}
|
||||
|
||||
// returns the top most menu
|
||||
getTopMenu(): ContextMenu<TValue> {
|
||||
return this.options.parentMenu
|
||||
? this.options.parentMenu.getTopMenu()
|
||||
: this
|
||||
}
|
||||
|
||||
getFirstEvent(): MouseEvent | undefined {
|
||||
return this.options.parentMenu
|
||||
? this.options.parentMenu.getFirstEvent()
|
||||
: this.options.event
|
||||
}
|
||||
|
||||
/** @deprecated Unused. */
|
||||
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
|
||||
}
|
||||
}
|
||||
195
src/lib/litegraph/src/CurveEditor.ts
Normal file
195
src/lib/litegraph/src/CurveEditor.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { Point, Rect } from "./interfaces"
|
||||
|
||||
import { clamp, LGraphCanvas } from "./litegraph"
|
||||
import { distance } from "./measure"
|
||||
|
||||
// used by some widgets to render a curve editor
|
||||
|
||||
export class CurveEditor {
|
||||
points: Point[]
|
||||
selected: number
|
||||
nearest: number
|
||||
size: Rect | null
|
||||
must_update: boolean
|
||||
margin: number
|
||||
_nearest?: number
|
||||
|
||||
constructor(points: Point[]) {
|
||||
this.points = points
|
||||
this.selected = -1
|
||||
this.nearest = -1
|
||||
// stores last size used
|
||||
this.size = null
|
||||
this.must_update = true
|
||||
this.margin = 5
|
||||
}
|
||||
|
||||
static sampleCurve(f: number, points: Point[]): number | undefined {
|
||||
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.000_01) return p[1]
|
||||
|
||||
const local_f = (f - p[0]) / r
|
||||
return p[1] * (1.0 - local_f) + pn[1] * local_f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
draw(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
size: Rect,
|
||||
graphcanvas?: LGraphCanvas,
|
||||
background_color?: string,
|
||||
line_color?: string,
|
||||
inactive = false,
|
||||
): void {
|
||||
const points = this.points
|
||||
if (!points) return
|
||||
|
||||
this.size = size
|
||||
const w = size[0] - this.margin * 2
|
||||
const h = size[1] - this.margin * 2
|
||||
|
||||
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
|
||||
if (inactive) ctx.globalAlpha = 0.5
|
||||
ctx.beginPath()
|
||||
for (const p of points) {
|
||||
ctx.lineTo(p[0] * w, (1.0 - p[1]) * h)
|
||||
}
|
||||
ctx.stroke()
|
||||
ctx.globalAlpha = 1
|
||||
if (!inactive) {
|
||||
for (const [i, p] of points.entries()) {
|
||||
ctx.fillStyle = this.selected == i
|
||||
? "#FFF"
|
||||
: (this.nearest == i ? "#DDD" : "#AAA")
|
||||
ctx.beginPath()
|
||||
ctx.arc(p[0] * w, (1.0 - p[1]) * h, 2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// localpos is mouse in curve editor space
|
||||
onMouseDown(localpos: Point, graphcanvas: LGraphCanvas): boolean | undefined {
|
||||
const points = this.points
|
||||
if (!points) return
|
||||
if (localpos[1] < 0) return
|
||||
|
||||
// this.captureInput(true);
|
||||
if (this.size == null) throw new Error("CurveEditor.size was null or undefined.")
|
||||
const w = this.size[0] - this.margin * 2
|
||||
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 points = this.points
|
||||
if (!points) return
|
||||
|
||||
const s = this.selected
|
||||
if (s < 0) return
|
||||
|
||||
if (this.size == null) throw new Error("CurveEditor.size was null or undefined.")
|
||||
const x = (localpos[0] - this.margin) / (this.size[0] - this.margin * 2)
|
||||
const y = (localpos[1] - this.margin) / (this.size[1] - this.margin * 2)
|
||||
const curvepos: Point = [
|
||||
localpos[0] - this.margin,
|
||||
localpos[1] - this.margin,
|
||||
]
|
||||
const max_dist = 30 / graphcanvas.ds.scale
|
||||
this._nearest = this.getCloserPoint(curvepos, max_dist)
|
||||
const point = points[s]
|
||||
if (point) {
|
||||
const is_edge_point = s == 0 || s == points.length - 1
|
||||
if (
|
||||
!is_edge_point &&
|
||||
(localpos[0] < -10 ||
|
||||
localpos[0] > this.size[0] + 10 ||
|
||||
localpos[1] < -10 ||
|
||||
localpos[1] > this.size[1] + 10)
|
||||
) {
|
||||
points.splice(s, 1)
|
||||
this.selected = -1
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Former params: localpos, graphcanvas
|
||||
onMouseUp(): boolean {
|
||||
this.selected = -1
|
||||
return false
|
||||
}
|
||||
|
||||
getCloserPoint(pos: Point, max_dist: number): number {
|
||||
const points = this.points
|
||||
if (!points) return -1
|
||||
|
||||
max_dist = max_dist || 30
|
||||
if (this.size == null) throw new Error("CurveEditor.size was null or undefined.")
|
||||
const w = this.size[0] - this.margin * 2
|
||||
const h = this.size[1] - this.margin * 2
|
||||
const num = points.length
|
||||
const p2: Point = [0, 0]
|
||||
let min_dist = 1_000_000
|
||||
let closest = -1
|
||||
|
||||
for (let i = 0; i < num; ++i) {
|
||||
const p = points[i]
|
||||
p2[0] = p[0] * w
|
||||
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
|
||||
}
|
||||
}
|
||||
311
src/lib/litegraph/src/DragAndScale.ts
Normal file
311
src/lib/litegraph/src/DragAndScale.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import type { Point, ReadOnlyRect, Rect } from "./interfaces"
|
||||
|
||||
import { EaseFunction, Rectangle } from "./litegraph"
|
||||
|
||||
export interface DragAndScaleState {
|
||||
/**
|
||||
* The offset from the top-left of the current canvas viewport to `[0, 0]` in graph space.
|
||||
* Or said another way, the inverse offset of the viewport.
|
||||
*/
|
||||
offset: [number, number]
|
||||
/** The scale of the graph. */
|
||||
scale: number
|
||||
}
|
||||
|
||||
export type AnimationOptions = {
|
||||
/** Duration of the animation in milliseconds. */
|
||||
duration?: number
|
||||
/** Relative target zoom level. 1 means the view is fit exactly on the bounding box. */
|
||||
zoom?: number
|
||||
/** The animation easing function (curve) */
|
||||
easing?: EaseFunction
|
||||
}
|
||||
|
||||
export class DragAndScale {
|
||||
/**
|
||||
* The state of this DragAndScale instance.
|
||||
*
|
||||
* Implemented as a POCO that can be proxied without side-effects.
|
||||
*/
|
||||
state: DragAndScaleState
|
||||
lastState: DragAndScaleState = {
|
||||
offset: [0, 0],
|
||||
scale: 0,
|
||||
}
|
||||
|
||||
/** Maximum scale (zoom in) */
|
||||
max_scale: number
|
||||
/** Minimum scale (zoom out) */
|
||||
min_scale: number
|
||||
enabled: boolean
|
||||
last_mouse: Point
|
||||
element: HTMLCanvasElement
|
||||
visible_area: Rectangle
|
||||
dragging?: boolean
|
||||
viewport?: Rect
|
||||
|
||||
onredraw?(das: DragAndScale): void
|
||||
onChanged?(scale: number, offset: Point): void
|
||||
|
||||
get offset(): [number, number] {
|
||||
return this.state.offset
|
||||
}
|
||||
|
||||
set offset(value: Point) {
|
||||
this.state.offset[0] = value[0]
|
||||
this.state.offset[1] = value[1]
|
||||
}
|
||||
|
||||
get scale(): number {
|
||||
return this.state.scale
|
||||
}
|
||||
|
||||
set scale(value: number) {
|
||||
this.state.scale = value
|
||||
}
|
||||
|
||||
constructor(element: HTMLCanvasElement) {
|
||||
this.state = {
|
||||
offset: [0, 0],
|
||||
scale: 1,
|
||||
}
|
||||
this.max_scale = 10
|
||||
this.min_scale = 0.1
|
||||
this.enabled = true
|
||||
this.last_mouse = [0, 0]
|
||||
this.visible_area = new Rectangle()
|
||||
|
||||
this.element = element
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the current state has changed from the previous state.
|
||||
* @returns `true` if the current state has changed from the previous state, otherwise `false`.
|
||||
*/
|
||||
#stateHasChanged(): boolean {
|
||||
const current = this.state
|
||||
const previous = this.lastState
|
||||
|
||||
return current.scale !== previous.scale ||
|
||||
current.offset[0] !== previous.offset[0] ||
|
||||
current.offset[1] !== previous.offset[1]
|
||||
}
|
||||
|
||||
computeVisibleArea(viewport: Rect | undefined): void {
|
||||
const { scale, offset, visible_area } = this
|
||||
|
||||
if (this.#stateHasChanged()) {
|
||||
this.onChanged?.(scale, offset)
|
||||
copyState(this.state, this.lastState)
|
||||
}
|
||||
|
||||
if (!this.element) {
|
||||
visible_area[0] = visible_area[1] = visible_area[2] = visible_area[3] = 0
|
||||
return
|
||||
}
|
||||
let { width, height } = this.element
|
||||
let startx = -offset[0]
|
||||
let starty = -offset[1]
|
||||
if (viewport) {
|
||||
startx += viewport[0] / scale
|
||||
starty += viewport[1] / scale
|
||||
width = viewport[2]
|
||||
height = viewport[3]
|
||||
}
|
||||
const endx = startx + width / scale
|
||||
const endy = starty + height / scale
|
||||
visible_area[0] = startx
|
||||
visible_area[1] = starty
|
||||
visible_area.resizeBottomRight(endx, endy)
|
||||
}
|
||||
|
||||
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, roundToScaleOne = true): 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
|
||||
|
||||
const rect = this.element.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
||||
zooming_center = zooming_center ?? [rect.width * 0.5, rect.height * 0.5]
|
||||
|
||||
const normalizedCenter: Point = [
|
||||
zooming_center[0] - rect.x,
|
||||
zooming_center[1] - rect.y,
|
||||
]
|
||||
const center = this.convertCanvasToOffset(normalizedCenter)
|
||||
this.scale = value
|
||||
if (roundToScaleOne && Math.abs(this.scale - 1) < 0.01) this.scale = 1
|
||||
const new_center = this.convertCanvasToOffset(normalizedCenter)
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fits the view to the specified bounds.
|
||||
* @param bounds The bounds to fit the view to, defined by a rectangle.
|
||||
*/
|
||||
fitToBounds(bounds: ReadOnlyRect, { zoom = 0.75 }: { zoom?: number } = {}): void {
|
||||
const cw = this.element.width / window.devicePixelRatio
|
||||
const ch = this.element.height / window.devicePixelRatio
|
||||
let targetScale = this.scale
|
||||
|
||||
if (zoom > 0) {
|
||||
const targetScaleX = (zoom * cw) / Math.max(bounds[2], 300)
|
||||
const targetScaleY = (zoom * ch) / Math.max(bounds[3], 300)
|
||||
|
||||
// Choose the smaller scale to ensure the node fits into the viewport
|
||||
// Ensure we don't go over the max scale
|
||||
targetScale = Math.min(targetScaleX, targetScaleY, this.max_scale)
|
||||
}
|
||||
|
||||
const scaledWidth = cw / targetScale
|
||||
const scaledHeight = ch / targetScale
|
||||
|
||||
// Calculate the target position to center the bounds in the viewport
|
||||
const targetX = -bounds[0] - (bounds[2] * 0.5) + (scaledWidth * 0.5)
|
||||
const targetY = -bounds[1] - (bounds[3] * 0.5) + (scaledHeight * 0.5)
|
||||
|
||||
// Apply the changes immediately
|
||||
this.offset[0] = targetX
|
||||
this.offset[1] = targetY
|
||||
this.scale = targetScale
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an animation to fit the view around the specified selection of nodes.
|
||||
* @param bounds The bounds to animate the view to, defined by a rectangle.
|
||||
*/
|
||||
animateToBounds(
|
||||
bounds: ReadOnlyRect,
|
||||
setDirty: () => void,
|
||||
{
|
||||
duration = 350,
|
||||
zoom = 0.75,
|
||||
easing = EaseFunction.EASE_IN_OUT_QUAD,
|
||||
}: AnimationOptions = {},
|
||||
) {
|
||||
if (!(duration > 0)) throw new RangeError("Duration must be greater than 0")
|
||||
|
||||
const easeFunctions = {
|
||||
linear: (t: number) => t,
|
||||
easeInQuad: (t: number) => t * t,
|
||||
easeOutQuad: (t: number) => t * (2 - t),
|
||||
easeInOutQuad: (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
||||
}
|
||||
const easeFunction = easeFunctions[easing] ?? easeFunctions.linear
|
||||
|
||||
const startTimestamp = performance.now()
|
||||
const cw = this.element.width / window.devicePixelRatio
|
||||
const ch = this.element.height / window.devicePixelRatio
|
||||
const startX = this.offset[0]
|
||||
const startY = this.offset[1]
|
||||
const startX2 = startX - (cw / this.scale)
|
||||
const startY2 = startY - (ch / this.scale)
|
||||
const startScale = this.scale
|
||||
let targetScale = startScale
|
||||
|
||||
if (zoom > 0) {
|
||||
const targetScaleX = (zoom * cw) / Math.max(bounds[2], 300)
|
||||
const targetScaleY = (zoom * ch) / Math.max(bounds[3], 300)
|
||||
|
||||
// Choose the smaller scale to ensure the node fits into the viewport
|
||||
// Ensure we don't go over the max scale
|
||||
targetScale = Math.min(targetScaleX, targetScaleY, this.max_scale)
|
||||
}
|
||||
const scaledWidth = cw / targetScale
|
||||
const scaledHeight = ch / targetScale
|
||||
|
||||
const targetX = -bounds[0] - (bounds[2] * 0.5) + (scaledWidth * 0.5)
|
||||
const targetY = -bounds[1] - (bounds[3] * 0.5) + (scaledHeight * 0.5)
|
||||
const targetX2 = targetX - scaledWidth
|
||||
const targetY2 = targetY - scaledHeight
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
const elapsed = timestamp - startTimestamp
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
const easedProgress = easeFunction(progress)
|
||||
|
||||
const currentX = startX + ((targetX - startX) * easedProgress)
|
||||
const currentY = startY + ((targetY - startY) * easedProgress)
|
||||
this.offset[0] = currentX
|
||||
this.offset[1] = currentY
|
||||
|
||||
if (zoom > 0) {
|
||||
const currentX2 = startX2 + ((targetX2 - startX2) * easedProgress)
|
||||
const currentY2 = startY2 + ((targetY2 - startY2) * easedProgress)
|
||||
const currentWidth = Math.abs(currentX2 - currentX)
|
||||
const currentHeight = Math.abs(currentY2 - currentY)
|
||||
|
||||
this.scale = Math.min(cw / currentWidth, ch / currentHeight)
|
||||
}
|
||||
|
||||
setDirty()
|
||||
|
||||
if (progress < 1) {
|
||||
animationId = requestAnimationFrame(animate)
|
||||
} else {
|
||||
cancelAnimationFrame(animationId)
|
||||
}
|
||||
}
|
||||
let animationId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.scale = 1
|
||||
this.offset[0] = 0
|
||||
this.offset[1] = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the values of one state into another, preserving references.
|
||||
* @param from The state to copy values from.
|
||||
* @param to The state to copy values into.
|
||||
*/
|
||||
function copyState(from: DragAndScaleState, to: DragAndScaleState): void {
|
||||
to.scale = from.scale
|
||||
to.offset[0] = from.offset[0]
|
||||
to.offset[1] = from.offset[1]
|
||||
}
|
||||
1936
src/lib/litegraph/src/LGraph.ts
Normal file
1936
src/lib/litegraph/src/LGraph.ts
Normal file
File diff suppressed because it is too large
Load Diff
126
src/lib/litegraph/src/LGraphBadge.ts
Normal file
126
src/lib/litegraph/src/LGraphBadge.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { LGraphIcon, type LGraphIconOptions } from "./LGraphIcon"
|
||||
|
||||
export enum BadgePosition {
|
||||
TopLeft = "top-left",
|
||||
TopRight = "top-right",
|
||||
}
|
||||
|
||||
export interface LGraphBadgeOptions {
|
||||
text: string
|
||||
fgColor?: string
|
||||
bgColor?: string
|
||||
fontSize?: number
|
||||
padding?: number
|
||||
height?: number
|
||||
cornerRadius?: number
|
||||
iconOptions?: LGraphIconOptions
|
||||
xOffset?: number
|
||||
yOffset?: number
|
||||
}
|
||||
|
||||
export class LGraphBadge {
|
||||
text: string
|
||||
fgColor: string
|
||||
bgColor: string
|
||||
fontSize: number
|
||||
padding: number
|
||||
height: number
|
||||
cornerRadius: number
|
||||
icon?: LGraphIcon
|
||||
xOffset: number
|
||||
yOffset: number
|
||||
|
||||
constructor({
|
||||
text,
|
||||
fgColor = "white",
|
||||
bgColor = "#0F1F0F",
|
||||
fontSize = 12,
|
||||
padding = 6,
|
||||
height = 20,
|
||||
cornerRadius = 5,
|
||||
iconOptions,
|
||||
xOffset = 0,
|
||||
yOffset = 0,
|
||||
}: LGraphBadgeOptions) {
|
||||
this.text = text
|
||||
this.fgColor = fgColor
|
||||
this.bgColor = bgColor
|
||||
this.fontSize = fontSize
|
||||
this.padding = padding
|
||||
this.height = height
|
||||
this.cornerRadius = cornerRadius
|
||||
if (iconOptions) {
|
||||
this.icon = new LGraphIcon(iconOptions)
|
||||
}
|
||||
this.xOffset = xOffset
|
||||
this.yOffset = yOffset
|
||||
}
|
||||
|
||||
get visible() {
|
||||
return (this.text?.length ?? 0) > 0 || !!this.icon
|
||||
}
|
||||
|
||||
getWidth(ctx: CanvasRenderingContext2D) {
|
||||
if (!this.visible) return 0
|
||||
const { font } = ctx
|
||||
let iconWidth = 0
|
||||
if (this.icon) {
|
||||
ctx.font = `${this.icon.fontSize}px '${this.icon.fontFamily}'`
|
||||
iconWidth = ctx.measureText(this.icon.unicode).width + this.padding
|
||||
}
|
||||
ctx.font = `${this.fontSize}px sans-serif`
|
||||
const textWidth = this.text ? ctx.measureText(this.text).width : 0
|
||||
ctx.font = font
|
||||
return iconWidth + textWidth + this.padding * 2
|
||||
}
|
||||
|
||||
draw(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
): void {
|
||||
if (!this.visible) return
|
||||
|
||||
x += this.xOffset
|
||||
y += this.yOffset
|
||||
|
||||
const { font, fillStyle, textBaseline, textAlign } = ctx
|
||||
|
||||
ctx.font = `${this.fontSize}px sans-serif`
|
||||
const badgeWidth = this.getWidth(ctx)
|
||||
const badgeX = 0
|
||||
|
||||
// Draw badge background
|
||||
ctx.fillStyle = this.bgColor
|
||||
ctx.beginPath()
|
||||
if (ctx.roundRect) {
|
||||
ctx.roundRect(x + badgeX, y, badgeWidth, this.height, this.cornerRadius)
|
||||
} else {
|
||||
// Fallback for browsers that don't support roundRect
|
||||
ctx.rect(x + badgeX, y, badgeWidth, this.height)
|
||||
}
|
||||
ctx.fill()
|
||||
|
||||
let drawX = x + badgeX + this.padding
|
||||
const centerY = y + this.height / 2
|
||||
|
||||
// Draw icon if present
|
||||
if (this.icon) {
|
||||
this.icon.draw(ctx, drawX, centerY)
|
||||
drawX += this.icon.fontSize + this.padding / 2 + 4
|
||||
}
|
||||
|
||||
// Draw badge text
|
||||
if (this.text) {
|
||||
ctx.fillStyle = this.fgColor
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.textAlign = "left"
|
||||
ctx.fillText(this.text, drawX, centerY + 1)
|
||||
}
|
||||
|
||||
ctx.font = font
|
||||
ctx.fillStyle = fillStyle
|
||||
ctx.textBaseline = textBaseline
|
||||
ctx.textAlign = textAlign
|
||||
}
|
||||
}
|
||||
89
src/lib/litegraph/src/LGraphButton.ts
Normal file
89
src/lib/litegraph/src/LGraphButton.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Rectangle } from "./infrastructure/Rectangle"
|
||||
import { LGraphBadge, type LGraphBadgeOptions } from "./LGraphBadge"
|
||||
|
||||
export interface LGraphButtonOptions extends LGraphBadgeOptions {
|
||||
name?: string // To identify the button
|
||||
}
|
||||
|
||||
export class LGraphButton extends LGraphBadge {
|
||||
name?: string
|
||||
_last_area: Rectangle = new Rectangle()
|
||||
|
||||
constructor(options: LGraphButtonOptions) {
|
||||
super(options)
|
||||
this.name = options.name
|
||||
}
|
||||
|
||||
override getWidth(ctx: CanvasRenderingContext2D): number {
|
||||
if (!this.visible) return 0
|
||||
|
||||
const { font } = ctx
|
||||
ctx.font = `${this.fontSize}px 'PrimeIcons'`
|
||||
|
||||
// For icon buttons, just measure the text width without padding
|
||||
const textWidth = this.text ? ctx.measureText(this.text).width : 0
|
||||
|
||||
ctx.font = font
|
||||
return textWidth
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* Draws the button and updates its last rendered area for hit detection.
|
||||
* @param ctx The canvas rendering context.
|
||||
* @param x The x-coordinate to draw the button at.
|
||||
* @param y The y-coordinate to draw the button at.
|
||||
*/
|
||||
override draw(ctx: CanvasRenderingContext2D, x: number, y: number): void {
|
||||
if (!this.visible) {
|
||||
return
|
||||
}
|
||||
|
||||
const width = this.getWidth(ctx)
|
||||
|
||||
// Update the hit area
|
||||
this._last_area[0] = x + this.xOffset
|
||||
this._last_area[1] = y + this.yOffset
|
||||
this._last_area[2] = width
|
||||
this._last_area[3] = this.height
|
||||
|
||||
// Custom drawing for buttons - no background, just icon/text
|
||||
const adjustedX = x + this.xOffset
|
||||
const adjustedY = y + this.yOffset
|
||||
|
||||
const { font, fillStyle, textBaseline, textAlign } = ctx
|
||||
|
||||
// Use the same color as the title text (usually white)
|
||||
const titleTextColor = ctx.fillStyle || "white"
|
||||
|
||||
// Draw as icon-only without background
|
||||
ctx.font = `${this.fontSize}px 'PrimeIcons'`
|
||||
ctx.fillStyle = titleTextColor
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.textAlign = "center"
|
||||
|
||||
const centerX = adjustedX + width / 2
|
||||
const centerY = adjustedY + this.height / 2
|
||||
|
||||
if (this.text) {
|
||||
ctx.fillText(this.text, centerX, centerY)
|
||||
}
|
||||
|
||||
// Restore context
|
||||
ctx.font = font
|
||||
ctx.fillStyle = fillStyle
|
||||
ctx.textBaseline = textBaseline
|
||||
ctx.textAlign = textAlign
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a point is inside the button's last rendered area.
|
||||
* @param x The x-coordinate of the point.
|
||||
* @param y The y-coordinate of the point.
|
||||
* @returns `true` if the point is inside the button, otherwise `false`.
|
||||
*/
|
||||
isPointInside(x: number, y: number): boolean {
|
||||
return this._last_area.containsPoint([x, y])
|
||||
}
|
||||
}
|
||||
7812
src/lib/litegraph/src/LGraphCanvas.ts
Normal file
7812
src/lib/litegraph/src/LGraphCanvas.ts
Normal file
File diff suppressed because it is too large
Load Diff
350
src/lib/litegraph/src/LGraphGroup.ts
Normal file
350
src/lib/litegraph/src/LGraphGroup.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import type {
|
||||
ColorOption,
|
||||
IColorable,
|
||||
IContextMenuValue,
|
||||
IPinnable,
|
||||
Point,
|
||||
Positionable,
|
||||
Size,
|
||||
} from "./interfaces"
|
||||
import type { LGraph } from "./LGraph"
|
||||
import type { ISerialisedGroup } from "./types/serialisation"
|
||||
|
||||
import { NullGraphError } from "@/infrastructure/NullGraphError"
|
||||
|
||||
import { strokeShape } from "./draw"
|
||||
import { LGraphCanvas } from "./LGraphCanvas"
|
||||
import { LGraphNode } from "./LGraphNode"
|
||||
import { LiteGraph } from "./litegraph"
|
||||
import {
|
||||
containsCentre,
|
||||
containsRect,
|
||||
createBounds,
|
||||
isInRectangle,
|
||||
isPointInRect,
|
||||
snapPoint,
|
||||
} from "./measure"
|
||||
|
||||
export interface IGraphGroupFlags extends Record<string, unknown> {
|
||||
pinned?: true
|
||||
}
|
||||
|
||||
export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
static minWidth = 140
|
||||
static minHeight = 80
|
||||
static resizeLength = 10
|
||||
static padding = 4
|
||||
static defaultColour = "#335"
|
||||
|
||||
id: number
|
||||
color?: string
|
||||
title: string
|
||||
font?: string
|
||||
font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24
|
||||
_bounding: Float32Array = new Float32Array([
|
||||
10,
|
||||
10,
|
||||
LGraphGroup.minWidth,
|
||||
LGraphGroup.minHeight,
|
||||
])
|
||||
|
||||
_pos: Point = this._bounding.subarray(0, 2)
|
||||
_size: Size = this._bounding.subarray(2, 4)
|
||||
/** @deprecated See {@link _children} */
|
||||
_nodes: LGraphNode[] = []
|
||||
_children: Set<Positionable> = new Set()
|
||||
graph?: LGraph
|
||||
flags: IGraphGroupFlags = {}
|
||||
selected?: boolean
|
||||
|
||||
constructor(title?: string, id?: number) {
|
||||
// TODO: Object instantiation pattern requires too much boilerplate and null checking. ID should be passed in via constructor.
|
||||
this.id = id ?? -1
|
||||
this.title = title || "Group"
|
||||
|
||||
const { pale_blue } = LGraphCanvas.node_colors
|
||||
this.color = pale_blue ? pale_blue.groupcolor : "#AAA"
|
||||
}
|
||||
|
||||
/** @inheritdoc {@link IColorable.setColorOption} */
|
||||
setColorOption(colorOption: ColorOption | null): void {
|
||||
if (colorOption == null) {
|
||||
delete this.color
|
||||
} else {
|
||||
this.color = colorOption.groupcolor
|
||||
}
|
||||
}
|
||||
|
||||
/** @inheritdoc {@link IColorable.getColorOption} */
|
||||
getColorOption(): ColorOption | null {
|
||||
return Object.values(LGraphCanvas.node_colors).find(
|
||||
colorOption => colorOption.groupcolor === this.color,
|
||||
) ?? null
|
||||
}
|
||||
|
||||
/** 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 graphCanvas
|
||||
* @param 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
|
||||
const color = this.color || defaultColour
|
||||
|
||||
// Titlebar
|
||||
ctx.globalAlpha = 0.25 * graphCanvas.editor_alpha
|
||||
ctx.fillStyle = color
|
||||
ctx.strokeStyle = color
|
||||
ctx.beginPath()
|
||||
ctx.rect(x + 0.5, y + 0.5, width, font_size * 1.4)
|
||||
ctx.fill()
|
||||
|
||||
// Group background, border
|
||||
ctx.fillStyle = color
|
||||
ctx.strokeStyle = 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 ${LiteGraph.GROUP_FONT}`
|
||||
ctx.textAlign = "left"
|
||||
ctx.fillText(this.title + (this.pinned ? "📌" : ""), x + padding, y + font_size)
|
||||
|
||||
if (LiteGraph.highlight_selected_group && this.selected) {
|
||||
strokeShape(ctx, this._bounding, {
|
||||
title_height: this.titleHeight,
|
||||
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 {
|
||||
if (!this.graph) throw new NullGraphError()
|
||||
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
|
||||
} else {
|
||||
return 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 nodes The nodes to add to the group
|
||||
* @param padding The padding around the group
|
||||
*/
|
||||
addNodes(nodes: LGraphNode[], padding: number = 10): void {
|
||||
if (!this._nodes && nodes.length === 0) return
|
||||
this.resizeTo([...this.children, ...this._nodes, ...nodes], padding)
|
||||
}
|
||||
|
||||
getMenuOptions(): (IContextMenuValue<string> | IContextMenuValue<string | null> | null)[] {
|
||||
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
|
||||
}
|
||||
68
src/lib/litegraph/src/LGraphIcon.ts
Normal file
68
src/lib/litegraph/src/LGraphIcon.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export interface LGraphIconOptions {
|
||||
unicode: string
|
||||
fontFamily?: string
|
||||
color?: string
|
||||
bgColor?: string
|
||||
fontSize?: number
|
||||
circlePadding?: number
|
||||
xOffset?: number
|
||||
yOffset?: number
|
||||
}
|
||||
|
||||
export class LGraphIcon {
|
||||
unicode: string
|
||||
fontFamily: string
|
||||
color: string
|
||||
bgColor?: string
|
||||
fontSize: number
|
||||
circlePadding: number
|
||||
xOffset: number
|
||||
yOffset: number
|
||||
|
||||
constructor({
|
||||
unicode,
|
||||
fontFamily = "PrimeIcons",
|
||||
color = "#e6c200",
|
||||
bgColor,
|
||||
fontSize = 16,
|
||||
circlePadding = 2,
|
||||
xOffset = 0,
|
||||
yOffset = 0,
|
||||
}: LGraphIconOptions) {
|
||||
this.unicode = unicode
|
||||
this.fontFamily = fontFamily
|
||||
this.color = color
|
||||
this.bgColor = bgColor
|
||||
this.fontSize = fontSize
|
||||
this.circlePadding = circlePadding
|
||||
this.xOffset = xOffset
|
||||
this.yOffset = yOffset
|
||||
}
|
||||
|
||||
draw(ctx: CanvasRenderingContext2D, x: number, y: number) {
|
||||
x += this.xOffset
|
||||
y += this.yOffset
|
||||
|
||||
const { font, textBaseline, textAlign, fillStyle } = ctx
|
||||
|
||||
ctx.font = `${this.fontSize}px '${this.fontFamily}'`
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.textAlign = "center"
|
||||
const iconRadius = this.fontSize / 2 + this.circlePadding
|
||||
// Draw icon background circle if bgColor is set
|
||||
if (this.bgColor) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
|
||||
ctx.fillStyle = this.bgColor
|
||||
ctx.fill()
|
||||
}
|
||||
// Draw icon
|
||||
ctx.fillStyle = this.color
|
||||
ctx.fillText(this.unicode, x + iconRadius, y)
|
||||
|
||||
ctx.font = font
|
||||
ctx.textBaseline = textBaseline
|
||||
ctx.textAlign = textAlign
|
||||
ctx.fillStyle = fillStyle
|
||||
}
|
||||
}
|
||||
3854
src/lib/litegraph/src/LGraphNode.ts
Normal file
3854
src/lib/litegraph/src/LGraphNode.ts
Normal file
File diff suppressed because it is too large
Load Diff
446
src/lib/litegraph/src/LLink.ts
Normal file
446
src/lib/litegraph/src/LLink.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import type {
|
||||
CanvasColour,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
ISlotType,
|
||||
LinkNetwork,
|
||||
LinkSegment,
|
||||
ReadonlyLinkNetwork,
|
||||
} from "./interfaces"
|
||||
import type { LGraphNode, NodeId } from "./LGraphNode"
|
||||
import type { Reroute, RerouteId } from "./Reroute"
|
||||
import type { Serialisable, SerialisableLLink, SubgraphIO } from "./types/serialisation"
|
||||
|
||||
import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID } from "@/constants"
|
||||
|
||||
import { Subgraph } from "./litegraph"
|
||||
|
||||
export type LinkId = number
|
||||
|
||||
export type SerialisedLLinkArray = [
|
||||
id: LinkId,
|
||||
origin_id: NodeId,
|
||||
origin_slot: number,
|
||||
target_id: NodeId,
|
||||
target_slot: number,
|
||||
type: ISlotType,
|
||||
]
|
||||
|
||||
// Resolved connection union; eliminates subgraph in/out as a possibility
|
||||
export type ResolvedConnection = BaseResolvedConnection &
|
||||
(
|
||||
(ResolvedSubgraphInput & ResolvedNormalOutput) |
|
||||
(ResolvedNormalInput & ResolvedSubgraphOutput) |
|
||||
(ResolvedNormalInput & ResolvedNormalOutput)
|
||||
)
|
||||
|
||||
interface BaseResolvedConnection {
|
||||
link: LLink
|
||||
/** The node on the input side of the link (owns {@link input}) */
|
||||
inputNode?: LGraphNode
|
||||
/** The input the link is connected to (mutually exclusive with {@link subgraphOutput}) */
|
||||
input?: INodeInputSlot
|
||||
/** The node on the output side of the link (owns {@link output}) */
|
||||
outputNode?: LGraphNode
|
||||
/** The output the link is connected to (mutually exclusive with {@link subgraphInput}) */
|
||||
output?: INodeOutputSlot
|
||||
/** The subgraph output the link is connected to (mutually exclusive with {@link input}) */
|
||||
subgraphOutput?: SubgraphIO
|
||||
/** The subgraph input the link is connected to (mutually exclusive with {@link output}) */
|
||||
subgraphInput?: SubgraphIO
|
||||
}
|
||||
|
||||
interface ResolvedNormalInput {
|
||||
inputNode: LGraphNode | undefined
|
||||
input: INodeInputSlot | undefined
|
||||
subgraphOutput?: undefined
|
||||
}
|
||||
|
||||
interface ResolvedNormalOutput {
|
||||
outputNode: LGraphNode | undefined
|
||||
output: INodeOutputSlot | undefined
|
||||
subgraphInput?: undefined
|
||||
}
|
||||
|
||||
interface ResolvedSubgraphInput {
|
||||
inputNode?: undefined
|
||||
/** The actual input slot the link is connected to (mutually exclusive with {@link subgraphOutput}) */
|
||||
input?: undefined
|
||||
subgraphOutput: SubgraphIO
|
||||
}
|
||||
|
||||
interface ResolvedSubgraphOutput {
|
||||
outputNode?: undefined
|
||||
output?: undefined
|
||||
subgraphInput: SubgraphIO
|
||||
}
|
||||
|
||||
type BasicReadonlyNetwork = Pick<ReadonlyLinkNetwork, "getNodeById" | "links" | "getLink" | "inputNode" | "outputNode">
|
||||
|
||||
// this is the class in charge of storing link information
|
||||
export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
static _drawDebug = false
|
||||
|
||||
/** Link ID */
|
||||
id: LinkId
|
||||
parentId?: RerouteId
|
||||
type: ISlotType
|
||||
/** Output node ID */
|
||||
origin_id: NodeId
|
||||
/** Output slot index */
|
||||
origin_slot: number
|
||||
/** Input node ID */
|
||||
target_id: NodeId
|
||||
/** Input slot index */
|
||||
target_slot: number
|
||||
|
||||
data?: number | string | boolean | { toToolTip?(): string }
|
||||
_data?: unknown
|
||||
/** Centre point of the link, calculated during render only - can be inaccurate */
|
||||
_pos: Float32Array
|
||||
/** @todo Clean up - never implemented in comfy. */
|
||||
_last_time?: number
|
||||
/** The last canvas 2D path that was used to render this link */
|
||||
path?: Path2D
|
||||
/** @inheritdoc */
|
||||
_centreAngle?: number
|
||||
|
||||
/** @inheritdoc */
|
||||
_dragging?: boolean
|
||||
|
||||
#color?: CanvasColour | null
|
||||
/** Custom colour for this link only */
|
||||
public get color(): CanvasColour | null | undefined {
|
||||
return this.#color
|
||||
}
|
||||
|
||||
public set color(value: CanvasColour) {
|
||||
this.#color = value === "" ? null : value
|
||||
}
|
||||
|
||||
public get isFloatingOutput(): boolean {
|
||||
return this.origin_id === -1 && this.origin_slot === -1
|
||||
}
|
||||
|
||||
public get isFloatingInput(): boolean {
|
||||
return this.target_id === -1 && this.target_slot === -1
|
||||
}
|
||||
|
||||
public get isFloating(): boolean {
|
||||
return this.isFloatingOutput || this.isFloatingInput
|
||||
}
|
||||
|
||||
/** `true` if this link is connected to a subgraph input node (the actual origin is in a different graph). */
|
||||
get originIsIoNode(): boolean {
|
||||
return this.origin_id === SUBGRAPH_INPUT_ID
|
||||
}
|
||||
|
||||
/** `true` if this link is connected to a subgraph output node (the actual target is in a different graph). */
|
||||
get targetIsIoNode(): boolean {
|
||||
return this.target_id === SUBGRAPH_OUTPUT_ID
|
||||
}
|
||||
|
||||
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
|
||||
// center
|
||||
this._pos = new Float32Array(2)
|
||||
}
|
||||
|
||||
/** @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 not be included.
|
||||
* @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: Pick<ReadonlyLinkNetwork, "reroutes">,
|
||||
linkSegment: LinkSegment,
|
||||
): Reroute[] {
|
||||
if (!linkSegment.parentId) return []
|
||||
return network.reroutes
|
||||
.get(linkSegment.parentId)
|
||||
?.getReroutes() ?? []
|
||||
}
|
||||
|
||||
static getFirstReroute(
|
||||
network: Pick<ReadonlyLinkNetwork, "reroutes">,
|
||||
linkSegment: LinkSegment,
|
||||
): Reroute | undefined {
|
||||
return LLink.getReroutes(network, linkSegment).at(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: Pick<ReadonlyLinkNetwork, "reroutes">,
|
||||
linkSegment: LinkSegment,
|
||||
rerouteId: RerouteId,
|
||||
): Reroute | null | undefined {
|
||||
if (!linkSegment.parentId) return
|
||||
return network.reroutes
|
||||
.get(linkSegment.parentId)
|
||||
?.findNextReroute(rerouteId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the origin node of a link.
|
||||
* @param network The network to search
|
||||
* @param linkId The ID of the link to get the origin node of
|
||||
* @returns The origin node of the link, or `undefined` if the link is not found or the origin node is not found
|
||||
*/
|
||||
static getOriginNode(network: BasicReadonlyNetwork, linkId: LinkId): LGraphNode | undefined {
|
||||
const id = network.links.get(linkId)?.origin_id
|
||||
return network.getNodeById(id) ?? undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the target node of a link.
|
||||
* @param network The network to search
|
||||
* @param linkId The ID of the link to get the target node of
|
||||
* @returns The target node of the link, or `undefined` if the link is not found or the target node is not found
|
||||
*/
|
||||
static getTargetNode(network: BasicReadonlyNetwork, linkId: LinkId): LGraphNode | undefined {
|
||||
const id = network.links.get(linkId)?.target_id
|
||||
return network.getNodeById(id) ?? undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a link ID to the link, node, and slot objects.
|
||||
* @param linkId The {@link id} of the link to resolve
|
||||
* @param network The link network to search
|
||||
* @returns An object containing the input and output nodes, as well as the input and output slots.
|
||||
* @remarks This method is heavier than others; it will always resolve all objects.
|
||||
* Whilst the performance difference should in most cases be negligible,
|
||||
* it is recommended to use simpler methods where appropriate.
|
||||
*/
|
||||
static resolve(linkId: LinkId | null | undefined, network: BasicReadonlyNetwork): ResolvedConnection | undefined {
|
||||
return network.getLink(linkId)?.resolve(network)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a list of link IDs to the link, node, and slot objects.
|
||||
* Discards invalid link IDs.
|
||||
* @param linkIds An iterable of link {@link id}s to resolve
|
||||
* @param network The link network to search
|
||||
* @returns An array of resolved connections. If a link is not found, it is not included in the array.
|
||||
* @see {@link LLink.resolve}
|
||||
*/
|
||||
static resolveMany(linkIds: Iterable<LinkId>, network: BasicReadonlyNetwork): ResolvedConnection[] {
|
||||
const resolved: ResolvedConnection[] = []
|
||||
for (const id of linkIds) {
|
||||
const r = network.getLink(id)?.resolve(network)
|
||||
if (r) resolved.push(r)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the primitive ID values stored in the link to the referenced objects.
|
||||
* @param network The link network to search
|
||||
* @returns An object containing the input and output nodes, as well as the input and output slots.
|
||||
* @remarks This method is heavier than others; it will always resolve all objects.
|
||||
* Whilst the performance difference should in most cases be negligible,
|
||||
* it is recommended to use simpler methods where appropriate.
|
||||
*/
|
||||
resolve(network: BasicReadonlyNetwork): ResolvedConnection {
|
||||
const inputNode = this.target_id === -1 ? undefined : network.getNodeById(this.target_id) ?? undefined
|
||||
const input = inputNode?.inputs[this.target_slot]
|
||||
const subgraphInput = this.originIsIoNode ? network.inputNode?.slots[this.origin_slot] : undefined
|
||||
if (subgraphInput) {
|
||||
return { inputNode, input, subgraphInput, link: this }
|
||||
}
|
||||
|
||||
const outputNode = this.origin_id === -1 ? undefined : network.getNodeById(this.origin_id) ?? undefined
|
||||
const output = outputNode?.outputs[this.origin_slot]
|
||||
const subgraphOutput = this.targetIsIoNode ? network.outputNode?.slots[this.target_slot] : undefined
|
||||
if (subgraphOutput) {
|
||||
return { outputNode, output, subgraphInput: undefined, subgraphOutput, link: this }
|
||||
}
|
||||
|
||||
return { inputNode, outputNode, input, output, subgraphInput, subgraphOutput, link: this }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the specified node id and output index are this link's origin (output side).
|
||||
* @param nodeId ID of the node to check
|
||||
* @param outputIndex The array index of the node output
|
||||
* @returns `true` if the origin matches, otherwise `false`.
|
||||
*/
|
||||
hasOrigin(nodeId: NodeId, outputIndex: number): boolean {
|
||||
return this.origin_id === nodeId && this.origin_slot === outputIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the specified node id and input index are this link's target (input side).
|
||||
* @param nodeId ID of the node to check
|
||||
* @param inputIndex The array index of the node input
|
||||
* @returns `true` if the target matches, otherwise `false`.
|
||||
*/
|
||||
hasTarget(nodeId: NodeId, inputIndex: number): boolean {
|
||||
return this.target_id === nodeId && this.target_slot === inputIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a floating link from this link.
|
||||
* @param slotType The side of the link that is still connected
|
||||
* @param parentId The parent reroute ID of the link
|
||||
* @returns A new LLink that is floating
|
||||
*/
|
||||
toFloating(slotType: "input" | "output", parentId: RerouteId): LLink {
|
||||
const exported = this.asSerialisable()
|
||||
exported.id = -1
|
||||
exported.parentId = parentId
|
||||
|
||||
if (slotType === "input") {
|
||||
exported.origin_id = -1
|
||||
exported.origin_slot = -1
|
||||
} else {
|
||||
exported.target_id = -1
|
||||
exported.target_slot = -1
|
||||
}
|
||||
|
||||
return LLink.create(exported)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `undefined`, reroutes will be automatically removed if no links remain.
|
||||
* If `input` or `output`, reroutes will not be automatically removed, and retain a connection to the input or output, respectively.
|
||||
*/
|
||||
disconnect(network: LinkNetwork, keepReroutes?: "input" | "output"): void {
|
||||
const reroutes = LLink.getReroutes(network, this)
|
||||
|
||||
const lastReroute = reroutes.at(-1)
|
||||
|
||||
// When floating from output, 1-to-1 ratio of floating link to final reroute (tree-like)
|
||||
const outputFloating = keepReroutes === "output" &&
|
||||
lastReroute?.linkIds.size === 1 &&
|
||||
lastReroute.floatingLinkIds.size === 0
|
||||
|
||||
// When floating from inputs, the final (input side) reroute may have many floating links
|
||||
if (outputFloating || (keepReroutes === "input" && lastReroute)) {
|
||||
const newLink = LLink.create(this)
|
||||
newLink.id = -1
|
||||
|
||||
if (keepReroutes === "input") {
|
||||
newLink.origin_id = -1
|
||||
newLink.origin_slot = -1
|
||||
|
||||
lastReroute.floating = { slotType: "input" }
|
||||
} else {
|
||||
newLink.target_id = -1
|
||||
newLink.target_slot = -1
|
||||
|
||||
lastReroute.floating = { slotType: "output" }
|
||||
}
|
||||
|
||||
network.addFloatingLink(newLink)
|
||||
}
|
||||
|
||||
for (const reroute of reroutes) {
|
||||
reroute.linkIds.delete(this.id)
|
||||
if (!keepReroutes && !reroute.totalLinks) {
|
||||
network.reroutes.delete(reroute.id)
|
||||
}
|
||||
}
|
||||
network.links.delete(this.id)
|
||||
|
||||
if (this.originIsIoNode && network instanceof Subgraph) {
|
||||
const subgraphInput = network.inputs.at(this.origin_slot)
|
||||
if (!subgraphInput) throw new Error("Invalid link - subgraph input not found")
|
||||
|
||||
subgraphInput.events.dispatch("input-disconnected", { input: subgraphInput })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
}
|
||||
922
src/lib/litegraph/src/LiteGraphGlobal.ts
Normal file
922
src/lib/litegraph/src/LiteGraphGlobal.ts
Normal file
@@ -0,0 +1,922 @@
|
||||
import type { Dictionary, ISlotType, Rect, WhenNullish } from "./interfaces"
|
||||
|
||||
import { InputIndicators } from "./canvas/InputIndicators"
|
||||
import { ContextMenu } from "./ContextMenu"
|
||||
import { CurveEditor } from "./CurveEditor"
|
||||
import { DragAndScale } from "./DragAndScale"
|
||||
import { LabelPosition, SlotDirection, SlotShape, SlotType } from "./draw"
|
||||
import { Rectangle } from "./infrastructure/Rectangle"
|
||||
import { LGraph } from "./LGraph"
|
||||
import { LGraphCanvas } from "./LGraphCanvas"
|
||||
import { LGraphGroup } from "./LGraphGroup"
|
||||
import { LGraphNode } from "./LGraphNode"
|
||||
import { LLink } from "./LLink"
|
||||
import { distance, isInsideRectangle, overlapBounding } from "./measure"
|
||||
import { Reroute } from "./Reroute"
|
||||
import { SubgraphIONodeBase } from "./subgraph/SubgraphIONodeBase"
|
||||
import { SubgraphSlot } from "./subgraph/SubgraphSlotBase"
|
||||
import {
|
||||
LGraphEventMode,
|
||||
LinkDirection,
|
||||
LinkRenderType,
|
||||
NodeSlotType,
|
||||
RenderShape,
|
||||
TitleMode,
|
||||
} from "./types/globalEnums"
|
||||
import { createUuidv4 } from "./utils/uuid"
|
||||
|
||||
/**
|
||||
* The Global Scope. It contains all the registered node classes.
|
||||
*/
|
||||
export class LiteGraphGlobal {
|
||||
// Enums
|
||||
SlotShape = SlotShape
|
||||
SlotDirection = SlotDirection
|
||||
SlotType = SlotType
|
||||
LabelPosition = LabelPosition
|
||||
|
||||
/** Used in serialised graphs at one point. */
|
||||
VERSION = 0.4 as const
|
||||
|
||||
CANVAS_GRID_SIZE = 10
|
||||
|
||||
NODE_TITLE_HEIGHT = 30
|
||||
NODE_TITLE_TEXT_Y = 20
|
||||
NODE_SLOT_HEIGHT = 20
|
||||
NODE_WIDGET_HEIGHT = 20
|
||||
NODE_WIDTH = 140
|
||||
NODE_MIN_WIDTH = 50
|
||||
NODE_COLLAPSED_RADIUS = 10
|
||||
NODE_COLLAPSED_WIDTH = 80
|
||||
NODE_TITLE_COLOR = "#999"
|
||||
NODE_SELECTED_TITLE_COLOR = "#FFF"
|
||||
NODE_TEXT_SIZE = 14
|
||||
NODE_TEXT_COLOR = "#AAA"
|
||||
NODE_TEXT_HIGHLIGHT_COLOR = "#EEE"
|
||||
NODE_SUBTEXT_SIZE = 12
|
||||
NODE_DEFAULT_COLOR = "#333"
|
||||
NODE_DEFAULT_BGCOLOR = "#353535"
|
||||
NODE_DEFAULT_BOXCOLOR = "#666"
|
||||
NODE_DEFAULT_SHAPE = RenderShape.ROUND
|
||||
NODE_BOX_OUTLINE_COLOR = "#FFF"
|
||||
NODE_ERROR_COLOUR = "#E00"
|
||||
NODE_FONT = "Arial"
|
||||
|
||||
DEFAULT_FONT = "Arial"
|
||||
DEFAULT_SHADOW_COLOR = "rgba(0,0,0,0.5)"
|
||||
|
||||
DEFAULT_GROUP_FONT = 24
|
||||
DEFAULT_GROUP_FONT_SIZE?: any
|
||||
GROUP_FONT = "Arial"
|
||||
|
||||
WIDGET_BGCOLOR = "#222"
|
||||
WIDGET_OUTLINE_COLOR = "#666"
|
||||
WIDGET_ADVANCED_OUTLINE_COLOR = "rgba(56, 139, 253, 0.8)"
|
||||
WIDGET_TEXT_COLOR = "#DDD"
|
||||
WIDGET_SECONDARY_TEXT_COLOR = "#999"
|
||||
WIDGET_DISABLED_TEXT_COLOR = "#666"
|
||||
|
||||
LINK_COLOR = "#9A9"
|
||||
EVENT_LINK_COLOR = "#A86"
|
||||
CONNECTING_LINK_COLOR = "#AFA"
|
||||
|
||||
/** avoid infinite loops */
|
||||
MAX_NUMBER_OF_NODES = 10_000
|
||||
/** default node position */
|
||||
DEFAULT_POSITION = [100, 100]
|
||||
/** ,"circle" */
|
||||
VALID_SHAPES = ["default", "box", "round", "card"] satisfies ("default" | Lowercase<keyof typeof RenderShape>)[]
|
||||
ROUND_RADIUS = 8
|
||||
|
||||
// shapes are used for nodes but also for slots
|
||||
BOX_SHAPE = RenderShape.BOX
|
||||
ROUND_SHAPE = RenderShape.ROUND
|
||||
CIRCLE_SHAPE = RenderShape.CIRCLE
|
||||
CARD_SHAPE = RenderShape.CARD
|
||||
ARROW_SHAPE = RenderShape.ARROW
|
||||
/** intended for slot arrays */
|
||||
GRID_SHAPE = RenderShape.GRID
|
||||
|
||||
// enums
|
||||
INPUT = NodeSlotType.INPUT
|
||||
OUTPUT = NodeSlotType.OUTPUT
|
||||
|
||||
// TODO: -1 can lead to ambiguity in JS; these should be updated to a more explicit constant or Symbol.
|
||||
/** for outputs */
|
||||
EVENT = -1 as const
|
||||
/** for inputs */
|
||||
ACTION = -1 as const
|
||||
|
||||
/** helper, will add "On Request" and more in the future */
|
||||
NODE_MODES = ["Always", "On Event", "Never", "On Trigger"]
|
||||
/** use with node_box_coloured_by_mode */
|
||||
NODE_MODES_COLORS = ["#666", "#422", "#333", "#224", "#626"]
|
||||
ALWAYS = LGraphEventMode.ALWAYS
|
||||
ON_EVENT = LGraphEventMode.ON_EVENT
|
||||
NEVER = LGraphEventMode.NEVER
|
||||
ON_TRIGGER = LGraphEventMode.ON_TRIGGER
|
||||
|
||||
UP = LinkDirection.UP
|
||||
DOWN = LinkDirection.DOWN
|
||||
LEFT = LinkDirection.LEFT
|
||||
RIGHT = LinkDirection.RIGHT
|
||||
CENTER = LinkDirection.CENTER
|
||||
|
||||
/** helper */
|
||||
LINK_RENDER_MODES = ["Straight", "Linear", "Spline"]
|
||||
HIDDEN_LINK = LinkRenderType.HIDDEN_LINK
|
||||
STRAIGHT_LINK = LinkRenderType.STRAIGHT_LINK
|
||||
LINEAR_LINK = LinkRenderType.LINEAR_LINK
|
||||
SPLINE_LINK = LinkRenderType.SPLINE_LINK
|
||||
|
||||
NORMAL_TITLE = TitleMode.NORMAL_TITLE
|
||||
NO_TITLE = TitleMode.NO_TITLE
|
||||
TRANSPARENT_TITLE = TitleMode.TRANSPARENT_TITLE
|
||||
AUTOHIDE_TITLE = TitleMode.AUTOHIDE_TITLE
|
||||
|
||||
/** arrange nodes vertically */
|
||||
VERTICAL_LAYOUT = "vertical"
|
||||
|
||||
/** used to redirect calls */
|
||||
proxy = null
|
||||
node_images_path = ""
|
||||
|
||||
debug = false
|
||||
catch_exceptions = true
|
||||
throw_errors = true
|
||||
/** if set to true some nodes like Formula would be allowed to evaluate code that comes from unsafe sources (like node configuration), which could lead to exploits */
|
||||
allow_scripts = false
|
||||
/** nodetypes by string */
|
||||
registered_node_types: Record<string, typeof LGraphNode> = {}
|
||||
/** @deprecated used for dropping files in the canvas. It appears the code that enables this was removed, but the object remains and is references by built-in drag drop. */
|
||||
node_types_by_file_extension: Record<string, { type: string }> = {}
|
||||
/** node types by classname */
|
||||
Nodes: Record<string, typeof LGraphNode> = {}
|
||||
/** used to store vars between graphs */
|
||||
Globals = {}
|
||||
|
||||
/** @deprecated Unused and will be deleted. */
|
||||
searchbox_extras: Dictionary<unknown> = {}
|
||||
|
||||
/** [true!] this make the nodes box (top left circle) coloured when triggered (execute/action), visual feedback */
|
||||
node_box_coloured_when_on = false
|
||||
/** [true!] nodebox based on node mode, visual feedback */
|
||||
node_box_coloured_by_mode = false
|
||||
|
||||
/** [false on mobile] better true if not touch device, TODO add an helper/listener to close if false */
|
||||
dialog_close_on_mouse_leave = false
|
||||
dialog_close_on_mouse_leave_delay = 500
|
||||
|
||||
/** [false!] prefer false if results too easy to break links - implement with ALT or TODO custom keys */
|
||||
shift_click_do_break_link_from = false
|
||||
/** [false!]prefer false, way too easy to break links */
|
||||
click_do_break_link_to = false
|
||||
/** [true!] who accidentally ctrl-alt-clicks on an in/output? nobody! that's who! */
|
||||
ctrl_alt_click_do_break_link = true
|
||||
/** [true!] snaps links when dragging connections over valid targets */
|
||||
snaps_for_comfy = true
|
||||
/** [true!] renders a partial border to highlight when a dragged link is snapped to a node */
|
||||
snap_highlights_node = true
|
||||
|
||||
/**
|
||||
* If `true`, items always snap to the grid - modifier keys are ignored.
|
||||
* When {@link snapToGrid} is falsy, a value of `1` is used.
|
||||
* Default: `false`
|
||||
*/
|
||||
alwaysSnapToGrid?: boolean
|
||||
|
||||
/**
|
||||
* When set to a positive number, when nodes are moved their positions will
|
||||
* be rounded to the nearest multiple of this value. Half up.
|
||||
* Default: `undefined`
|
||||
* @todo Not implemented - see {@link LiteGraph.CANVAS_GRID_SIZE}
|
||||
*/
|
||||
snapToGrid?: number
|
||||
|
||||
/** [false on mobile] better true if not touch device, TODO add an helper/listener to close if false */
|
||||
search_hide_on_mouse_leave = true
|
||||
/**
|
||||
* [true!] enable filtering slots type in the search widget
|
||||
* !requires auto_load_slot_types or manual set registered_slot_[in/out]_types and slot_types_[in/out]
|
||||
*/
|
||||
search_filter_enabled = false
|
||||
/** [true!] opens the results list when opening the search widget */
|
||||
search_show_all_on_open = true
|
||||
|
||||
/**
|
||||
* [if want false, use true, run, get vars values to be statically set, than disable]
|
||||
* nodes types and nodeclass association with node types need to be calculated,
|
||||
* if dont want this, calculate once and set registered_slot_[in/out]_types and slot_types_[in/out]
|
||||
*/
|
||||
auto_load_slot_types = false
|
||||
|
||||
// set these values if not using auto_load_slot_types
|
||||
/** slot types for nodeclass */
|
||||
registered_slot_in_types: Record<string, { nodes: string[] }> = {}
|
||||
/** slot types for nodeclass */
|
||||
registered_slot_out_types: Record<string, { nodes: string[] }> = {}
|
||||
/** slot types IN */
|
||||
slot_types_in: string[] = []
|
||||
/** slot types OUT */
|
||||
slot_types_out: string[] = []
|
||||
/**
|
||||
* specify for each IN slot type a(/many) default node(s), use single string, array, or object
|
||||
* (with node, title, parameters, ..) like for search
|
||||
*/
|
||||
slot_types_default_in: Record<string, string[]> = {}
|
||||
/**
|
||||
* specify for each OUT slot type a(/many) default node(s), use single string, array, or object
|
||||
* (with node, title, parameters, ..) like for search
|
||||
*/
|
||||
slot_types_default_out: Record<string, string[]> = {}
|
||||
|
||||
/** [true!] very handy, ALT click to clone and drag the new node */
|
||||
alt_drag_do_clone_nodes = false
|
||||
|
||||
/**
|
||||
* [true!] will create and connect event slots when using action/events connections,
|
||||
* !WILL CHANGE node mode when using onTrigger (enable mode colors), onExecuted does not need this
|
||||
*/
|
||||
do_add_triggers_slots = false
|
||||
|
||||
/** [false!] being events, it is strongly reccomended to use them sequentially, one by one */
|
||||
allow_multi_output_for_events = true
|
||||
|
||||
/** [true!] allows to create and connect a ndoe clicking with the third button (wheel) */
|
||||
middle_click_slot_add_default_node = false
|
||||
|
||||
/** [true!] dragging a link to empty space will open a menu, add from list, search or defaults */
|
||||
release_link_on_empty_shows_menu = false
|
||||
|
||||
/** "mouse"|"pointer" use mouse for retrocompatibility issues? (none found @ now) */
|
||||
pointerevents_method = "pointer"
|
||||
|
||||
/**
|
||||
* [true!] allows ctrl + shift + v to paste nodes with the outputs of the unselected nodes connected
|
||||
* with the inputs of the newly pasted nodes
|
||||
*/
|
||||
ctrl_shift_v_paste_connect_unselected_outputs = true
|
||||
|
||||
// if true, all newly created nodes/links will use string UUIDs for their id fields instead of integers.
|
||||
// use this if you must have node IDs that are unique across all graphs and subgraphs.
|
||||
use_uuids = false
|
||||
|
||||
// Whether to highlight the bounding box of selected groups
|
||||
highlight_selected_group = true
|
||||
|
||||
/** Whether to scale context with the graph when zooming in. Zooming out never makes context menus smaller. */
|
||||
context_menu_scaling = false
|
||||
|
||||
/**
|
||||
* Debugging flag. Repeats deprecation warnings every time they are reported.
|
||||
* May impact performance.
|
||||
*/
|
||||
alwaysRepeatWarnings: boolean = false
|
||||
|
||||
/**
|
||||
* Array of callbacks to execute when Litegraph first reports a deprecated API being used.
|
||||
* @see alwaysRepeatWarnings By default, will not repeat identical messages.
|
||||
*/
|
||||
onDeprecationWarning: ((message: string, source?: object) => void)[] = [console.warn]
|
||||
|
||||
/**
|
||||
* If `true`, mouse wheel events will be interpreted as trackpad gestures.
|
||||
* Tested on MacBook M4 Pro.
|
||||
* @default false
|
||||
* @see macGesturesRequireMac
|
||||
*/
|
||||
macTrackpadGestures: boolean = false
|
||||
|
||||
/**
|
||||
* If both this setting and {@link macTrackpadGestures} are `true`, trackpad gestures will
|
||||
* only be enabled when the browser user agent includes "Mac".
|
||||
* @default true
|
||||
* @see macTrackpadGestures
|
||||
*/
|
||||
macGesturesRequireMac: boolean = true
|
||||
|
||||
/**
|
||||
* "standard": change the dragging on left mouse button click to select, enable middle-click or spacebar+left-click dragging
|
||||
* "legacy": Enable dragging on left-click (original behavior)
|
||||
* @default "legacy"
|
||||
*/
|
||||
canvasNavigationMode: "standard" | "legacy" = "legacy"
|
||||
|
||||
/**
|
||||
* If `true`, widget labels and values will both be truncated (proportionally to size),
|
||||
* until they fit within the widget.
|
||||
*
|
||||
* Otherwise, the label will be truncated completely before the value is truncated.
|
||||
* @default false
|
||||
*/
|
||||
truncateWidgetTextEvenly: boolean = false
|
||||
|
||||
/**
|
||||
* If `true`, widget values will be completely truncated when shrinking a widget,
|
||||
* before truncating widget labels. {@link truncateWidgetTextEvenly} must be `false`.
|
||||
* @default false
|
||||
*/
|
||||
truncateWidgetValuesFirst: boolean = false
|
||||
|
||||
/**
|
||||
* If `true`, the current viewport scale & offset of the first attached canvas will be included with the graph when exporting.
|
||||
* @default true
|
||||
*/
|
||||
saveViewportWithGraph: boolean = true
|
||||
|
||||
// TODO: Remove legacy accessors
|
||||
LGraph = LGraph
|
||||
LLink = LLink
|
||||
LGraphNode = LGraphNode
|
||||
LGraphGroup = LGraphGroup
|
||||
DragAndScale = DragAndScale
|
||||
LGraphCanvas = LGraphCanvas
|
||||
ContextMenu = ContextMenu
|
||||
CurveEditor = CurveEditor
|
||||
Reroute = Reroute
|
||||
|
||||
constructor() {
|
||||
Object.defineProperty(this, "Classes", { writable: false })
|
||||
}
|
||||
|
||||
Classes = {
|
||||
get SubgraphSlot() { return SubgraphSlot },
|
||||
get SubgraphIONodeBase() { return SubgraphIONodeBase },
|
||||
|
||||
// Rich drawing
|
||||
get Rectangle() { return Rectangle },
|
||||
|
||||
// Debug / helpers
|
||||
get InputIndicators() { return InputIndicators },
|
||||
}
|
||||
|
||||
onNodeTypeRegistered?(type: string, base_class: typeof LGraphNode): void
|
||||
onNodeTypeReplaced?(type: string, base_class: typeof LGraphNode, prev: unknown): void
|
||||
|
||||
/**
|
||||
* Register a node class so it can be listed when the user wants to create a new one
|
||||
* @param type name of the node and path
|
||||
* @param base_class class containing the structure of a node
|
||||
*/
|
||||
registerNodeType(type: string, base_class: typeof LGraphNode): void {
|
||||
if (!base_class.prototype)
|
||||
throw "Cannot register a simple object, it must be a class with a prototype"
|
||||
base_class.type = type
|
||||
|
||||
if (this.debug) console.log("Node registered:", type)
|
||||
|
||||
const classname = base_class.name
|
||||
|
||||
const pos = type.lastIndexOf("/")
|
||||
base_class.category = type.substring(0, pos)
|
||||
|
||||
base_class.title ||= classname
|
||||
|
||||
// extend class
|
||||
for (const i in LGraphNode.prototype) {
|
||||
// @ts-expect-error #576 This functionality is deprecated and should be removed.
|
||||
base_class.prototype[i] ||= LGraphNode.prototype[i]
|
||||
}
|
||||
|
||||
const prev = this.registered_node_types[type]
|
||||
if (prev && this.debug) {
|
||||
console.log("replacing node type:", type)
|
||||
}
|
||||
|
||||
this.registered_node_types[type] = base_class
|
||||
if (base_class.constructor.name) this.Nodes[classname] = base_class
|
||||
|
||||
this.onNodeTypeRegistered?.(type, base_class)
|
||||
if (prev) this.onNodeTypeReplaced?.(type, base_class, prev)
|
||||
|
||||
// warnings
|
||||
if (base_class.prototype.onPropertyChange)
|
||||
console.warn(`LiteGraph node class ${type} has onPropertyChange method, it must be called onPropertyChanged with d at the end`)
|
||||
|
||||
// TODO one would want to know input and ouput :: this would allow through registerNodeAndSlotType to get all the slots types
|
||||
if (this.auto_load_slot_types) new base_class(base_class.title || "tmpnode")
|
||||
}
|
||||
|
||||
/**
|
||||
* removes a node type from the system
|
||||
* @param type name of the node or the node constructor itself
|
||||
*/
|
||||
unregisterNodeType(type: string | typeof LGraphNode): void {
|
||||
const base_class = typeof type === "string"
|
||||
? this.registered_node_types[type]
|
||||
: type
|
||||
if (!base_class) throw `node type not found: ${String(type)}`
|
||||
|
||||
delete this.registered_node_types[String(base_class.type)]
|
||||
|
||||
const name = base_class.constructor.name
|
||||
if (name) delete this.Nodes[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a slot type and his node
|
||||
* @param type name of the node or the node constructor itself
|
||||
* @param slot_type name of the slot type (variable type), eg. string, number, array, boolean, ..
|
||||
*/
|
||||
registerNodeAndSlotType(
|
||||
type: LGraphNode,
|
||||
slot_type: ISlotType,
|
||||
out?: boolean,
|
||||
): void {
|
||||
out ||= false
|
||||
// @ts-expect-error Confirm this function no longer supports string types - base_class should always be an instance not a constructor.
|
||||
const base_class = typeof type === "string" && this.registered_node_types[type] !== "anonymous"
|
||||
? this.registered_node_types[type]
|
||||
: type
|
||||
|
||||
// @ts-expect-error Confirm this function no longer supports string types - base_class should always be an instance not a constructor.
|
||||
const class_type = base_class.constructor.type
|
||||
|
||||
let allTypes = []
|
||||
if (typeof slot_type === "string") {
|
||||
allTypes = slot_type.split(",")
|
||||
} else if (slot_type == this.EVENT || slot_type == this.ACTION) {
|
||||
allTypes = ["_event_"]
|
||||
} else {
|
||||
allTypes = ["*"]
|
||||
}
|
||||
|
||||
for (let slotType of allTypes) {
|
||||
if (slotType === "") slotType = "*"
|
||||
|
||||
const register = out
|
||||
? this.registered_slot_out_types
|
||||
: this.registered_slot_in_types
|
||||
register[slotType] ??= { nodes: [] }
|
||||
|
||||
const { nodes } = register[slotType]
|
||||
if (!nodes.includes(class_type)) nodes.push(class_type)
|
||||
|
||||
// check if is a new type
|
||||
const types = out
|
||||
? this.slot_types_out
|
||||
: this.slot_types_in
|
||||
const type = slotType.toLowerCase()
|
||||
|
||||
if (!types.includes(type)) {
|
||||
types.push(type)
|
||||
types.sort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all previously registered node's types
|
||||
*/
|
||||
clearRegisteredTypes(): void {
|
||||
this.registered_node_types = {}
|
||||
this.node_types_by_file_extension = {}
|
||||
this.Nodes = {}
|
||||
this.searchbox_extras = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a node of a given type with a name. The node is not attached to any graph yet.
|
||||
* @param type full name of the node class. p.e. "math/sin"
|
||||
* @param title a name to distinguish from other nodes
|
||||
* @param options to set options
|
||||
*/
|
||||
createNode(
|
||||
type: string,
|
||||
title?: string,
|
||||
options?: Dictionary<unknown>,
|
||||
): LGraphNode | null {
|
||||
const base_class = this.registered_node_types[type]
|
||||
if (!base_class) {
|
||||
if (this.debug) console.log(`GraphNode type "${type}" not registered.`)
|
||||
return null
|
||||
}
|
||||
|
||||
title = title || base_class.title || type
|
||||
|
||||
let node = null
|
||||
|
||||
if (this.catch_exceptions) {
|
||||
try {
|
||||
node = new base_class(title)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
node = new base_class(title)
|
||||
}
|
||||
|
||||
node.type = type
|
||||
|
||||
if (!node.title && title) node.title = title
|
||||
node.properties ||= {}
|
||||
node.properties_info ||= []
|
||||
node.flags ||= {}
|
||||
// call onresize?
|
||||
node.size ||= node.computeSize()
|
||||
node.pos ||= [this.DEFAULT_POSITION[0], this.DEFAULT_POSITION[1]]
|
||||
node.mode ||= LGraphEventMode.ALWAYS
|
||||
|
||||
// extra options
|
||||
if (options) {
|
||||
for (const i in options) {
|
||||
// @ts-expect-error #577 Requires interface
|
||||
node[i] = options[i]
|
||||
}
|
||||
}
|
||||
|
||||
// callback
|
||||
node.onNodeCreated?.()
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a registered node type with a given name
|
||||
* @param type full name of the node class. p.e. "math/sin"
|
||||
* @returns the node class
|
||||
*/
|
||||
getNodeType(type: string): typeof LGraphNode {
|
||||
return this.registered_node_types[type]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of node types matching one category
|
||||
* @param category category name
|
||||
* @returns array with all the node classes
|
||||
*/
|
||||
getNodeTypesInCategory(category: string, filter?: string) {
|
||||
const r = []
|
||||
for (const i in this.registered_node_types) {
|
||||
const type = this.registered_node_types[i]
|
||||
if (type.filter != filter) continue
|
||||
|
||||
if (category == "") {
|
||||
if (type.category == null) r.push(type)
|
||||
} else if (type.category == category) {
|
||||
r.push(type)
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list with all the node type categories
|
||||
* @param filter only nodes with ctor.filter equal can be shown
|
||||
* @returns array with all the names of the categories
|
||||
*/
|
||||
getNodeTypesCategories(filter?: string): string[] {
|
||||
const categories: Dictionary<number> = { "": 1 }
|
||||
for (const i in this.registered_node_types) {
|
||||
const type = this.registered_node_types[i]
|
||||
if (type.category && !type.skip_list) {
|
||||
if (type.filter != filter) continue
|
||||
|
||||
categories[type.category] = 1
|
||||
}
|
||||
}
|
||||
const result = []
|
||||
for (const i in categories) {
|
||||
result.push(i)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// debug purposes: reloads all the js scripts that matches a wildcard
|
||||
reloadNodes(folder_wildcard: string): void {
|
||||
const tmp = document.getElementsByTagName("script")
|
||||
// weird, this array changes by its own, so we use a copy
|
||||
const script_files = []
|
||||
for (const element of tmp) {
|
||||
script_files.push(element)
|
||||
}
|
||||
|
||||
const docHeadObj = document.getElementsByTagName("head")[0]
|
||||
folder_wildcard = document.location.href + folder_wildcard
|
||||
|
||||
for (const script_file of script_files) {
|
||||
const src = script_file.src
|
||||
if (!src || src.substr(0, folder_wildcard.length) != folder_wildcard)
|
||||
continue
|
||||
|
||||
try {
|
||||
if (this.debug) console.log("Reloading:", src)
|
||||
const dynamicScript = document.createElement("script")
|
||||
dynamicScript.type = "text/javascript"
|
||||
dynamicScript.src = src
|
||||
docHeadObj.append(dynamicScript)
|
||||
script_file.remove()
|
||||
} catch (error) {
|
||||
if (this.throw_errors) throw error
|
||||
if (this.debug) console.log("Error while reloading", src)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.debug) console.log("Nodes reloaded")
|
||||
}
|
||||
|
||||
// separated just to improve if it doesn't work
|
||||
/** @deprecated Prefer {@link structuredClone} */
|
||||
cloneObject<T extends object | undefined | null>(obj: T, target?: T): WhenNullish<T, null> {
|
||||
if (obj == null) return null as WhenNullish<T, null>
|
||||
|
||||
const r = JSON.parse(JSON.stringify(obj))
|
||||
if (!target) return r
|
||||
|
||||
for (const i in r) {
|
||||
// @ts-expect-error deprecated
|
||||
target[i] = r[i]
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
/** @see {@link createUuidv4} @inheritdoc */
|
||||
uuidv4 = createUuidv4
|
||||
|
||||
/**
|
||||
* Returns if the types of two slots are compatible (taking into account wildcards, etc)
|
||||
* @param type_a output
|
||||
* @param type_b input
|
||||
* @returns true if they can be connected
|
||||
*/
|
||||
isValidConnection(type_a: ISlotType, type_b: ISlotType): boolean {
|
||||
if (type_a == "" || type_a === "*") type_a = 0
|
||||
if (type_b == "" || type_b === "*") type_b = 0
|
||||
// If generic in/output, matching types (valid for triggers), or event/action types
|
||||
if (
|
||||
!type_a ||
|
||||
!type_b ||
|
||||
type_a == type_b ||
|
||||
(type_a == this.EVENT && type_b == this.ACTION)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Enforce string type to handle toLowerCase call (-1 number not ok)
|
||||
type_a = String(type_a)
|
||||
type_b = String(type_b)
|
||||
type_a = type_a.toLowerCase()
|
||||
type_b = type_b.toLowerCase()
|
||||
|
||||
// For nodes supporting multiple connection types
|
||||
if (!type_a.includes(",") && !type_b.includes(","))
|
||||
return type_a == type_b
|
||||
|
||||
// Check all permutations to see if one is valid
|
||||
const supported_types_a = type_a.split(",")
|
||||
const supported_types_b = type_b.split(",")
|
||||
for (const a of supported_types_a) {
|
||||
for (const b of supported_types_b) {
|
||||
if (this.isValidConnection(a, b))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// used to create nodes from wrapping functions
|
||||
getParameterNames(func: (...args: any) => any): string[] {
|
||||
return String(func)
|
||||
.replaceAll(/\/\/.*$/gm, "") // strip single-line comments
|
||||
.replaceAll(/\s+/g, "") // strip white space
|
||||
.replaceAll(/\/\*[^*/]*\*\//g, "") // strip multi-line comments /**/
|
||||
.split("){", 1)[0]
|
||||
.replace(/^[^(]*\(/, "") // extract the parameters
|
||||
.replaceAll(/=[^,]+/g, "") // strip any ES6 defaults
|
||||
.split(",")
|
||||
.filter(Boolean) // split & filter [""]
|
||||
}
|
||||
|
||||
/* helper for interaction: pointer, touch, mouse Listeners
|
||||
used by LGraphCanvas DragAndScale ContextMenu */
|
||||
pointerListenerAdd(oDOM: Node, sEvIn: string, fCall: (e: Event) => boolean | void, capture = false): void {
|
||||
if (!oDOM || !oDOM.addEventListener || !sEvIn || typeof fCall !== "function") return
|
||||
|
||||
let sMethod = this.pointerevents_method
|
||||
let sEvent = sEvIn
|
||||
|
||||
// UNDER CONSTRUCTION
|
||||
// convert pointerevents to touch event when not available
|
||||
if (sMethod == "pointer" && !window.PointerEvent) {
|
||||
console.warn("sMethod=='pointer' && !window.PointerEvent")
|
||||
console.log(`Converting pointer[${sEvent}] : down move up cancel enter TO touchstart touchmove touchend, etc ..`)
|
||||
switch (sEvent) {
|
||||
case "down": {
|
||||
sMethod = "touch"
|
||||
sEvent = "start"
|
||||
break
|
||||
}
|
||||
case "move": {
|
||||
sMethod = "touch"
|
||||
// sEvent = "move";
|
||||
break
|
||||
}
|
||||
case "up": {
|
||||
sMethod = "touch"
|
||||
sEvent = "end"
|
||||
break
|
||||
}
|
||||
case "cancel": {
|
||||
sMethod = "touch"
|
||||
// sEvent = "cancel";
|
||||
break
|
||||
}
|
||||
case "enter": {
|
||||
console.log("debug: Should I send a move event?") // ???
|
||||
break
|
||||
}
|
||||
// case "over": case "out": not used at now
|
||||
default: {
|
||||
console.warn(`PointerEvent not available in this browser ? The event ${sEvent} would not be called`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (sEvent) {
|
||||
// @ts-expect-error
|
||||
// both pointer and move events
|
||||
case "down": case "up": case "move": case "over": case "out": case "enter":
|
||||
{
|
||||
oDOM.addEventListener(sMethod + sEvent, fCall, capture)
|
||||
}
|
||||
// @ts-expect-error
|
||||
// only pointerevents
|
||||
case "leave": case "cancel": case "gotpointercapture": case "lostpointercapture":
|
||||
{
|
||||
if (sMethod != "mouse") {
|
||||
return oDOM.addEventListener(sMethod + sEvent, fCall, capture)
|
||||
}
|
||||
}
|
||||
// not "pointer" || "mouse"
|
||||
default:
|
||||
return oDOM.addEventListener(sEvent, fCall, capture)
|
||||
}
|
||||
}
|
||||
|
||||
pointerListenerRemove(oDOM: Node, sEvent: string, fCall: (e: Event) => boolean | void, capture = false): void {
|
||||
if (!oDOM || !oDOM.removeEventListener || !sEvent || typeof fCall !== "function") return
|
||||
|
||||
switch (sEvent) {
|
||||
// @ts-expect-error
|
||||
// both pointer and move events
|
||||
case "down": case "up": case "move": case "over": case "out": case "enter":
|
||||
{
|
||||
if (this.pointerevents_method == "pointer" || this.pointerevents_method == "mouse") {
|
||||
oDOM.removeEventListener(this.pointerevents_method + sEvent, fCall, capture)
|
||||
}
|
||||
}
|
||||
// @ts-expect-error
|
||||
// only pointerevents
|
||||
case "leave": case "cancel": case "gotpointercapture": case "lostpointercapture":
|
||||
{
|
||||
if (this.pointerevents_method == "pointer") {
|
||||
return oDOM.removeEventListener(this.pointerevents_method + sEvent, fCall, capture)
|
||||
}
|
||||
}
|
||||
// not "pointer" || "mouse"
|
||||
default:
|
||||
return oDOM.removeEventListener(sEvent, fCall, capture)
|
||||
}
|
||||
}
|
||||
|
||||
getTime(): number {
|
||||
return performance.now()
|
||||
}
|
||||
|
||||
distance = distance
|
||||
|
||||
colorToString(c: [number, number, number, number]): string {
|
||||
return (
|
||||
`rgba(${
|
||||
Math.round(c[0] * 255).toFixed()
|
||||
},${
|
||||
Math.round(c[1] * 255).toFixed()
|
||||
},${
|
||||
Math.round(c[2] * 255).toFixed()
|
||||
},${
|
||||
c.length == 4 ? c[3].toFixed(2) : "1.0"
|
||||
})`
|
||||
)
|
||||
}
|
||||
|
||||
isInsideRectangle = isInsideRectangle
|
||||
|
||||
// [minx,miny,maxx,maxy]
|
||||
growBounding(bounding: Rect, x: number, y: number): void {
|
||||
if (x < bounding[0]) {
|
||||
bounding[0] = x
|
||||
} else if (x > bounding[2]) {
|
||||
bounding[2] = x
|
||||
}
|
||||
|
||||
if (y < bounding[1]) {
|
||||
bounding[1] = y
|
||||
} else if (y > bounding[3]) {
|
||||
bounding[3] = y
|
||||
}
|
||||
}
|
||||
|
||||
overlapBounding = overlapBounding
|
||||
|
||||
// point inside bounding box
|
||||
isInsideBounding(p: number[], bb: number[][]): boolean {
|
||||
if (
|
||||
p[0] < bb[0][0] ||
|
||||
p[1] < bb[0][1] ||
|
||||
p[0] > bb[1][0] ||
|
||||
p[1] > bb[1][1]
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Convert a hex value to its decimal value - the inputted hex must be in the
|
||||
// format of a hex triplet - the kind we use for HTML colours. The function
|
||||
// will return an array with three values.
|
||||
hex2num(hex: string): number[] {
|
||||
if (hex.charAt(0) == "#") {
|
||||
hex = hex.slice(1)
|
||||
// Remove the '#' char - if there is one.
|
||||
}
|
||||
hex = hex.toUpperCase()
|
||||
const hex_alphabets = "0123456789ABCDEF"
|
||||
const value = new Array(3)
|
||||
let k = 0
|
||||
let int1, int2
|
||||
for (let i = 0; i < 6; i += 2) {
|
||||
int1 = hex_alphabets.indexOf(hex.charAt(i))
|
||||
int2 = hex_alphabets.indexOf(hex.charAt(i + 1))
|
||||
value[k] = int1 * 16 + int2
|
||||
k++
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Give a array with three values as the argument and the function will return
|
||||
// the corresponding hex triplet.
|
||||
num2hex(triplet: number[]): string {
|
||||
const hex_alphabets = "0123456789ABCDEF"
|
||||
let hex = "#"
|
||||
let int1, int2
|
||||
for (let i = 0; i < 3; i++) {
|
||||
int1 = triplet[i] / 16
|
||||
int2 = triplet[i] % 16
|
||||
|
||||
hex += hex_alphabets.charAt(int1) + hex_alphabets.charAt(int2)
|
||||
}
|
||||
return hex
|
||||
}
|
||||
|
||||
closeAllContextMenus(ref_window: Window = window): void {
|
||||
const elements = [...ref_window.document.querySelectorAll(".litecontextmenu")]
|
||||
if (!elements.length) return
|
||||
|
||||
for (const element of elements) {
|
||||
if ("close" in element && typeof element.close === "function") {
|
||||
element.close()
|
||||
} else {
|
||||
element.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extendClass(target: any, origin: any): void {
|
||||
for (const i in origin) {
|
||||
// copy class properties
|
||||
if (target.hasOwnProperty(i)) continue
|
||||
target[i] = origin[i]
|
||||
}
|
||||
|
||||
if (origin.prototype) {
|
||||
// copy prototype properties
|
||||
for (const i in origin.prototype) {
|
||||
// only enumerable
|
||||
if (!origin.prototype.hasOwnProperty(i)) continue
|
||||
|
||||
// avoid overwriting existing ones
|
||||
if (target.prototype.hasOwnProperty(i)) continue
|
||||
|
||||
// copy getters
|
||||
if (origin.prototype.__lookupGetter__(i)) {
|
||||
target.prototype.__defineGetter__(
|
||||
i,
|
||||
origin.prototype.__lookupGetter__(i),
|
||||
)
|
||||
} else {
|
||||
target.prototype[i] = origin.prototype[i]
|
||||
}
|
||||
|
||||
// and setters
|
||||
if (origin.prototype.__lookupSetter__(i)) {
|
||||
target.prototype.__defineSetter__(
|
||||
i,
|
||||
origin.prototype.__lookupSetter__(i),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/lib/litegraph/src/MapProxyHandler.ts
Normal file
65
src/lib/litegraph/src/MapProxyHandler.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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>> {
|
||||
getOwnPropertyDescriptor(
|
||||
target: Map<number | string, V>,
|
||||
p: string | symbol,
|
||||
): PropertyDescriptor | undefined {
|
||||
const value = this.get(target, p)
|
||||
if (value) {
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
has(target: Map<number | string, V>, p: string | symbol): boolean {
|
||||
if (typeof p === "symbol") return false
|
||||
|
||||
const int = parseInt(p, 10)
|
||||
return target.has(!isNaN(int) ? int : p)
|
||||
}
|
||||
|
||||
ownKeys(target: Map<number | string, V>): ArrayLike<string | symbol> {
|
||||
return [...target.keys()].map(String)
|
||||
}
|
||||
|
||||
get(target: Map<number | string, V>, p: string | symbol): any {
|
||||
// Workaround does not support link IDs of "values", "entries", "constructor", etc.
|
||||
if (p in target) return Reflect.get(target, p, target)
|
||||
if (typeof p === "symbol") return
|
||||
|
||||
const int = parseInt(p, 10)
|
||||
return target.get(!isNaN(int) ? int : p)
|
||||
}
|
||||
|
||||
set(target: Map<number | string, V>, p: string | symbol, newValue: any): boolean {
|
||||
if (typeof p === "symbol") return false
|
||||
|
||||
const int = parseInt(p, 10)
|
||||
target.set(!isNaN(int) ? int : p, newValue)
|
||||
return true
|
||||
}
|
||||
|
||||
deleteProperty(target: Map<number | string, V>, p: string | symbol): boolean {
|
||||
return target.delete(p as number | string)
|
||||
}
|
||||
|
||||
static bindAllMethods(map: Map<any, any>): void {
|
||||
map.clear = map.clear.bind(map)
|
||||
map.delete = map.delete.bind(map)
|
||||
map.forEach = map.forEach.bind(map)
|
||||
map.get = map.get.bind(map)
|
||||
map.has = map.has.bind(map)
|
||||
map.set = map.set.bind(map)
|
||||
map.entries = map.entries.bind(map)
|
||||
map.keys = map.keys.bind(map)
|
||||
map.values = map.values.bind(map)
|
||||
|
||||
map[Symbol.iterator] = map[Symbol.iterator].bind(map)
|
||||
}
|
||||
}
|
||||
778
src/lib/litegraph/src/Reroute.ts
Normal file
778
src/lib/litegraph/src/Reroute.ts
Normal file
@@ -0,0 +1,778 @@
|
||||
import type {
|
||||
CanvasColour,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
LinkNetwork,
|
||||
LinkSegment,
|
||||
Point,
|
||||
Positionable,
|
||||
ReadonlyLinkNetwork,
|
||||
ReadOnlyRect,
|
||||
} from "./interfaces"
|
||||
import type { LGraphNode, NodeId } from "./LGraphNode"
|
||||
import type { Serialisable, SerialisableReroute } from "./types/serialisation"
|
||||
|
||||
import { LGraphBadge } from "./LGraphBadge"
|
||||
import { type LinkId, LLink } from "./LLink"
|
||||
import { distance, isPointInRect } from "./measure"
|
||||
|
||||
export type RerouteId = number
|
||||
|
||||
/** The input or output slot that an incomplete reroute link is connected to. */
|
||||
export interface FloatingRerouteSlot {
|
||||
/** Floating connection to an input or output */
|
||||
slotType: "input" | "output"
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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> {
|
||||
static radius: number = 10
|
||||
/** Maximum distance from reroutes to their bezier curve control points. */
|
||||
static maxSplineOffset: number = 80
|
||||
static drawIdBadge: boolean = false
|
||||
static slotRadius: number = 5
|
||||
/** Distance from reroute centre to slot centre. */
|
||||
static get slotOffset(): number {
|
||||
const gap = Reroute.slotRadius * 0.33
|
||||
return Reroute.radius + gap + Reroute.slotRadius
|
||||
}
|
||||
|
||||
#malloc = new Float32Array(8)
|
||||
|
||||
/** The network this reroute belongs to. Contains all valid links and reroutes. */
|
||||
#network: WeakRef<LinkNetwork>
|
||||
|
||||
#parentId?: RerouteId
|
||||
public get parentId(): RerouteId | undefined {
|
||||
return this.#parentId
|
||||
}
|
||||
|
||||
/** Ignores attempts to create an infinite loop. @inheritdoc */
|
||||
public set parentId(value) {
|
||||
if (value === this.id) return
|
||||
if (this.getReroutes() === null) return
|
||||
this.#parentId = value
|
||||
}
|
||||
|
||||
public get parent(): Reroute | undefined {
|
||||
return this.#network.deref()?.getReroute(this.#parentId)
|
||||
}
|
||||
|
||||
/** This property is only defined on the last reroute of a floating reroute chain (closest to input end). */
|
||||
floating?: FloatingRerouteSlot
|
||||
|
||||
#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]
|
||||
}
|
||||
|
||||
/**
|
||||
* Slightly over-sized rectangle, guaranteed to contain the entire surface area for hover detection.
|
||||
* Eliminates most hover positions using an extremely cheap check.
|
||||
*/
|
||||
get #hoverArea(): ReadOnlyRect {
|
||||
const xOffset = 2 * Reroute.slotOffset
|
||||
const yOffset = 2 * Math.max(Reroute.radius, Reroute.slotRadius)
|
||||
|
||||
const [x, y] = this.#pos
|
||||
return [x - xOffset, y - yOffset, 2 * xOffset, 2 * yOffset]
|
||||
}
|
||||
|
||||
/** The total number of links & floating links using this reroute */
|
||||
get totalLinks(): number {
|
||||
return this.linkIds.size + this.floatingLinkIds.size
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
selected?: boolean
|
||||
|
||||
/** The ID ({@link LLink.id}) of every link using this reroute */
|
||||
linkIds: Set<LinkId>
|
||||
|
||||
/** The ID ({@link LLink.id}) of every floating link using this reroute */
|
||||
floatingLinkIds: Set<LinkId>
|
||||
|
||||
/** 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)
|
||||
|
||||
/** @inheritdoc */
|
||||
_dragging?: boolean
|
||||
|
||||
/** Colour of the first link that rendered this reroute */
|
||||
_colour?: CanvasColour
|
||||
|
||||
/** Colour of the first link that rendered this reroute */
|
||||
get colour(): CanvasColour {
|
||||
return this._colour ?? "#18184d"
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to ensure reroute angles are only executed once per frame.
|
||||
* @todo Calculate on change instead.
|
||||
*/
|
||||
#lastRenderTime: number = -Infinity
|
||||
|
||||
#inputSlot = new RerouteSlot(this, true)
|
||||
#outputSlot = new RerouteSlot(this, false)
|
||||
|
||||
get isSlotHovered(): boolean {
|
||||
return this.isInputHovered || this.isOutputHovered
|
||||
}
|
||||
|
||||
get isInputHovered(): boolean {
|
||||
return this.#inputSlot.hovering
|
||||
}
|
||||
|
||||
get isOutputHovered(): boolean {
|
||||
return this.#outputSlot.hovering
|
||||
}
|
||||
|
||||
get firstLink(): LLink | undefined {
|
||||
const linkId = this.linkIds.values().next().value
|
||||
return linkId === undefined
|
||||
? undefined
|
||||
: this.#network
|
||||
.deref()
|
||||
?.links
|
||||
.get(linkId)
|
||||
}
|
||||
|
||||
get firstFloatingLink(): LLink | undefined {
|
||||
const linkId = this.floatingLinkIds.values().next().value
|
||||
return linkId === undefined
|
||||
? undefined
|
||||
: this.#network
|
||||
.deref()
|
||||
?.floatingLinks
|
||||
.get(linkId)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
get origin_id(): NodeId | undefined {
|
||||
return this.firstLink?.origin_id
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
get origin_slot(): number | undefined {
|
||||
return this.firstLink?.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>,
|
||||
floatingLinkIds?: Iterable<LinkId>,
|
||||
) {
|
||||
this.#network = new WeakRef(network)
|
||||
this.parentId = parentId
|
||||
if (pos) this.pos = pos
|
||||
this.linkIds = new Set(linkIds)
|
||||
this.floatingLinkIds = new Set(floatingLinkIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>,
|
||||
floating?: FloatingRerouteSlot,
|
||||
): void {
|
||||
this.parentId = parentId
|
||||
if (pos) this.pos = pos
|
||||
if (linkIds) this.linkIds = new Set(linkIds)
|
||||
this.floating = floating
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: ReadonlyMap<LinkId, LLink>, floatingLinks: ReadonlyMap<LinkId, LLink>): boolean {
|
||||
const { linkIds, floatingLinkIds } = this
|
||||
for (const linkId of linkIds) {
|
||||
if (!links.has(linkId)) linkIds.delete(linkId)
|
||||
}
|
||||
for (const linkId of floatingLinkIds) {
|
||||
if (!floatingLinks.has(linkId)) floatingLinkIds.delete(linkId)
|
||||
}
|
||||
return linkIds.size > 0 || floatingLinkIds.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 {
|
||||
// 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)
|
||||
if (this.#parentId === undefined) return
|
||||
|
||||
return this.#network
|
||||
.deref()
|
||||
?.reroutes
|
||||
.get(this.#parentId)
|
||||
?.findNextReroute(withParentId, visited)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the output node and output slot of the first link passing through this reroute.
|
||||
* @returns The output node and output slot of the first link passing through this reroute, or `undefined` if no link is found.
|
||||
*/
|
||||
findSourceOutput(): { node: LGraphNode, output: INodeOutputSlot } | undefined {
|
||||
const link = this.firstLink ?? this.firstFloatingLink
|
||||
if (!link) return
|
||||
|
||||
const node = this.#network.deref()?.getNodeById(link.origin_id)
|
||||
if (!node) return
|
||||
|
||||
return {
|
||||
node,
|
||||
output: node.outputs[link.origin_slot],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the inputs and nodes of (floating) links passing through this reroute.
|
||||
* @returns An array of objects containing the node and input slot of each link passing through this reroute.
|
||||
*/
|
||||
findTargetInputs(): { node: LGraphNode, input: INodeInputSlot, link: LLink }[] | undefined {
|
||||
const network = this.#network.deref()
|
||||
if (!network) return
|
||||
|
||||
const results: {
|
||||
node: LGraphNode
|
||||
input: INodeInputSlot
|
||||
link: LLink
|
||||
}[] = []
|
||||
|
||||
addAllResults(network, this.linkIds, network.links)
|
||||
addAllResults(network, this.floatingLinkIds, network.floatingLinks)
|
||||
|
||||
return results
|
||||
|
||||
function addAllResults(
|
||||
network: ReadonlyLinkNetwork,
|
||||
linkIds: Iterable<LinkId>,
|
||||
links: ReadonlyMap<LinkId, LLink>,
|
||||
) {
|
||||
for (const linkId of linkIds) {
|
||||
const link = links.get(linkId)
|
||||
if (!link) continue
|
||||
|
||||
const node = network.getNodeById(link.target_id)
|
||||
const input = node?.inputs[link.target_slot]
|
||||
if (!input) continue
|
||||
|
||||
results.push({ node, input, link })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all floating links passing through this reroute.
|
||||
* @param from Filters the links by the currently connected link side.
|
||||
* @returns An array of floating links
|
||||
*/
|
||||
getFloatingLinks(from: "input" | "output"): LLink[] | undefined {
|
||||
const floatingLinks = this.#network.deref()?.floatingLinks
|
||||
if (!floatingLinks) return
|
||||
|
||||
const idProp = from === "input" ? "origin_id" : "target_id"
|
||||
const out: LLink[] = []
|
||||
|
||||
for (const linkId of this.floatingLinkIds) {
|
||||
const link = floatingLinks.get(linkId)
|
||||
if (link?.[idProp] === -1) out.push(link)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the origin node/output of all floating links that pass through this reroute.
|
||||
* @param node The new origin node
|
||||
* @param output The new origin output slot
|
||||
* @param index The slot index of {@link output}
|
||||
*/
|
||||
setFloatingLinkOrigin(node: LGraphNode, output: INodeOutputSlot, index: number) {
|
||||
const network = this.#network.deref()
|
||||
const floatingOutLinks = this.getFloatingLinks("output")
|
||||
if (!floatingOutLinks) throw new Error("[setFloatingLinkOrigin]: Invalid network.")
|
||||
if (!floatingOutLinks.length) return
|
||||
|
||||
output._floatingLinks ??= new Set()
|
||||
|
||||
for (const link of floatingOutLinks) {
|
||||
// Update cached floating links
|
||||
output._floatingLinks.add(link)
|
||||
|
||||
network?.getNodeById(link.origin_id)
|
||||
?.outputs[link.origin_slot]
|
||||
?._floatingLinks
|
||||
?.delete(link)
|
||||
|
||||
// Update the floating link
|
||||
link.origin_id = node.id
|
||||
link.origin_slot = index
|
||||
}
|
||||
}
|
||||
|
||||
/** @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
|
||||
}
|
||||
|
||||
removeAllFloatingLinks() {
|
||||
for (const linkId of this.floatingLinkIds) {
|
||||
this.removeFloatingLink(linkId)
|
||||
}
|
||||
}
|
||||
|
||||
removeFloatingLink(linkId: LinkId) {
|
||||
const network = this.#network.deref()
|
||||
if (!network) return
|
||||
|
||||
const floatingLink = network.floatingLinks.get(linkId)
|
||||
if (!floatingLink) {
|
||||
console.warn(`[Reroute.removeFloatingLink] Floating link not found: ${linkId}, ignoring and discarding ID.`)
|
||||
this.floatingLinkIds.delete(linkId)
|
||||
return
|
||||
}
|
||||
|
||||
network.removeFloatingLink(floatingLink)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a link or floating link from this reroute, by matching link object instance equality.
|
||||
* @param link The link to remove.
|
||||
* @remarks Does not remove the link from the network.
|
||||
*/
|
||||
removeLink(link: LLink) {
|
||||
const network = this.#network.deref()
|
||||
if (!network) return
|
||||
|
||||
const floatingLink = network.floatingLinks.get(link.id)
|
||||
if (link === floatingLink) {
|
||||
this.floatingLinkIds.delete(link.id)
|
||||
} else {
|
||||
this.linkIds.delete(link.id)
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
const network = this.#network.deref()
|
||||
if (!network) return
|
||||
|
||||
network.removeReroute(this.id)
|
||||
}
|
||||
|
||||
calculateAngle(lastRenderTime: number, network: ReadonlyLinkNetwork, linkStart: Point): void {
|
||||
// Ensure we run once per render
|
||||
if (!(lastRenderTime > this.#lastRenderTime)) return
|
||||
this.#lastRenderTime = lastRenderTime
|
||||
|
||||
const { id, pos: thisPos } = this
|
||||
|
||||
// Add all link angles
|
||||
const angles: number[] = []
|
||||
let sum = 0
|
||||
calculateAngles(this.linkIds, network.links)
|
||||
calculateAngles(this.floatingLinkIds, network.floatingLinks)
|
||||
|
||||
// Invalid - reset
|
||||
if (!angles.length) {
|
||||
this.cos = 0
|
||||
this.sin = 0
|
||||
this.controlPoint[0] = 0
|
||||
this.controlPoint[1] = 0
|
||||
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(Reroute.maxSplineOffset, distance(linkStart, this.#pos) * 0.25)
|
||||
|
||||
// Store results
|
||||
const originDiff = originToReroute - diff
|
||||
const cos = Math.cos(originDiff)
|
||||
const sin = Math.sin(originDiff)
|
||||
|
||||
this.cos = cos
|
||||
this.sin = sin
|
||||
this.controlPoint[0] = dist * -cos
|
||||
this.controlPoint[1] = dist * -sin
|
||||
|
||||
/**
|
||||
* Calculates the direction of each link and adds it to the array.
|
||||
* @param linkIds The IDs of the links to calculate
|
||||
* @param links The link container from the link network.
|
||||
*/
|
||||
function calculateAngles(linkIds: Iterable<LinkId>, links: ReadonlyMap<LinkId, LLink>) {
|
||||
for (const linkId of linkIds) {
|
||||
const link = links.get(linkId)
|
||||
const pos = getNextPos(network, link, id)
|
||||
if (!pos) continue
|
||||
|
||||
const angle = getDirection(thisPos, pos)
|
||||
angles.push(angle)
|
||||
sum += angle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the reroute on the canvas.
|
||||
* @param ctx Canvas context to draw on
|
||||
* @param backgroundPattern The canvas background pattern; used to make floating reroutes appear washed out.
|
||||
* @remarks Leaves {@link ctx}.fillStyle, strokeStyle, and lineWidth dirty (perf.).
|
||||
*/
|
||||
draw(ctx: CanvasRenderingContext2D, backgroundPattern?: CanvasPattern): void {
|
||||
const { globalAlpha } = ctx
|
||||
const { pos } = this
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(pos[0], pos[1], Reroute.radius, 0, 2 * Math.PI)
|
||||
|
||||
if (this.linkIds.size === 0) {
|
||||
ctx.fillStyle = backgroundPattern ?? "#797979"
|
||||
ctx.fill()
|
||||
ctx.globalAlpha = globalAlpha * 0.33
|
||||
}
|
||||
|
||||
ctx.fillStyle = this.colour
|
||||
ctx.lineWidth = Reroute.radius * 0.1
|
||||
ctx.strokeStyle = "rgb(0,0,0,0.5)"
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = "#ffffff55"
|
||||
ctx.strokeStyle = "rgb(0,0,0,0.3)"
|
||||
ctx.beginPath()
|
||||
ctx.arc(pos[0], pos[1], Reroute.radius * 0.8, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
if (this.selected) {
|
||||
ctx.strokeStyle = "#fff"
|
||||
ctx.beginPath()
|
||||
ctx.arc(pos[0], pos[1], Reroute.radius * 1.2, 0, 2 * Math.PI)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
if (Reroute.drawIdBadge) {
|
||||
const idBadge = new LGraphBadge({ text: this.id.toString() })
|
||||
const x = pos[0] - idBadge.getWidth(ctx) * 0.5
|
||||
const y = pos[1] - idBadge.height - Reroute.radius - 2
|
||||
idBadge.draw(ctx, x, y)
|
||||
}
|
||||
|
||||
ctx.globalAlpha = globalAlpha
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the input and output slots on the canvas, if the slots are visible.
|
||||
* @param ctx The canvas context to draw on.
|
||||
*/
|
||||
drawSlots(ctx: CanvasRenderingContext2D): void {
|
||||
this.#inputSlot.draw(ctx)
|
||||
this.#outputSlot.draw(ctx)
|
||||
}
|
||||
|
||||
drawHighlight(ctx: CanvasRenderingContext2D, colour: CanvasColour): void {
|
||||
const { pos } = this
|
||||
|
||||
const { strokeStyle, lineWidth } = ctx
|
||||
ctx.strokeStyle = colour
|
||||
ctx.lineWidth = 1
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(pos[0], pos[1], Reroute.radius * 1.5, 0, 2 * Math.PI)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.strokeStyle = strokeStyle
|
||||
ctx.lineWidth = lineWidth
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates visibility of the input and output slots, based on the position of the pointer.
|
||||
* @param pos The position of the pointer.
|
||||
* @returns `true` if any changes require a redraw.
|
||||
*/
|
||||
updateVisibility(pos: Point): boolean {
|
||||
const input = this.#inputSlot
|
||||
const output = this.#outputSlot
|
||||
input.dirty = false
|
||||
output.dirty = false
|
||||
|
||||
const { firstFloatingLink } = this
|
||||
const hasLink = !!this.firstLink
|
||||
|
||||
const showInput = hasLink || firstFloatingLink?.isFloatingOutput
|
||||
const showOutput = hasLink || firstFloatingLink?.isFloatingInput
|
||||
const showEither = showInput || showOutput
|
||||
|
||||
// Check if even in the vicinity
|
||||
if (showEither && isPointInRect(pos, this.#hoverArea)) {
|
||||
const outlineOnly = this.#contains(pos)
|
||||
|
||||
if (showInput) input.update(pos, outlineOnly)
|
||||
if (showOutput) output.update(pos, outlineOnly)
|
||||
} else {
|
||||
this.hideSlots()
|
||||
}
|
||||
|
||||
return input.dirty || output.dirty
|
||||
}
|
||||
|
||||
/** Prevents rendering of the input and output slots. */
|
||||
hideSlots() {
|
||||
this.#inputSlot.hide()
|
||||
this.#outputSlot.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* Precisely determines if {@link pos} is inside this reroute.
|
||||
* @param pos The position to check (canvas space)
|
||||
* @returns `true` if {@link pos} is within the reroute's radius.
|
||||
*/
|
||||
containsPoint(pos: Point): boolean {
|
||||
return isPointInRect(pos, this.#hoverArea) && this.#contains(pos)
|
||||
}
|
||||
|
||||
#contains(pos: Point): boolean {
|
||||
return distance(this.pos, pos) <= Reroute.radius
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
asSerialisable(): SerialisableReroute {
|
||||
const { id, parentId, pos, linkIds } = this
|
||||
return {
|
||||
id,
|
||||
parentId,
|
||||
pos: [pos[0], pos[1]],
|
||||
linkIds: [...linkIds],
|
||||
floating: this.floating ? { slotType: this.floating.slotType } : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a slot on a reroute.
|
||||
* @private Designed for internal use within this module.
|
||||
*/
|
||||
class RerouteSlot {
|
||||
/** The reroute that the slot belongs to. */
|
||||
readonly #reroute: Reroute
|
||||
|
||||
readonly #offsetMultiplier: 1 | -1
|
||||
/** Centre point of this slot. */
|
||||
get pos(): Point {
|
||||
const [x, y] = this.#reroute.pos
|
||||
return [x + Reroute.slotOffset * this.#offsetMultiplier, y]
|
||||
}
|
||||
|
||||
/** Whether any changes require a redraw. */
|
||||
dirty: boolean = false
|
||||
|
||||
#hovering = false
|
||||
/** Whether the pointer is hovering over the slot itself. */
|
||||
get hovering() {
|
||||
return this.#hovering
|
||||
}
|
||||
|
||||
set hovering(value) {
|
||||
if (!Object.is(this.#hovering, value)) {
|
||||
this.#hovering = value
|
||||
this.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
#showOutline = false
|
||||
/** Whether the slot outline / faint background is visible. */
|
||||
get showOutline() {
|
||||
return this.#showOutline
|
||||
}
|
||||
|
||||
set showOutline(value) {
|
||||
if (!Object.is(this.#showOutline, value)) {
|
||||
this.#showOutline = value
|
||||
this.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
constructor(reroute: Reroute, isInput: boolean) {
|
||||
this.#reroute = reroute
|
||||
this.#offsetMultiplier = isInput ? -1 : 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the slot's visibility based on the position of the pointer.
|
||||
* @param pos The position of the pointer.
|
||||
* @param outlineOnly If `true`, slot will display with the faded outline only ({@link showOutline}).
|
||||
*/
|
||||
update(pos: Point, outlineOnly?: boolean) {
|
||||
if (outlineOnly) {
|
||||
this.hovering = false
|
||||
this.showOutline = true
|
||||
} else {
|
||||
const dist = distance(this.pos, pos)
|
||||
this.hovering = dist <= 2 * Reroute.slotRadius
|
||||
this.showOutline = dist <= 5 * Reroute.slotRadius
|
||||
}
|
||||
}
|
||||
|
||||
/** Hides the slot. */
|
||||
hide() {
|
||||
this.hovering = false
|
||||
this.showOutline = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the slot on the canvas.
|
||||
* @param ctx The canvas context to draw on.
|
||||
*/
|
||||
draw(ctx: CanvasRenderingContext2D): void {
|
||||
const { fillStyle, strokeStyle, lineWidth } = ctx
|
||||
const { showOutline, hovering, pos: [x, y] } = this
|
||||
if (!showOutline) return
|
||||
|
||||
try {
|
||||
ctx.fillStyle = hovering
|
||||
? this.#reroute.colour
|
||||
: "rgba(127,127,127,0.3)"
|
||||
ctx.strokeStyle = "rgb(0,0,0,0.5)"
|
||||
ctx.lineWidth = 1
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, Reroute.slotRadius, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
} finally {
|
||||
ctx.fillStyle = fillStyle
|
||||
ctx.strokeStyle = strokeStyle
|
||||
ctx.lineWidth = lineWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the position of the next reroute in the chain, or the destination input slot on this link.
|
||||
* @param network The network of links
|
||||
* @param link The link representing the current reroute chain
|
||||
* @param id The ID of "this" reroute
|
||||
* @returns The position of the next reroute or the input slot target, otherwise `undefined`.
|
||||
*/
|
||||
function getNextPos(network: ReadonlyLinkNetwork, link: LLink | undefined, id: RerouteId) {
|
||||
if (!link) return
|
||||
|
||||
const linkPos = LLink.findNextReroute(network, link, id)?.pos
|
||||
if (linkPos) return linkPos
|
||||
|
||||
// Floating link with no input to find
|
||||
if (link.target_id === -1 || link.target_slot === -1) return
|
||||
|
||||
return network.getNodeById(link.target_id)?.getInputPos(link.target_slot)
|
||||
}
|
||||
|
||||
/** Returns the direction from one point to another in radians. */
|
||||
function getDirection(fromPos: Point, toPos: Point) {
|
||||
return Math.atan2(toPos[1] - fromPos[1], toPos[0] - fromPos[0])
|
||||
}
|
||||
194
src/lib/litegraph/src/canvas/FloatingRenderLink.ts
Normal file
194
src/lib/litegraph/src/canvas/FloatingRenderLink.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { RenderLink } from "./RenderLink"
|
||||
import type { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
|
||||
import type { LinkConnectorEventMap } from "@/infrastructure/LinkConnectorEventMap"
|
||||
import type { INodeOutputSlot, LinkNetwork } from "@/interfaces"
|
||||
import type { INodeInputSlot } from "@/interfaces"
|
||||
import type { Point } from "@/interfaces"
|
||||
import type { LGraphNode, NodeId } from "@/LGraphNode"
|
||||
import type { LLink } from "@/LLink"
|
||||
import type { Reroute } from "@/Reroute"
|
||||
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
|
||||
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
|
||||
|
||||
import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID } from "@/constants"
|
||||
import { LinkDirection } from "@/types/globalEnums"
|
||||
|
||||
/**
|
||||
* Represents a floating link that is currently being dragged from one slot to another.
|
||||
*
|
||||
* This is a heavier, but short-lived convenience data structure. All refs to FloatingRenderLinks should be discarded on drop.
|
||||
* @remarks
|
||||
* At time of writing, Litegraph is using several different styles and methods to handle link dragging.
|
||||
*
|
||||
* Once the library has undergone more substantial changes to the way links are managed,
|
||||
* many properties of this class will be superfluous and removable.
|
||||
*/
|
||||
export class FloatingRenderLink implements RenderLink {
|
||||
readonly node: LGraphNode
|
||||
readonly fromSlot: INodeOutputSlot | INodeInputSlot
|
||||
readonly fromPos: Point
|
||||
readonly fromDirection: LinkDirection
|
||||
readonly fromSlotIndex: number
|
||||
|
||||
readonly outputNodeId: NodeId = -1
|
||||
readonly outputNode?: LGraphNode
|
||||
readonly outputSlot?: INodeOutputSlot
|
||||
readonly outputIndex: number = -1
|
||||
readonly outputPos?: Point
|
||||
|
||||
readonly inputNodeId: NodeId = -1
|
||||
readonly inputNode?: LGraphNode
|
||||
readonly inputSlot?: INodeInputSlot
|
||||
readonly inputIndex: number = -1
|
||||
readonly inputPos?: Point
|
||||
|
||||
constructor(
|
||||
readonly network: LinkNetwork,
|
||||
readonly link: LLink,
|
||||
readonly toType: "input" | "output",
|
||||
readonly fromReroute: Reroute,
|
||||
readonly dragDirection: LinkDirection = LinkDirection.CENTER,
|
||||
) {
|
||||
const {
|
||||
origin_id: outputNodeId,
|
||||
target_id: inputNodeId,
|
||||
origin_slot: outputIndex,
|
||||
target_slot: inputIndex,
|
||||
} = link
|
||||
|
||||
if (outputNodeId !== -1) {
|
||||
// Output connected
|
||||
const outputNode = network.getNodeById(outputNodeId) ?? undefined
|
||||
if (!outputNode) throw new Error(`Creating DraggingRenderLink for link [${link.id}] failed: Output node [${outputNodeId}] not found.`)
|
||||
|
||||
const outputSlot = outputNode?.outputs.at(outputIndex)
|
||||
if (!outputSlot) throw new Error(`Creating DraggingRenderLink for link [${link.id}] failed: Output slot [${outputIndex}] not found.`)
|
||||
|
||||
this.outputNodeId = outputNodeId
|
||||
this.outputNode = outputNode
|
||||
this.outputSlot = outputSlot
|
||||
this.outputIndex = outputIndex
|
||||
this.outputPos = outputNode.getOutputPos(outputIndex)
|
||||
|
||||
// RenderLink props
|
||||
this.node = outputNode
|
||||
this.fromSlot = outputSlot
|
||||
this.fromPos = fromReroute?.pos ?? this.outputPos
|
||||
this.fromDirection = LinkDirection.LEFT
|
||||
this.dragDirection = LinkDirection.RIGHT
|
||||
this.fromSlotIndex = outputIndex
|
||||
} else {
|
||||
// Input connected
|
||||
const inputNode = network.getNodeById(inputNodeId) ?? undefined
|
||||
if (!inputNode) throw new Error(`Creating DraggingRenderLink for link [${link.id}] failed: Input node [${inputNodeId}] not found.`)
|
||||
|
||||
const inputSlot = inputNode?.inputs.at(inputIndex)
|
||||
if (!inputSlot) throw new Error(`Creating DraggingRenderLink for link [${link.id}] failed: Input slot [${inputIndex}] not found.`)
|
||||
|
||||
this.inputNodeId = inputNodeId
|
||||
this.inputNode = inputNode
|
||||
this.inputSlot = inputSlot
|
||||
this.inputIndex = inputIndex
|
||||
this.inputPos = inputNode.getInputPos(inputIndex)
|
||||
|
||||
// RenderLink props
|
||||
this.node = inputNode
|
||||
this.fromSlot = inputSlot
|
||||
this.fromDirection = LinkDirection.RIGHT
|
||||
this.fromSlotIndex = inputIndex
|
||||
}
|
||||
this.fromPos = fromReroute.pos
|
||||
}
|
||||
|
||||
canConnectToInput(): boolean {
|
||||
return this.toType === "input"
|
||||
}
|
||||
|
||||
canConnectToOutput(): boolean {
|
||||
return this.toType === "output"
|
||||
}
|
||||
|
||||
canConnectToReroute(reroute: Reroute): boolean {
|
||||
if (this.toType === "input") {
|
||||
if (reroute.origin_id === this.inputNode?.id) return false
|
||||
} else {
|
||||
if (reroute.origin_id === this.outputNode?.id) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
connectToInput(node: LGraphNode, input: INodeInputSlot, _events?: CustomEventTarget<LinkConnectorEventMap>): void {
|
||||
const floatingLink = this.link
|
||||
floatingLink.target_id = node.id
|
||||
floatingLink.target_slot = node.inputs.indexOf(input)
|
||||
|
||||
node.disconnectInput(node.inputs.indexOf(input))
|
||||
|
||||
this.fromSlot._floatingLinks?.delete(floatingLink)
|
||||
input._floatingLinks ??= new Set()
|
||||
input._floatingLinks.add(floatingLink)
|
||||
}
|
||||
|
||||
connectToOutput(node: LGraphNode, output: INodeOutputSlot, _events?: CustomEventTarget<LinkConnectorEventMap>): void {
|
||||
const floatingLink = this.link
|
||||
floatingLink.origin_id = node.id
|
||||
floatingLink.origin_slot = node.outputs.indexOf(output)
|
||||
|
||||
this.fromSlot._floatingLinks?.delete(floatingLink)
|
||||
output._floatingLinks ??= new Set()
|
||||
output._floatingLinks.add(floatingLink)
|
||||
}
|
||||
|
||||
connectToSubgraphInput(input: SubgraphInput, _events?: CustomEventTarget<LinkConnectorEventMap>): void {
|
||||
const floatingLink = this.link
|
||||
floatingLink.origin_id = SUBGRAPH_INPUT_ID
|
||||
floatingLink.origin_slot = input.parent.slots.indexOf(input)
|
||||
|
||||
this.fromSlot._floatingLinks?.delete(floatingLink)
|
||||
input._floatingLinks ??= new Set()
|
||||
input._floatingLinks.add(floatingLink)
|
||||
}
|
||||
|
||||
connectToSubgraphOutput(output: SubgraphOutput, _events?: CustomEventTarget<LinkConnectorEventMap>): void {
|
||||
const floatingLink = this.link
|
||||
floatingLink.origin_id = SUBGRAPH_OUTPUT_ID
|
||||
floatingLink.origin_slot = output.parent.slots.indexOf(output)
|
||||
|
||||
this.fromSlot._floatingLinks?.delete(floatingLink)
|
||||
output._floatingLinks ??= new Set()
|
||||
output._floatingLinks.add(floatingLink)
|
||||
}
|
||||
|
||||
connectToRerouteInput(
|
||||
reroute: Reroute,
|
||||
{ node: inputNode, input }: { node: LGraphNode, input: INodeInputSlot },
|
||||
events: CustomEventTarget<LinkConnectorEventMap>,
|
||||
) {
|
||||
const floatingLink = this.link
|
||||
floatingLink.target_id = inputNode.id
|
||||
floatingLink.target_slot = inputNode.inputs.indexOf(input)
|
||||
|
||||
this.fromSlot._floatingLinks?.delete(floatingLink)
|
||||
input._floatingLinks ??= new Set()
|
||||
input._floatingLinks.add(floatingLink)
|
||||
|
||||
events.dispatch("input-moved", this)
|
||||
}
|
||||
|
||||
connectToRerouteOutput(
|
||||
reroute: Reroute,
|
||||
outputNode: LGraphNode,
|
||||
output: INodeOutputSlot,
|
||||
events: CustomEventTarget<LinkConnectorEventMap>,
|
||||
) {
|
||||
const floatingLink = this.link
|
||||
floatingLink.origin_id = outputNode.id
|
||||
floatingLink.origin_slot = outputNode.outputs.indexOf(output)
|
||||
|
||||
this.fromSlot._floatingLinks?.delete(floatingLink)
|
||||
output._floatingLinks ??= new Set()
|
||||
output._floatingLinks.add(floatingLink)
|
||||
|
||||
events.dispatch("output-moved", this)
|
||||
}
|
||||
}
|
||||
171
src/lib/litegraph/src/canvas/InputIndicators.ts
Normal file
171
src/lib/litegraph/src/canvas/InputIndicators.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { LGraphCanvas } from "@/LGraphCanvas"
|
||||
|
||||
/**
|
||||
* A class that can be added to the render cycle to show pointer / keyboard status symbols.
|
||||
*
|
||||
* Used to create videos of feature changes.
|
||||
*
|
||||
* Example usage with ComfyUI_frontend, via console / devtools:
|
||||
*
|
||||
* ```ts
|
||||
* const inputIndicators = new InputIndicators(canvas)
|
||||
* // Dispose:
|
||||
* inputIndicators.dispose()
|
||||
* ```
|
||||
*/
|
||||
export class InputIndicators implements Disposable {
|
||||
// #region config
|
||||
radius = 8
|
||||
startAngle = 0
|
||||
endAngle = Math.PI * 2
|
||||
|
||||
inactiveColour = "#ffffff10"
|
||||
colour1 = "#ff5f00"
|
||||
colour2 = "#00ff7c"
|
||||
colour3 = "#dea7ff"
|
||||
fontString = "bold 12px Arial"
|
||||
// #endregion
|
||||
|
||||
// #region state
|
||||
enabled: boolean = true
|
||||
|
||||
shiftDown: boolean = false
|
||||
undoDown: boolean = false
|
||||
redoDown: boolean = false
|
||||
ctrlDown: boolean = false
|
||||
altDown: boolean = false
|
||||
mouse0Down: boolean = false
|
||||
mouse1Down: boolean = false
|
||||
mouse2Down: boolean = false
|
||||
|
||||
x: number = 0
|
||||
y: number = 0
|
||||
// #endregion
|
||||
|
||||
controller?: AbortController
|
||||
|
||||
constructor(public canvas: LGraphCanvas) {
|
||||
this.controller = new AbortController()
|
||||
const { signal } = this.controller
|
||||
|
||||
const element = canvas.canvas
|
||||
const options = { capture: true, signal } satisfies AddEventListenerOptions
|
||||
|
||||
element.addEventListener("pointerdown", this.#onPointerDownOrMove, options)
|
||||
element.addEventListener("pointermove", this.#onPointerDownOrMove, options)
|
||||
element.addEventListener("pointerup", this.#onPointerUp, options)
|
||||
element.addEventListener("keydown", this.#onKeyDownOrUp, options)
|
||||
document.addEventListener("keyup", this.#onKeyDownOrUp, options)
|
||||
|
||||
const origDrawFrontCanvas = canvas.drawFrontCanvas.bind(canvas)
|
||||
signal.addEventListener("abort", () => {
|
||||
canvas.drawFrontCanvas = origDrawFrontCanvas
|
||||
})
|
||||
|
||||
canvas.drawFrontCanvas = () => {
|
||||
origDrawFrontCanvas()
|
||||
this.draw()
|
||||
}
|
||||
}
|
||||
|
||||
#onPointerDownOrMove = this.onPointerDownOrMove.bind(this)
|
||||
onPointerDownOrMove(e: MouseEvent): void {
|
||||
this.mouse0Down = (e.buttons & 1) === 1
|
||||
this.mouse1Down = (e.buttons & 4) === 4
|
||||
this.mouse2Down = (e.buttons & 2) === 2
|
||||
|
||||
this.x = e.clientX
|
||||
this.y = e.clientY
|
||||
|
||||
this.canvas.setDirty(true)
|
||||
}
|
||||
|
||||
#onPointerUp = this.onPointerUp.bind(this)
|
||||
onPointerUp(): void {
|
||||
this.mouse0Down = false
|
||||
this.mouse1Down = false
|
||||
this.mouse2Down = false
|
||||
}
|
||||
|
||||
#onKeyDownOrUp = this.onKeyDownOrUp.bind(this)
|
||||
onKeyDownOrUp(e: KeyboardEvent): void {
|
||||
this.ctrlDown = e.ctrlKey
|
||||
this.altDown = e.altKey
|
||||
this.shiftDown = e.shiftKey
|
||||
this.undoDown = e.ctrlKey && e.code === "KeyZ" && e.type === "keydown"
|
||||
this.redoDown = e.ctrlKey && e.code === "KeyY" && e.type === "keydown"
|
||||
}
|
||||
|
||||
draw() {
|
||||
const {
|
||||
canvas: { ctx },
|
||||
radius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
x,
|
||||
y,
|
||||
inactiveColour,
|
||||
colour1,
|
||||
colour2,
|
||||
colour3,
|
||||
fontString,
|
||||
} = this
|
||||
|
||||
const { fillStyle, font } = ctx
|
||||
|
||||
const mouseDotX = x
|
||||
const mouseDotY = y - 80
|
||||
|
||||
const textX = mouseDotX
|
||||
const textY = mouseDotY - 15
|
||||
ctx.font = fontString
|
||||
|
||||
textMarker(textX + 0, textY, "Shift", this.shiftDown ? colour1 : inactiveColour)
|
||||
textMarker(textX + 45, textY + 20, "Alt", this.altDown ? colour2 : inactiveColour)
|
||||
textMarker(textX + 30, textY, "Control", this.ctrlDown ? colour3 : inactiveColour)
|
||||
textMarker(textX - 30, textY, "↩️", this.undoDown ? "#000" : "transparent")
|
||||
textMarker(textX + 45, textY, "↪️", this.redoDown ? "#000" : "transparent")
|
||||
|
||||
ctx.beginPath()
|
||||
drawDot(mouseDotX, mouseDotY)
|
||||
drawDot(mouseDotX + 15, mouseDotY)
|
||||
drawDot(mouseDotX + 30, mouseDotY)
|
||||
ctx.fillStyle = inactiveColour
|
||||
ctx.fill()
|
||||
|
||||
const leftButtonColour = this.mouse0Down ? colour1 : inactiveColour
|
||||
const middleButtonColour = this.mouse1Down ? colour2 : inactiveColour
|
||||
const rightButtonColour = this.mouse2Down ? colour3 : inactiveColour
|
||||
if (this.mouse0Down) mouseMarker(mouseDotX, mouseDotY, leftButtonColour)
|
||||
if (this.mouse1Down) mouseMarker(mouseDotX + 15, mouseDotY, middleButtonColour)
|
||||
if (this.mouse2Down) mouseMarker(mouseDotX + 30, mouseDotY, rightButtonColour)
|
||||
|
||||
ctx.fillStyle = fillStyle
|
||||
ctx.font = font
|
||||
|
||||
function textMarker(x: number, y: number, text: string, colour: string) {
|
||||
ctx.fillStyle = colour
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
function mouseMarker(x: number, y: number, colour: string) {
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = colour
|
||||
drawDot(x, y)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
function drawDot(x: number, y: number) {
|
||||
ctx.arc(x, y, radius, startAngle, endAngle)
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.controller?.abort()
|
||||
this.controller = undefined
|
||||
}
|
||||
|
||||
[Symbol.dispose](): void {
|
||||
this.dispose()
|
||||
}
|
||||
}
|
||||
860
src/lib/litegraph/src/canvas/LinkConnector.ts
Normal file
860
src/lib/litegraph/src/canvas/LinkConnector.ts
Normal file
@@ -0,0 +1,860 @@
|
||||
import type { RenderLink } from "./RenderLink"
|
||||
import type { LinkConnectorEventMap } from "@/infrastructure/LinkConnectorEventMap"
|
||||
import type { ConnectingLink, ItemLocator, LinkNetwork, LinkSegment } from "@/interfaces"
|
||||
import type { INodeInputSlot, INodeOutputSlot } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { Reroute } from "@/Reroute"
|
||||
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
|
||||
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
|
||||
import type { CanvasPointerEvent } from "@/types/events"
|
||||
import type { IBaseWidget } from "@/types/widgets"
|
||||
|
||||
import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID } from "@/constants"
|
||||
import { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
|
||||
import { LLink } from "@/LLink"
|
||||
import { Subgraph } from "@/subgraph/Subgraph"
|
||||
import { SubgraphInputNode } from "@/subgraph/SubgraphInputNode"
|
||||
import { SubgraphOutputNode } from "@/subgraph/SubgraphOutputNode"
|
||||
import { LinkDirection } from "@/types/globalEnums"
|
||||
|
||||
import { FloatingRenderLink } from "./FloatingRenderLink"
|
||||
import { MovingInputLink } from "./MovingInputLink"
|
||||
import { MovingLinkBase } from "./MovingLinkBase"
|
||||
import { MovingOutputLink } from "./MovingOutputLink"
|
||||
import { ToInputFromIoNodeLink } from "./ToInputFromIoNodeLink"
|
||||
import { ToInputRenderLink } from "./ToInputRenderLink"
|
||||
import { ToOutputFromIoNodeLink } from "./ToOutputFromIoNodeLink"
|
||||
import { ToOutputFromRerouteLink } from "./ToOutputFromRerouteLink"
|
||||
import { ToOutputRenderLink } from "./ToOutputRenderLink"
|
||||
|
||||
/**
|
||||
* A Litegraph state object for the {@link LinkConnector}.
|
||||
* References are only held atomically within a function, never passed.
|
||||
* The concrete implementation may be replaced or proxied without side-effects.
|
||||
*/
|
||||
export interface LinkConnectorState {
|
||||
/**
|
||||
* The type of slot that links are being connected **to**.
|
||||
* - When `undefined`, no operation is being performed.
|
||||
* - A change in this property indicates the start or end of dragging links.
|
||||
*/
|
||||
connectingTo: "input" | "output" | undefined
|
||||
multi: boolean
|
||||
/** When `true`, existing links are being repositioned. Otherwise, new links are being created. */
|
||||
draggingExistingLinks: boolean
|
||||
/** When set, connecting links will all snap to this position. */
|
||||
snapLinksPos?: [number, number]
|
||||
}
|
||||
|
||||
/** Discriminated union to simplify type narrowing. */
|
||||
type RenderLinkUnion =
|
||||
| MovingInputLink
|
||||
| MovingOutputLink
|
||||
| FloatingRenderLink
|
||||
| ToInputRenderLink
|
||||
| ToOutputRenderLink
|
||||
| ToInputFromIoNodeLink
|
||||
| ToOutputFromIoNodeLink
|
||||
|
||||
export interface LinkConnectorExport {
|
||||
renderLinks: RenderLink[]
|
||||
inputLinks: LLink[]
|
||||
outputLinks: LLink[]
|
||||
floatingLinks: LLink[]
|
||||
state: LinkConnectorState
|
||||
network: LinkNetwork
|
||||
}
|
||||
|
||||
/**
|
||||
* Component of {@link LGraphCanvas} that handles connecting and moving links.
|
||||
* @see {@link LLink}
|
||||
*/
|
||||
export class LinkConnector {
|
||||
/**
|
||||
* Link connection state POJO. Source of truth for state of link drag operations.
|
||||
*
|
||||
* Can be replaced or proxied to allow notifications.
|
||||
* Is always dereferenced at the start of an operation.
|
||||
*/
|
||||
state: LinkConnectorState = {
|
||||
connectingTo: undefined,
|
||||
multi: false,
|
||||
draggingExistingLinks: false,
|
||||
snapLinksPos: undefined,
|
||||
}
|
||||
|
||||
readonly events = new CustomEventTarget<LinkConnectorEventMap>()
|
||||
|
||||
/** Contains information for rendering purposes only. */
|
||||
readonly renderLinks: RenderLinkUnion[] = []
|
||||
|
||||
/** Existing links that are being moved **to** a new input slot. */
|
||||
readonly inputLinks: LLink[] = []
|
||||
/** Existing links that are being moved **to** a new output slot. */
|
||||
readonly outputLinks: LLink[] = []
|
||||
/** Existing floating links that are being moved to a new slot. */
|
||||
readonly floatingLinks: LLink[] = []
|
||||
|
||||
readonly hiddenReroutes: Set<Reroute> = new Set()
|
||||
|
||||
/** The widget beneath the pointer, if it is a valid connection target. */
|
||||
overWidget?: IBaseWidget
|
||||
/** The type (returned by downstream callback) for {@link overWidget} */
|
||||
overWidgetType?: string
|
||||
|
||||
/** The reroute beneath the pointer, if it is a valid connection target. */
|
||||
overReroute?: Reroute
|
||||
|
||||
readonly #setConnectingLinks: (value: ConnectingLink[]) => void
|
||||
|
||||
constructor(setConnectingLinks: (value: ConnectingLink[]) => void) {
|
||||
this.#setConnectingLinks = setConnectingLinks
|
||||
}
|
||||
|
||||
get isConnecting() {
|
||||
return this.state.connectingTo !== undefined
|
||||
}
|
||||
|
||||
get draggingExistingLinks() {
|
||||
return this.state.draggingExistingLinks
|
||||
}
|
||||
|
||||
/** Drag an existing link to a different input. */
|
||||
moveInputLink(network: LinkNetwork, input: INodeInputSlot): void {
|
||||
if (this.isConnecting) throw new Error("Already dragging links.")
|
||||
|
||||
const { state, inputLinks, renderLinks } = this
|
||||
|
||||
const linkId = input.link
|
||||
if (linkId == null) {
|
||||
// No link connected, check for a floating link
|
||||
const floatingLink = input._floatingLinks?.values().next().value
|
||||
if (floatingLink?.parentId == null) return
|
||||
|
||||
try {
|
||||
const reroute = network.reroutes.get(floatingLink.parentId)
|
||||
if (!reroute) throw new Error(`Invalid reroute id: [${floatingLink.parentId}] for floating link id: [${floatingLink.id}].`)
|
||||
|
||||
const renderLink = new FloatingRenderLink(network, floatingLink, "input", reroute)
|
||||
const mayContinue = this.events.dispatch("before-move-input", renderLink)
|
||||
if (mayContinue === false) return
|
||||
|
||||
renderLinks.push(renderLink)
|
||||
} catch (error) {
|
||||
console.warn(`Could not create render link for link id: [${floatingLink.id}].`, floatingLink, error)
|
||||
}
|
||||
|
||||
floatingLink._dragging = true
|
||||
this.floatingLinks.push(floatingLink)
|
||||
} else {
|
||||
const link = network.links.get(linkId)
|
||||
if (!link) return
|
||||
|
||||
// Special handling for links from subgraph input nodes
|
||||
if (link.origin_id === SUBGRAPH_INPUT_ID) {
|
||||
// For subgraph input links, we need to handle them differently
|
||||
// since they don't have a regular output node
|
||||
const subgraphInput = network.inputNode?.slots[link.origin_slot]
|
||||
if (!subgraphInput) {
|
||||
console.warn(`Could not find subgraph input for slot [${link.origin_slot}]`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const reroute = network.getReroute(link.parentId)
|
||||
const renderLink = new ToInputFromIoNodeLink(network, network.inputNode, subgraphInput, reroute, LinkDirection.CENTER, link)
|
||||
|
||||
// Note: We don't dispatch the before-move-input event for subgraph input links
|
||||
// as the event type doesn't support ToInputFromIoNodeLink
|
||||
|
||||
renderLinks.push(renderLink)
|
||||
|
||||
this.listenUntilReset("input-moved", () => {
|
||||
link.disconnect(network, "input")
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn(`Could not create render link for subgraph input link id: [${link.id}].`, link, error)
|
||||
return
|
||||
}
|
||||
|
||||
link._dragging = true
|
||||
inputLinks.push(link)
|
||||
} else {
|
||||
// Regular node links
|
||||
try {
|
||||
const reroute = network.getReroute(link.parentId)
|
||||
const renderLink = new MovingInputLink(network, link, reroute)
|
||||
|
||||
const mayContinue = this.events.dispatch("before-move-input", renderLink)
|
||||
if (mayContinue === false) return
|
||||
|
||||
renderLinks.push(renderLink)
|
||||
|
||||
this.listenUntilReset("input-moved", (e) => {
|
||||
if ("link" in e.detail && e.detail.link) {
|
||||
e.detail.link.disconnect(network, "output")
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn(`Could not create render link for link id: [${link.id}].`, link, error)
|
||||
return
|
||||
}
|
||||
|
||||
link._dragging = true
|
||||
inputLinks.push(link)
|
||||
}
|
||||
}
|
||||
|
||||
state.connectingTo = "input"
|
||||
state.draggingExistingLinks = true
|
||||
|
||||
this.#setLegacyLinks(false)
|
||||
}
|
||||
|
||||
/** Drag all links from an output to a new output. */
|
||||
moveOutputLink(network: LinkNetwork, output: INodeOutputSlot): void {
|
||||
if (this.isConnecting) throw new Error("Already dragging links.")
|
||||
|
||||
const { state, renderLinks } = this
|
||||
|
||||
// Floating links
|
||||
if (output._floatingLinks?.size) {
|
||||
for (const floatingLink of output._floatingLinks.values()) {
|
||||
try {
|
||||
const reroute = LLink.getFirstReroute(network, floatingLink)
|
||||
if (!reroute) throw new Error(`Invalid reroute id: [${floatingLink.parentId}] for floating link id: [${floatingLink.id}].`)
|
||||
|
||||
const renderLink = new FloatingRenderLink(network, floatingLink, "output", reroute)
|
||||
const mayContinue = this.events.dispatch("before-move-output", renderLink)
|
||||
if (mayContinue === false) continue
|
||||
|
||||
renderLinks.push(renderLink)
|
||||
this.floatingLinks.push(floatingLink)
|
||||
} catch (error) {
|
||||
console.warn(`Could not create render link for link id: [${floatingLink.id}].`, floatingLink, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normal links
|
||||
if (output.links?.length) {
|
||||
for (const linkId of output.links) {
|
||||
const link = network.links.get(linkId)
|
||||
if (!link) continue
|
||||
|
||||
const firstReroute = LLink.getFirstReroute(network, link)
|
||||
if (firstReroute) {
|
||||
firstReroute._dragging = true
|
||||
this.hiddenReroutes.add(firstReroute)
|
||||
} else {
|
||||
link._dragging = true
|
||||
}
|
||||
this.outputLinks.push(link)
|
||||
|
||||
try {
|
||||
const renderLink = new MovingOutputLink(network, link, firstReroute, LinkDirection.RIGHT)
|
||||
|
||||
const mayContinue = this.events.dispatch("before-move-output", renderLink)
|
||||
if (mayContinue === false) continue
|
||||
|
||||
renderLinks.push(renderLink)
|
||||
} catch (error) {
|
||||
console.warn(`Could not create render link for link id: [${link.id}].`, link, error)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (renderLinks.length === 0) return
|
||||
|
||||
state.draggingExistingLinks = true
|
||||
state.multi = true
|
||||
state.connectingTo = "output"
|
||||
|
||||
this.#setLegacyLinks(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drags a new link from an output slot to an input slot.
|
||||
* @param network The network that the link being connected belongs to
|
||||
* @param node The node the link is being dragged from
|
||||
* @param output The output slot that the link is being dragged from
|
||||
*/
|
||||
dragNewFromOutput(network: LinkNetwork, node: LGraphNode, output: INodeOutputSlot, fromReroute?: Reroute): void {
|
||||
if (this.isConnecting) throw new Error("Already dragging links.")
|
||||
|
||||
const { state } = this
|
||||
const renderLink = new ToInputRenderLink(network, node, output, fromReroute)
|
||||
this.renderLinks.push(renderLink)
|
||||
|
||||
state.connectingTo = "input"
|
||||
|
||||
this.#setLegacyLinks(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drags a new link from an input slot to an output slot.
|
||||
* @param network The network that the link being connected belongs to
|
||||
* @param node The node the link is being dragged from
|
||||
* @param input The input slot that the link is being dragged from
|
||||
*/
|
||||
dragNewFromInput(network: LinkNetwork, node: LGraphNode, input: INodeInputSlot, fromReroute?: Reroute): void {
|
||||
if (this.isConnecting) throw new Error("Already dragging links.")
|
||||
|
||||
const { state } = this
|
||||
const renderLink = new ToOutputRenderLink(network, node, input, fromReroute)
|
||||
this.renderLinks.push(renderLink)
|
||||
|
||||
state.connectingTo = "output"
|
||||
|
||||
this.#setLegacyLinks(true)
|
||||
}
|
||||
|
||||
dragNewFromSubgraphInput(network: LinkNetwork, inputNode: SubgraphInputNode, input: SubgraphInput, fromReroute?: Reroute): void {
|
||||
if (this.isConnecting) throw new Error("Already dragging links.")
|
||||
|
||||
const renderLink = new ToInputFromIoNodeLink(network, inputNode, input, fromReroute)
|
||||
this.renderLinks.push(renderLink)
|
||||
|
||||
this.state.connectingTo = "input"
|
||||
|
||||
this.#setLegacyLinks(false)
|
||||
}
|
||||
|
||||
dragNewFromSubgraphOutput(network: LinkNetwork, outputNode: SubgraphOutputNode, output: SubgraphOutput, fromReroute?: Reroute): void {
|
||||
if (this.isConnecting) throw new Error("Already dragging links.")
|
||||
|
||||
const renderLink = new ToOutputFromIoNodeLink(network, outputNode, output, fromReroute)
|
||||
this.renderLinks.push(renderLink)
|
||||
|
||||
this.state.connectingTo = "output"
|
||||
|
||||
this.#setLegacyLinks(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drags a new link from a reroute to an input slot.
|
||||
* @param network The network that the link being connected belongs to
|
||||
* @param reroute The reroute that the link is being dragged from
|
||||
*/
|
||||
dragFromReroute(network: LinkNetwork, reroute: Reroute): void {
|
||||
if (this.isConnecting) throw new Error("Already dragging links.")
|
||||
|
||||
const link = reroute.firstLink ?? reroute.firstFloatingLink
|
||||
if (!link) {
|
||||
console.warn("No link found for reroute.")
|
||||
return
|
||||
}
|
||||
|
||||
if (link.origin_id === SUBGRAPH_INPUT_ID) {
|
||||
if (!(network instanceof Subgraph)) {
|
||||
console.warn("Subgraph input link found in non-subgraph network.")
|
||||
return
|
||||
}
|
||||
|
||||
const input = network.inputs.at(link.origin_slot)
|
||||
if (!input) throw new Error("No subgraph input found for link.")
|
||||
|
||||
const renderLink = new ToInputFromIoNodeLink(network, network.inputNode, input, reroute)
|
||||
renderLink.fromDirection = LinkDirection.NONE
|
||||
this.renderLinks.push(renderLink)
|
||||
|
||||
this.state.connectingTo = "input"
|
||||
|
||||
this.#setLegacyLinks(false)
|
||||
return
|
||||
}
|
||||
|
||||
const outputNode = network.getNodeById(link.origin_id)
|
||||
if (!outputNode) {
|
||||
console.warn("No output node found for link.", link)
|
||||
return
|
||||
}
|
||||
|
||||
const outputSlot = outputNode.outputs.at(link.origin_slot)
|
||||
if (!outputSlot) {
|
||||
console.warn("No output slot found for link.", link)
|
||||
return
|
||||
}
|
||||
|
||||
const renderLink = new ToInputRenderLink(network, outputNode, outputSlot, reroute)
|
||||
renderLink.fromDirection = LinkDirection.NONE
|
||||
this.renderLinks.push(renderLink)
|
||||
|
||||
this.state.connectingTo = "input"
|
||||
|
||||
this.#setLegacyLinks(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drags a new link from a reroute to an output slot.
|
||||
* @param network The network that the link being connected belongs to
|
||||
* @param reroute The reroute that the link is being dragged from
|
||||
*/
|
||||
dragFromRerouteToOutput(network: LinkNetwork, reroute: Reroute): void {
|
||||
if (this.isConnecting) throw new Error("Already dragging links.")
|
||||
|
||||
const link = reroute.firstLink ?? reroute.firstFloatingLink
|
||||
if (!link) {
|
||||
console.warn("No link found for reroute.")
|
||||
return
|
||||
}
|
||||
|
||||
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
|
||||
if (!(network instanceof Subgraph)) {
|
||||
console.warn("Subgraph output link found in non-subgraph network.")
|
||||
return
|
||||
}
|
||||
|
||||
const output = network.outputs.at(link.target_slot)
|
||||
if (!output) throw new Error("No subgraph output found for link.")
|
||||
|
||||
const renderLink = new ToOutputFromIoNodeLink(network, network.outputNode, output, reroute)
|
||||
renderLink.fromDirection = LinkDirection.NONE
|
||||
this.renderLinks.push(renderLink)
|
||||
|
||||
this.state.connectingTo = "output"
|
||||
|
||||
this.#setLegacyLinks(false)
|
||||
return
|
||||
}
|
||||
|
||||
const inputNode = network.getNodeById(link.target_id)
|
||||
if (!inputNode) {
|
||||
console.warn("No input node found for link.", link)
|
||||
return
|
||||
}
|
||||
|
||||
const inputSlot = inputNode.inputs.at(link.target_slot)
|
||||
if (!inputSlot) {
|
||||
console.warn("No input slot found for link.", link)
|
||||
return
|
||||
}
|
||||
|
||||
const renderLink = new ToOutputFromRerouteLink(network, inputNode, inputSlot, reroute, this)
|
||||
renderLink.fromDirection = LinkDirection.LEFT
|
||||
this.renderLinks.push(renderLink)
|
||||
|
||||
this.state.connectingTo = "output"
|
||||
|
||||
this.#setLegacyLinks(true)
|
||||
}
|
||||
|
||||
dragFromLinkSegment(network: LinkNetwork, linkSegment: LinkSegment): void {
|
||||
if (this.isConnecting) throw new Error("Already dragging links.")
|
||||
|
||||
const { state } = this
|
||||
if (linkSegment.origin_id == null || linkSegment.origin_slot == null) return
|
||||
|
||||
const node = network.getNodeById(linkSegment.origin_id)
|
||||
if (!node) return
|
||||
|
||||
const slot = node.outputs.at(linkSegment.origin_slot)
|
||||
if (!slot) return
|
||||
|
||||
const reroute = network.getReroute(linkSegment.parentId)
|
||||
const renderLink = new ToInputRenderLink(network, node, slot, reroute)
|
||||
renderLink.fromDirection = LinkDirection.NONE
|
||||
this.renderLinks.push(renderLink)
|
||||
|
||||
state.connectingTo = "input"
|
||||
|
||||
this.#setLegacyLinks(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects the links being dropped
|
||||
* @param event Contains the drop location, in canvas space
|
||||
*/
|
||||
dropLinks(locator: ItemLocator, event: CanvasPointerEvent): void {
|
||||
if (!this.isConnecting) {
|
||||
const mayContinue = this.events.dispatch("before-drop-links", { renderLinks: this.renderLinks, event })
|
||||
if (mayContinue === false) return
|
||||
}
|
||||
|
||||
try {
|
||||
const { canvasX, canvasY } = event
|
||||
|
||||
const ioNode = locator.getIoNodeOnPos?.(canvasX, canvasY)
|
||||
if (ioNode) {
|
||||
this.dropOnIoNode(ioNode, event)
|
||||
return
|
||||
}
|
||||
|
||||
const node = locator.getNodeOnPos(canvasX, canvasY) ?? undefined
|
||||
if (node) {
|
||||
this.dropOnNode(node, event)
|
||||
} else {
|
||||
// Get reroute if no node is found
|
||||
const reroute = locator.getRerouteOnPos(canvasX, canvasY)
|
||||
// Drop output->input link on reroute is not impl.
|
||||
if (reroute && this.isRerouteValidDrop(reroute)) {
|
||||
this.dropOnReroute(reroute, event)
|
||||
} else {
|
||||
this.dropOnNothing(event)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.events.dispatch("after-drop-links", { renderLinks: this.renderLinks, event })
|
||||
}
|
||||
}
|
||||
|
||||
dropOnIoNode(ioNode: SubgraphInputNode | SubgraphOutputNode, event: CanvasPointerEvent): void {
|
||||
const { renderLinks, state } = this
|
||||
const { connectingTo } = state
|
||||
const { canvasX, canvasY } = event
|
||||
|
||||
if (connectingTo === "input" && ioNode instanceof SubgraphOutputNode) {
|
||||
const output = ioNode.getSlotInPosition(canvasX, canvasY)
|
||||
if (!output) throw new Error("No output slot found for link.")
|
||||
|
||||
for (const link of renderLinks) {
|
||||
link.connectToSubgraphOutput(output, this.events)
|
||||
}
|
||||
} else if (connectingTo === "output" && ioNode instanceof SubgraphInputNode) {
|
||||
const input = ioNode.getSlotInPosition(canvasX, canvasY)
|
||||
if (!input) throw new Error("No input slot found for link.")
|
||||
|
||||
for (const link of renderLinks) {
|
||||
link.connectToSubgraphInput(input, this.events)
|
||||
}
|
||||
} else {
|
||||
console.error("Invalid connectingTo state &/ ioNode", connectingTo, ioNode)
|
||||
}
|
||||
}
|
||||
|
||||
dropOnNode(node: LGraphNode, event: CanvasPointerEvent) {
|
||||
const { renderLinks, state } = this
|
||||
const { connectingTo } = state
|
||||
const { canvasX, canvasY } = event
|
||||
|
||||
// Do nothing if every connection would loop back
|
||||
if (renderLinks.every(link => link.node === node)) return
|
||||
|
||||
// To output
|
||||
if (connectingTo === "output") {
|
||||
const output = node.getOutputOnPos([canvasX, canvasY])
|
||||
|
||||
if (output) {
|
||||
this.#dropOnOutput(node, output)
|
||||
} else {
|
||||
this.connectToNode(node, event)
|
||||
}
|
||||
// To input
|
||||
} else if (connectingTo === "input") {
|
||||
const input = node.getInputOnPos([canvasX, canvasY])
|
||||
const inputOrSocket = input ?? node.getSlotFromWidget(this.overWidget)
|
||||
|
||||
// Input slot
|
||||
if (inputOrSocket) {
|
||||
this.#dropOnInput(node, inputOrSocket)
|
||||
} else {
|
||||
// Node background / title
|
||||
this.connectToNode(node, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dropOnReroute(reroute: Reroute, event: CanvasPointerEvent): void {
|
||||
const mayContinue = this.events.dispatch("dropped-on-reroute", { reroute, event })
|
||||
if (mayContinue === false) return
|
||||
|
||||
// Connecting to input
|
||||
if (this.state.connectingTo === "input") {
|
||||
if (this.renderLinks.length !== 1) throw new Error(`Attempted to connect ${this.renderLinks.length} input links to a reroute.`)
|
||||
|
||||
const renderLink = this.renderLinks[0]
|
||||
this._connectOutputToReroute(reroute, renderLink)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Connecting to output
|
||||
for (const link of this.renderLinks) {
|
||||
if (link.toType !== "output") continue
|
||||
|
||||
const result = reroute.findSourceOutput()
|
||||
if (!result) continue
|
||||
|
||||
const { node, output } = result
|
||||
if (!link.canConnectToOutput(node, output)) continue
|
||||
|
||||
link.connectToRerouteOutput(reroute, node, output, this.events)
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Temporary workaround - requires refactor. */
|
||||
_connectOutputToReroute(reroute: Reroute, renderLink: RenderLinkUnion): void {
|
||||
const results = reroute.findTargetInputs()
|
||||
if (!results?.length) return
|
||||
|
||||
const maybeReroutes = reroute.getReroutes()
|
||||
if (maybeReroutes === null) throw new Error("Reroute loop detected.")
|
||||
|
||||
const originalReroutes = maybeReroutes.slice(0, -1).reverse()
|
||||
|
||||
// From reroute to reroute
|
||||
if (renderLink instanceof ToInputRenderLink) {
|
||||
const { node, fromSlot, fromSlotIndex, fromReroute } = renderLink
|
||||
|
||||
reroute.setFloatingLinkOrigin(node, fromSlot, fromSlotIndex)
|
||||
|
||||
// Clean floating link IDs from reroutes about to be removed from the chain
|
||||
if (fromReroute != null) {
|
||||
for (const originalReroute of originalReroutes) {
|
||||
if (originalReroute.id === fromReroute.id) break
|
||||
|
||||
for (const linkId of reroute.floatingLinkIds) {
|
||||
originalReroute.floatingLinkIds.delete(linkId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter before any connections are re-created
|
||||
const filtered = results.filter(result => renderLink.toType === "input" && canConnectInputLinkToReroute(renderLink, result.node, result.input, reroute))
|
||||
|
||||
for (const result of filtered) {
|
||||
renderLink.connectToRerouteInput(reroute, result, this.events, originalReroutes)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
dropOnNothing(event: CanvasPointerEvent): void {
|
||||
// For external event only.
|
||||
const mayContinue = this.events.dispatch("dropped-on-canvas", event)
|
||||
if (mayContinue === false) return
|
||||
|
||||
this.disconnectLinks()
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects all moving links.
|
||||
* @remarks This is called when the links are dropped on the canvas.
|
||||
* May be called by consumers to e.g. drag links into a bin / void.
|
||||
*/
|
||||
disconnectLinks(): void {
|
||||
for (const link of this.renderLinks) {
|
||||
if (link instanceof MovingLinkBase) {
|
||||
link.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects the links being dropped onto a node to the first matching slot.
|
||||
* @param node The node that the links are being dropped on
|
||||
* @param event Contains the drop location, in canvas space
|
||||
*/
|
||||
connectToNode(node: LGraphNode, event: CanvasPointerEvent): void {
|
||||
const { state: { connectingTo } } = this
|
||||
|
||||
const mayContinue = this.events.dispatch("dropped-on-node", { node, event })
|
||||
if (mayContinue === false) return
|
||||
|
||||
// Assume all links are the same type, disallow loopback
|
||||
const firstLink = this.renderLinks[0]
|
||||
if (!firstLink) return
|
||||
|
||||
// Use a single type check before looping; ensures all dropped links go to the same slot
|
||||
if (connectingTo === "output") {
|
||||
// Dropping new output link
|
||||
const output = node.findOutputByType(firstLink.fromSlot.type)?.slot
|
||||
console.debug("out", node, output, firstLink.fromSlot)
|
||||
if (output === undefined) {
|
||||
console.warn(`Could not find slot for link type: [${firstLink.fromSlot.type}].`)
|
||||
return
|
||||
}
|
||||
|
||||
this.#dropOnOutput(node, output)
|
||||
} else if (connectingTo === "input") {
|
||||
// Dropping new input link
|
||||
const input = node.findInputByType(firstLink.fromSlot.type)?.slot
|
||||
console.debug("in", node, input, firstLink.fromSlot)
|
||||
if (input === undefined) {
|
||||
console.warn(`Could not find slot for link type: [${firstLink.fromSlot.type}].`)
|
||||
return
|
||||
}
|
||||
|
||||
this.#dropOnInput(node, input)
|
||||
}
|
||||
}
|
||||
|
||||
#dropOnInput(node: LGraphNode, input: INodeInputSlot): void {
|
||||
for (const link of this.renderLinks) {
|
||||
if (!link.canConnectToInput(node, input)) continue
|
||||
|
||||
link.connectToInput(node, input, this.events)
|
||||
}
|
||||
}
|
||||
|
||||
#dropOnOutput(node: LGraphNode, output: INodeOutputSlot): void {
|
||||
for (const link of this.renderLinks) {
|
||||
if (!link.canConnectToOutput(node, output)) {
|
||||
if (link instanceof MovingOutputLink && link.link.parentId !== undefined) {
|
||||
// Reconnect link without reroutes
|
||||
link.outputNode.connectSlots(link.outputSlot, link.inputNode, link.inputSlot, undefined!)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
link.connectToOutput(node, output, this.events)
|
||||
}
|
||||
}
|
||||
|
||||
isInputValidDrop(node: LGraphNode, input: INodeInputSlot): boolean {
|
||||
return this.renderLinks.some(link => link.canConnectToInput(node, input))
|
||||
}
|
||||
|
||||
isNodeValidDrop(node: LGraphNode): boolean {
|
||||
if (this.state.connectingTo === "output") {
|
||||
return node.outputs.some(output => this.renderLinks.some(link => link.canConnectToOutput(node, output)))
|
||||
}
|
||||
|
||||
return node.inputs.some(input => this.renderLinks.some(link => link.canConnectToInput(node, input)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a reroute is a valid drop target for any of the links being connected.
|
||||
* @param reroute The reroute that would be dropped on.
|
||||
* @returns `true` if any of the current links being connected are valid for the given reroute.
|
||||
*/
|
||||
isRerouteValidDrop(reroute: Reroute): boolean {
|
||||
if (this.state.connectingTo === "input") {
|
||||
const results = reroute.findTargetInputs()
|
||||
if (!results?.length) return false
|
||||
|
||||
for (const { node, input } of results) {
|
||||
for (const renderLink of this.renderLinks) {
|
||||
if (renderLink.toType !== "input") continue
|
||||
if (canConnectInputLinkToReroute(renderLink, node, input, reroute)) return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const result = reroute.findSourceOutput()
|
||||
if (!result) return false
|
||||
|
||||
const { node, output } = result
|
||||
|
||||
for (const renderLink of this.renderLinks) {
|
||||
if (renderLink.toType !== "output") continue
|
||||
if (!renderLink.canConnectToReroute(reroute)) continue
|
||||
if (renderLink.canConnectToOutput(node, output)) return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/** Sets connecting_links, used by some extensions still. */
|
||||
#setLegacyLinks(fromSlotIsInput: boolean): void {
|
||||
const links = this.renderLinks.map((link) => {
|
||||
const input = fromSlotIsInput ? link.fromSlot as INodeInputSlot : null
|
||||
const output = fromSlotIsInput ? null : link.fromSlot as INodeOutputSlot
|
||||
|
||||
const afterRerouteId = link instanceof MovingLinkBase ? link.link?.parentId : link.fromReroute?.id
|
||||
|
||||
return {
|
||||
node: link.node as LGraphNode,
|
||||
slot: link.fromSlotIndex,
|
||||
input,
|
||||
output,
|
||||
pos: link.fromPos,
|
||||
afterRerouteId,
|
||||
} satisfies ConnectingLink
|
||||
})
|
||||
this.#setConnectingLinks(links)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the current state of the link connector.
|
||||
* @param network The network that the links being connected belong to.
|
||||
* @returns A POJO with the state of the link connector, links being connected, and their network.
|
||||
* @remarks Other than {@link network}, all properties are shallow cloned.
|
||||
*/
|
||||
export(network: LinkNetwork): LinkConnectorExport {
|
||||
return {
|
||||
renderLinks: [...this.renderLinks],
|
||||
inputLinks: [...this.inputLinks],
|
||||
outputLinks: [...this.outputLinks],
|
||||
floatingLinks: [...this.floatingLinks],
|
||||
state: { ...this.state },
|
||||
network,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event listener that will be automatically removed when the reset event is fired.
|
||||
* @param eventName The event to listen for.
|
||||
* @param listener The listener to call when the event is fired.
|
||||
*/
|
||||
listenUntilReset<K extends keyof LinkConnectorEventMap>(
|
||||
eventName: K,
|
||||
listener: Parameters<typeof this.events.addEventListener<K>>[1],
|
||||
options?: Parameters<typeof this.events.addEventListener<K>>[2],
|
||||
) {
|
||||
this.events.addEventListener(eventName, listener, options)
|
||||
this.events.addEventListener("reset", () => this.events.removeEventListener(eventName, listener), { once: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets everything to its initial state.
|
||||
*
|
||||
* Effectively cancels moving or connecting links.
|
||||
*/
|
||||
reset(force = false): void {
|
||||
const mayContinue = this.events.dispatch("reset", force)
|
||||
if (mayContinue === false) return
|
||||
|
||||
const { state, outputLinks, inputLinks, hiddenReroutes, renderLinks, floatingLinks } = this
|
||||
|
||||
if (!force && state.connectingTo === undefined) return
|
||||
state.connectingTo = undefined
|
||||
|
||||
for (const link of outputLinks) delete link._dragging
|
||||
for (const link of inputLinks) delete link._dragging
|
||||
for (const link of floatingLinks) delete link._dragging
|
||||
for (const reroute of hiddenReroutes) delete reroute._dragging
|
||||
|
||||
renderLinks.length = 0
|
||||
inputLinks.length = 0
|
||||
outputLinks.length = 0
|
||||
floatingLinks.length = 0
|
||||
hiddenReroutes.clear()
|
||||
state.multi = false
|
||||
state.draggingExistingLinks = false
|
||||
state.snapLinksPos = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates that a single {@link RenderLink} can be dropped on the specified reroute. */
|
||||
function canConnectInputLinkToReroute(
|
||||
link: ToInputRenderLink | MovingInputLink | FloatingRenderLink | ToInputFromIoNodeLink,
|
||||
inputNode: LGraphNode,
|
||||
input: INodeInputSlot,
|
||||
reroute: Reroute,
|
||||
): boolean {
|
||||
const { fromReroute } = link
|
||||
|
||||
if (
|
||||
!link.canConnectToInput(inputNode, input) ||
|
||||
// Would result in no change
|
||||
fromReroute?.id === reroute.id ||
|
||||
// Cannot connect from child to parent reroute
|
||||
fromReroute?.getReroutes()?.includes(reroute)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Would result in no change
|
||||
if (link instanceof ToInputRenderLink) {
|
||||
if (reroute.parentId == null) {
|
||||
// Link would make no change - output to reroute
|
||||
if (reroute.firstLink?.hasOrigin(link.node.id, link.fromSlotIndex)) return false
|
||||
} else if (link.fromReroute?.id === reroute.parentId) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
96
src/lib/litegraph/src/canvas/MovingInputLink.ts
Normal file
96
src/lib/litegraph/src/canvas/MovingInputLink.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
|
||||
import type { LinkConnectorEventMap } from "@/infrastructure/LinkConnectorEventMap"
|
||||
import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, Point } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { LLink } from "@/LLink"
|
||||
import type { Reroute } from "@/Reroute"
|
||||
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
|
||||
import type { NodeLike } from "@/types/NodeLike"
|
||||
import type { SubgraphIO } from "@/types/serialisation"
|
||||
|
||||
import { LinkDirection } from "@/types/globalEnums"
|
||||
|
||||
import { MovingLinkBase } from "./MovingLinkBase"
|
||||
|
||||
export class MovingInputLink extends MovingLinkBase {
|
||||
override readonly toType = "input"
|
||||
|
||||
readonly node: LGraphNode
|
||||
readonly fromSlot: INodeOutputSlot
|
||||
readonly fromPos: Point
|
||||
readonly fromDirection: LinkDirection
|
||||
readonly fromSlotIndex: number
|
||||
|
||||
constructor(network: LinkNetwork, link: LLink, fromReroute?: Reroute, dragDirection: LinkDirection = LinkDirection.CENTER) {
|
||||
super(network, link, "input", fromReroute, dragDirection)
|
||||
|
||||
this.node = this.outputNode
|
||||
this.fromSlot = this.outputSlot
|
||||
this.fromPos = fromReroute?.pos ?? this.outputPos
|
||||
this.fromDirection = LinkDirection.NONE
|
||||
this.fromSlotIndex = this.outputIndex
|
||||
}
|
||||
|
||||
canConnectToInput(inputNode: NodeLike, input: INodeInputSlot | SubgraphIO): boolean {
|
||||
return this.node.canConnectTo(inputNode, input, this.outputSlot)
|
||||
}
|
||||
|
||||
canConnectToOutput(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
canConnectToReroute(reroute: Reroute): boolean {
|
||||
return reroute.origin_id !== this.inputNode.id
|
||||
}
|
||||
|
||||
connectToInput(inputNode: LGraphNode, input: INodeInputSlot, events: CustomEventTarget<LinkConnectorEventMap>): LLink | null | undefined {
|
||||
if (input === this.inputSlot) return
|
||||
|
||||
this.inputNode.disconnectInput(this.inputIndex, true)
|
||||
const link = this.outputNode.connectSlots(this.outputSlot, inputNode, input, this.fromReroute?.id)
|
||||
if (link) events.dispatch("input-moved", this)
|
||||
return link
|
||||
}
|
||||
|
||||
connectToOutput(): never {
|
||||
throw new Error("MovingInputLink cannot connect to an output.")
|
||||
}
|
||||
|
||||
connectToSubgraphInput(): void {
|
||||
throw new Error("MovingInputLink cannot connect to a subgraph input.")
|
||||
}
|
||||
|
||||
connectToSubgraphOutput(output: SubgraphOutput, events?: CustomEventTarget<LinkConnectorEventMap>): void {
|
||||
const newLink = output.connect(this.fromSlot, this.node, this.fromReroute?.id)
|
||||
events?.dispatch("link-created", newLink)
|
||||
}
|
||||
|
||||
connectToRerouteInput(
|
||||
reroute: Reroute,
|
||||
{ node: inputNode, input, link: existingLink }: { node: LGraphNode, input: INodeInputSlot, link: LLink },
|
||||
events: CustomEventTarget<LinkConnectorEventMap>,
|
||||
originalReroutes: Reroute[],
|
||||
): void {
|
||||
const { outputNode, outputSlot, fromReroute } = this
|
||||
|
||||
// Clean up reroutes
|
||||
for (const reroute of originalReroutes) {
|
||||
if (reroute.id === this.link.parentId) break
|
||||
|
||||
if (reroute.totalLinks === 1) reroute.remove()
|
||||
}
|
||||
// Set the parentId of the reroute we dropped on, to the reroute we dragged from
|
||||
reroute.parentId = fromReroute?.id
|
||||
|
||||
const newLink = outputNode.connectSlots(outputSlot, inputNode, input, existingLink.parentId)
|
||||
if (newLink) events.dispatch("input-moved", this)
|
||||
}
|
||||
|
||||
connectToRerouteOutput(): never {
|
||||
throw new Error("MovingInputLink cannot connect to an output.")
|
||||
}
|
||||
|
||||
disconnect(): boolean {
|
||||
return this.inputNode.disconnectInput(this.inputIndex, true)
|
||||
}
|
||||
}
|
||||
93
src/lib/litegraph/src/canvas/MovingLinkBase.ts
Normal file
93
src/lib/litegraph/src/canvas/MovingLinkBase.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { RenderLink } from "./RenderLink"
|
||||
import type { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
|
||||
import type { LinkConnectorEventMap } from "@/infrastructure/LinkConnectorEventMap"
|
||||
import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, Point } from "@/interfaces"
|
||||
import type { LGraphNode, NodeId } from "@/LGraphNode"
|
||||
import type { LLink } from "@/LLink"
|
||||
import type { Reroute } from "@/Reroute"
|
||||
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
|
||||
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
|
||||
|
||||
import { LinkDirection } from "@/types/globalEnums"
|
||||
|
||||
/**
|
||||
* Represents an existing link that is currently being dragged by the user from one slot to another.
|
||||
*
|
||||
* This is a heavier, but short-lived convenience data structure.
|
||||
* All refs to {@link MovingInputLink} and {@link MovingOutputLink} should be discarded on drop.
|
||||
* @remarks
|
||||
* At time of writing, Litegraph is using several different styles and methods to handle link dragging.
|
||||
*
|
||||
* Once the library has undergone more substantial changes to the way links are managed,
|
||||
* many properties of this class will be superfluous and removable.
|
||||
*/
|
||||
|
||||
export abstract class MovingLinkBase implements RenderLink {
|
||||
abstract readonly node: LGraphNode
|
||||
abstract readonly fromSlot: INodeOutputSlot | INodeInputSlot
|
||||
abstract readonly fromPos: Point
|
||||
abstract readonly fromDirection: LinkDirection
|
||||
abstract readonly fromSlotIndex: number
|
||||
|
||||
readonly outputNodeId: NodeId
|
||||
readonly outputNode: LGraphNode
|
||||
readonly outputSlot: INodeOutputSlot
|
||||
readonly outputIndex: number
|
||||
readonly outputPos: Point
|
||||
|
||||
readonly inputNodeId: NodeId
|
||||
readonly inputNode: LGraphNode
|
||||
readonly inputSlot: INodeInputSlot
|
||||
readonly inputIndex: number
|
||||
readonly inputPos: Point
|
||||
|
||||
constructor(
|
||||
readonly network: LinkNetwork,
|
||||
readonly link: LLink,
|
||||
readonly toType: "input" | "output",
|
||||
readonly fromReroute?: Reroute,
|
||||
readonly dragDirection: LinkDirection = LinkDirection.CENTER,
|
||||
) {
|
||||
const {
|
||||
origin_id: outputNodeId,
|
||||
target_id: inputNodeId,
|
||||
origin_slot: outputIndex,
|
||||
target_slot: inputIndex,
|
||||
} = link
|
||||
|
||||
// Store output info
|
||||
const outputNode = network.getNodeById(outputNodeId) ?? undefined
|
||||
if (!outputNode) throw new Error(`Creating MovingRenderLink for link [${link.id}] failed: Output node [${outputNodeId}] not found.`)
|
||||
|
||||
const outputSlot = outputNode.outputs.at(outputIndex)
|
||||
if (!outputSlot) throw new Error(`Creating MovingRenderLink for link [${link.id}] failed: Output slot [${outputIndex}] not found.`)
|
||||
|
||||
this.outputNodeId = outputNodeId
|
||||
this.outputNode = outputNode
|
||||
this.outputSlot = outputSlot
|
||||
this.outputIndex = outputIndex
|
||||
this.outputPos = outputNode.getOutputPos(outputIndex)
|
||||
|
||||
// Store input info
|
||||
const inputNode = network.getNodeById(inputNodeId) ?? undefined
|
||||
if (!inputNode) throw new Error(`Creating DraggingRenderLink for link [${link.id}] failed: Input node [${inputNodeId}] not found.`)
|
||||
|
||||
const inputSlot = inputNode.inputs.at(inputIndex)
|
||||
if (!inputSlot) throw new Error(`Creating DraggingRenderLink for link [${link.id}] failed: Input slot [${inputIndex}] not found.`)
|
||||
|
||||
this.inputNodeId = inputNodeId
|
||||
this.inputNode = inputNode
|
||||
this.inputSlot = inputSlot
|
||||
this.inputIndex = inputIndex
|
||||
this.inputPos = inputNode.getInputPos(inputIndex)
|
||||
}
|
||||
|
||||
abstract connectToInput(node: LGraphNode, input: INodeInputSlot, events?: CustomEventTarget<LinkConnectorEventMap>): void
|
||||
abstract connectToOutput(node: LGraphNode, output: INodeOutputSlot, events?: CustomEventTarget<LinkConnectorEventMap>): void
|
||||
abstract connectToSubgraphInput(input: SubgraphInput, events?: CustomEventTarget<LinkConnectorEventMap>): void
|
||||
abstract connectToSubgraphOutput(output: SubgraphOutput, events?: CustomEventTarget<LinkConnectorEventMap>): void
|
||||
abstract connectToRerouteInput(reroute: Reroute, { node, input, link }: { node: LGraphNode, input: INodeInputSlot, link: LLink }, events: CustomEventTarget<LinkConnectorEventMap>, originalReroutes: Reroute[]): void
|
||||
abstract connectToRerouteOutput(reroute: Reroute, outputNode: LGraphNode, output: INodeOutputSlot, events: CustomEventTarget<LinkConnectorEventMap>): void
|
||||
|
||||
abstract disconnect(): boolean
|
||||
}
|
||||
102
src/lib/litegraph/src/canvas/MovingOutputLink.ts
Normal file
102
src/lib/litegraph/src/canvas/MovingOutputLink.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
|
||||
import type { LinkConnectorEventMap } from "@/infrastructure/LinkConnectorEventMap"
|
||||
import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, Point } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { LLink } from "@/LLink"
|
||||
import type { Reroute } from "@/Reroute"
|
||||
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
|
||||
import type { NodeLike } from "@/types/NodeLike"
|
||||
import type { SubgraphIO } from "@/types/serialisation"
|
||||
|
||||
import { LinkDirection } from "@/types/globalEnums"
|
||||
|
||||
import { MovingLinkBase } from "./MovingLinkBase"
|
||||
|
||||
export class MovingOutputLink extends MovingLinkBase {
|
||||
override readonly toType = "output"
|
||||
|
||||
readonly node: LGraphNode
|
||||
readonly fromSlot: INodeInputSlot
|
||||
readonly fromPos: Point
|
||||
readonly fromDirection: LinkDirection
|
||||
readonly fromSlotIndex: number
|
||||
|
||||
constructor(network: LinkNetwork, link: LLink, fromReroute?: Reroute, dragDirection: LinkDirection = LinkDirection.CENTER) {
|
||||
super(network, link, "output", fromReroute, dragDirection)
|
||||
|
||||
this.node = this.inputNode
|
||||
this.fromSlot = this.inputSlot
|
||||
this.fromPos = fromReroute?.pos ?? this.inputPos
|
||||
this.fromDirection = LinkDirection.LEFT
|
||||
this.fromSlotIndex = this.inputIndex
|
||||
}
|
||||
|
||||
canConnectToInput(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
canConnectToOutput(outputNode: NodeLike, output: INodeOutputSlot | SubgraphIO): boolean {
|
||||
return outputNode.canConnectTo(this.node, this.inputSlot, output)
|
||||
}
|
||||
|
||||
canConnectToReroute(reroute: Reroute): boolean {
|
||||
return reroute.origin_id !== this.outputNode.id
|
||||
}
|
||||
|
||||
connectToInput(): never {
|
||||
throw new Error("MovingOutputLink cannot connect to an input.")
|
||||
}
|
||||
|
||||
connectToOutput(outputNode: LGraphNode, output: INodeOutputSlot, events: CustomEventTarget<LinkConnectorEventMap>): LLink | null | undefined {
|
||||
if (output === this.outputSlot) return
|
||||
|
||||
const link = outputNode.connectSlots(output, this.inputNode, this.inputSlot, this.link.parentId)
|
||||
if (link) events.dispatch("output-moved", this)
|
||||
return link
|
||||
}
|
||||
|
||||
connectToSubgraphInput(input: SubgraphInput, events?: CustomEventTarget<LinkConnectorEventMap>): void {
|
||||
const newLink = input.connect(this.fromSlot, this.node, this.fromReroute?.id)
|
||||
events?.dispatch("link-created", newLink)
|
||||
}
|
||||
|
||||
connectToSubgraphOutput(): void {
|
||||
throw new Error("MovingOutputLink cannot connect to a subgraph output.")
|
||||
}
|
||||
|
||||
connectToRerouteInput(): never {
|
||||
throw new Error("MovingOutputLink cannot connect to an input.")
|
||||
}
|
||||
|
||||
connectToRerouteOutput(
|
||||
reroute: Reroute,
|
||||
outputNode: LGraphNode,
|
||||
output: INodeOutputSlot,
|
||||
events: CustomEventTarget<LinkConnectorEventMap>,
|
||||
): void {
|
||||
// Moving output side of links
|
||||
const { inputNode, inputSlot, fromReroute } = this
|
||||
|
||||
// Creating a new link removes floating prop - check before connecting
|
||||
const floatingTerminus = reroute?.floating?.slotType === "output"
|
||||
|
||||
// Connect the first reroute of the link being dragged to the reroute being dropped on
|
||||
if (fromReroute) {
|
||||
fromReroute.parentId = reroute.id
|
||||
} else {
|
||||
// If there are no reroutes, directly connect the link
|
||||
this.link.parentId = reroute.id
|
||||
}
|
||||
// Use the last reroute id on the link to retain all reroutes
|
||||
outputNode.connectSlots(output, inputNode, inputSlot, this.link.parentId)
|
||||
|
||||
// Connecting from the final reroute of a floating reroute chain
|
||||
if (floatingTerminus) reroute.removeAllFloatingLinks()
|
||||
|
||||
events.dispatch("output-moved", this)
|
||||
}
|
||||
|
||||
disconnect(): boolean {
|
||||
return this.outputNode.disconnectOutput(this.outputIndex, this.inputNode)
|
||||
}
|
||||
}
|
||||
50
src/lib/litegraph/src/canvas/RenderLink.ts
Normal file
50
src/lib/litegraph/src/canvas/RenderLink.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
|
||||
import type { LinkConnectorEventMap } from "@/infrastructure/LinkConnectorEventMap"
|
||||
import type { LinkNetwork, Point } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { INodeInputSlot, INodeOutputSlot, LLink, Reroute } from "@/litegraph"
|
||||
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
|
||||
import type { SubgraphIONodeBase } from "@/subgraph/SubgraphIONodeBase"
|
||||
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
|
||||
import type { LinkDirection } from "@/types/globalEnums"
|
||||
|
||||
export interface RenderLink {
|
||||
/** The type of link being connected. */
|
||||
readonly toType: "input" | "output"
|
||||
/** The source {@link Point} of the link being connected. */
|
||||
readonly fromPos: Point
|
||||
/** The direction the link starts off as. If {@link toType} is `output`, this will be the direction the link input faces. */
|
||||
readonly fromDirection: LinkDirection
|
||||
/** If set, this will force a dragged link "point" from the cursor in the specified direction. */
|
||||
dragDirection: LinkDirection
|
||||
|
||||
/** The network that the link belongs to. */
|
||||
readonly network: LinkNetwork
|
||||
/** The node that the link is being connected from. */
|
||||
readonly node: LGraphNode | SubgraphIONodeBase<SubgraphInput | SubgraphOutput>
|
||||
/** The slot that the link is being connected from. */
|
||||
readonly fromSlot: INodeOutputSlot | INodeInputSlot | SubgraphInput | SubgraphOutput
|
||||
/** The index of the slot that the link is being connected from. */
|
||||
readonly fromSlotIndex: number
|
||||
/** The reroute that the link is being connected from. */
|
||||
readonly fromReroute?: Reroute
|
||||
|
||||
connectToInput(node: LGraphNode, input: INodeInputSlot, events?: CustomEventTarget<LinkConnectorEventMap>): void
|
||||
connectToOutput(node: LGraphNode, output: INodeOutputSlot, events?: CustomEventTarget<LinkConnectorEventMap>): void
|
||||
connectToSubgraphInput(input: SubgraphInput, events?: CustomEventTarget<LinkConnectorEventMap>): void
|
||||
connectToSubgraphOutput(output: SubgraphOutput, events?: CustomEventTarget<LinkConnectorEventMap>): void
|
||||
|
||||
connectToRerouteInput(
|
||||
reroute: Reroute,
|
||||
{ node, input, link }: { node: LGraphNode, input: INodeInputSlot, link: LLink },
|
||||
events: CustomEventTarget<LinkConnectorEventMap>,
|
||||
originalReroutes: Reroute[],
|
||||
): void
|
||||
|
||||
connectToRerouteOutput(
|
||||
reroute: Reroute,
|
||||
outputNode: LGraphNode,
|
||||
output: INodeOutputSlot,
|
||||
events: CustomEventTarget<LinkConnectorEventMap>,
|
||||
): void
|
||||
}
|
||||
130
src/lib/litegraph/src/canvas/ToInputFromIoNodeLink.ts
Normal file
130
src/lib/litegraph/src/canvas/ToInputFromIoNodeLink.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { RenderLink } from "./RenderLink"
|
||||
import type { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
|
||||
import type { LinkConnectorEventMap } from "@/infrastructure/LinkConnectorEventMap"
|
||||
import type { INodeInputSlot, LinkNetwork, Point } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { LLink } from "@/LLink"
|
||||
import type { Reroute } from "@/Reroute"
|
||||
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
|
||||
import type { SubgraphInputNode } from "@/subgraph/SubgraphInputNode"
|
||||
import type { NodeLike } from "@/types/NodeLike"
|
||||
|
||||
import { LinkDirection } from "@/types/globalEnums"
|
||||
|
||||
/** Connecting TO an input slot. */
|
||||
|
||||
export class ToInputFromIoNodeLink implements RenderLink {
|
||||
readonly toType = "input"
|
||||
readonly fromSlotIndex: number
|
||||
readonly fromPos: Point
|
||||
fromDirection: LinkDirection = LinkDirection.RIGHT
|
||||
readonly existingLink?: LLink
|
||||
|
||||
constructor(
|
||||
readonly network: LinkNetwork,
|
||||
readonly node: SubgraphInputNode,
|
||||
readonly fromSlot: SubgraphInput,
|
||||
readonly fromReroute?: Reroute,
|
||||
public dragDirection: LinkDirection = LinkDirection.CENTER,
|
||||
existingLink?: LLink,
|
||||
) {
|
||||
const outputIndex = node.slots.indexOf(fromSlot)
|
||||
if (outputIndex === -1 && fromSlot !== node.emptySlot) {
|
||||
throw new Error(`Creating render link for node [${this.node.id}] failed: Slot index not found.`)
|
||||
}
|
||||
|
||||
this.fromSlotIndex = outputIndex
|
||||
this.fromPos = fromReroute
|
||||
? fromReroute.pos
|
||||
: fromSlot.pos
|
||||
this.existingLink = existingLink
|
||||
}
|
||||
|
||||
canConnectToInput(inputNode: NodeLike, input: INodeInputSlot): boolean {
|
||||
return this.node.canConnectTo(inputNode, input, this.fromSlot)
|
||||
}
|
||||
|
||||
canConnectToOutput(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
connectToInput(node: LGraphNode, input: INodeInputSlot, events: CustomEventTarget<LinkConnectorEventMap>) {
|
||||
const { fromSlot, fromReroute, existingLink } = this
|
||||
|
||||
const newLink = fromSlot.connect(input, node, fromReroute?.id)
|
||||
|
||||
if (existingLink) {
|
||||
// Moving an existing link
|
||||
events.dispatch("input-moved", this)
|
||||
} else {
|
||||
// Creating a new link
|
||||
events.dispatch("link-created", newLink)
|
||||
}
|
||||
}
|
||||
|
||||
connectToSubgraphOutput(): void {
|
||||
throw new Error("Not implemented")
|
||||
}
|
||||
|
||||
connectToRerouteInput(
|
||||
reroute: Reroute,
|
||||
{
|
||||
node: inputNode,
|
||||
input,
|
||||
link,
|
||||
}: { node: LGraphNode, input: INodeInputSlot, link: LLink },
|
||||
events: CustomEventTarget<LinkConnectorEventMap>,
|
||||
originalReroutes: Reroute[],
|
||||
) {
|
||||
const { fromSlot, fromReroute } = this
|
||||
|
||||
// Check before creating new link overwrites the value
|
||||
const floatingTerminus = fromReroute?.floating?.slotType === "output"
|
||||
|
||||
// Set the parentId of the reroute we dropped on, to the reroute we dragged from
|
||||
reroute.parentId = fromReroute?.id
|
||||
|
||||
const newLink = fromSlot.connect(input, inputNode, link.parentId)
|
||||
|
||||
// Connecting from the final reroute of a floating reroute chain
|
||||
if (floatingTerminus) fromReroute.removeAllFloatingLinks()
|
||||
|
||||
// Clean up reroutes
|
||||
for (const reroute of originalReroutes) {
|
||||
if (reroute.id === fromReroute?.id) break
|
||||
|
||||
reroute.removeLink(link)
|
||||
if (reroute.totalLinks === 0) {
|
||||
if (link.isFloating) {
|
||||
// Cannot float from both sides - remove
|
||||
reroute.remove()
|
||||
} else {
|
||||
// Convert to floating
|
||||
const cl = link.toFloating("output", reroute.id)
|
||||
this.network.addFloatingLink(cl)
|
||||
reroute.floating = { slotType: "output" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.existingLink) {
|
||||
// Moving an existing link
|
||||
events.dispatch("input-moved", this)
|
||||
} else {
|
||||
// Creating a new link
|
||||
events.dispatch("link-created", newLink)
|
||||
}
|
||||
}
|
||||
|
||||
connectToOutput() {
|
||||
throw new Error("ToInputRenderLink cannot connect to an output.")
|
||||
}
|
||||
|
||||
connectToSubgraphInput(): void {
|
||||
throw new Error("ToInputRenderLink cannot connect to a subgraph input.")
|
||||
}
|
||||
|
||||
connectToRerouteOutput() {
|
||||
throw new Error("ToInputRenderLink cannot connect to an output.")
|
||||
}
|
||||
}
|
||||
112
src/lib/litegraph/src/canvas/ToInputRenderLink.ts
Normal file
112
src/lib/litegraph/src/canvas/ToInputRenderLink.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { RenderLink } from "./RenderLink"
|
||||
import type { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
|
||||
import type { LinkConnectorEventMap } from "@/infrastructure/LinkConnectorEventMap"
|
||||
import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, Point } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { LLink } from "@/LLink"
|
||||
import type { Reroute } from "@/Reroute"
|
||||
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
|
||||
import type { NodeLike } from "@/types/NodeLike"
|
||||
|
||||
import { LinkDirection } from "@/types/globalEnums"
|
||||
|
||||
/** Connecting TO an input slot. */
|
||||
|
||||
export class ToInputRenderLink implements RenderLink {
|
||||
readonly toType = "input"
|
||||
readonly fromPos: Point
|
||||
readonly fromSlotIndex: number
|
||||
fromDirection: LinkDirection = LinkDirection.RIGHT
|
||||
|
||||
constructor(
|
||||
readonly network: LinkNetwork,
|
||||
readonly node: LGraphNode,
|
||||
readonly fromSlot: INodeOutputSlot,
|
||||
readonly fromReroute?: Reroute,
|
||||
public dragDirection: LinkDirection = LinkDirection.CENTER,
|
||||
) {
|
||||
const outputIndex = node.outputs.indexOf(fromSlot)
|
||||
if (outputIndex === -1) throw new Error(`Creating render link for node [${this.node.id}] failed: Slot index not found.`)
|
||||
|
||||
this.fromSlotIndex = outputIndex
|
||||
this.fromPos = fromReroute
|
||||
? fromReroute.pos
|
||||
: this.node.getOutputPos(outputIndex)
|
||||
}
|
||||
|
||||
canConnectToInput(inputNode: NodeLike, input: INodeInputSlot): boolean {
|
||||
return this.node.canConnectTo(inputNode, input, this.fromSlot)
|
||||
}
|
||||
|
||||
canConnectToOutput(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
connectToInput(node: LGraphNode, input: INodeInputSlot, events: CustomEventTarget<LinkConnectorEventMap>) {
|
||||
const { node: outputNode, fromSlot, fromReroute } = this
|
||||
if (node === outputNode) return
|
||||
|
||||
const newLink = outputNode.connectSlots(fromSlot, node, input, fromReroute?.id)
|
||||
events.dispatch("link-created", newLink)
|
||||
}
|
||||
|
||||
connectToSubgraphOutput(output: SubgraphOutput, events: CustomEventTarget<LinkConnectorEventMap>) {
|
||||
const newLink = output.connect(this.fromSlot, this.node, this.fromReroute?.id)
|
||||
events.dispatch("link-created", newLink)
|
||||
}
|
||||
|
||||
connectToRerouteInput(
|
||||
reroute: Reroute,
|
||||
{
|
||||
node: inputNode,
|
||||
input,
|
||||
link,
|
||||
}: { node: LGraphNode, input: INodeInputSlot, link: LLink },
|
||||
events: CustomEventTarget<LinkConnectorEventMap>,
|
||||
originalReroutes: Reroute[],
|
||||
) {
|
||||
const { node: outputNode, fromSlot, fromReroute } = this
|
||||
|
||||
// Check before creating new link overwrites the value
|
||||
const floatingTerminus = fromReroute?.floating?.slotType === "output"
|
||||
|
||||
// Set the parentId of the reroute we dropped on, to the reroute we dragged from
|
||||
reroute.parentId = fromReroute?.id
|
||||
|
||||
const newLink = outputNode.connectSlots(fromSlot, inputNode, input, link.parentId)
|
||||
|
||||
// Connecting from the final reroute of a floating reroute chain
|
||||
if (floatingTerminus) fromReroute.removeAllFloatingLinks()
|
||||
|
||||
// Clean up reroutes
|
||||
for (const reroute of originalReroutes) {
|
||||
if (reroute.id === fromReroute?.id) break
|
||||
|
||||
reroute.removeLink(link)
|
||||
if (reroute.totalLinks === 0) {
|
||||
if (link.isFloating) {
|
||||
// Cannot float from both sides - remove
|
||||
reroute.remove()
|
||||
} else {
|
||||
// Convert to floating
|
||||
const cl = link.toFloating("output", reroute.id)
|
||||
this.network.addFloatingLink(cl)
|
||||
reroute.floating = { slotType: "output" }
|
||||
}
|
||||
}
|
||||
}
|
||||
events.dispatch("link-created", newLink)
|
||||
}
|
||||
|
||||
connectToOutput() {
|
||||
throw new Error("ToInputRenderLink cannot connect to an output.")
|
||||
}
|
||||
|
||||
connectToSubgraphInput(): void {
|
||||
throw new Error("ToInputRenderLink cannot connect to a subgraph input.")
|
||||
}
|
||||
|
||||
connectToRerouteOutput() {
|
||||
throw new Error("ToInputRenderLink cannot connect to an output.")
|
||||
}
|
||||
}
|
||||
87
src/lib/litegraph/src/canvas/ToOutputFromIoNodeLink.ts
Normal file
87
src/lib/litegraph/src/canvas/ToOutputFromIoNodeLink.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { RenderLink } from "./RenderLink"
|
||||
import type { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
|
||||
import type { LinkConnectorEventMap } from "@/infrastructure/LinkConnectorEventMap"
|
||||
import type { INodeOutputSlot, LinkNetwork, Point } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { Reroute } from "@/Reroute"
|
||||
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
|
||||
import type { SubgraphOutputNode } from "@/subgraph/SubgraphOutputNode"
|
||||
import type { NodeLike } from "@/types/NodeLike"
|
||||
import type { SubgraphIO } from "@/types/serialisation"
|
||||
|
||||
import { LinkDirection } from "@/types/globalEnums"
|
||||
|
||||
/** Connecting TO an output slot. */
|
||||
|
||||
export class ToOutputFromIoNodeLink implements RenderLink {
|
||||
readonly toType = "output"
|
||||
readonly fromPos: Point
|
||||
readonly fromSlotIndex: number
|
||||
fromDirection: LinkDirection = LinkDirection.LEFT
|
||||
|
||||
constructor(
|
||||
readonly network: LinkNetwork,
|
||||
readonly node: SubgraphOutputNode,
|
||||
readonly fromSlot: SubgraphOutput,
|
||||
readonly fromReroute?: Reroute,
|
||||
public dragDirection: LinkDirection = LinkDirection.CENTER,
|
||||
) {
|
||||
const inputIndex = node.slots.indexOf(fromSlot)
|
||||
if (inputIndex === -1 && fromSlot !== node.emptySlot) {
|
||||
throw new Error(`Creating render link for node [${this.node.id}] failed: Slot index not found.`)
|
||||
}
|
||||
|
||||
this.fromSlotIndex = inputIndex
|
||||
this.fromPos = fromReroute
|
||||
? fromReroute.pos
|
||||
: fromSlot.pos
|
||||
}
|
||||
|
||||
canConnectToInput(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
canConnectToOutput(outputNode: NodeLike, output: INodeOutputSlot | SubgraphIO): boolean {
|
||||
return this.node.canConnectTo(outputNode, this.fromSlot, output)
|
||||
}
|
||||
|
||||
canConnectToReroute(reroute: Reroute): boolean {
|
||||
if (reroute.origin_id === this.node.id) return false
|
||||
return true
|
||||
}
|
||||
|
||||
connectToOutput(node: LGraphNode, output: INodeOutputSlot, events: CustomEventTarget<LinkConnectorEventMap>) {
|
||||
const { fromSlot, fromReroute } = this
|
||||
|
||||
const newLink = fromSlot.connect(output, node, fromReroute?.id)
|
||||
events.dispatch("link-created", newLink)
|
||||
}
|
||||
|
||||
connectToSubgraphInput(): void {
|
||||
throw new Error("Not implemented")
|
||||
}
|
||||
|
||||
connectToRerouteOutput(
|
||||
reroute: Reroute,
|
||||
outputNode: LGraphNode,
|
||||
output: INodeOutputSlot,
|
||||
events: CustomEventTarget<LinkConnectorEventMap>,
|
||||
): void {
|
||||
const { fromSlot } = this
|
||||
|
||||
const newLink = fromSlot.connect(output, outputNode, reroute?.id)
|
||||
events.dispatch("link-created", newLink)
|
||||
}
|
||||
|
||||
connectToInput() {
|
||||
throw new Error("ToOutputRenderLink cannot connect to an input.")
|
||||
}
|
||||
|
||||
connectToSubgraphOutput(): void {
|
||||
throw new Error("ToOutputRenderLink cannot connect to a subgraph output.")
|
||||
}
|
||||
|
||||
connectToRerouteInput() {
|
||||
throw new Error("ToOutputRenderLink cannot connect to an input.")
|
||||
}
|
||||
}
|
||||
31
src/lib/litegraph/src/canvas/ToOutputFromRerouteLink.ts
Normal file
31
src/lib/litegraph/src/canvas/ToOutputFromRerouteLink.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { LinkConnector } from "./LinkConnector"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { INodeInputSlot, INodeOutputSlot, LinkNetwork } from "@/litegraph"
|
||||
import type { Reroute } from "@/Reroute"
|
||||
|
||||
import { ToInputRenderLink } from "./ToInputRenderLink"
|
||||
import { ToOutputRenderLink } from "./ToOutputRenderLink"
|
||||
|
||||
/**
|
||||
* @internal A workaround class to support connecting to reroutes to node outputs.
|
||||
*/
|
||||
export class ToOutputFromRerouteLink extends ToOutputRenderLink {
|
||||
constructor(
|
||||
network: LinkNetwork,
|
||||
node: LGraphNode,
|
||||
fromSlot: INodeInputSlot,
|
||||
override readonly fromReroute: Reroute,
|
||||
readonly linkConnector: LinkConnector,
|
||||
) {
|
||||
super(network, node, fromSlot, fromReroute)
|
||||
}
|
||||
|
||||
override canConnectToReroute(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
override connectToOutput(node: LGraphNode, output: INodeOutputSlot) {
|
||||
const nuRenderLink = new ToInputRenderLink(this.network, node, output)
|
||||
this.linkConnector._connectOutputToReroute(this.fromReroute, nuRenderLink)
|
||||
}
|
||||
}
|
||||
85
src/lib/litegraph/src/canvas/ToOutputRenderLink.ts
Normal file
85
src/lib/litegraph/src/canvas/ToOutputRenderLink.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { RenderLink } from "./RenderLink"
|
||||
import type { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
|
||||
import type { LinkConnectorEventMap } from "@/infrastructure/LinkConnectorEventMap"
|
||||
import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, Point } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { Reroute } from "@/Reroute"
|
||||
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
|
||||
import type { NodeLike } from "@/types/NodeLike"
|
||||
import type { SubgraphIO } from "@/types/serialisation"
|
||||
|
||||
import { LinkDirection } from "@/types/globalEnums"
|
||||
|
||||
/** Connecting TO an output slot. */
|
||||
|
||||
export class ToOutputRenderLink implements RenderLink {
|
||||
readonly toType = "output"
|
||||
readonly fromPos: Point
|
||||
readonly fromSlotIndex: number
|
||||
fromDirection: LinkDirection = LinkDirection.LEFT
|
||||
|
||||
constructor(
|
||||
readonly network: LinkNetwork,
|
||||
readonly node: LGraphNode,
|
||||
readonly fromSlot: INodeInputSlot,
|
||||
readonly fromReroute?: Reroute,
|
||||
public dragDirection: LinkDirection = LinkDirection.CENTER,
|
||||
) {
|
||||
const inputIndex = node.inputs.indexOf(fromSlot)
|
||||
if (inputIndex === -1) throw new Error(`Creating render link for node [${this.node.id}] failed: Slot index not found.`)
|
||||
|
||||
this.fromSlotIndex = inputIndex
|
||||
this.fromPos = fromReroute
|
||||
? fromReroute.pos
|
||||
: this.node.getInputPos(inputIndex)
|
||||
}
|
||||
|
||||
canConnectToInput(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
canConnectToOutput(outputNode: NodeLike, output: INodeOutputSlot | SubgraphIO): boolean {
|
||||
return this.node.canConnectTo(outputNode, this.fromSlot, output)
|
||||
}
|
||||
|
||||
canConnectToReroute(reroute: Reroute): boolean {
|
||||
if (reroute.origin_id === this.node.id) return false
|
||||
return true
|
||||
}
|
||||
|
||||
connectToOutput(node: LGraphNode, output: INodeOutputSlot, events: CustomEventTarget<LinkConnectorEventMap>) {
|
||||
const { node: inputNode, fromSlot, fromReroute } = this
|
||||
if (!inputNode) return
|
||||
|
||||
const newLink = node.connectSlots(output, inputNode, fromSlot, fromReroute?.id)
|
||||
events.dispatch("link-created", newLink)
|
||||
}
|
||||
|
||||
connectToSubgraphInput(input: SubgraphInput, events?: CustomEventTarget<LinkConnectorEventMap>): void {
|
||||
const newLink = input.connect(this.fromSlot, this.node, this.fromReroute?.id)
|
||||
events?.dispatch("link-created", newLink)
|
||||
}
|
||||
|
||||
connectToRerouteOutput(
|
||||
reroute: Reroute,
|
||||
outputNode: LGraphNode,
|
||||
output: INodeOutputSlot,
|
||||
events: CustomEventTarget<LinkConnectorEventMap>,
|
||||
): void {
|
||||
const { node: inputNode, fromSlot } = this
|
||||
const newLink = outputNode.connectSlots(output, inputNode, fromSlot, reroute?.id)
|
||||
events.dispatch("link-created", newLink)
|
||||
}
|
||||
|
||||
connectToInput() {
|
||||
throw new Error("ToOutputRenderLink cannot connect to an input.")
|
||||
}
|
||||
|
||||
connectToSubgraphOutput(): void {
|
||||
throw new Error("ToOutputRenderLink cannot connect to a subgraph output.")
|
||||
}
|
||||
|
||||
connectToRerouteInput() {
|
||||
throw new Error("ToOutputRenderLink cannot connect to an input.")
|
||||
}
|
||||
}
|
||||
89
src/lib/litegraph/src/canvas/measureSlots.ts
Normal file
89
src/lib/litegraph/src/canvas/measureSlots.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { INodeInputSlot, INodeOutputSlot, Point } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
|
||||
import { isInRectangle } from "@/measure"
|
||||
|
||||
export function getNodeInputOnPos(node: LGraphNode, x: number, y: number): { index: number, input: INodeInputSlot, pos: Point } | undefined {
|
||||
const { inputs } = node
|
||||
if (!inputs) return
|
||||
|
||||
for (const [index, input] of inputs.entries()) {
|
||||
const pos = node.getInputPos(index)
|
||||
|
||||
// TODO: Find a cheap way to measure text, and do it on node label change instead of here
|
||||
// Input icon width + text approximation
|
||||
const nameLength = input.label?.length ?? input.localized_name?.length ?? input.name?.length
|
||||
const width = 20 + (nameLength || 3) * 7
|
||||
|
||||
if (isInRectangle(
|
||||
x,
|
||||
y,
|
||||
pos[0] - 10,
|
||||
pos[1] - 10,
|
||||
width,
|
||||
20,
|
||||
)) {
|
||||
return { index, input, pos }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getNodeOutputOnPos(node: LGraphNode, x: number, y: number): { index: number, output: INodeOutputSlot, pos: Point } | undefined {
|
||||
const { outputs } = node
|
||||
if (!outputs) return
|
||||
|
||||
for (const [index, output] of outputs.entries()) {
|
||||
const pos = node.getOutputPos(index)
|
||||
|
||||
if (isInRectangle(
|
||||
x,
|
||||
y,
|
||||
pos[0] - 10,
|
||||
pos[1] - 10,
|
||||
40,
|
||||
20,
|
||||
)) {
|
||||
return { index, output, pos }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the input slot index if the given position (in graph space) is on top of a node input slot.
|
||||
* A helper function - originally on the prototype of LGraphCanvas.
|
||||
*/
|
||||
export function isOverNodeInput(
|
||||
node: LGraphNode,
|
||||
canvasx: number,
|
||||
canvasy: number,
|
||||
slot_pos?: Point,
|
||||
): number {
|
||||
const result = getNodeInputOnPos(node, canvasx, canvasy)
|
||||
if (!result) return -1
|
||||
|
||||
if (slot_pos) {
|
||||
slot_pos[0] = result.pos[0]
|
||||
slot_pos[1] = result.pos[1]
|
||||
}
|
||||
return result.index
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the output slot index if the given position (in graph space) is on top of a node output slot.
|
||||
* A helper function - originally on the prototype of LGraphCanvas.
|
||||
*/
|
||||
export function isOverNodeOutput(
|
||||
node: LGraphNode,
|
||||
canvasx: number,
|
||||
canvasy: number,
|
||||
slot_pos?: Point,
|
||||
): number {
|
||||
const result = getNodeOutputOnPos(node, canvasx, canvasy)
|
||||
if (!result) return -1
|
||||
|
||||
if (slot_pos) {
|
||||
slot_pos[0] = result.pos[0]
|
||||
slot_pos[1] = result.pos[1]
|
||||
}
|
||||
return result.index
|
||||
}
|
||||
11
src/lib/litegraph/src/constants.ts
Normal file
11
src/lib/litegraph/src/constants.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Subgraph constants
|
||||
*
|
||||
* This entire module is exported as `Constants`.
|
||||
*/
|
||||
|
||||
/** ID of the virtual input node of a subgraph. */
|
||||
export const SUBGRAPH_INPUT_ID = -10
|
||||
|
||||
/** ID of the virtual output node of a subgraph. */
|
||||
export const SUBGRAPH_OUTPUT_ID = -20
|
||||
240
src/lib/litegraph/src/draw.ts
Normal file
240
src/lib/litegraph/src/draw.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import type { Rectangle } from "./infrastructure/Rectangle"
|
||||
import type { CanvasColour, Rect } from "./interfaces"
|
||||
|
||||
import { LiteGraph } from "./litegraph"
|
||||
import { LinkDirection, RenderShape, TitleMode } from "./types/globalEnums"
|
||||
|
||||
const ELLIPSIS = "\u2026"
|
||||
const TWO_DOT_LEADER = "\u2025"
|
||||
const ONE_DOT_LEADER = "\u2024"
|
||||
|
||||
export enum SlotType {
|
||||
Array = "array",
|
||||
Event = -1,
|
||||
}
|
||||
|
||||
/** @see RenderShape */
|
||||
export enum SlotShape {
|
||||
Box = RenderShape.BOX,
|
||||
Arrow = RenderShape.ARROW,
|
||||
Grid = RenderShape.GRID,
|
||||
Circle = RenderShape.CIRCLE,
|
||||
HollowCircle = RenderShape.HollowCircle,
|
||||
}
|
||||
|
||||
/** @see LinkDirection */
|
||||
export enum SlotDirection {
|
||||
Up = LinkDirection.UP,
|
||||
Right = LinkDirection.RIGHT,
|
||||
Down = LinkDirection.DOWN,
|
||||
Left = LinkDirection.LEFT,
|
||||
}
|
||||
|
||||
export enum LabelPosition {
|
||||
Left = "left",
|
||||
Right = "right",
|
||||
}
|
||||
|
||||
export interface IDrawBoundingOptions {
|
||||
/** The shape to render */
|
||||
shape?: RenderShape
|
||||
/** The radius of the rounded corners for {@link RenderShape.ROUND} and {@link RenderShape.CARD} */
|
||||
round_radius?: number
|
||||
/** Shape will extend above the Y-axis 0 by this amount @deprecated This is node-specific: it should be removed entirely, and behaviour defined by the caller more explicitly */
|
||||
title_height?: number
|
||||
/** @deprecated This is node-specific: it should be removed entirely, and behaviour defined by the caller more explicitly */
|
||||
title_mode?: TitleMode
|
||||
/** The color that should be drawn */
|
||||
color?: CanvasColour
|
||||
/** The distance between the edge of the {@link area} and the middle of the line */
|
||||
padding?: number
|
||||
/** @deprecated This is node-specific: it should be removed entirely, and behaviour defined by the caller more explicitly */
|
||||
collapsed?: boolean
|
||||
/** Thickness of the line drawn (`lineWidth`) */
|
||||
lineWidth?: number
|
||||
}
|
||||
|
||||
export interface IDrawTextInAreaOptions {
|
||||
/** The canvas to draw the text on. */
|
||||
ctx: CanvasRenderingContext2D
|
||||
/** The text to draw. */
|
||||
text: string
|
||||
/** The area the text will be drawn in. */
|
||||
area: Rectangle
|
||||
/** The alignment of the text. */
|
||||
align?: "left" | "right" | "center"
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws only the path of a shape on the canvas, without filling.
|
||||
* Used to draw indicators for node status, e.g. "selected".
|
||||
* @param ctx The 2D context to draw on
|
||||
* @param area The position and size of the shape to render
|
||||
*/
|
||||
export function strokeShape(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
area: Rect,
|
||||
{
|
||||
shape = RenderShape.BOX,
|
||||
round_radius,
|
||||
title_height,
|
||||
title_mode = TitleMode.NORMAL_TITLE,
|
||||
color,
|
||||
padding = 6,
|
||||
collapsed = false,
|
||||
lineWidth: thickness = 1,
|
||||
}: IDrawBoundingOptions = {},
|
||||
): void {
|
||||
// These param defaults are not compile-time static, and must be re-evaluated at runtime
|
||||
round_radius ??= LiteGraph.ROUND_RADIUS
|
||||
color ??= LiteGraph.NODE_BOX_OUTLINE_COLOR
|
||||
|
||||
// Adjust area if title is transparent
|
||||
if (title_mode === TitleMode.TRANSPARENT_TITLE) {
|
||||
const height = title_height ?? LiteGraph.NODE_TITLE_HEIGHT
|
||||
area[1] -= height
|
||||
area[3] += height
|
||||
}
|
||||
|
||||
// Set up context
|
||||
const { lineWidth, strokeStyle } = ctx
|
||||
ctx.lineWidth = thickness
|
||||
ctx.globalAlpha = 0.8
|
||||
ctx.strokeStyle = color
|
||||
ctx.beginPath()
|
||||
|
||||
// Draw shape based on type
|
||||
const [x, y, width, height] = area
|
||||
switch (shape) {
|
||||
case RenderShape.BOX: {
|
||||
ctx.rect(
|
||||
x - padding,
|
||||
y - padding,
|
||||
width + 2 * padding,
|
||||
height + 2 * padding,
|
||||
)
|
||||
break
|
||||
}
|
||||
case RenderShape.ROUND:
|
||||
case RenderShape.CARD: {
|
||||
const radius = round_radius + padding
|
||||
const isCollapsed = shape === RenderShape.CARD && collapsed
|
||||
const cornerRadii =
|
||||
isCollapsed || shape === RenderShape.ROUND
|
||||
? [radius]
|
||||
: [radius, 2, radius, 2]
|
||||
ctx.roundRect(
|
||||
x - padding,
|
||||
y - padding,
|
||||
width + 2 * padding,
|
||||
height + 2 * padding,
|
||||
cornerRadii,
|
||||
)
|
||||
break
|
||||
}
|
||||
case RenderShape.CIRCLE: {
|
||||
const centerX = x + width / 2
|
||||
const centerY = y + height / 2
|
||||
const radius = Math.max(width, height) / 2 + padding
|
||||
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Stroke the shape
|
||||
ctx.stroke()
|
||||
|
||||
// Reset context
|
||||
ctx.lineWidth = lineWidth
|
||||
ctx.strokeStyle = strokeStyle
|
||||
|
||||
// TODO: Store and reset value properly. Callers currently expect this behaviour (e.g. muted nodes).
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates text using binary search to fit within a given width, appending an ellipsis if needed.
|
||||
* @param ctx The canvas rendering context.
|
||||
* @param text The text to truncate.
|
||||
* @param maxWidth The maximum width the text (plus ellipsis) can occupy.
|
||||
* @returns The truncated text, or the original text if it fits.
|
||||
*/
|
||||
function truncateTextToWidth(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
|
||||
if (!(maxWidth > 0)) return ""
|
||||
|
||||
// Text fits
|
||||
const fullWidth = ctx.measureText(text).width
|
||||
if (fullWidth <= maxWidth) return text
|
||||
|
||||
const ellipsisWidth = ctx.measureText(ELLIPSIS).width * 0.75
|
||||
|
||||
// Can't even fit ellipsis
|
||||
if (ellipsisWidth > maxWidth) {
|
||||
const twoDotsWidth = ctx.measureText(TWO_DOT_LEADER).width * 0.75
|
||||
if (twoDotsWidth < maxWidth) return TWO_DOT_LEADER
|
||||
|
||||
const oneDotWidth = ctx.measureText(ONE_DOT_LEADER).width * 0.75
|
||||
return oneDotWidth < maxWidth ? ONE_DOT_LEADER : ""
|
||||
}
|
||||
|
||||
let min = 0
|
||||
let max = text.length
|
||||
let bestLen = 0
|
||||
|
||||
// Binary search for the longest substring that fits with the ellipsis
|
||||
while (min <= max) {
|
||||
const mid = Math.floor((min + max) * 0.5)
|
||||
|
||||
// Avoid measuring empty string + ellipsis
|
||||
if (mid === 0) {
|
||||
min = mid + 1
|
||||
continue
|
||||
}
|
||||
|
||||
const sub = text.substring(0, mid)
|
||||
const currentWidth = ctx.measureText(sub).width + ellipsisWidth
|
||||
|
||||
if (currentWidth <= maxWidth) {
|
||||
// This length fits, try potentially longer
|
||||
bestLen = mid
|
||||
min = mid + 1
|
||||
} else {
|
||||
// Too long, try shorter
|
||||
max = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
return bestLen === 0
|
||||
? ELLIPSIS
|
||||
: text.substring(0, bestLen) + ELLIPSIS
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws text within an area, truncating it and adding an ellipsis if necessary.
|
||||
*/
|
||||
export function drawTextInArea({ ctx, text, area, align = "left" }: IDrawTextInAreaOptions) {
|
||||
const { left, right, bottom, width, centreX } = area
|
||||
|
||||
// Text already fits
|
||||
const fullWidth = ctx.measureText(text).width
|
||||
if (fullWidth <= width) {
|
||||
ctx.textAlign = align
|
||||
const x = align === "left" ? left : (align === "right" ? right : centreX)
|
||||
ctx.fillText(text, x, bottom)
|
||||
return
|
||||
}
|
||||
|
||||
// Need to truncate text
|
||||
const truncated = truncateTextToWidth(ctx, text, width)
|
||||
if (truncated.length === 0) return
|
||||
|
||||
// Draw text - left-aligned to prevent bouncing during resize
|
||||
ctx.textAlign = "left"
|
||||
ctx.fillText(truncated.slice(0, -1), left, bottom)
|
||||
ctx.rect(left, bottom, width, 1)
|
||||
|
||||
// Draw the ellipsis, right-aligned to the button
|
||||
ctx.textAlign = "right"
|
||||
const ellipsis = truncated.at(-1)!
|
||||
ctx.fillText(ellipsis, right, bottom, ctx.measureText(ellipsis).width * 0.75)
|
||||
}
|
||||
75
src/lib/litegraph/src/infrastructure/ConstrainedSize.ts
Normal file
75
src/lib/litegraph/src/infrastructure/ConstrainedSize.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { ReadOnlyRect, ReadOnlySize, Size } from "@/interfaces"
|
||||
|
||||
import { clamp } from "@/litegraph"
|
||||
|
||||
/**
|
||||
* Basic width and height, with min/max constraints.
|
||||
*
|
||||
* - The {@link width} and {@link height} properties are readonly
|
||||
* - Size is set via {@link desiredWidth} and {@link desiredHeight} properties
|
||||
* - Width and height are then updated, clamped to min/max values
|
||||
*/
|
||||
export class ConstrainedSize {
|
||||
#width: number = 0
|
||||
#height: number = 0
|
||||
#desiredWidth: number = 0
|
||||
#desiredHeight: number = 0
|
||||
|
||||
minWidth: number = 0
|
||||
minHeight: number = 0
|
||||
maxWidth: number = Infinity
|
||||
maxHeight: number = Infinity
|
||||
|
||||
get width() {
|
||||
return this.#width
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.#height
|
||||
}
|
||||
|
||||
get desiredWidth() {
|
||||
return this.#desiredWidth
|
||||
}
|
||||
|
||||
set desiredWidth(value: number) {
|
||||
this.#desiredWidth = value
|
||||
this.#width = clamp(value, this.minWidth, this.maxWidth)
|
||||
}
|
||||
|
||||
get desiredHeight() {
|
||||
return this.#desiredHeight
|
||||
}
|
||||
|
||||
set desiredHeight(value: number) {
|
||||
this.#desiredHeight = value
|
||||
this.#height = clamp(value, this.minHeight, this.maxHeight)
|
||||
}
|
||||
|
||||
constructor(width: number, height: number) {
|
||||
this.desiredWidth = width
|
||||
this.desiredHeight = height
|
||||
}
|
||||
|
||||
static fromSize(size: ReadOnlySize): ConstrainedSize {
|
||||
return new ConstrainedSize(size[0], size[1])
|
||||
}
|
||||
|
||||
static fromRect(rect: ReadOnlyRect): ConstrainedSize {
|
||||
return new ConstrainedSize(rect[2], rect[3])
|
||||
}
|
||||
|
||||
setSize(size: ReadOnlySize): void {
|
||||
this.desiredWidth = size[0]
|
||||
this.desiredHeight = size[1]
|
||||
}
|
||||
|
||||
setValues(width: number, height: number): void {
|
||||
this.desiredWidth = width
|
||||
this.desiredHeight = height
|
||||
}
|
||||
|
||||
toSize(): Size {
|
||||
return [this.#width, this.#height]
|
||||
}
|
||||
}
|
||||
118
src/lib/litegraph/src/infrastructure/CustomEventTarget.ts
Normal file
118
src/lib/litegraph/src/infrastructure/CustomEventTarget.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { NeverNever, PickNevers } from "@/types/utility"
|
||||
|
||||
type EventListeners<T> = {
|
||||
readonly [K in keyof T]: ((this: EventTarget, ev: CustomEvent<T[K]>) => any) | EventListenerObject | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Has strongly-typed overrides of {@link EventTarget.addEventListener} and {@link EventTarget.removeEventListener}.
|
||||
*/
|
||||
export interface ICustomEventTarget<
|
||||
EventMap extends Record<Keys, unknown>,
|
||||
Keys extends keyof EventMap & string = keyof EventMap & string,
|
||||
> {
|
||||
addEventListener<K extends Keys>(
|
||||
type: K,
|
||||
listener: EventListeners<EventMap>[K],
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
|
||||
removeEventListener<K extends Keys>(
|
||||
type: K,
|
||||
listener: EventListeners<EventMap>[K],
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void
|
||||
|
||||
/** @deprecated Use {@link dispatch}. */
|
||||
dispatchEvent(event: never): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Capable of dispatching strongly-typed events via {@link dispatch}.
|
||||
* Overloads are used to ensure detail param is correctly optional.
|
||||
*/
|
||||
export interface CustomEventDispatcher<
|
||||
EventMap extends Record<Keys, unknown>,
|
||||
Keys extends keyof EventMap & string = keyof EventMap & string,
|
||||
> {
|
||||
dispatch<T extends keyof NeverNever<EventMap>>(type: T, detail: EventMap[T]): boolean
|
||||
dispatch<T extends keyof PickNevers<EventMap>>(type: T): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly-typed, custom {@link EventTarget} that can dispatch and listen for events.
|
||||
*
|
||||
* 1. Define an event map
|
||||
* ```ts
|
||||
* export interface CustomEventMap {
|
||||
* "my-event": { message: string }
|
||||
* "simple-event": never
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* 2. Create an event emitter
|
||||
* ```ts
|
||||
* // By subclassing
|
||||
* class MyClass extends CustomEventTarget<CustomEventMap> {
|
||||
* // ...
|
||||
* }
|
||||
*
|
||||
* // Or simply create an instance:
|
||||
* const events = new CustomEventTarget<CustomEventMap>()
|
||||
* ```
|
||||
*
|
||||
* 3. Dispatch events
|
||||
* ```ts
|
||||
* // Extended class
|
||||
* const myClass = new MyClass()
|
||||
* myClass.dispatch("my-event", { message: "Hello, world!" })
|
||||
* myClass.dispatch("simple-event")
|
||||
*
|
||||
* // Instance
|
||||
* const events = new CustomEventTarget<CustomEventMap>()
|
||||
* events.dispatch("my-event", { message: "Hello, world!" })
|
||||
* events.dispatch("simple-event")
|
||||
* ```
|
||||
*/
|
||||
export class CustomEventTarget<
|
||||
EventMap extends Record<Keys, unknown>,
|
||||
Keys extends keyof EventMap & string = keyof EventMap & string,
|
||||
>
|
||||
extends EventTarget implements ICustomEventTarget<EventMap, Keys> {
|
||||
/**
|
||||
* Type-safe event dispatching.
|
||||
* @see {@link EventTarget.dispatchEvent}
|
||||
* @param type Name of the event to dispatch
|
||||
* @param detail A custom object to send with the event
|
||||
* @returns `true` if the event was dispatched successfully, otherwise `false`.
|
||||
*/
|
||||
dispatch<T extends keyof NeverNever<EventMap>>(type: T, detail: EventMap[T]): boolean
|
||||
dispatch<T extends keyof PickNevers<EventMap>>(type: T): boolean
|
||||
dispatch<T extends keyof EventMap>(type: T, detail?: EventMap[T]) {
|
||||
const event = new CustomEvent(type as string, { detail, cancelable: true })
|
||||
return super.dispatchEvent(event)
|
||||
}
|
||||
|
||||
override addEventListener<K extends Keys>(
|
||||
type: K,
|
||||
listener: EventListeners<EventMap>[K],
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void {
|
||||
// Assertion: Contravariance on CustomEvent => Event
|
||||
super.addEventListener(type as string, listener as EventListener, options)
|
||||
}
|
||||
|
||||
override removeEventListener<K extends Keys>(
|
||||
type: K,
|
||||
listener: EventListeners<EventMap>[K],
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void {
|
||||
// Assertion: Contravariance on CustomEvent => Event
|
||||
super.removeEventListener(type as string, listener as EventListener, options)
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link dispatch}. */
|
||||
override dispatchEvent(event: never): boolean {
|
||||
return super.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
6
src/lib/litegraph/src/infrastructure/InvalidLinkError.ts
Normal file
6
src/lib/litegraph/src/infrastructure/InvalidLinkError.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class InvalidLinkError extends Error {
|
||||
constructor(message: string = "Attempted to access a link that was invalid.", cause?: Error) {
|
||||
super(message, { cause })
|
||||
this.name = "InvalidLinkError"
|
||||
}
|
||||
}
|
||||
45
src/lib/litegraph/src/infrastructure/LGraphCanvasEventMap.ts
Normal file
45
src/lib/litegraph/src/infrastructure/LGraphCanvasEventMap.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ConnectingLink } from "@/interfaces"
|
||||
import type { LGraph } from "@/LGraph"
|
||||
import type { LGraphButton } from "@/LGraphButton"
|
||||
import type { LGraphGroup } from "@/LGraphGroup"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { Subgraph } from "@/subgraph/Subgraph"
|
||||
import type { CanvasPointerEvent } from "@/types/events"
|
||||
|
||||
export interface LGraphCanvasEventMap {
|
||||
/** The active graph has changed. */
|
||||
"litegraph:set-graph": {
|
||||
/** The new active graph. */
|
||||
newGraph: LGraph | Subgraph
|
||||
/** The old active graph, or `null` if there was no active graph. */
|
||||
oldGraph: LGraph | Subgraph | null | undefined
|
||||
}
|
||||
|
||||
"litegraph:canvas":
|
||||
| { subType: "before-change" | "after-change" }
|
||||
| {
|
||||
subType: "empty-release"
|
||||
originalEvent?: CanvasPointerEvent
|
||||
linkReleaseContext?: { links: ConnectingLink[] }
|
||||
}
|
||||
| {
|
||||
subType: "group-double-click"
|
||||
originalEvent?: CanvasPointerEvent
|
||||
group: LGraphGroup
|
||||
}
|
||||
| {
|
||||
subType: "empty-double-click"
|
||||
originalEvent?: CanvasPointerEvent
|
||||
}
|
||||
| {
|
||||
subType: "node-double-click"
|
||||
originalEvent?: CanvasPointerEvent
|
||||
node: LGraphNode
|
||||
}
|
||||
|
||||
/** A title button on a node was clicked. */
|
||||
"litegraph:node-title-button-clicked": {
|
||||
node: LGraphNode
|
||||
button: LGraphButton
|
||||
}
|
||||
}
|
||||
47
src/lib/litegraph/src/infrastructure/LGraphEventMap.ts
Normal file
47
src/lib/litegraph/src/infrastructure/LGraphEventMap.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ReadOnlyRect } from "@/interfaces"
|
||||
import type { LGraph } from "@/LGraph"
|
||||
import type { LLink, ResolvedConnection } from "@/LLink"
|
||||
import type { Subgraph } from "@/subgraph/Subgraph"
|
||||
import type { ExportedSubgraph, ISerialisedGraph, SerialisableGraph } from "@/types/serialisation"
|
||||
|
||||
export interface LGraphEventMap {
|
||||
"configuring": {
|
||||
/** The data that was used to configure the graph. */
|
||||
data: ISerialisedGraph | SerialisableGraph
|
||||
/** If `true`, the graph will be cleared prior to adding the configuration. */
|
||||
clearGraph: boolean
|
||||
}
|
||||
"configured": never
|
||||
|
||||
"subgraph-created": {
|
||||
/** The subgraph that was created. */
|
||||
subgraph: Subgraph
|
||||
/** The raw data that was used to create the subgraph. */
|
||||
data: ExportedSubgraph
|
||||
}
|
||||
|
||||
/** Dispatched when a group of items are converted to a subgraph. */
|
||||
"convert-to-subgraph": {
|
||||
/** The type of subgraph to create. */
|
||||
subgraph: Subgraph
|
||||
/** The boundary around every item that was moved into the subgraph. */
|
||||
bounds: ReadOnlyRect
|
||||
/** The raw data that was used to create the subgraph. */
|
||||
exportedSubgraph: ExportedSubgraph
|
||||
/** The links that were used to create the subgraph. */
|
||||
boundaryLinks: LLink[]
|
||||
/** Links that go from outside the subgraph in, via an input on the subgraph node. */
|
||||
resolvedInputLinks: ResolvedConnection[]
|
||||
/** Links that go from inside the subgraph out, via an output on the subgraph node. */
|
||||
resolvedOutputLinks: ResolvedConnection[]
|
||||
/** The floating links that were used to create the subgraph. */
|
||||
boundaryFloatingLinks: LLink[]
|
||||
/** The internal links that were used to create the subgraph. */
|
||||
internalLinks: LLink[]
|
||||
}
|
||||
|
||||
"open-subgraph": {
|
||||
subgraph: Subgraph
|
||||
closingGraph: LGraph | Subgraph
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { FloatingRenderLink } from "@/canvas/FloatingRenderLink"
|
||||
import type { MovingInputLink } from "@/canvas/MovingInputLink"
|
||||
import type { MovingOutputLink } from "@/canvas/MovingOutputLink"
|
||||
import type { RenderLink } from "@/canvas/RenderLink"
|
||||
import type { ToInputFromIoNodeLink } from "@/canvas/ToInputFromIoNodeLink"
|
||||
import type { ToInputRenderLink } from "@/canvas/ToInputRenderLink"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { LLink } from "@/LLink"
|
||||
import type { Reroute } from "@/Reroute"
|
||||
import type { SubgraphInputNode } from "@/subgraph/SubgraphInputNode"
|
||||
import type { SubgraphOutputNode } from "@/subgraph/SubgraphOutputNode"
|
||||
import type { CanvasPointerEvent } from "@/types/events"
|
||||
import type { IWidget } from "@/types/widgets"
|
||||
|
||||
export interface LinkConnectorEventMap {
|
||||
"reset": boolean
|
||||
|
||||
"before-drop-links": {
|
||||
renderLinks: RenderLink[]
|
||||
event: CanvasPointerEvent
|
||||
}
|
||||
"after-drop-links": {
|
||||
renderLinks: RenderLink[]
|
||||
event: CanvasPointerEvent
|
||||
}
|
||||
|
||||
"before-move-input": MovingInputLink | FloatingRenderLink
|
||||
"before-move-output": MovingOutputLink | FloatingRenderLink
|
||||
|
||||
"input-moved": MovingInputLink | FloatingRenderLink | ToInputFromIoNodeLink
|
||||
"output-moved": MovingOutputLink | FloatingRenderLink
|
||||
|
||||
"link-created": LLink | null | undefined
|
||||
|
||||
"dropped-on-reroute": {
|
||||
reroute: Reroute
|
||||
event: CanvasPointerEvent
|
||||
}
|
||||
"dropped-on-node": {
|
||||
node: LGraphNode
|
||||
event: CanvasPointerEvent
|
||||
}
|
||||
"dropped-on-io-node": {
|
||||
node: SubgraphInputNode | SubgraphOutputNode
|
||||
event: CanvasPointerEvent
|
||||
}
|
||||
"dropped-on-canvas": CanvasPointerEvent
|
||||
|
||||
"dropped-on-widget": {
|
||||
link: ToInputRenderLink
|
||||
node: LGraphNode
|
||||
widget: IWidget
|
||||
}
|
||||
}
|
||||
6
src/lib/litegraph/src/infrastructure/NullGraphError.ts
Normal file
6
src/lib/litegraph/src/infrastructure/NullGraphError.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class NullGraphError extends Error {
|
||||
constructor(message: string = "Attempted to access LGraph reference that was null or undefined.", cause?: Error) {
|
||||
super(message, { cause })
|
||||
this.name = "NullGraphError"
|
||||
}
|
||||
}
|
||||
424
src/lib/litegraph/src/infrastructure/Rectangle.ts
Normal file
424
src/lib/litegraph/src/infrastructure/Rectangle.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import type { CompassCorners, Point, ReadOnlyPoint, ReadOnlyRect, ReadOnlySize, ReadOnlyTypedArray, Size } from "@/interfaces"
|
||||
|
||||
import { isInRectangle } from "@/measure"
|
||||
|
||||
/**
|
||||
* A rectangle, represented as a float64 array of 4 numbers: [x, y, width, height].
|
||||
*
|
||||
* This class is a subclass of Float64Array, and so has all the methods of that class. Notably,
|
||||
* {@link Rectangle.from} can be used to convert a {@link ReadOnlyRect}. Typing of this however,
|
||||
* is broken due to the base TS lib returning Float64Array rather than `this`.
|
||||
*
|
||||
* Sub-array properties ({@link Float64Array.subarray}):
|
||||
* - {@link pos}: The position of the top-left corner of the rectangle.
|
||||
* - {@link size}: The size of the rectangle.
|
||||
*/
|
||||
export class Rectangle extends Float64Array {
|
||||
#pos: Point | undefined
|
||||
#size: Size | undefined
|
||||
|
||||
constructor(x: number = 0, y: number = 0, width: number = 0, height: number = 0) {
|
||||
super(4)
|
||||
|
||||
this[0] = x
|
||||
this[1] = y
|
||||
this[2] = width
|
||||
this[3] = height
|
||||
}
|
||||
|
||||
static override from([x, y, width, height]: ReadOnlyRect): Rectangle {
|
||||
return new Rectangle(x, y, width, height)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new rectangle positioned at the given centre, with the given width/height.
|
||||
* @param centre The centre of the rectangle, as an `[x, y]` point
|
||||
* @param width The width of the rectangle
|
||||
* @param height The height of the rectangle. Default: {@link width}
|
||||
* @returns A new rectangle whose centre is at {@link x}
|
||||
*/
|
||||
static fromCentre([x, y]: ReadOnlyPoint, width: number, height = width): Rectangle {
|
||||
const left = x - width * 0.5
|
||||
const top = y - height * 0.5
|
||||
return new Rectangle(left, top, width, height)
|
||||
}
|
||||
|
||||
static ensureRect(rect: ReadOnlyRect): Rectangle {
|
||||
return rect instanceof Rectangle
|
||||
? rect
|
||||
: new Rectangle(rect[0], rect[1], rect[2], rect[3])
|
||||
}
|
||||
|
||||
override subarray(begin: number = 0, end?: number): Float64Array<ArrayBuffer> {
|
||||
const byteOffset = begin << 3
|
||||
const length = end === undefined ? end : end - begin
|
||||
return new Float64Array(this.buffer, byteOffset, length)
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to the position of the top-left corner of this rectangle.
|
||||
*
|
||||
* Updating the values of the returned object will update this rectangle.
|
||||
*/
|
||||
get pos(): Point {
|
||||
this.#pos ??= this.subarray(0, 2)
|
||||
return this.#pos
|
||||
}
|
||||
|
||||
set pos(value: ReadOnlyPoint) {
|
||||
this[0] = value[0]
|
||||
this[1] = value[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to the size of this rectangle.
|
||||
*
|
||||
* Updating the values of the returned object will update this rectangle.
|
||||
*/
|
||||
get size(): Size {
|
||||
this.#size ??= this.subarray(2, 4)
|
||||
return this.#size
|
||||
}
|
||||
|
||||
set size(value: ReadOnlySize) {
|
||||
this[2] = value[0]
|
||||
this[3] = value[1]
|
||||
}
|
||||
|
||||
// #region Property accessors
|
||||
/** The x co-ordinate of the top-left corner of this rectangle. */
|
||||
get x() {
|
||||
return this[0]
|
||||
}
|
||||
|
||||
set x(value: number) {
|
||||
this[0] = value
|
||||
}
|
||||
|
||||
/** The y co-ordinate of the top-left corner of this rectangle. */
|
||||
get y() {
|
||||
return this[1]
|
||||
}
|
||||
|
||||
set y(value: number) {
|
||||
this[1] = value
|
||||
}
|
||||
|
||||
/** The width of this rectangle. */
|
||||
get width() {
|
||||
return this[2]
|
||||
}
|
||||
|
||||
set width(value: number) {
|
||||
this[2] = value
|
||||
}
|
||||
|
||||
/** The height of this rectangle. */
|
||||
get height() {
|
||||
return this[3]
|
||||
}
|
||||
|
||||
set height(value: number) {
|
||||
this[3] = value
|
||||
}
|
||||
|
||||
/** The x co-ordinate of the left edge of this rectangle. */
|
||||
get left() {
|
||||
return this[0]
|
||||
}
|
||||
|
||||
set left(value: number) {
|
||||
this[0] = value
|
||||
}
|
||||
|
||||
/** The y co-ordinate of the top edge of this rectangle. */
|
||||
get top() {
|
||||
return this[1]
|
||||
}
|
||||
|
||||
set top(value: number) {
|
||||
this[1] = value
|
||||
}
|
||||
|
||||
/** The x co-ordinate of the right edge of this rectangle. */
|
||||
get right() {
|
||||
return this[0] + this[2]
|
||||
}
|
||||
|
||||
set right(value: number) {
|
||||
this[0] = value - this[2]
|
||||
}
|
||||
|
||||
/** The y co-ordinate of the bottom edge of this rectangle. */
|
||||
get bottom() {
|
||||
return this[1] + this[3]
|
||||
}
|
||||
|
||||
set bottom(value: number) {
|
||||
this[1] = value - this[3]
|
||||
}
|
||||
|
||||
/** The x co-ordinate of the centre of this rectangle. */
|
||||
get centreX() {
|
||||
return this[0] + (this[2] * 0.5)
|
||||
}
|
||||
|
||||
/** The y co-ordinate of the centre of this rectangle. */
|
||||
get centreY() {
|
||||
return this[1] + (this[3] * 0.5)
|
||||
}
|
||||
// #endregion Property accessors
|
||||
|
||||
/**
|
||||
* Updates the rectangle to the values of {@link rect}.
|
||||
* @param rect The rectangle to update to.
|
||||
*/
|
||||
updateTo(rect: ReadOnlyRect) {
|
||||
this[0] = rect[0]
|
||||
this[1] = rect[1]
|
||||
this[2] = rect[2]
|
||||
this[3] = rect[3]
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the point [{@link x}, {@link y}] is inside this rectangle.
|
||||
* @param x The x-coordinate to check
|
||||
* @param y The y-coordinate to check
|
||||
* @returns `true` if the point is inside this rectangle, otherwise `false`.
|
||||
*/
|
||||
containsXy(x: number, y: number): boolean {
|
||||
const [left, top, width, height] = this
|
||||
return x >= left &&
|
||||
x < left + width &&
|
||||
y >= top &&
|
||||
y < top + height
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if {@link point} is inside this rectangle.
|
||||
* @param point The point to check
|
||||
* @returns `true` if {@link point} is inside this rectangle, otherwise `false`.
|
||||
*/
|
||||
containsPoint([x, y]: ReadOnlyPoint): boolean {
|
||||
const [left, top, width, height] = this
|
||||
return x >= left &&
|
||||
x < left + width &&
|
||||
y >= top &&
|
||||
y < top + height
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if {@link other} is a smaller rectangle inside this rectangle.
|
||||
* One **must** be larger than the other; identical rectangles are not considered to contain each other.
|
||||
* @param other The rectangle to check
|
||||
* @returns `true` if {@link other} is inside this rectangle, otherwise `false`.
|
||||
*/
|
||||
containsRect(other: ReadOnlyRect): boolean {
|
||||
const { right, bottom } = this
|
||||
const otherRight = other[0] + other[2]
|
||||
const otherBottom = other[1] + other[3]
|
||||
|
||||
const identical = this.x === other[0] &&
|
||||
this.y === other[1] &&
|
||||
right === otherRight &&
|
||||
bottom === otherBottom
|
||||
|
||||
return !identical &&
|
||||
this.x <= other[0] &&
|
||||
this.y <= other[1] &&
|
||||
right >= otherRight &&
|
||||
bottom >= otherBottom
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if {@link rect} overlaps with this rectangle.
|
||||
* @param rect The rectangle to check
|
||||
* @returns `true` if {@link rect} overlaps with this rectangle, otherwise `false`.
|
||||
*/
|
||||
overlaps(rect: ReadOnlyRect): boolean {
|
||||
return this.x < rect[0] + rect[2] &&
|
||||
this.y < rect[1] + rect[3] &&
|
||||
this.x + this.width > rect[0] &&
|
||||
this.y + this.height > rect[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the corner (if any) of this rectangle that contains the point [{@link x}, {@link y}].
|
||||
* @param x The x-coordinate to check
|
||||
* @param y The y-coordinate to check
|
||||
* @param cornerSize Each corner is treated as an inset square with this width and height.
|
||||
* @returns The compass direction of the corner that contains the point, or `undefined` if the point is not in any corner.
|
||||
*/
|
||||
findContainingCorner(x: number, y: number, cornerSize: number): CompassCorners | undefined {
|
||||
if (this.isInTopLeftCorner(x, y, cornerSize)) return "NW"
|
||||
if (this.isInTopRightCorner(x, y, cornerSize)) return "NE"
|
||||
if (this.isInBottomLeftCorner(x, y, cornerSize)) return "SW"
|
||||
if (this.isInBottomRightCorner(x, y, cornerSize)) return "SE"
|
||||
}
|
||||
|
||||
/** @returns `true` if the point [{@link x}, {@link y}] is in the top-left corner of this rectangle, otherwise `false`. */
|
||||
isInTopLeftCorner(x: number, y: number, cornerSize: number): boolean {
|
||||
return isInRectangle(x, y, this.x, this.y, cornerSize, cornerSize)
|
||||
}
|
||||
|
||||
/** @returns `true` if the point [{@link x}, {@link y}] is in the top-right corner of this rectangle, otherwise `false`. */
|
||||
isInTopRightCorner(x: number, y: number, cornerSize: number): boolean {
|
||||
return isInRectangle(x, y, this.right - cornerSize, this.y, cornerSize, cornerSize)
|
||||
}
|
||||
|
||||
/** @returns `true` if the point [{@link x}, {@link y}] is in the bottom-left corner of this rectangle, otherwise `false`. */
|
||||
isInBottomLeftCorner(x: number, y: number, cornerSize: number): boolean {
|
||||
return isInRectangle(x, y, this.x, this.bottom - cornerSize, cornerSize, cornerSize)
|
||||
}
|
||||
|
||||
/** @returns `true` if the point [{@link x}, {@link y}] is in the bottom-right corner of this rectangle, otherwise `false`. */
|
||||
isInBottomRightCorner(x: number, y: number, cornerSize: number): boolean {
|
||||
return isInRectangle(x, y, this.right - cornerSize, this.bottom - cornerSize, cornerSize, cornerSize)
|
||||
}
|
||||
|
||||
/** @returns `true` if the point [{@link x}, {@link y}] is in the top edge of this rectangle, otherwise `false`. */
|
||||
isInTopEdge(x: number, y: number, edgeSize: number): boolean {
|
||||
return isInRectangle(x, y, this.x, this.y, this.width, edgeSize)
|
||||
}
|
||||
|
||||
/** @returns `true` if the point [{@link x}, {@link y}] is in the bottom edge of this rectangle, otherwise `false`. */
|
||||
isInBottomEdge(x: number, y: number, edgeSize: number): boolean {
|
||||
return isInRectangle(x, y, this.x, this.bottom - edgeSize, this.width, edgeSize)
|
||||
}
|
||||
|
||||
/** @returns `true` if the point [{@link x}, {@link y}] is in the left edge of this rectangle, otherwise `false`. */
|
||||
isInLeftEdge(x: number, y: number, edgeSize: number): boolean {
|
||||
return isInRectangle(x, y, this.x, this.y, edgeSize, this.height)
|
||||
}
|
||||
|
||||
/** @returns `true` if the point [{@link x}, {@link y}] is in the right edge of this rectangle, otherwise `false`. */
|
||||
isInRightEdge(x: number, y: number, edgeSize: number): boolean {
|
||||
return isInRectangle(x, y, this.right - edgeSize, this.y, edgeSize, this.height)
|
||||
}
|
||||
|
||||
/** @returns The centre point of this rectangle, as a new {@link Point}. */
|
||||
getCentre(): Point {
|
||||
return [this.centreX, this.centreY]
|
||||
}
|
||||
|
||||
/** @returns The area of this rectangle. */
|
||||
getArea(): number {
|
||||
return this.width * this.height
|
||||
}
|
||||
|
||||
/** @returns The perimeter of this rectangle. */
|
||||
getPerimeter(): number {
|
||||
return 2 * (this.width + this.height)
|
||||
}
|
||||
|
||||
/** @returns The top-left corner of this rectangle, as a new {@link Point}. */
|
||||
getTopLeft(): Point {
|
||||
return [this[0], this[1]]
|
||||
}
|
||||
|
||||
/** @returns The bottom-right corner of this rectangle, as a new {@link Point}. */
|
||||
getBottomRight(): Point {
|
||||
return [this.right, this.bottom]
|
||||
}
|
||||
|
||||
/** @returns The width and height of this rectangle, as a new {@link Size}. */
|
||||
getSize(): Size {
|
||||
return [this[2], this[3]]
|
||||
}
|
||||
|
||||
/** @returns The offset from the top-left of this rectangle to the point [{@link x}, {@link y}], as a new {@link Point}. */
|
||||
getOffsetTo([x, y]: ReadOnlyPoint): Point {
|
||||
return [x - this[0], y - this[1]]
|
||||
}
|
||||
|
||||
/** @returns The offset from the point [{@link x}, {@link y}] to the top-left of this rectangle, as a new {@link Point}. */
|
||||
getOffsetFrom([x, y]: ReadOnlyPoint): Point {
|
||||
return [this[0] - x, this[1] - y]
|
||||
}
|
||||
|
||||
/** Resizes the rectangle without moving it, setting its top-left corner to [{@link x}, {@link y}]. */
|
||||
resizeTopLeft(x1: number, y1: number) {
|
||||
this[2] += this[0] - x1
|
||||
this[3] += this[1] - y1
|
||||
|
||||
this[0] = x1
|
||||
this[1] = y1
|
||||
}
|
||||
|
||||
/** Resizes the rectangle without moving it, setting its bottom-left corner to [{@link x}, {@link y}]. */
|
||||
resizeBottomLeft(x1: number, y2: number) {
|
||||
this[2] += this[0] - x1
|
||||
this[3] = y2 - this[1]
|
||||
|
||||
this[0] = x1
|
||||
}
|
||||
|
||||
/** Resizes the rectangle without moving it, setting its top-right corner to [{@link x}, {@link y}]. */
|
||||
resizeTopRight(x2: number, y1: number) {
|
||||
this[2] = x2 - this[0]
|
||||
this[3] += this[1] - y1
|
||||
|
||||
this[1] = y1
|
||||
}
|
||||
|
||||
/** Resizes the rectangle without moving it, setting its bottom-right corner to [{@link x}, {@link y}]. */
|
||||
resizeBottomRight(x2: number, y2: number) {
|
||||
this[2] = x2 - this[0]
|
||||
this[3] = y2 - this[1]
|
||||
}
|
||||
|
||||
/** Sets the width without moving the right edge (changes position) */
|
||||
setWidthRightAnchored(width: number) {
|
||||
const currentWidth = this[2]
|
||||
this[2] = width
|
||||
this[0] += currentWidth - width
|
||||
}
|
||||
|
||||
/** Sets the height without moving the bottom edge (changes position) */
|
||||
setHeightBottomAnchored(height: number) {
|
||||
const currentHeight = this[3]
|
||||
this[3] = height
|
||||
this[1] += currentHeight - height
|
||||
}
|
||||
|
||||
clone(): Rectangle {
|
||||
return new Rectangle(this[0], this[1], this[2], this[3])
|
||||
}
|
||||
|
||||
/** Alias of {@link export}. */
|
||||
toArray() { return this.export() }
|
||||
|
||||
/** @returns A new, untyped array (serializable) containing the values of this rectangle. */
|
||||
export(): [number, number, number, number] {
|
||||
return [this[0], this[1], this[2], this[3]]
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a debug outline of this rectangle.
|
||||
* @internal Convenience debug/development interface; not for production use.
|
||||
*/
|
||||
_drawDebug(ctx: CanvasRenderingContext2D, colour = "red") {
|
||||
const { strokeStyle, lineWidth } = ctx
|
||||
try {
|
||||
ctx.strokeStyle = colour
|
||||
ctx.lineWidth = 0.5
|
||||
ctx.beginPath()
|
||||
ctx.strokeRect(this[0], this[1], this[2], this[3])
|
||||
} finally {
|
||||
ctx.strokeStyle = strokeStyle
|
||||
ctx.lineWidth = lineWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ReadOnlyRectangle = Omit<
|
||||
ReadOnlyTypedArray<Rectangle>,
|
||||
| "setHeightBottomAnchored"
|
||||
| "setWidthRightAnchored"
|
||||
| "resizeTopLeft"
|
||||
| "resizeBottomLeft"
|
||||
| "resizeTopRight"
|
||||
| "resizeBottomRight"
|
||||
| "resizeBottomRight"
|
||||
| "updateTo"
|
||||
>
|
||||
9
src/lib/litegraph/src/infrastructure/RecursionError.ts
Normal file
9
src/lib/litegraph/src/infrastructure/RecursionError.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Error thrown when infinite recursion is detected.
|
||||
*/
|
||||
export class RecursionError extends Error {
|
||||
constructor(subject: string) {
|
||||
super(subject)
|
||||
this.name = "RecursionError"
|
||||
}
|
||||
}
|
||||
6
src/lib/litegraph/src/infrastructure/SlotIndexError.ts
Normal file
6
src/lib/litegraph/src/infrastructure/SlotIndexError.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class SlotIndexError extends Error {
|
||||
constructor(message: string = "Attempted to access a slot that was out of bounds.", cause?: Error) {
|
||||
super(message, { cause })
|
||||
this.name = "SlotIndexError"
|
||||
}
|
||||
}
|
||||
54
src/lib/litegraph/src/infrastructure/SubgraphEventMap.ts
Normal file
54
src/lib/litegraph/src/infrastructure/SubgraphEventMap.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { LGraphEventMap } from "./LGraphEventMap"
|
||||
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
|
||||
import type { SubgraphNode } from "@/subgraph/SubgraphNode"
|
||||
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
|
||||
import type { IBaseWidget } from "@/types/widgets"
|
||||
|
||||
export interface SubgraphEventMap extends LGraphEventMap {
|
||||
"adding-input": {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
"adding-output": {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
"input-added": {
|
||||
input: SubgraphInput
|
||||
}
|
||||
"output-added": {
|
||||
output: SubgraphOutput
|
||||
}
|
||||
|
||||
"removing-input": {
|
||||
input: SubgraphInput
|
||||
index: number
|
||||
}
|
||||
"removing-output": {
|
||||
output: SubgraphOutput
|
||||
index: number
|
||||
}
|
||||
|
||||
"renaming-input": {
|
||||
input: SubgraphInput
|
||||
index: number
|
||||
oldName: string
|
||||
newName: string
|
||||
}
|
||||
"renaming-output": {
|
||||
output: SubgraphOutput
|
||||
index: number
|
||||
oldName: string
|
||||
newName: string
|
||||
}
|
||||
|
||||
"widget-promoted": {
|
||||
widget: IBaseWidget
|
||||
subgraphNode: SubgraphNode
|
||||
}
|
||||
"widget-demoted": {
|
||||
widget: IBaseWidget
|
||||
subgraphNode: SubgraphNode
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { LGraphEventMap } from "./LGraphEventMap"
|
||||
import type { INodeInputSlot } from "@/litegraph"
|
||||
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
|
||||
import type { IBaseWidget } from "@/types/widgets"
|
||||
|
||||
export interface SubgraphInputEventMap extends LGraphEventMap {
|
||||
"input-connected": {
|
||||
input: INodeInputSlot
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
"input-disconnected": {
|
||||
input: SubgraphInput
|
||||
}
|
||||
}
|
||||
480
src/lib/litegraph/src/interfaces.ts
Normal file
480
src/lib/litegraph/src/interfaces.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
import type { ContextMenu } from "./ContextMenu"
|
||||
import type { LGraphNode, NodeId } from "./LGraphNode"
|
||||
import type { LinkId, LLink } from "./LLink"
|
||||
import type { Reroute, RerouteId } from "./Reroute"
|
||||
import type { SubgraphInputNode } from "./subgraph/SubgraphInputNode"
|
||||
import type { SubgraphOutputNode } from "./subgraph/SubgraphOutputNode"
|
||||
import type { LinkDirection, RenderShape } from "./types/globalEnums"
|
||||
import type { IBaseWidget } from "./types/widgets"
|
||||
import type { Rectangle } from "@/infrastructure/Rectangle"
|
||||
import type { CanvasPointerEvent } from "@/types/events"
|
||||
|
||||
export type Dictionary<T> = { [key: string]: T }
|
||||
|
||||
/** Allows all properties to be null. The same as `Partial<T>`, but adds null instead of undefined. */
|
||||
export type NullableProperties<T> = {
|
||||
[P in keyof T]: T[P] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* If {@link T} is `null` or `undefined`, evaluates to {@link Result}. Otherwise, evaluates to {@link T}.
|
||||
* Useful for functions that return e.g. `undefined` when a param is nullish.
|
||||
*/
|
||||
export type WhenNullish<T, Result> = T & {} | (T extends null ? Result : T extends undefined ? Result : T & {})
|
||||
|
||||
/** A type with each of the {@link Properties} made optional. */
|
||||
export type OptionalProps<T, Properties extends keyof T> = Omit<T, Properties> & { [K in Properties]?: T[K] }
|
||||
|
||||
/** A type with each of the {@link Properties} marked as required. */
|
||||
export type RequiredProps<T, Properties extends keyof T> = Omit<T, Properties> & { [K in Properties]-?: T[K] }
|
||||
|
||||
/** Bitwise AND intersection of two types; returns a new, non-union type that includes only properties that exist on both types. */
|
||||
export type SharedIntersection<T1, T2> = {
|
||||
[P in keyof T1 as P extends keyof T2 ? P : never]: T1[P]
|
||||
} & {
|
||||
[P in keyof T2 as P extends keyof T1 ? P : never]: T2[P]
|
||||
}
|
||||
|
||||
export type CanvasColour = string | CanvasGradient | CanvasPattern
|
||||
|
||||
/**
|
||||
* Any object that has a {@link boundingRect}.
|
||||
*/
|
||||
export interface HasBoundingRect {
|
||||
/**
|
||||
* A rectangle that represents the outer edges of the item.
|
||||
*
|
||||
* Used for various calculations, such as overlap, selective rendering, and click checks.
|
||||
* For most items, this is cached position & size as `x, y, width, height`.
|
||||
* Some items (such as nodes and slots) may extend above and/or to the left of their {@link pos}.
|
||||
* @readonly
|
||||
* @see {@link move}
|
||||
*/
|
||||
readonly boundingRect: ReadOnlyRect
|
||||
}
|
||||
|
||||
/** An object containing a set of child objects */
|
||||
export interface Parent<TChild> {
|
||||
/** All objects owned by the parent object. */
|
||||
readonly children?: ReadonlySet<TChild>
|
||||
}
|
||||
|
||||
/**
|
||||
* An object that can be positioned, selected, and moved.
|
||||
*
|
||||
* May contain other {@link Positionable} objects.
|
||||
*/
|
||||
export interface Positionable extends Parent<Positionable>, HasBoundingRect {
|
||||
readonly id: NodeId | RerouteId | number
|
||||
/**
|
||||
* Position in graph coordinates. This may be the top-left corner,
|
||||
* the centre, or another point depending on concrete type.
|
||||
* @default 0,0
|
||||
*/
|
||||
readonly pos: Point
|
||||
/** true if this object is part of the selection, otherwise false. */
|
||||
selected?: boolean
|
||||
|
||||
/** See {@link IPinnable.pinned} */
|
||||
readonly pinned?: boolean
|
||||
|
||||
/**
|
||||
* When explicitly set to `false`, no options to delete this item will be provided.
|
||||
* @default undefined (true)
|
||||
*/
|
||||
readonly removable?: boolean
|
||||
|
||||
/**
|
||||
* Adds a delta to the current position.
|
||||
* @param deltaX X 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
|
||||
*/
|
||||
move(deltaX: number, deltaY: number, skipChildren?: boolean): void
|
||||
|
||||
/**
|
||||
* Snaps this item to a grid.
|
||||
*
|
||||
* Position values are rounded to the nearest multiple of {@link snapTo}.
|
||||
* @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`)
|
||||
*/
|
||||
snapToGrid(snapTo: number): boolean
|
||||
|
||||
/** Called whenever the item is selected */
|
||||
onSelected?(): void
|
||||
/** Called whenever the item is deselected */
|
||||
onDeselected?(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* A color option to customize the color of {@link LGraphNode} or {@link LGraphGroup}.
|
||||
* @see {@link LGraphCanvas.node_colors}
|
||||
*/
|
||||
export interface ColorOption {
|
||||
color: string
|
||||
bgcolor: string
|
||||
groupcolor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* An object that can be colored with a {@link ColorOption}.
|
||||
*/
|
||||
export interface IColorable {
|
||||
setColorOption(colorOption: ColorOption | null): void
|
||||
getColorOption(): ColorOption | null
|
||||
}
|
||||
|
||||
/**
|
||||
* An object that can be pinned.
|
||||
*
|
||||
* Prevents the object being accidentally moved or resized by mouse interaction.
|
||||
*/
|
||||
export interface IPinnable {
|
||||
readonly pinned: boolean
|
||||
pin(value?: boolean): void
|
||||
unpin(): void
|
||||
}
|
||||
|
||||
export interface ReadonlyLinkNetwork {
|
||||
readonly links: ReadonlyMap<LinkId, LLink>
|
||||
readonly reroutes: ReadonlyMap<RerouteId, Reroute>
|
||||
readonly floatingLinks: ReadonlyMap<LinkId, LLink>
|
||||
getNodeById(id: NodeId | null | undefined): LGraphNode | null
|
||||
getLink(id: null | undefined): undefined
|
||||
getLink(id: LinkId | null | undefined): LLink | undefined
|
||||
getReroute(parentId: null | undefined): undefined
|
||||
getReroute(parentId: RerouteId | null | undefined): Reroute | undefined
|
||||
|
||||
readonly inputNode?: SubgraphInputNode
|
||||
readonly outputNode?: SubgraphOutputNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains a list of links, reroutes, and nodes.
|
||||
*/
|
||||
export interface LinkNetwork extends ReadonlyLinkNetwork {
|
||||
readonly links: Map<LinkId, LLink>
|
||||
readonly reroutes: Map<RerouteId, Reroute>
|
||||
addFloatingLink(link: LLink): LLink
|
||||
removeReroute(id: number): unknown
|
||||
removeFloatingLink(link: LLink): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates graph items.
|
||||
*/
|
||||
export interface ItemLocator {
|
||||
getNodeOnPos(x: number, y: number, nodeList?: LGraphNode[]): LGraphNode | null
|
||||
getRerouteOnPos(x: number, y: number): Reroute | undefined
|
||||
getIoNodeOnPos?(x: number, y: number): SubgraphInputNode | SubgraphOutputNode | undefined
|
||||
}
|
||||
|
||||
/** Contains a cached 2D canvas path and a centre point, with an optional forward angle. */
|
||||
export interface LinkSegment {
|
||||
/** Link / reroute ID */
|
||||
readonly id: LinkId | RerouteId
|
||||
/** The {@link id} of the reroute that this segment starts from (output side), otherwise `undefined`. */
|
||||
readonly parentId?: RerouteId
|
||||
|
||||
/** The last canvas 2D path that was used to render this segment */
|
||||
path?: Path2D
|
||||
/** Centre point of the {@link path}. Calculated during render only - can be inaccurate */
|
||||
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.
|
||||
*/
|
||||
_centreAngle?: number
|
||||
|
||||
/** Whether the link is currently being moved. @internal */
|
||||
_dragging?: boolean
|
||||
|
||||
/** Output node ID */
|
||||
readonly origin_id: NodeId | undefined
|
||||
/** Output slot index */
|
||||
readonly origin_slot: number | undefined
|
||||
}
|
||||
|
||||
export interface IInputOrOutput {
|
||||
// If an input, this will be defined
|
||||
input?: INodeInputSlot | null
|
||||
// If an output, this will be defined
|
||||
output?: INodeOutputSlot | null
|
||||
}
|
||||
|
||||
export interface IFoundSlot extends IInputOrOutput {
|
||||
// Slot index
|
||||
slot: number
|
||||
// Centre point of the rendered slot connection
|
||||
link_pos: Point
|
||||
}
|
||||
|
||||
/** A point represented as `[x, y]` co-ordinates */
|
||||
export type Point = [x: number, y: number] | Float32Array | Float64Array
|
||||
|
||||
/** A size represented as `[width, height]` */
|
||||
export type Size = [width: number, height: number] | Float32Array | Float64Array
|
||||
|
||||
/** A very firm array */
|
||||
type ArRect = [x: number, y: number, width: number, height: number]
|
||||
|
||||
/** A rectangle starting at top-left coordinates `[x, y, width, height]` */
|
||||
export type Rect = ArRect | Float32Array | Float64Array
|
||||
|
||||
/** 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>
|
||||
|
||||
/** A size represented as `[width, height]` that will not be modified */
|
||||
export type ReadOnlySize =
|
||||
| readonly [width: number, height: number]
|
||||
| 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 TypedBigIntArrays = BigInt64Array | BigUint64Array
|
||||
export type ReadOnlyTypedArray<T extends TypedArrays | TypedBigIntArrays> =
|
||||
Omit<Readonly<T>, "fill" | "copyWithin" | "reverse" | "set" | "sort" | "subarray">
|
||||
|
||||
/** Union of property names that are of type Match */
|
||||
export type KeysOfType<T, Match> = Exclude<{ [P in keyof T]: T[P] extends Match ? P : never }[keyof T], undefined>
|
||||
|
||||
/** A new type that contains only the properties of T that are of type Match */
|
||||
export type PickByType<T, Match> = { [P in keyof T]: Extract<T[P], Match> }
|
||||
|
||||
/** The names of all (optional) methods and functions in T */
|
||||
export type MethodNames<T> = KeysOfType<T, ((...args: any) => any) | undefined>
|
||||
|
||||
export interface IBoundaryNodes {
|
||||
top: LGraphNode
|
||||
right: LGraphNode
|
||||
bottom: LGraphNode
|
||||
left: LGraphNode
|
||||
}
|
||||
|
||||
export type Direction = "top" | "bottom" | "left" | "right"
|
||||
|
||||
/** Resize handle positions (compass points) */
|
||||
export type CompassCorners = "NE" | "SE" | "SW" | "NW"
|
||||
|
||||
/**
|
||||
* A string that represents a specific data / slot type, e.g. `STRING`.
|
||||
*
|
||||
* Can be comma-delimited to specify multiple allowed types, e.g. `STRING,INT`.
|
||||
*/
|
||||
export type ISlotType = number | string
|
||||
|
||||
export interface INodeSlot extends HasBoundingRect {
|
||||
/**
|
||||
* The name of the slot in English.
|
||||
* Will be included in the serialized data.
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* The localized name of the slot to display in the UI.
|
||||
* Takes higher priority than {@link name} if set.
|
||||
* Will be included in the serialized data.
|
||||
*/
|
||||
localized_name?: string
|
||||
/**
|
||||
* The name of the slot to display in the UI, modified by the user.
|
||||
* Takes higher priority than {@link display_name} if set.
|
||||
* Will be included in the serialized data.
|
||||
*/
|
||||
label?: string
|
||||
|
||||
type: ISlotType
|
||||
dir?: LinkDirection
|
||||
removable?: boolean
|
||||
shape?: RenderShape
|
||||
color_off?: CanvasColour
|
||||
color_on?: CanvasColour
|
||||
locked?: boolean
|
||||
nameLocked?: boolean
|
||||
pos?: Point
|
||||
/** @remarks Automatically calculated; not included in serialisation. */
|
||||
boundingRect: Rect
|
||||
/**
|
||||
* A list of floating link IDs that are connected to this slot.
|
||||
* This is calculated at runtime; it is **not** serialized.
|
||||
*/
|
||||
_floatingLinks?: Set<LLink>
|
||||
/**
|
||||
* Whether the slot has errors. It is **not** serialized.
|
||||
*/
|
||||
hasErrors?: boolean
|
||||
}
|
||||
|
||||
export interface INodeFlags {
|
||||
skip_repeated_outputs?: boolean
|
||||
allow_interaction?: boolean
|
||||
pinned?: boolean
|
||||
collapsed?: boolean
|
||||
/** Configuration setting for {@link LGraphNode.connectInputToOutput} */
|
||||
keepAllLinksOnBypass?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A widget that is linked to a slot.
|
||||
*
|
||||
* This is set by the ComfyUI_frontend logic. See
|
||||
* https://github.com/Comfy-Org/ComfyUI_frontend/blob/b80e0e1a3c74040f328c4e344326c969c97f67e0/src/extensions/core/widgetInputs.ts#L659
|
||||
*/
|
||||
export interface IWidgetLocator {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface INodeInputSlot extends INodeSlot {
|
||||
link: LinkId | null
|
||||
widget?: IWidgetLocator
|
||||
|
||||
/**
|
||||
* Internal use only; API is not finalised and may change at any time.
|
||||
*/
|
||||
_widget?: IBaseWidget
|
||||
}
|
||||
|
||||
export interface IWidgetInputSlot extends INodeInputSlot {
|
||||
widget: IWidgetLocator
|
||||
}
|
||||
|
||||
export interface INodeOutputSlot extends INodeSlot {
|
||||
links: LinkId[] | null
|
||||
_data?: unknown
|
||||
slot_index?: number
|
||||
}
|
||||
|
||||
/** Links */
|
||||
export interface ConnectingLink extends IInputOrOutput {
|
||||
node: LGraphNode
|
||||
slot: number
|
||||
pos: Point
|
||||
direction?: LinkDirection
|
||||
afterRerouteId?: RerouteId
|
||||
/** The first reroute on a chain */
|
||||
firstRerouteId?: RerouteId
|
||||
/** The link being moved, or `undefined` if creating a new link. */
|
||||
link?: LLink
|
||||
}
|
||||
|
||||
interface IContextMenuBase {
|
||||
title?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** ContextMenu */
|
||||
export interface IContextMenuOptions<TValue = unknown, TExtra = unknown> extends IContextMenuBase {
|
||||
ignore_item_callbacks?: boolean
|
||||
parentMenu?: ContextMenu<TValue>
|
||||
event?: MouseEvent
|
||||
extra?: TExtra
|
||||
/** @deprecated Context menu scrolling is now controlled by the browser */
|
||||
scroll_speed?: number
|
||||
left?: number
|
||||
top?: number
|
||||
/** @deprecated Context menus no longer scale using transform */
|
||||
scale?: number
|
||||
node?: LGraphNode
|
||||
autoopen?: boolean
|
||||
callback?(
|
||||
value?: string | IContextMenuValue<TValue>,
|
||||
options?: unknown,
|
||||
event?: MouseEvent,
|
||||
previous_menu?: ContextMenu<TValue>,
|
||||
extra?: unknown,
|
||||
): void | boolean
|
||||
}
|
||||
|
||||
export interface IContextMenuValue<TValue = unknown, TExtra = unknown, TCallbackValue = unknown> extends IContextMenuBase {
|
||||
value?: TValue
|
||||
content: string | undefined
|
||||
has_submenu?: boolean
|
||||
disabled?: boolean
|
||||
submenu?: IContextMenuSubmenu<TValue>
|
||||
property?: string
|
||||
type?: string
|
||||
slot?: IFoundSlot
|
||||
callback?(
|
||||
this: ContextMenuDivElement<TValue>,
|
||||
value?: TCallbackValue,
|
||||
options?: unknown,
|
||||
event?: MouseEvent,
|
||||
previous_menu?: ContextMenu<TValue>,
|
||||
extra?: TExtra,
|
||||
): void | boolean
|
||||
}
|
||||
|
||||
export interface IContextMenuSubmenu<TValue = unknown> extends IContextMenuOptions<TValue> {
|
||||
options: ConstructorParameters<typeof ContextMenu<TValue>>[0]
|
||||
}
|
||||
|
||||
export interface ContextMenuDivElement<TValue = unknown> extends HTMLDivElement {
|
||||
value?: string | IContextMenuValue<TValue>
|
||||
onclick_callback?: never
|
||||
}
|
||||
|
||||
export type INodeSlotContextItem = [string, ISlotType, Partial<INodeInputSlot & INodeOutputSlot>]
|
||||
|
||||
export interface DefaultConnectionColors {
|
||||
getConnectedColor(type: ISlotType): CanvasColour
|
||||
getDisconnectedColor(type: ISlotType): CanvasColour
|
||||
}
|
||||
|
||||
export interface ISubgraphInput extends INodeInputSlot {
|
||||
_listenerController?: AbortController
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for {@link Parameters} of optional callbacks.
|
||||
* @example
|
||||
* ```ts
|
||||
* const { onClick } = CustomClass.prototype
|
||||
* CustomClass.prototype.onClick = function (...args: CallbackParams<typeof onClick>) {
|
||||
* const r = onClick?.apply(this, args)
|
||||
* // ...
|
||||
* return r
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type CallbackParams<T extends ((...args: any) => any) | undefined> =
|
||||
Parameters<Exclude<T, undefined>>
|
||||
|
||||
/**
|
||||
* Shorthand for {@link ReturnType} of optional callbacks.
|
||||
* @see {@link CallbackParams}
|
||||
*/
|
||||
export type CallbackReturn<T extends ((...args: any) => any) | undefined> = ReturnType<Exclude<T, undefined>>
|
||||
|
||||
/**
|
||||
* An object that can be hovered over.
|
||||
*/
|
||||
export interface Hoverable extends HasBoundingRect {
|
||||
readonly boundingRect: Rectangle
|
||||
isPointerOver: boolean
|
||||
|
||||
containsPoint(point: Point): boolean
|
||||
|
||||
onPointerMove(e: CanvasPointerEvent): void
|
||||
onPointerEnter?(e?: CanvasPointerEvent): void
|
||||
onPointerLeave?(e?: CanvasPointerEvent): void
|
||||
}
|
||||
172
src/lib/litegraph/src/litegraph.ts
Normal file
172
src/lib/litegraph/src/litegraph.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { ContextMenu } from "./ContextMenu"
|
||||
import type { ConnectingLink, Point } from "./interfaces"
|
||||
import type {
|
||||
IContextMenuOptions,
|
||||
INodeSlot,
|
||||
Size,
|
||||
} from "./interfaces"
|
||||
import type { LGraphNode } from "./LGraphNode"
|
||||
import type { CanvasEventDetail } from "./types/events"
|
||||
import type { RenderShape, TitleMode } from "./types/globalEnums"
|
||||
|
||||
// Must remain above LiteGraphGlobal (circular dependency due to abstract factory behaviour in `configure`)
|
||||
export { Subgraph } from "./subgraph/Subgraph"
|
||||
|
||||
import { LiteGraphGlobal } from "./LiteGraphGlobal"
|
||||
import { loadPolyfills } from "./polyfills"
|
||||
|
||||
export const LiteGraph = new LiteGraphGlobal()
|
||||
|
||||
// Load legacy polyfills
|
||||
loadPolyfills()
|
||||
|
||||
// Backwards compat
|
||||
|
||||
// Type definitions for litegraph.js 0.7.0
|
||||
// Project: litegraph.js
|
||||
// Definitions by: NateScarlet <https://github.com/NateScarlet>
|
||||
/** @deprecated Use {@link Point} instead. */
|
||||
export type Vector2 = Point
|
||||
/** @deprecated Use {@link Rect} instead. */
|
||||
export type Vector4 = [number, number, number, number]
|
||||
|
||||
export interface IContextMenuItem {
|
||||
content: string
|
||||
callback?: ContextMenuEventListener
|
||||
/** Used as innerHTML for extra child element */
|
||||
title?: string
|
||||
disabled?: boolean
|
||||
has_submenu?: boolean
|
||||
submenu?: {
|
||||
options: IContextMenuItem[]
|
||||
} & IContextMenuOptions
|
||||
className?: string
|
||||
}
|
||||
|
||||
export type ContextMenuEventListener = (
|
||||
value: IContextMenuItem,
|
||||
options: IContextMenuOptions,
|
||||
event: MouseEvent,
|
||||
parentMenu: ContextMenu<unknown> | undefined,
|
||||
node: LGraphNode,
|
||||
) => boolean | void
|
||||
|
||||
export interface LinkReleaseContext {
|
||||
node_to?: LGraphNode
|
||||
node_from?: LGraphNode
|
||||
slot_from: INodeSlot
|
||||
type_filter_in?: string
|
||||
type_filter_out?: string
|
||||
}
|
||||
|
||||
export interface LinkReleaseContextExtended {
|
||||
links: ConnectingLink[]
|
||||
}
|
||||
|
||||
export interface LiteGraphCanvasEvent extends CustomEvent<CanvasEventDetail> {}
|
||||
|
||||
export interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {
|
||||
new (title: string, type?: string): T
|
||||
|
||||
title: string
|
||||
type: string
|
||||
size?: Size
|
||||
min_height?: number
|
||||
slot_start_y?: number
|
||||
widgets_info?: any
|
||||
collapsable?: boolean
|
||||
color?: string
|
||||
bgcolor?: string
|
||||
shape?: RenderShape
|
||||
title_mode?: TitleMode
|
||||
title_color?: string
|
||||
title_text_color?: string
|
||||
keepAllLinksOnBypass: boolean
|
||||
resizeHandleSize?: number
|
||||
resizeEdgeSize?: number
|
||||
}
|
||||
|
||||
// End backwards compat
|
||||
|
||||
export { InputIndicators } from "./canvas/InputIndicators"
|
||||
export { LinkConnector } from "./canvas/LinkConnector"
|
||||
export { isOverNodeInput, isOverNodeOutput } from "./canvas/measureSlots"
|
||||
export { CanvasPointer } from "./CanvasPointer"
|
||||
export * as Constants from "./constants"
|
||||
export { ContextMenu } from "./ContextMenu"
|
||||
export { CurveEditor } from "./CurveEditor"
|
||||
export { DragAndScale } from "./DragAndScale"
|
||||
export { LabelPosition, SlotDirection, SlotShape, SlotType } from "./draw"
|
||||
export { strokeShape } from "./draw"
|
||||
export { Rectangle } from "./infrastructure/Rectangle"
|
||||
export type {
|
||||
CanvasColour,
|
||||
ColorOption,
|
||||
ConnectingLink,
|
||||
Direction,
|
||||
IBoundaryNodes,
|
||||
IColorable,
|
||||
IContextMenuOptions,
|
||||
IContextMenuValue,
|
||||
IFoundSlot,
|
||||
IInputOrOutput,
|
||||
INodeFlags,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
INodeSlot,
|
||||
ISlotType,
|
||||
KeysOfType,
|
||||
LinkNetwork,
|
||||
LinkSegment,
|
||||
MethodNames,
|
||||
PickByType,
|
||||
Point,
|
||||
Positionable,
|
||||
ReadonlyLinkNetwork,
|
||||
ReadOnlyPoint,
|
||||
ReadOnlyRect,
|
||||
Rect,
|
||||
Size,
|
||||
} from "./interfaces"
|
||||
export { LGraph } from "./LGraph"
|
||||
export { BadgePosition, LGraphBadge, type LGraphBadgeOptions } from "./LGraphBadge"
|
||||
export { LGraphCanvas, type LGraphCanvasState } from "./LGraphCanvas"
|
||||
export { LGraphGroup } from "./LGraphGroup"
|
||||
export { LGraphNode, type NodeId } from "./LGraphNode"
|
||||
export { type LinkId, LLink } from "./LLink"
|
||||
export { clamp, createBounds } from "./measure"
|
||||
export { Reroute, type RerouteId } from "./Reroute"
|
||||
export { type ExecutableLGraphNode, ExecutableNodeDTO, type ExecutionId } from "./subgraph/ExecutableNodeDTO"
|
||||
export { SubgraphNode } from "./subgraph/SubgraphNode"
|
||||
export type { CanvasPointerEvent } from "./types/events"
|
||||
export {
|
||||
CanvasItem,
|
||||
EaseFunction,
|
||||
LGraphEventMode,
|
||||
LinkMarkerShape,
|
||||
RenderShape,
|
||||
TitleMode,
|
||||
} from "./types/globalEnums"
|
||||
export type {
|
||||
ExportedSubgraph,
|
||||
ExportedSubgraphInstance,
|
||||
ExportedSubgraphIONode,
|
||||
ISerialisedGraph,
|
||||
SerialisableGraph,
|
||||
SerialisableLLink,
|
||||
SubgraphIO,
|
||||
} from "./types/serialisation"
|
||||
export type { IWidget } from "./types/widgets"
|
||||
export { isColorable } from "./utils/type"
|
||||
export { createUuidv4 } from "./utils/uuid"
|
||||
export { BaseSteppedWidget } from "./widgets/BaseSteppedWidget"
|
||||
export { BaseWidget } from "./widgets/BaseWidget"
|
||||
export { BooleanWidget } from "./widgets/BooleanWidget"
|
||||
export { ButtonWidget } from "./widgets/ButtonWidget"
|
||||
export { ComboWidget } from "./widgets/ComboWidget"
|
||||
export { KnobWidget } from "./widgets/KnobWidget"
|
||||
export { LegacyWidget } from "./widgets/LegacyWidget"
|
||||
export { NumberWidget } from "./widgets/NumberWidget"
|
||||
export { SliderWidget } from "./widgets/SliderWidget"
|
||||
export { TextWidget } from "./widgets/TextWidget"
|
||||
export { isComboWidget } from "./widgets/widgetMap"
|
||||
460
src/lib/litegraph/src/measure.ts
Normal file
460
src/lib/litegraph/src/measure.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
import type {
|
||||
HasBoundingRect,
|
||||
Point,
|
||||
ReadOnlyPoint,
|
||||
ReadOnlyRect,
|
||||
Rect,
|
||||
} from "./interfaces"
|
||||
|
||||
import { Alignment, hasFlag, LinkDirection } from "./types/globalEnums"
|
||||
|
||||
/**
|
||||
* Calculates the distance between two points (2D vector)
|
||||
* @param a Point a as `x, y`
|
||||
* @param b Point b as `x, y`
|
||||
* @returns Distance between point {@link a} & {@link b}
|
||||
*/
|
||||
export function distance(a: ReadOnlyPoint, b: ReadOnlyPoint): number {
|
||||
return Math.sqrt(
|
||||
(b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the distance2 (squared) between two points (2D vector).
|
||||
* Much faster when only comparing distances (closest/furthest point).
|
||||
* @param x1 Origin point X
|
||||
* @param y1 Origin point Y
|
||||
* @param x2 Destination point X
|
||||
* @param y2 Destination point Y
|
||||
* @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 {
|
||||
return ((x2 - x1) * (x2 - x1)) + ((y2 - y1) * (y2 - y1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* @param x Point x
|
||||
* @param y Point y
|
||||
* @param left Rect x
|
||||
* @param top Rect y
|
||||
* @param width Rect width
|
||||
* @param height Rect height
|
||||
* @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 {
|
||||
return x >= left &&
|
||||
x < left + width &&
|
||||
y >= top &&
|
||||
y < top + height
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a {@link Point} is inside a {@link Rect}.
|
||||
* @param point The point to check, as `x, y`
|
||||
* @param rect The rectangle, as `x, y, width, height`
|
||||
* @returns `true` if the point is inside the rect, otherwise `false`
|
||||
*/
|
||||
export function isPointInRect(point: ReadOnlyPoint, rect: ReadOnlyRect): boolean {
|
||||
return point[0] >= rect[0] &&
|
||||
point[0] < rect[0] + rect[2] &&
|
||||
point[1] >= rect[1] &&
|
||||
point[1] < rect[1] + rect[3]
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the point represented by {@link x}, {@link y} is inside a {@link Rect}.
|
||||
* @param x X co-ordinate of the point to check
|
||||
* @param y Y co-ordinate of the point to check
|
||||
* @param rect The rectangle, as `x, y, width, height`
|
||||
* @returns `true` if the point is inside the rect, otherwise `false`
|
||||
*/
|
||||
export function isInRect(x: number, y: number, rect: ReadOnlyRect): boolean {
|
||||
return x >= rect[0] &&
|
||||
x < rect[0] + rect[2] &&
|
||||
y >= rect[1] &&
|
||||
y < rect[1] + rect[3]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* @deprecated
|
||||
* 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.
|
||||
* @param x Point x
|
||||
* @param y Point y
|
||||
* @param left Rect x
|
||||
* @param top Rect y
|
||||
* @param width Rect width
|
||||
* @param height Rect height
|
||||
* @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 {
|
||||
return left < x &&
|
||||
left + width > x &&
|
||||
top < y &&
|
||||
top + height > y
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if two rectangles have any overlap
|
||||
* @param a Rectangle A as `x, y, width, height`
|
||||
* @param b Rectangle B as `x, y, width, height`
|
||||
* @returns `true` if rectangles overlap, otherwise `false`
|
||||
*/
|
||||
export function overlapBounding(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
|
||||
const aRight = a[0] + a[2]
|
||||
const aBottom = a[1] + a[3]
|
||||
const bRight = b[0] + b[2]
|
||||
const bBottom = b[1] + b[3]
|
||||
|
||||
return a[0] > bRight ||
|
||||
a[1] > bBottom ||
|
||||
aRight < b[0] ||
|
||||
aBottom < b[1]
|
||||
? false
|
||||
: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the centre of a rectangle.
|
||||
* @param rect The rectangle, as `x, y, width, height`
|
||||
* @returns The centre of the rectangle, as `x, y`
|
||||
*/
|
||||
export function getCentre(rect: ReadOnlyRect): Point {
|
||||
return [
|
||||
rect[0] + (rect[2] * 0.5),
|
||||
rect[1] + (rect[3] * 0.5),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if rectangle {@link a} contains the centre point of rectangle {@link b}.
|
||||
* @param a Container rectangle A as `x, y, width, height`
|
||||
* @param b Sub-rectangle B as `x, y, width, height`
|
||||
* @returns `true` if {@link a} contains most of {@link b}, otherwise `false`
|
||||
*/
|
||||
export function containsCentre(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
|
||||
const centreX = b[0] + (b[2] * 0.5)
|
||||
const centreY = b[1] + (b[3] * 0.5)
|
||||
return isInRect(centreX, centreY, a)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if rectangle {@link a} wholly contains rectangle {@link b}.
|
||||
* @param a Container rectangle A as `x, y, width, height`
|
||||
* @param b Sub-rectangle B as `x, y, width, height`
|
||||
* @returns `true` if {@link a} wholly contains {@link b}, otherwise `false`
|
||||
*/
|
||||
export function containsRect(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
|
||||
const aRight = a[0] + a[2]
|
||||
const aBottom = a[1] + a[3]
|
||||
const bRight = b[0] + b[2]
|
||||
const bBottom = b[1] + b[3]
|
||||
|
||||
const identical = a[0] === b[0] &&
|
||||
a[1] === b[1] &&
|
||||
aRight === bRight &&
|
||||
aBottom === bBottom
|
||||
|
||||
return !identical &&
|
||||
a[0] <= b[0] &&
|
||||
a[1] <= b[1] &&
|
||||
aRight >= bRight &&
|
||||
aBottom >= bBottom
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an offset in the specified direction to {@link out}
|
||||
* @param amount Amount of offset to add
|
||||
* @param direction Direction 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 {
|
||||
switch (direction) {
|
||||
case LinkDirection.LEFT:
|
||||
out[0] -= amount
|
||||
return
|
||||
case LinkDirection.RIGHT:
|
||||
out[0] += amount
|
||||
return
|
||||
case LinkDirection.UP:
|
||||
out[1] -= amount
|
||||
return
|
||||
case LinkDirection.DOWN:
|
||||
out[1] += amount
|
||||
return
|
||||
// LinkDirection.CENTER: Nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates an offset in 90° increments.
|
||||
*
|
||||
* Swaps/flips axis values of a 2D vector offset - effectively rotating
|
||||
* {@link offset} by 90°
|
||||
* @param offset The zero-based offset to rotate
|
||||
* @param from Direction to rotate from
|
||||
* @param to Direction to rotate to
|
||||
*/
|
||||
export function rotateLink(
|
||||
offset: Point,
|
||||
from: LinkDirection,
|
||||
to: LinkDirection,
|
||||
): void {
|
||||
let x: number
|
||||
let y: number
|
||||
|
||||
// Normalise to left
|
||||
switch (from) {
|
||||
case to:
|
||||
case LinkDirection.CENTER:
|
||||
case LinkDirection.NONE:
|
||||
default:
|
||||
// Nothing to do
|
||||
return
|
||||
|
||||
case LinkDirection.LEFT:
|
||||
x = offset[0]
|
||||
y = offset[1]
|
||||
break
|
||||
case LinkDirection.RIGHT:
|
||||
x = -offset[0]
|
||||
y = -offset[1]
|
||||
break
|
||||
case LinkDirection.UP:
|
||||
x = -offset[1]
|
||||
y = offset[0]
|
||||
break
|
||||
case LinkDirection.DOWN:
|
||||
x = offset[1]
|
||||
y = -offset[0]
|
||||
break
|
||||
}
|
||||
|
||||
// Apply new direction
|
||||
switch (to) {
|
||||
case LinkDirection.CENTER:
|
||||
case LinkDirection.NONE:
|
||||
// Nothing to do
|
||||
return
|
||||
|
||||
case LinkDirection.LEFT:
|
||||
offset[0] = x
|
||||
offset[1] = y
|
||||
break
|
||||
case LinkDirection.RIGHT:
|
||||
offset[0] = -x
|
||||
offset[1] = -y
|
||||
break
|
||||
case LinkDirection.UP:
|
||||
offset[0] = y
|
||||
offset[1] = -x
|
||||
break
|
||||
case LinkDirection.DOWN:
|
||||
offset[0] = -y
|
||||
offset[1] = x
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* {@link https://www.geeksforgeeks.org/orientation-3-ordered-points/}
|
||||
* @param lineStart The start point of the line
|
||||
* @param lineEnd The end point of the line
|
||||
* @param x X co-ordinate of the point to check
|
||||
* @param y Y co-ordinate of 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
|
||||
*/
|
||||
export function getOrientation(
|
||||
lineStart: ReadOnlyPoint,
|
||||
lineEnd: ReadOnlyPoint,
|
||||
x: number,
|
||||
y: number,
|
||||
): number {
|
||||
return ((lineEnd[1] - lineStart[1]) * (x - lineEnd[0])) -
|
||||
((lineEnd[0] - lineStart[0]) * (y - lineEnd[1]))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param out The array to store the point in
|
||||
* @param a Start point
|
||||
* @param b End point
|
||||
* @param controlA Start curve control point
|
||||
* @param controlB End curve control point
|
||||
* @param t Time: factor of distance to travel along the curve (e.g 0.25 is 25% along the curve)
|
||||
*/
|
||||
export function findPointOnCurve(
|
||||
out: Point,
|
||||
a: ReadOnlyPoint,
|
||||
b: ReadOnlyPoint,
|
||||
controlA: ReadOnlyPoint,
|
||||
controlB: ReadOnlyPoint,
|
||||
t: number = 0.5,
|
||||
): void {
|
||||
const iT = 1 - t
|
||||
|
||||
const c1 = iT * iT * iT
|
||||
const c2 = 3 * (iT * iT) * t
|
||||
const c3 = 3 * iT * (t * t)
|
||||
const c4 = t * t * t
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
export function createBounds(
|
||||
objects: Iterable<HasBoundingRect>,
|
||||
padding: number = 10,
|
||||
): ReadOnlyRect | null {
|
||||
const bounds = new Float32Array([Infinity, Infinity, -Infinity, -Infinity])
|
||||
|
||||
for (const obj of objects) {
|
||||
const rect = obj.boundingRect
|
||||
bounds[0] = Math.min(bounds[0], rect[0])
|
||||
bounds[1] = Math.min(bounds[1], rect[1])
|
||||
bounds[2] = Math.max(bounds[2], rect[0] + rect[2])
|
||||
bounds[3] = Math.max(bounds[3], rect[1] + rect[3])
|
||||
}
|
||||
if (!bounds.every(x => isFinite(x))) return null
|
||||
|
||||
return [
|
||||
bounds[0] - padding,
|
||||
bounds[1] - padding,
|
||||
bounds[2] - bounds[0] + (2 * padding),
|
||||
bounds[3] - bounds[1] + (2 * padding),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Snaps the provided {@link Point} or {@link Rect} ({@link pos}) to a grid of size {@link snapTo}.
|
||||
* @param pos The point that will be snapped
|
||||
* @param snapTo The value to round up/down by (multiples thereof)
|
||||
* @returns `true` if snapTo is truthy, otherwise `false`
|
||||
* @remarks `NaN` propagates through this function and does not affect return value.
|
||||
*/
|
||||
export function snapPoint(pos: Point | Rect, snapTo: number): boolean {
|
||||
if (!snapTo) return false
|
||||
|
||||
pos[0] = snapTo * Math.round(pos[0] / snapTo)
|
||||
pos[1] = snapTo * Math.round(pos[1] / snapTo)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Aligns a {@link Rect} relative to the edges or centre of a {@link container} rectangle.
|
||||
*
|
||||
* With no {@link inset}, the element will be placed on the interior of the {@link container},
|
||||
* with their edges lined up on the {@link anchors}. A positive {@link inset} moves the element towards the centre,
|
||||
* negative will push it outside the {@link container}.
|
||||
* @param rect The bounding rect of the element to align.
|
||||
* If using the element's pos/size backing store, this function will move the element.
|
||||
* @param anchors The direction(s) to anchor the element to
|
||||
* @param container The rectangle inside which to align the element
|
||||
* @param inset Relative offset from each {@link anchors} edge, with positive always leading to the centre, as an `[x, y]` point
|
||||
* @returns The original {@link rect}, modified in place.
|
||||
*/
|
||||
export function alignToContainer(
|
||||
rect: Rect,
|
||||
anchors: Alignment,
|
||||
[containerX, containerY, containerWidth, containerHeight]: ReadOnlyRect,
|
||||
[insetX, insetY]: ReadOnlyPoint = [0, 0],
|
||||
): Rect {
|
||||
if (hasFlag(anchors, Alignment.Left)) {
|
||||
// Left
|
||||
rect[0] = containerX + insetX
|
||||
} else if (hasFlag(anchors, Alignment.Right)) {
|
||||
// Right
|
||||
rect[0] = containerX + containerWidth - insetX - rect[2]
|
||||
} else if (hasFlag(anchors, Alignment.Centre)) {
|
||||
// Horizontal centre
|
||||
rect[0] = containerX + (containerWidth * 0.5) - (rect[2] * 0.5)
|
||||
}
|
||||
|
||||
if (hasFlag(anchors, Alignment.Top)) {
|
||||
// Top
|
||||
rect[1] = containerY + insetY
|
||||
} else if (hasFlag(anchors, Alignment.Bottom)) {
|
||||
// Bottom
|
||||
rect[1] = containerY + containerHeight - insetY - rect[3]
|
||||
} else if (hasFlag(anchors, Alignment.Middle)) {
|
||||
// Vertical middle
|
||||
rect[1] = containerY + (containerHeight * 0.5) - (rect[3] * 0.5)
|
||||
}
|
||||
return rect
|
||||
}
|
||||
|
||||
/**
|
||||
* Aligns a {@link Rect} relative to the edges of {@link other}.
|
||||
*
|
||||
* With no {@link outset}, the element will be placed on the exterior of the {@link other},
|
||||
* with their edges lined up on the {@link anchors}. A positive {@link outset} moves the element away from the {@link other},
|
||||
* negative will push it inside the {@link other}.
|
||||
* @param rect The bounding rect of the element to align.
|
||||
* If using the element's pos/size backing store, this function will move the element.
|
||||
* @param anchors The direction(s) to anchor the element to
|
||||
* @param other The rectangle to align {@link rect} to
|
||||
* @param outset Relative offset from each {@link anchors} edge, with positive always moving away from the centre of the {@link other}, as an `[x, y]` point
|
||||
* @returns The original {@link rect}, modified in place.
|
||||
*/
|
||||
export function alignOutsideContainer(
|
||||
rect: Rect,
|
||||
anchors: Alignment,
|
||||
[otherX, otherY, otherWidth, otherHeight]: ReadOnlyRect,
|
||||
[outsetX, outsetY]: ReadOnlyPoint = [0, 0],
|
||||
): Rect {
|
||||
if (hasFlag(anchors, Alignment.Left)) {
|
||||
// Left
|
||||
rect[0] = otherX - outsetX - rect[2]
|
||||
} else if (hasFlag(anchors, Alignment.Right)) {
|
||||
// Right
|
||||
rect[0] = otherX + otherWidth + outsetX
|
||||
} else if (hasFlag(anchors, Alignment.Centre)) {
|
||||
// Horizontal centre
|
||||
rect[0] = otherX + (otherWidth * 0.5) - (rect[2] * 0.5)
|
||||
}
|
||||
|
||||
if (hasFlag(anchors, Alignment.Top)) {
|
||||
// Top
|
||||
rect[1] = otherY - outsetY - rect[3]
|
||||
} else if (hasFlag(anchors, Alignment.Bottom)) {
|
||||
// Bottom
|
||||
rect[1] = otherY + otherHeight + outsetY
|
||||
} else if (hasFlag(anchors, Alignment.Middle)) {
|
||||
// Vertical middle
|
||||
rect[1] = otherY + (otherHeight * 0.5) - (rect[3] * 0.5)
|
||||
}
|
||||
return rect
|
||||
}
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return value < min ? min : (value > max ? max : value)
|
||||
}
|
||||
68
src/lib/litegraph/src/node/NodeInputSlot.ts
Normal file
68
src/lib/litegraph/src/node/NodeInputSlot.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { INodeInputSlot, INodeOutputSlot, OptionalProps, ReadOnlyPoint } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { LinkId } from "@/LLink"
|
||||
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
|
||||
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
|
||||
import type { IBaseWidget } from "@/types/widgets"
|
||||
|
||||
import { LabelPosition } from "@/draw"
|
||||
import { LiteGraph } from "@/litegraph"
|
||||
import { type IDrawOptions, NodeSlot } from "@/node/NodeSlot"
|
||||
import { isSubgraphInput } from "@/subgraph/subgraphUtils"
|
||||
|
||||
export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
|
||||
link: LinkId | null
|
||||
|
||||
get isWidgetInputSlot(): boolean {
|
||||
return !!this.widget
|
||||
}
|
||||
|
||||
#widget: WeakRef<IBaseWidget> | undefined
|
||||
|
||||
/** Internal use only; API is not finalised and may change at any time. */
|
||||
get _widget(): IBaseWidget | undefined {
|
||||
return this.#widget?.deref()
|
||||
}
|
||||
|
||||
set _widget(widget: IBaseWidget | undefined) {
|
||||
this.#widget = widget ? new WeakRef(widget) : undefined
|
||||
}
|
||||
|
||||
get collapsedPos(): ReadOnlyPoint {
|
||||
return [0, LiteGraph.NODE_TITLE_HEIGHT * -0.5]
|
||||
}
|
||||
|
||||
constructor(slot: OptionalProps<INodeInputSlot, "boundingRect">, node: LGraphNode) {
|
||||
super(slot, node)
|
||||
this.link = slot.link
|
||||
}
|
||||
|
||||
override get isConnected(): boolean {
|
||||
return this.link != null
|
||||
}
|
||||
|
||||
override isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput): boolean {
|
||||
if ("links" in fromSlot) {
|
||||
return LiteGraph.isValidConnection(fromSlot.type, this.type)
|
||||
}
|
||||
|
||||
if (isSubgraphInput(fromSlot)) {
|
||||
return LiteGraph.isValidConnection(fromSlot.type, this.type)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
override draw(ctx: CanvasRenderingContext2D, options: Omit<IDrawOptions, "doStroke" | "labelPosition">) {
|
||||
const { textAlign } = ctx
|
||||
ctx.textAlign = "left"
|
||||
|
||||
super.draw(ctx, {
|
||||
...options,
|
||||
labelPosition: LabelPosition.Right,
|
||||
doStroke: false,
|
||||
})
|
||||
|
||||
ctx.textAlign = textAlign
|
||||
}
|
||||
}
|
||||
68
src/lib/litegraph/src/node/NodeOutputSlot.ts
Normal file
68
src/lib/litegraph/src/node/NodeOutputSlot.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { INodeInputSlot, INodeOutputSlot, OptionalProps, ReadOnlyPoint } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { LinkId } from "@/LLink"
|
||||
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
|
||||
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
|
||||
|
||||
import { LabelPosition } from "@/draw"
|
||||
import { LiteGraph } from "@/litegraph"
|
||||
import { type IDrawOptions, NodeSlot } from "@/node/NodeSlot"
|
||||
import { isSubgraphOutput } from "@/subgraph/subgraphUtils"
|
||||
|
||||
export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
|
||||
#node: LGraphNode
|
||||
|
||||
links: LinkId[] | null
|
||||
_data?: unknown
|
||||
slot_index?: number
|
||||
|
||||
get isWidgetInputSlot(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
get collapsedPos(): ReadOnlyPoint {
|
||||
return [
|
||||
this.#node._collapsed_width ?? LiteGraph.NODE_COLLAPSED_WIDTH,
|
||||
LiteGraph.NODE_TITLE_HEIGHT * -0.5,
|
||||
]
|
||||
}
|
||||
|
||||
constructor(slot: OptionalProps<INodeOutputSlot, "boundingRect">, node: LGraphNode) {
|
||||
super(slot, node)
|
||||
this.links = slot.links
|
||||
this._data = slot._data
|
||||
this.slot_index = slot.slot_index
|
||||
this.#node = node
|
||||
}
|
||||
|
||||
override isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput): boolean {
|
||||
if ("link" in fromSlot) {
|
||||
return LiteGraph.isValidConnection(this.type, fromSlot.type)
|
||||
}
|
||||
|
||||
if (isSubgraphOutput(fromSlot)) {
|
||||
return LiteGraph.isValidConnection(this.type, fromSlot.type)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
override get isConnected(): boolean {
|
||||
return this.links != null && this.links.length > 0
|
||||
}
|
||||
|
||||
override draw(ctx: CanvasRenderingContext2D, options: Omit<IDrawOptions, "doStroke" | "labelPosition">) {
|
||||
const { textAlign, strokeStyle } = ctx
|
||||
ctx.textAlign = "right"
|
||||
ctx.strokeStyle = "black"
|
||||
|
||||
super.draw(ctx, {
|
||||
...options,
|
||||
labelPosition: LabelPosition.Left,
|
||||
doStroke: true,
|
||||
})
|
||||
|
||||
ctx.textAlign = textAlign
|
||||
ctx.strokeStyle = strokeStyle
|
||||
}
|
||||
}
|
||||
229
src/lib/litegraph/src/node/NodeSlot.ts
Normal file
229
src/lib/litegraph/src/node/NodeSlot.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import type { CanvasColour, DefaultConnectionColors, INodeInputSlot, INodeOutputSlot, INodeSlot, ISubgraphInput, OptionalProps, Point, ReadOnlyPoint } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
|
||||
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
|
||||
|
||||
import { LabelPosition, SlotShape, SlotType } from "@/draw"
|
||||
import { LiteGraph, Rectangle } from "@/litegraph"
|
||||
import { getCentre } from "@/measure"
|
||||
import { LinkDirection, RenderShape } from "@/types/globalEnums"
|
||||
|
||||
import { NodeInputSlot } from "./NodeInputSlot"
|
||||
import { SlotBase } from "./SlotBase"
|
||||
|
||||
export interface IDrawOptions {
|
||||
colorContext: DefaultConnectionColors
|
||||
labelPosition?: LabelPosition
|
||||
lowQuality?: boolean
|
||||
doStroke?: boolean
|
||||
highlight?: boolean
|
||||
}
|
||||
|
||||
/** Shared base class for {@link LGraphNode} input and output slots. */
|
||||
export abstract class NodeSlot extends SlotBase implements INodeSlot {
|
||||
pos?: Point
|
||||
|
||||
/** The offset from the parent node to the centre point of this slot. */
|
||||
get #centreOffset(): ReadOnlyPoint {
|
||||
const nodePos = this.node.pos
|
||||
const { boundingRect } = this
|
||||
|
||||
// Use height; widget input slots may be thinner.
|
||||
const diameter = boundingRect[3]
|
||||
|
||||
return getCentre([
|
||||
boundingRect[0] - nodePos[0],
|
||||
boundingRect[1] - nodePos[1],
|
||||
diameter,
|
||||
diameter,
|
||||
])
|
||||
}
|
||||
|
||||
/** The center point of this slot when the node is collapsed. */
|
||||
abstract get collapsedPos(): ReadOnlyPoint
|
||||
|
||||
#node: LGraphNode
|
||||
get node(): LGraphNode {
|
||||
return this.#node
|
||||
}
|
||||
|
||||
get highlightColor(): CanvasColour {
|
||||
return LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR ?? LiteGraph.NODE_SELECTED_TITLE_COLOR ?? LiteGraph.NODE_TEXT_COLOR
|
||||
}
|
||||
|
||||
abstract get isWidgetInputSlot(): boolean
|
||||
|
||||
constructor(slot: OptionalProps<INodeSlot, "boundingRect">, node: LGraphNode) {
|
||||
// Workaround: Ensure internal properties are not copied to the slot (_listenerController
|
||||
// https://github.com/Comfy-Org/litegraph.js/issues/1138
|
||||
const maybeSubgraphSlot: OptionalProps<ISubgraphInput, "link" | "boundingRect"> = slot
|
||||
const { boundingRect, name, type, _listenerController, ...rest } = maybeSubgraphSlot
|
||||
const rectangle = boundingRect ? Rectangle.ensureRect(boundingRect) : new Rectangle()
|
||||
|
||||
super(name, type, rectangle)
|
||||
|
||||
Object.assign(this, rest)
|
||||
this.#node = node
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this slot is a valid target for a dragging link.
|
||||
* @param fromSlot The slot that the link is being connected from.
|
||||
*/
|
||||
abstract isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput): boolean
|
||||
|
||||
/**
|
||||
* The label to display in the UI.
|
||||
*/
|
||||
get renderingLabel(): string {
|
||||
return this.label || this.localized_name || this.name || ""
|
||||
}
|
||||
|
||||
draw(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
{
|
||||
colorContext,
|
||||
labelPosition = LabelPosition.Right,
|
||||
lowQuality = false,
|
||||
highlight = false,
|
||||
doStroke = false,
|
||||
}: IDrawOptions,
|
||||
) {
|
||||
// Save the current fillStyle and strokeStyle
|
||||
const originalFillStyle = ctx.fillStyle
|
||||
const originalStrokeStyle = ctx.strokeStyle
|
||||
const originalLineWidth = ctx.lineWidth
|
||||
|
||||
const labelColor = highlight
|
||||
? this.highlightColor
|
||||
: LiteGraph.NODE_TEXT_COLOR
|
||||
|
||||
const pos = this.#centreOffset
|
||||
const slot_type = this.type
|
||||
const slot_shape = (
|
||||
slot_type === SlotType.Array ? SlotShape.Grid : this.shape
|
||||
) as SlotShape
|
||||
|
||||
ctx.beginPath()
|
||||
let doFill = true
|
||||
|
||||
ctx.fillStyle = this.renderingColor(colorContext)
|
||||
ctx.lineWidth = 1
|
||||
if (slot_type === SlotType.Event || slot_shape === SlotShape.Box) {
|
||||
ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10)
|
||||
} else if (slot_shape === SlotShape.Arrow) {
|
||||
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.closePath()
|
||||
} else if (slot_shape === SlotShape.Grid) {
|
||||
const gridSize = 3
|
||||
const cellSize = 2
|
||||
const spacing = 3
|
||||
|
||||
for (let x = 0; x < gridSize; x++) {
|
||||
for (let y = 0; y < gridSize; y++) {
|
||||
ctx.rect(
|
||||
pos[0] - 4 + x * spacing,
|
||||
pos[1] - 4 + y * spacing,
|
||||
cellSize,
|
||||
cellSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
doStroke = false
|
||||
} else {
|
||||
// Default rendering for circle, hollow circle.
|
||||
if (lowQuality) {
|
||||
ctx.rect(pos[0] - 4, pos[1] - 4, 8, 8)
|
||||
} else {
|
||||
let radius: number
|
||||
if (slot_shape === SlotShape.HollowCircle) {
|
||||
doFill = false
|
||||
doStroke = true
|
||||
ctx.lineWidth = 3
|
||||
ctx.strokeStyle = ctx.fillStyle
|
||||
radius = highlight ? 4 : 3
|
||||
} else {
|
||||
// Normal circle
|
||||
radius = highlight ? 5 : 4
|
||||
}
|
||||
ctx.arc(pos[0], pos[1], radius, 0, Math.PI * 2)
|
||||
}
|
||||
}
|
||||
|
||||
if (doFill) ctx.fill()
|
||||
if (!lowQuality && doStroke) ctx.stroke()
|
||||
|
||||
// render slot label
|
||||
const hideLabel = lowQuality || this.isWidgetInputSlot
|
||||
if (!hideLabel) {
|
||||
const text = this.renderingLabel
|
||||
if (text) {
|
||||
// TODO: Finish impl. Highlight text on mouseover unless we're connecting links.
|
||||
ctx.fillStyle = labelColor
|
||||
|
||||
if (labelPosition === LabelPosition.Right) {
|
||||
if (this.dir == LinkDirection.UP) {
|
||||
ctx.fillText(text, pos[0], pos[1] - 10)
|
||||
} else {
|
||||
ctx.fillText(text, pos[0] + 10, pos[1] + 5)
|
||||
}
|
||||
} else {
|
||||
if (this.dir == LinkDirection.DOWN) {
|
||||
ctx.fillText(text, pos[0], pos[1] - 8)
|
||||
} else {
|
||||
ctx.fillText(text, pos[0] - 10, pos[1] + 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw a red circle if the slot has errors.
|
||||
if (this.hasErrors) {
|
||||
ctx.lineWidth = 2
|
||||
ctx.strokeStyle = "red"
|
||||
ctx.beginPath()
|
||||
ctx.arc(pos[0], pos[1], 12, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Restore the original fillStyle and strokeStyle
|
||||
ctx.fillStyle = originalFillStyle
|
||||
ctx.strokeStyle = originalStrokeStyle
|
||||
ctx.lineWidth = originalLineWidth
|
||||
}
|
||||
|
||||
drawCollapsed(ctx: CanvasRenderingContext2D) {
|
||||
const [x, y] = this.collapsedPos
|
||||
|
||||
// Save original styles
|
||||
const { fillStyle } = ctx
|
||||
|
||||
ctx.fillStyle = "#686"
|
||||
ctx.beginPath()
|
||||
|
||||
if (this.type === SlotType.Event || this.shape === RenderShape.BOX) {
|
||||
ctx.rect(x - 7 + 0.5, y - 4, 14, 8)
|
||||
} else if (this.shape === RenderShape.ARROW) {
|
||||
// Adjust arrow direction based on whether this is an input or output slot
|
||||
const isInput = this instanceof NodeInputSlot
|
||||
if (isInput) {
|
||||
ctx.moveTo(x + 8, y)
|
||||
ctx.lineTo(x - 4, y - 4)
|
||||
ctx.lineTo(x - 4, y + 4)
|
||||
} else {
|
||||
ctx.moveTo(x + 6, y)
|
||||
ctx.lineTo(x - 6, y - 4)
|
||||
ctx.lineTo(x - 6, y + 4)
|
||||
}
|
||||
ctx.closePath()
|
||||
} else {
|
||||
ctx.arc(x, y, 4, 0, Math.PI * 2)
|
||||
}
|
||||
ctx.fill()
|
||||
|
||||
// Restore original styles
|
||||
ctx.fillStyle = fillStyle
|
||||
}
|
||||
}
|
||||
43
src/lib/litegraph/src/node/SlotBase.ts
Normal file
43
src/lib/litegraph/src/node/SlotBase.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { CanvasColour, DefaultConnectionColors, INodeSlot, ISlotType, IWidgetLocator, Point } from "@/interfaces"
|
||||
import type { LLink } from "@/LLink"
|
||||
import type { RenderShape } from "@/types/globalEnums"
|
||||
import type { LinkDirection } from "@/types/globalEnums"
|
||||
|
||||
import { Rectangle } from "@/infrastructure/Rectangle"
|
||||
|
||||
/** Base class for all input & output slots. */
|
||||
|
||||
export abstract class SlotBase implements INodeSlot {
|
||||
name: string
|
||||
localized_name?: string
|
||||
label?: string
|
||||
type: ISlotType
|
||||
dir?: LinkDirection
|
||||
removable?: boolean
|
||||
shape?: RenderShape
|
||||
color_off?: CanvasColour
|
||||
color_on?: CanvasColour
|
||||
locked?: boolean
|
||||
nameLocked?: boolean
|
||||
widget?: IWidgetLocator
|
||||
_floatingLinks?: Set<LLink>
|
||||
hasErrors?: boolean
|
||||
|
||||
/** The centre point of the slot. */
|
||||
abstract pos?: Point
|
||||
readonly boundingRect: Rectangle
|
||||
|
||||
constructor(name: string, type: ISlotType, boundingRect?: Rectangle) {
|
||||
this.name = name
|
||||
this.type = type
|
||||
this.boundingRect = boundingRect ?? new Rectangle()
|
||||
}
|
||||
|
||||
abstract get isConnected(): boolean
|
||||
|
||||
renderingColor(colorContext: DefaultConnectionColors): CanvasColour {
|
||||
return this.isConnected
|
||||
? this.color_on || colorContext.getConnectedColor(this.type)
|
||||
: this.color_off || colorContext.getDisconnectedColor(this.type)
|
||||
}
|
||||
}
|
||||
56
src/lib/litegraph/src/node/slotUtils.ts
Normal file
56
src/lib/litegraph/src/node/slotUtils.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { IWidgetInputSlot, SharedIntersection } from "@/interfaces"
|
||||
import type { INodeInputSlot, INodeOutputSlot, INodeSlot, IWidget } from "@/litegraph"
|
||||
import type { ISerialisableNodeInput, ISerialisableNodeOutput } from "@/types/serialisation"
|
||||
|
||||
type CommonIoSlotProps = SharedIntersection<ISerialisableNodeInput, ISerialisableNodeOutput>
|
||||
|
||||
export function shallowCloneCommonProps(slot: CommonIoSlotProps): CommonIoSlotProps {
|
||||
const { color_off, color_on, dir, label, localized_name, locked, name, nameLocked, removable, shape, type } = slot
|
||||
return { color_off, color_on, dir, label, localized_name, locked, name, nameLocked, removable, shape, type }
|
||||
}
|
||||
|
||||
export function inputAsSerialisable(slot: INodeInputSlot): ISerialisableNodeInput {
|
||||
const { link } = slot
|
||||
const widgetOrPos = slot.widget
|
||||
? { widget: { name: slot.widget.name } }
|
||||
: { pos: slot.pos }
|
||||
|
||||
return {
|
||||
...shallowCloneCommonProps(slot),
|
||||
...widgetOrPos,
|
||||
link,
|
||||
}
|
||||
}
|
||||
|
||||
export function outputAsSerialisable(slot: INodeOutputSlot & { widget?: IWidget }): ISerialisableNodeOutput {
|
||||
const { pos, slot_index, links, widget } = slot
|
||||
// Output widgets do not exist in Litegraph; this is a temporary downstream workaround.
|
||||
const outputWidget = widget
|
||||
? { widget: { name: widget.name } }
|
||||
: null
|
||||
|
||||
return {
|
||||
...shallowCloneCommonProps(slot),
|
||||
...outputWidget,
|
||||
pos,
|
||||
slot_index,
|
||||
links,
|
||||
}
|
||||
}
|
||||
|
||||
export function isINodeInputSlot(slot: INodeSlot): slot is INodeInputSlot {
|
||||
return "link" in slot
|
||||
}
|
||||
|
||||
export function isINodeOutputSlot(slot: INodeSlot): slot is INodeOutputSlot {
|
||||
return "links" in slot
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: Whether this input slot is attached to a widget.
|
||||
* @param slot The slot to check.
|
||||
*/
|
||||
|
||||
export function isWidgetInputSlot(slot: INodeInputSlot): slot is IWidgetInputSlot {
|
||||
return !!slot.widget
|
||||
}
|
||||
92
src/lib/litegraph/src/polyfills.ts
Normal file
92
src/lib/litegraph/src/polyfills.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// @ts-expect-error Polyfill
|
||||
Symbol.dispose ??= Symbol("Symbol.dispose")
|
||||
// @ts-expect-error Polyfill
|
||||
Symbol.asyncDispose ??= Symbol("Symbol.asyncDispose")
|
||||
|
||||
// API *************************************************
|
||||
// like rect but rounded corners
|
||||
export function loadPolyfills() {
|
||||
if (
|
||||
typeof window != "undefined" &&
|
||||
window.CanvasRenderingContext2D &&
|
||||
!window.CanvasRenderingContext2D.prototype.roundRect
|
||||
) {
|
||||
// @ts-expect-error Slightly broken polyfill - radius_low not impl. anywhere
|
||||
window.CanvasRenderingContext2D.prototype.roundRect = function (
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
radius: number | number[],
|
||||
radius_low: number | number[],
|
||||
) {
|
||||
let top_left_radius = 0
|
||||
let top_right_radius = 0
|
||||
let bottom_left_radius = 0
|
||||
let bottom_right_radius = 0
|
||||
|
||||
if (radius === 0) {
|
||||
this.rect(x, y, w, h)
|
||||
return
|
||||
}
|
||||
|
||||
if (radius_low === undefined) radius_low = radius
|
||||
|
||||
// make it compatible with official one
|
||||
if (Array.isArray(radius)) {
|
||||
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
|
||||
|
||||
const low = !Array.isArray(radius_low) && radius_low ? radius_low : 0
|
||||
bottom_left_radius = low
|
||||
bottom_right_radius = low
|
||||
}
|
||||
|
||||
// top right
|
||||
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)
|
||||
|
||||
// bottom right
|
||||
this.lineTo(x + w, y + h - bottom_right_radius)
|
||||
this.quadraticCurveTo(
|
||||
x + w,
|
||||
y + h,
|
||||
x + w - bottom_right_radius,
|
||||
y + h,
|
||||
)
|
||||
|
||||
// bottom left
|
||||
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 (typeof window != "undefined" && !window["requestAnimationFrame"]) {
|
||||
window.requestAnimationFrame =
|
||||
// @ts-expect-error Legacy code
|
||||
window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame ||
|
||||
function (callback) {
|
||||
window.setTimeout(callback, 1000 / 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/lib/litegraph/src/strings.ts
Normal file
39
src/lib/litegraph/src/strings.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ISlotType } from "./litegraph"
|
||||
|
||||
/**
|
||||
* Uses the standard String() function to coerce to string, unless the value is null or undefined - then null.
|
||||
* @param value The value to convert
|
||||
* @returns String(value) or null
|
||||
*/
|
||||
export function stringOrNull(value: unknown): string | null {
|
||||
return value == null ? null : String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the standard String() function to coerce to string, unless the value is null or undefined - then an empty string
|
||||
* @param value The value to convert
|
||||
* @returns String(value) or ""
|
||||
*/
|
||||
export function stringOrEmpty(value: unknown): string {
|
||||
return value == null ? "" : String(value)
|
||||
}
|
||||
|
||||
export function parseSlotTypes(type: ISlotType): string[] {
|
||||
return type == "" || type == "0" ? ["*"] : String(type).toLowerCase().split(",")
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unique name by appending an underscore and a number to the end of the name
|
||||
* if it already exists.
|
||||
* @param name The name to make unique
|
||||
* @param existingNames The names that already exist. Default: an empty array
|
||||
* @returns The name, or a unique name if it already exists.
|
||||
*/
|
||||
export function nextUniqueName(name: string, existingNames: string[] = []): string {
|
||||
let i = 1
|
||||
const baseName = name
|
||||
while (existingNames.includes(name)) {
|
||||
name = `${baseName}_${i++}`
|
||||
}
|
||||
return name
|
||||
}
|
||||
39
src/lib/litegraph/src/subgraph/EmptySubgraphInput.ts
Normal file
39
src/lib/litegraph/src/subgraph/EmptySubgraphInput.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { SubgraphInputNode } from "./SubgraphInputNode"
|
||||
import type { INodeInputSlot, Point } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { RerouteId } from "@/Reroute"
|
||||
|
||||
import { LLink } from "@/LLink"
|
||||
import { nextUniqueName } from "@/strings"
|
||||
import { zeroUuid } from "@/utils/uuid"
|
||||
|
||||
import { SubgraphInput } from "./SubgraphInput"
|
||||
|
||||
/**
|
||||
* A virtual slot that simply creates a new input slot when connected to.
|
||||
*/
|
||||
export class EmptySubgraphInput extends SubgraphInput {
|
||||
declare parent: SubgraphInputNode
|
||||
|
||||
constructor(parent: SubgraphInputNode) {
|
||||
super({
|
||||
id: zeroUuid,
|
||||
name: "",
|
||||
type: "",
|
||||
}, parent)
|
||||
}
|
||||
|
||||
override connect(slot: INodeInputSlot, node: LGraphNode, afterRerouteId?: RerouteId): LLink | undefined {
|
||||
const { subgraph } = this.parent
|
||||
const existingNames = subgraph.inputs.map(x => x.name)
|
||||
|
||||
const name = nextUniqueName(slot.name, existingNames)
|
||||
const input = subgraph.addInput(name, String(slot.type))
|
||||
return input.connect(slot, node, afterRerouteId)
|
||||
}
|
||||
|
||||
override get labelPos(): Point {
|
||||
const [x, y, , height] = this.boundingRect
|
||||
return [x, y + height * 0.5]
|
||||
}
|
||||
}
|
||||
39
src/lib/litegraph/src/subgraph/EmptySubgraphOutput.ts
Normal file
39
src/lib/litegraph/src/subgraph/EmptySubgraphOutput.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { SubgraphOutputNode } from "./SubgraphOutputNode"
|
||||
import type { INodeOutputSlot, Point } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { RerouteId } from "@/Reroute"
|
||||
|
||||
import { LLink } from "@/LLink"
|
||||
import { nextUniqueName } from "@/strings"
|
||||
import { zeroUuid } from "@/utils/uuid"
|
||||
|
||||
import { SubgraphOutput } from "./SubgraphOutput"
|
||||
|
||||
/**
|
||||
* A virtual slot that simply creates a new output slot when connected to.
|
||||
*/
|
||||
export class EmptySubgraphOutput extends SubgraphOutput {
|
||||
declare parent: SubgraphOutputNode
|
||||
|
||||
constructor(parent: SubgraphOutputNode) {
|
||||
super({
|
||||
id: zeroUuid,
|
||||
name: "",
|
||||
type: "",
|
||||
}, parent)
|
||||
}
|
||||
|
||||
override connect(slot: INodeOutputSlot, node: LGraphNode, afterRerouteId?: RerouteId): LLink | undefined {
|
||||
const { subgraph } = this.parent
|
||||
const existingNames = subgraph.outputs.map(x => x.name)
|
||||
|
||||
const name = nextUniqueName(slot.name, existingNames)
|
||||
const output = subgraph.addOutput(name, String(slot.type))
|
||||
return output.connect(slot, node, afterRerouteId)
|
||||
}
|
||||
|
||||
override get labelPos(): Point {
|
||||
const [x, y, , height] = this.boundingRect
|
||||
return [x, y + height * 0.5]
|
||||
}
|
||||
}
|
||||
292
src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts
Normal file
292
src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import type { SubgraphNode } from "./SubgraphNode"
|
||||
import type { CallbackParams, CallbackReturn, ISlotType } from "@/interfaces"
|
||||
import type { LGraph } from "@/LGraph"
|
||||
import type { LGraphNode, NodeId } from "@/LGraphNode"
|
||||
|
||||
import { InvalidLinkError } from "@/infrastructure/InvalidLinkError"
|
||||
import { NullGraphError } from "@/infrastructure/NullGraphError"
|
||||
import { RecursionError } from "@/infrastructure/RecursionError"
|
||||
import { SlotIndexError } from "@/infrastructure/SlotIndexError"
|
||||
import { LGraphEventMode } from "@/litegraph"
|
||||
|
||||
import { Subgraph } from "./Subgraph"
|
||||
|
||||
export type ExecutionId = string
|
||||
|
||||
/**
|
||||
* Interface describing the data transfer objects used when compiling a graph for execution.
|
||||
*/
|
||||
export type ExecutableLGraphNode = Omit<ExecutableNodeDTO, "graph" | "node" | "subgraphNode">
|
||||
|
||||
/**
|
||||
* The end result of resolving a DTO input.
|
||||
* When a widget value is returned, {@link widgetInfo} is present and {@link origin_slot} is `-1`.
|
||||
*/
|
||||
type ResolvedInput = {
|
||||
/** DTO for the node that the link originates from. */
|
||||
node: ExecutableLGraphNode
|
||||
/** Full unique execution ID of the node that the link originates from. In the case of a widget value, this is the ID of the subgraph node. */
|
||||
origin_id: ExecutionId
|
||||
/** The slot index of the output on the node that the link originates from. `-1` when widget value is set. */
|
||||
origin_slot: number
|
||||
/** Boxed widget value (e.g. for widgets). If this box is `undefined`, then an input link is connected, and widget values from the subgraph node are ignored. */
|
||||
widgetInfo?: { value: unknown }
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete implementation of {@link ExecutableLGraphNode}.
|
||||
* @remarks This is the class that is used to create the data transfer objects for executable nodes.
|
||||
*/
|
||||
export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
applyToGraph?(...args: CallbackParams<typeof this.node.applyToGraph>): CallbackReturn<typeof this.node.applyToGraph>
|
||||
|
||||
/** The graph that this node is a part of. */
|
||||
readonly graph: LGraph | Subgraph
|
||||
|
||||
inputs: { linkId: number | null, name: string, type: ISlotType }[]
|
||||
|
||||
/** Backing field for {@link id}. */
|
||||
#id: ExecutionId
|
||||
|
||||
/**
|
||||
* The path to the acutal node through subgraph instances, represented as a list of all subgraph node IDs (instances),
|
||||
* followed by the actual original node ID within the subgraph. Each segment is separated by `:`.
|
||||
*
|
||||
* e.g. `1:2:3`:
|
||||
* - `1` is the node ID of the first subgraph node in the parent workflow
|
||||
* - `2` is the node ID of the second subgraph node in the first subgraph
|
||||
* - `3` is the node ID of the actual node in the subgraph definition
|
||||
*/
|
||||
get id() {
|
||||
return this.#id
|
||||
}
|
||||
|
||||
get type() {
|
||||
return this.node.type
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.node.title
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.node.mode
|
||||
}
|
||||
|
||||
get comfyClass() {
|
||||
return this.node.comfyClass
|
||||
}
|
||||
|
||||
get isVirtualNode() {
|
||||
return this.node.isVirtualNode
|
||||
}
|
||||
|
||||
get widgets() {
|
||||
return this.node.widgets
|
||||
}
|
||||
|
||||
get subgraphId() {
|
||||
return this.subgraphNode?.subgraph.id
|
||||
}
|
||||
|
||||
constructor(
|
||||
/** The actual node that this DTO wraps. */
|
||||
readonly node: LGraphNode | SubgraphNode,
|
||||
/** A list of subgraph instance node IDs from the root graph to the containing instance. @see {@link id} */
|
||||
readonly subgraphNodePath: readonly NodeId[],
|
||||
/** A flattened map of all DTOs in this node network. Subgraph instances have been expanded into their inner nodes. */
|
||||
readonly nodesByExecutionId: Map<ExecutionId, ExecutableLGraphNode>,
|
||||
/** The actual subgraph instance that contains this node, otherise undefined. */
|
||||
readonly subgraphNode?: SubgraphNode,
|
||||
) {
|
||||
if (!node.graph) throw new NullGraphError()
|
||||
|
||||
// Set the internal ID of the DTO
|
||||
this.#id = [...this.subgraphNodePath, this.node.id].join(":")
|
||||
this.graph = node.graph
|
||||
this.inputs = this.node.inputs.map(x => ({
|
||||
linkId: x.link,
|
||||
name: x.name,
|
||||
type: x.type,
|
||||
}))
|
||||
|
||||
// Only create a wrapper if the node has an applyToGraph method
|
||||
if (this.node.applyToGraph) {
|
||||
this.applyToGraph = (...args) => this.node.applyToGraph?.(...args)
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns either the DTO itself, or the DTOs of the inner nodes of the subgraph. */
|
||||
getInnerNodes(): ExecutableLGraphNode[] {
|
||||
return this.subgraphNode ? this.subgraphNode.getInnerNodes(this.nodesByExecutionId, this.subgraphNodePath) : [this]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the executable node & link IDs for a given input slot.
|
||||
* @param slot The slot index of the input.
|
||||
* @param visited Leave empty unless overriding this method.
|
||||
* A set of unique IDs, used to guard against infinite recursion.
|
||||
* If overriding, ensure that the set is passed on all recursive calls.
|
||||
* @returns The node and the origin ID / slot index of the output.
|
||||
*/
|
||||
resolveInput(slot: number, visited = new Set<string>()): ResolvedInput | undefined {
|
||||
const uniqueId = `${this.subgraphNode?.subgraph.id}:${this.node.id}[I]${slot}`
|
||||
if (visited.has(uniqueId)) {
|
||||
const nodeInfo = `${this.node.id}${this.node.title ? ` (${this.node.title})` : ""}`
|
||||
const pathInfo = this.subgraphNodePath.length > 0 ? ` at path ${this.subgraphNodePath.join(":")}` : ""
|
||||
throw new RecursionError(
|
||||
`Circular reference detected while resolving input ${slot} of node ${nodeInfo}${pathInfo}. ` +
|
||||
`This creates an infinite loop in link resolution. UniqueID: [${uniqueId}]`,
|
||||
)
|
||||
}
|
||||
visited.add(uniqueId)
|
||||
|
||||
const input = this.inputs.at(slot)
|
||||
if (!input) throw new SlotIndexError(`No input found for flattened id [${this.id}] slot [${slot}]`)
|
||||
|
||||
// Nothing connected
|
||||
if (input.linkId == null) return
|
||||
|
||||
const link = this.graph.getLink(input.linkId)
|
||||
if (!link) throw new InvalidLinkError(`No link found in parent graph for id [${this.id}] slot [${slot}] ${input.name}`)
|
||||
|
||||
const { subgraphNode } = this
|
||||
|
||||
// Link goes up and out of this subgraph
|
||||
if (subgraphNode && link.originIsIoNode) {
|
||||
const subgraphNodeInput = subgraphNode.inputs.at(link.origin_slot)
|
||||
if (!subgraphNodeInput) throw new SlotIndexError(`No input found for slot [${link.origin_slot}] ${input.name}`)
|
||||
|
||||
// Nothing connected
|
||||
const linkId = subgraphNodeInput.link
|
||||
if (linkId == null) {
|
||||
const widget = subgraphNode.getWidgetFromSlot(subgraphNodeInput)
|
||||
if (!widget) return
|
||||
|
||||
// Special case: SubgraphNode widget.
|
||||
return {
|
||||
node: this,
|
||||
origin_id: this.id,
|
||||
origin_slot: -1,
|
||||
widgetInfo: { value: widget.value },
|
||||
}
|
||||
}
|
||||
|
||||
const outerLink = subgraphNode.graph.getLink(linkId)
|
||||
if (!outerLink) throw new InvalidLinkError(`No outer link found for slot [${link.origin_slot}] ${input.name}`)
|
||||
|
||||
const subgraphNodeExecutionId = this.subgraphNodePath.join(":")
|
||||
const subgraphNodeDto = this.nodesByExecutionId.get(subgraphNodeExecutionId)
|
||||
if (!subgraphNodeDto) throw new Error(`No subgraph node DTO found for id [${subgraphNodeExecutionId}]`)
|
||||
|
||||
return subgraphNodeDto.resolveInput(outerLink.target_slot, visited)
|
||||
}
|
||||
|
||||
// Not part of a subgraph; use the original link
|
||||
const outputNode = this.graph.getNodeById(link.origin_id)
|
||||
if (!outputNode) throw new InvalidLinkError(`No input node found for id [${this.id}] slot [${slot}] ${input.name}`)
|
||||
|
||||
const outputNodeExecutionId = [...this.subgraphNodePath, outputNode.id].join(":")
|
||||
const outputNodeDto = this.nodesByExecutionId.get(outputNodeExecutionId)
|
||||
if (!outputNodeDto) throw new Error(`No output node DTO found for id [${outputNodeExecutionId}]`)
|
||||
|
||||
return outputNodeDto.resolveOutput(link.origin_slot, input.type, visited)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether this output is a valid endpoint for a link (non-virtual, non-bypass).
|
||||
* @param slot The slot index of the output.
|
||||
* @param type The type of the input
|
||||
* @param visited A set of unique IDs to guard against infinite recursion. See {@link resolveInput}.
|
||||
* @returns The node and the origin ID / slot index of the output.
|
||||
*/
|
||||
resolveOutput(slot: number, type: ISlotType, visited: Set<string>): ResolvedInput | undefined {
|
||||
const uniqueId = `${this.subgraphNode?.subgraph.id}:${this.node.id}[O]${slot}`
|
||||
if (visited.has(uniqueId)) {
|
||||
const nodeInfo = `${this.node.id}${this.node.title ? ` (${this.node.title})` : ""}`
|
||||
const pathInfo = this.subgraphNodePath.length > 0 ? ` at path ${this.subgraphNodePath.join(":")}` : ""
|
||||
throw new RecursionError(
|
||||
`Circular reference detected while resolving output ${slot} of node ${nodeInfo}${pathInfo}. ` +
|
||||
`This creates an infinite loop in link resolution. UniqueID: [${uniqueId}]`,
|
||||
)
|
||||
}
|
||||
visited.add(uniqueId)
|
||||
|
||||
// Upstreamed: Bypass nodes are bypassed using the first input with matching type
|
||||
if (this.mode === LGraphEventMode.BYPASS) {
|
||||
const { inputs } = this
|
||||
|
||||
// Bypass nodes by finding first input with matching type
|
||||
const parentInputIndexes = Object.keys(inputs).map(Number)
|
||||
// Prioritise exact slot index
|
||||
const indexes = [slot, ...parentInputIndexes]
|
||||
const matchingIndex = indexes.find(i => inputs[i]?.type === type)
|
||||
|
||||
// No input types match
|
||||
if (matchingIndex === undefined) {
|
||||
console.debug(`[ExecutableNodeDTO.resolveOutput] No input types match type [${type}] for id [${this.id}] slot [${slot}]`, this)
|
||||
return
|
||||
}
|
||||
|
||||
return this.resolveInput(matchingIndex, visited)
|
||||
}
|
||||
|
||||
const { node } = this
|
||||
if (node.isSubgraphNode()) return this.#resolveSubgraphOutput(slot, type, visited)
|
||||
|
||||
// Upstreamed: Other virtual nodes are bypassed using the same input/output index (slots must match)
|
||||
if (node.isVirtualNode) {
|
||||
if (this.inputs.at(slot)) return this.resolveInput(slot, visited)
|
||||
|
||||
// Fallback check for nodes performing link redirection
|
||||
const virtualLink = this.node.getInputLink(slot)
|
||||
if (virtualLink) {
|
||||
const outputNode = this.graph.getNodeById(virtualLink.origin_id)
|
||||
if (!outputNode) throw new InvalidLinkError(`Virtual node failed to resolve parent [${this.id}] slot [${slot}]`)
|
||||
|
||||
const outputNodeExecutionId = [...this.subgraphNodePath, outputNode.id].join(":")
|
||||
const outputNodeDto = this.nodesByExecutionId.get(outputNodeExecutionId)
|
||||
if (!outputNodeDto) throw new Error(`No output node DTO found for id [${outputNode.id}]`)
|
||||
|
||||
return outputNodeDto.resolveOutput(virtualLink.origin_slot, type, visited)
|
||||
}
|
||||
|
||||
// Virtual nodes without a matching input should be discarded.
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
node: this,
|
||||
origin_id: this.id,
|
||||
origin_slot: slot,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the link inside a subgraph node, from the subgraph IO node to the node inside the subgraph.
|
||||
* @param slot The slot index of the output on the subgraph node.
|
||||
* @param visited A set of unique IDs to guard against infinite recursion. See {@link resolveInput}.
|
||||
* @returns A DTO for the node, and the origin ID / slot index of the output.
|
||||
*/
|
||||
#resolveSubgraphOutput(slot: number, type: ISlotType, visited: Set<string>): ResolvedInput | undefined {
|
||||
const { node } = this
|
||||
const output = node.outputs.at(slot)
|
||||
|
||||
if (!output) throw new SlotIndexError(`No output found for flattened id [${this.id}] slot [${slot}]`)
|
||||
if (!node.isSubgraphNode()) throw new TypeError(`Node is not a subgraph node: ${node.id}`)
|
||||
|
||||
// Link inside the subgraph
|
||||
const innerResolved = node.resolveSubgraphOutputLink(slot)
|
||||
if (!innerResolved) return
|
||||
|
||||
const innerNode = innerResolved.outputNode
|
||||
if (!innerNode) throw new Error(`No output node found for id [${this.id}] slot [${slot}] ${output.name}`)
|
||||
|
||||
// Recurse into the subgraph
|
||||
const innerNodeExecutionId = [...this.subgraphNodePath, node.id, innerNode.id].join(":")
|
||||
const innerNodeDto = this.nodesByExecutionId.get(innerNodeExecutionId)
|
||||
if (!innerNodeDto) throw new Error(`No inner node DTO found for id [${innerNodeExecutionId}]`)
|
||||
|
||||
return innerNodeDto.resolveOutput(innerResolved.link.origin_slot, type, visited)
|
||||
}
|
||||
}
|
||||
246
src/lib/litegraph/src/subgraph/Subgraph.ts
Normal file
246
src/lib/litegraph/src/subgraph/Subgraph.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type { SubgraphEventMap } from "@/infrastructure/SubgraphEventMap"
|
||||
import type { DefaultConnectionColors, INodeInputSlot, INodeOutputSlot } from "@/interfaces"
|
||||
import type { LGraphCanvas } from "@/LGraphCanvas"
|
||||
import type { ExportedSubgraph, ExposedWidget, ISerialisedGraph, Serialisable, SerialisableGraph } from "@/types/serialisation"
|
||||
|
||||
import { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
|
||||
import { type BaseLGraph, LGraph } from "@/LGraph"
|
||||
import { createUuidv4 } from "@/utils/uuid"
|
||||
|
||||
import { SubgraphInput } from "./SubgraphInput"
|
||||
import { SubgraphInputNode } from "./SubgraphInputNode"
|
||||
import { SubgraphOutput } from "./SubgraphOutput"
|
||||
import { SubgraphOutputNode } from "./SubgraphOutputNode"
|
||||
|
||||
/** Internal; simplifies type definitions. */
|
||||
export type GraphOrSubgraph = LGraph | Subgraph
|
||||
|
||||
/** A subgraph definition. */
|
||||
export class Subgraph extends LGraph implements BaseLGraph, Serialisable<ExportedSubgraph> {
|
||||
declare readonly events: CustomEventTarget<SubgraphEventMap>
|
||||
|
||||
/** Limits the number of levels / depth that subgraphs may be nested. Prevents uncontrolled programmatic nesting. */
|
||||
static MAX_NESTED_SUBGRAPHS = 1000
|
||||
|
||||
/** The display name of the subgraph. */
|
||||
name: string = "Unnamed Subgraph"
|
||||
|
||||
readonly inputNode = new SubgraphInputNode(this)
|
||||
readonly outputNode = new SubgraphOutputNode(this)
|
||||
|
||||
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
||||
readonly inputs: SubgraphInput[] = []
|
||||
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
|
||||
readonly outputs: SubgraphOutput[] = []
|
||||
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
|
||||
readonly widgets: ExposedWidget[] = []
|
||||
|
||||
#rootGraph: LGraph
|
||||
override get rootGraph(): LGraph {
|
||||
return this.#rootGraph
|
||||
}
|
||||
|
||||
constructor(
|
||||
rootGraph: LGraph,
|
||||
data: ExportedSubgraph,
|
||||
) {
|
||||
if (!rootGraph) throw new Error("Root graph is required")
|
||||
|
||||
super()
|
||||
|
||||
this.#rootGraph = rootGraph
|
||||
|
||||
const cloned = structuredClone(data)
|
||||
this._configureBase(cloned)
|
||||
this.#configureSubgraph(cloned)
|
||||
}
|
||||
|
||||
getIoNodeOnPos(x: number, y: number): SubgraphInputNode | SubgraphOutputNode | undefined {
|
||||
const { inputNode, outputNode } = this
|
||||
if (inputNode.containsPoint([x, y])) return inputNode
|
||||
if (outputNode.containsPoint([x, y])) return outputNode
|
||||
}
|
||||
|
||||
#configureSubgraph(data: ISerialisedGraph & ExportedSubgraph | SerialisableGraph & ExportedSubgraph): void {
|
||||
const { name, inputs, outputs, widgets } = data
|
||||
|
||||
this.name = name
|
||||
if (inputs) {
|
||||
this.inputs.length = 0
|
||||
for (const input of inputs) {
|
||||
const subgraphInput = new SubgraphInput(input, this.inputNode)
|
||||
this.inputs.push(subgraphInput)
|
||||
this.events.dispatch("input-added", { input: subgraphInput })
|
||||
}
|
||||
}
|
||||
|
||||
if (outputs) {
|
||||
this.outputs.length = 0
|
||||
for (const output of outputs) {
|
||||
this.outputs.push(new SubgraphOutput(output, this.outputNode))
|
||||
}
|
||||
}
|
||||
|
||||
if (widgets) {
|
||||
this.widgets.length = 0
|
||||
for (const widget of widgets) {
|
||||
this.widgets.push(widget)
|
||||
}
|
||||
}
|
||||
|
||||
this.inputNode.configure(data.inputNode)
|
||||
this.outputNode.configure(data.outputNode)
|
||||
}
|
||||
|
||||
override configure(data: ISerialisedGraph & ExportedSubgraph | SerialisableGraph & ExportedSubgraph, keep_old?: boolean): boolean | undefined {
|
||||
const r = super.configure(data, keep_old)
|
||||
|
||||
this.#configureSubgraph(data)
|
||||
return r
|
||||
}
|
||||
|
||||
override attachCanvas(canvas: LGraphCanvas): void {
|
||||
super.attachCanvas(canvas)
|
||||
canvas.subgraph = this
|
||||
}
|
||||
|
||||
addInput(name: string, type: string): SubgraphInput {
|
||||
this.events.dispatch("adding-input", { name, type })
|
||||
|
||||
const input = new SubgraphInput({
|
||||
id: createUuidv4(),
|
||||
name,
|
||||
type,
|
||||
}, this.inputNode)
|
||||
|
||||
this.inputs.push(input)
|
||||
this.events.dispatch("input-added", { input })
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
addOutput(name: string, type: string): SubgraphOutput {
|
||||
this.events.dispatch("adding-output", { name, type })
|
||||
|
||||
const output = new SubgraphOutput({
|
||||
id: createUuidv4(),
|
||||
name,
|
||||
type,
|
||||
}, this.outputNode)
|
||||
|
||||
this.outputs.push(output)
|
||||
this.events.dispatch("output-added", { output })
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames an input slot in the subgraph.
|
||||
* @param input The input slot to rename.
|
||||
* @param name The new name for the input slot.
|
||||
*/
|
||||
renameInput(input: SubgraphInput, name: string): void {
|
||||
const index = this.inputs.indexOf(input)
|
||||
if (index === -1) throw new Error("Input not found")
|
||||
|
||||
const oldName = input.displayName
|
||||
this.events.dispatch("renaming-input", { input, index, oldName, newName: name })
|
||||
|
||||
input.label = name
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames an output slot in the subgraph.
|
||||
* @param output The output slot to rename.
|
||||
* @param name The new name for the output slot.
|
||||
*/
|
||||
renameOutput(output: SubgraphOutput, name: string): void {
|
||||
const index = this.outputs.indexOf(output)
|
||||
if (index === -1) throw new Error("Output not found")
|
||||
|
||||
const oldName = output.displayName
|
||||
this.events.dispatch("renaming-output", { output, index, oldName, newName: name })
|
||||
|
||||
output.label = name
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an input slot from the subgraph.
|
||||
* @param input The input slot to remove.
|
||||
*/
|
||||
removeInput(input: SubgraphInput): void {
|
||||
input.disconnect()
|
||||
|
||||
const index = this.inputs.indexOf(input)
|
||||
if (index === -1) throw new Error("Input not found")
|
||||
|
||||
const mayContinue = this.events.dispatch("removing-input", { input, index })
|
||||
if (!mayContinue) return
|
||||
|
||||
this.inputs.splice(index, 1)
|
||||
|
||||
const { length } = this.inputs
|
||||
for (let i = index; i < length; i++) {
|
||||
this.inputs[i].decrementSlots("inputs")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an output slot from the subgraph.
|
||||
* @param output The output slot to remove.
|
||||
*/
|
||||
removeOutput(output: SubgraphOutput): void {
|
||||
output.disconnect()
|
||||
|
||||
const index = this.outputs.indexOf(output)
|
||||
if (index === -1) throw new Error("Output not found")
|
||||
|
||||
const mayContinue = this.events.dispatch("removing-output", { output, index })
|
||||
if (!mayContinue) return
|
||||
|
||||
this.outputs.splice(index, 1)
|
||||
|
||||
const { length } = this.outputs
|
||||
for (let i = index; i < length; i++) {
|
||||
this.outputs[i].decrementSlots("outputs")
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors, fromSlot?: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput, editorAlpha?: number): void {
|
||||
this.inputNode.draw(ctx, colorContext, fromSlot, editorAlpha)
|
||||
this.outputNode.draw(ctx, colorContext, fromSlot, editorAlpha)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones the subgraph, creating an identical copy with a new ID.
|
||||
* @returns A new subgraph with the same configuration, but a new ID.
|
||||
*/
|
||||
clone(keepId: boolean = false): Subgraph {
|
||||
const exported = this.asSerialisable()
|
||||
if (!keepId) exported.id = createUuidv4()
|
||||
|
||||
const subgraph = new Subgraph(this.rootGraph, exported)
|
||||
subgraph.configure(exported)
|
||||
return subgraph
|
||||
}
|
||||
|
||||
override asSerialisable(): ExportedSubgraph & Required<Pick<SerialisableGraph, "nodes" | "groups" | "extra">> {
|
||||
return {
|
||||
id: this.id,
|
||||
version: LGraph.serialisedSchemaVersion,
|
||||
state: this.state,
|
||||
revision: this.revision,
|
||||
config: this.config,
|
||||
name: this.name,
|
||||
inputNode: this.inputNode.asSerialisable(),
|
||||
outputNode: this.outputNode.asSerialisable(),
|
||||
inputs: this.inputs.map(x => x.asSerialisable()),
|
||||
outputs: this.outputs.map(x => x.asSerialisable()),
|
||||
widgets: [...this.widgets],
|
||||
nodes: this.nodes.map(node => node.serialize()),
|
||||
groups: this.groups.map(group => group.serialize()),
|
||||
links: [...this.links.values()].map(x => x.asSerialisable()),
|
||||
extra: this.extra,
|
||||
}
|
||||
}
|
||||
}
|
||||
284
src/lib/litegraph/src/subgraph/SubgraphIONodeBase.ts
Normal file
284
src/lib/litegraph/src/subgraph/SubgraphIONodeBase.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import type { EmptySubgraphInput } from "./EmptySubgraphInput"
|
||||
import type { EmptySubgraphOutput } from "./EmptySubgraphOutput"
|
||||
import type { Subgraph } from "./Subgraph"
|
||||
import type { SubgraphInput } from "./SubgraphInput"
|
||||
import type { SubgraphOutput } from "./SubgraphOutput"
|
||||
import type { LinkConnector } from "@/canvas/LinkConnector"
|
||||
import type { DefaultConnectionColors, Hoverable, INodeInputSlot, INodeOutputSlot, Point, Positionable } from "@/interfaces"
|
||||
import type { NodeId } from "@/LGraphNode"
|
||||
import type { ExportedSubgraphIONode, Serialisable } from "@/types/serialisation"
|
||||
|
||||
import { Rectangle } from "@/infrastructure/Rectangle"
|
||||
import { type CanvasColour, type CanvasPointer, type CanvasPointerEvent, type IContextMenuValue, LiteGraph } from "@/litegraph"
|
||||
import { snapPoint } from "@/measure"
|
||||
import { CanvasItem } from "@/types/globalEnums"
|
||||
|
||||
export abstract class SubgraphIONodeBase<TSlot extends SubgraphInput | SubgraphOutput> implements Positionable, Hoverable, Serialisable<ExportedSubgraphIONode> {
|
||||
static margin = 10
|
||||
static minWidth = 100
|
||||
static roundedRadius = 10
|
||||
|
||||
readonly #boundingRect: Rectangle = new Rectangle()
|
||||
|
||||
abstract readonly id: NodeId
|
||||
|
||||
get boundingRect(): Rectangle {
|
||||
return this.#boundingRect
|
||||
}
|
||||
|
||||
selected: boolean = false
|
||||
pinned: boolean = false
|
||||
readonly removable = false
|
||||
|
||||
isPointerOver: boolean = false
|
||||
|
||||
abstract readonly emptySlot: EmptySubgraphInput | EmptySubgraphOutput
|
||||
|
||||
get pos() {
|
||||
return this.boundingRect.pos
|
||||
}
|
||||
|
||||
set pos(value) {
|
||||
this.boundingRect.pos = value
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.boundingRect.size
|
||||
}
|
||||
|
||||
set size(value) {
|
||||
this.boundingRect.size = value
|
||||
}
|
||||
|
||||
protected get sideLineWidth(): number {
|
||||
return this.isPointerOver ? 2.5 : 2
|
||||
}
|
||||
|
||||
protected get sideStrokeStyle(): CanvasColour {
|
||||
return this.isPointerOver ? "white" : "#efefef"
|
||||
}
|
||||
|
||||
abstract readonly slots: TSlot[]
|
||||
abstract get allSlots(): TSlot[]
|
||||
|
||||
constructor(
|
||||
/** The subgraph that this node belongs to. */
|
||||
readonly subgraph: Subgraph,
|
||||
) {}
|
||||
|
||||
move(deltaX: number, deltaY: number): void {
|
||||
this.pos[0] += deltaX
|
||||
this.pos[1] += deltaY
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
snapToGrid(snapTo: number): boolean {
|
||||
return this.pinned ? false : snapPoint(this.pos, snapTo)
|
||||
}
|
||||
|
||||
abstract onPointerDown(e: CanvasPointerEvent, pointer: CanvasPointer, linkConnector: LinkConnector): void
|
||||
|
||||
// #region Hoverable
|
||||
|
||||
containsPoint(point: Point): boolean {
|
||||
return this.boundingRect.containsPoint(point)
|
||||
}
|
||||
|
||||
abstract get slotAnchorX(): number
|
||||
|
||||
onPointerMove(e: CanvasPointerEvent): CanvasItem {
|
||||
const containsPoint = this.boundingRect.containsXy(e.canvasX, e.canvasY)
|
||||
let underPointer = containsPoint ? CanvasItem.SubgraphIoNode : CanvasItem.Nothing
|
||||
|
||||
if (containsPoint) {
|
||||
if (!this.isPointerOver) this.onPointerEnter()
|
||||
|
||||
for (const slot of this.allSlots) {
|
||||
slot.onPointerMove(e)
|
||||
if (slot.isPointerOver) underPointer |= CanvasItem.SubgraphIoSlot
|
||||
}
|
||||
} else if (this.isPointerOver) {
|
||||
this.onPointerLeave()
|
||||
}
|
||||
return underPointer
|
||||
}
|
||||
|
||||
onPointerEnter() {
|
||||
this.isPointerOver = true
|
||||
}
|
||||
|
||||
onPointerLeave() {
|
||||
this.isPointerOver = false
|
||||
|
||||
for (const slot of this.slots) {
|
||||
slot.isPointerOver = false
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion Hoverable
|
||||
|
||||
/**
|
||||
* Renames an IO slot in the subgraph.
|
||||
* @param slot The slot to rename.
|
||||
* @param name The new name for the slot.
|
||||
*/
|
||||
abstract renameSlot(slot: TSlot, name: string): void
|
||||
|
||||
/**
|
||||
* Removes an IO slot from the subgraph.
|
||||
* @param slot The slot to remove.
|
||||
*/
|
||||
abstract removeSlot(slot: TSlot): void
|
||||
|
||||
/**
|
||||
* Gets the slot at a given position in canvas space.
|
||||
* @param x The x coordinate of the position.
|
||||
* @param y The y coordinate of the position.
|
||||
* @returns The slot at the given position, otherwise `undefined`.
|
||||
*/
|
||||
getSlotInPosition(x: number, y: number): TSlot | undefined {
|
||||
for (const slot of this.allSlots) {
|
||||
if (slot.boundingRect.containsXy(x, y)) {
|
||||
return slot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the context menu for an IO slot.
|
||||
* @param slot The slot to show the context menu for.
|
||||
* @param event The event that triggered the context menu.
|
||||
*/
|
||||
protected showSlotContextMenu(slot: TSlot, event: CanvasPointerEvent): void {
|
||||
const options: IContextMenuValue[] = this.#getSlotMenuOptions(slot)
|
||||
if (!(options.length > 0)) return
|
||||
|
||||
new LiteGraph.ContextMenu(
|
||||
options,
|
||||
{
|
||||
event: event as any,
|
||||
title: slot.name || "Subgraph Output",
|
||||
callback: (item: IContextMenuValue) => {
|
||||
this.#onSlotMenuAction(item, slot, event)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the context menu options for an IO slot.
|
||||
* @param slot The slot to get the context menu options for.
|
||||
* @returns The context menu options.
|
||||
*/
|
||||
#getSlotMenuOptions(slot: TSlot): IContextMenuValue[] {
|
||||
const options: IContextMenuValue[] = []
|
||||
|
||||
// Disconnect option if slot has connections
|
||||
if (slot !== this.emptySlot && slot.linkIds.length > 0) {
|
||||
options.push({ content: "Disconnect Links", value: "disconnect" })
|
||||
}
|
||||
|
||||
// Remove / rename slot option (except for the empty slot)
|
||||
if (slot !== this.emptySlot) {
|
||||
options.push(
|
||||
{ content: "Remove Slot", value: "remove" },
|
||||
{ content: "Rename Slot", value: "rename" },
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the action for an IO slot context menu.
|
||||
* @param selectedItem The item that was selected from the context menu.
|
||||
* @param slot The slot
|
||||
* @param event The event that triggered the context menu.
|
||||
*/
|
||||
#onSlotMenuAction(selectedItem: IContextMenuValue, slot: TSlot, event: CanvasPointerEvent): void {
|
||||
switch (selectedItem.value) {
|
||||
// Disconnect all links from this output
|
||||
case "disconnect":
|
||||
slot.disconnect()
|
||||
break
|
||||
|
||||
// Remove the slot
|
||||
case "remove":
|
||||
if (slot !== this.emptySlot) {
|
||||
this.removeSlot(slot)
|
||||
}
|
||||
break
|
||||
|
||||
// Rename the slot
|
||||
case "rename":
|
||||
if (slot !== this.emptySlot) {
|
||||
this.subgraph.canvasAction(c => c.prompt(
|
||||
"Slot name",
|
||||
slot.name,
|
||||
(newName: string) => {
|
||||
if (newName) this.renameSlot(slot, newName)
|
||||
},
|
||||
event,
|
||||
))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
this.subgraph.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
/** Arrange the slots in this node. */
|
||||
arrange(): void {
|
||||
const { minWidth, roundedRadius } = SubgraphIONodeBase
|
||||
const [, y] = this.boundingRect
|
||||
const x = this.slotAnchorX
|
||||
const { size } = this
|
||||
|
||||
let maxWidth = minWidth
|
||||
let currentY = y + roundedRadius
|
||||
|
||||
for (const slot of this.allSlots) {
|
||||
const [slotWidth, slotHeight] = slot.measure()
|
||||
slot.arrange([x, currentY, slotWidth, slotHeight])
|
||||
|
||||
currentY += slotHeight
|
||||
if (slotWidth > maxWidth) maxWidth = slotWidth
|
||||
}
|
||||
|
||||
size[0] = maxWidth + 2 * roundedRadius
|
||||
size[1] = currentY - y + roundedRadius
|
||||
}
|
||||
|
||||
draw(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors, fromSlot?: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput, editorAlpha?: number): void {
|
||||
const { lineWidth, strokeStyle, fillStyle, font, textBaseline } = ctx
|
||||
this.drawProtected(ctx, colorContext, fromSlot, editorAlpha)
|
||||
Object.assign(ctx, { lineWidth, strokeStyle, fillStyle, font, textBaseline })
|
||||
}
|
||||
|
||||
/** @internal Leaves {@link ctx} dirty. */
|
||||
protected abstract drawProtected(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors, fromSlot?: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput, editorAlpha?: number): void
|
||||
|
||||
/** @internal Leaves {@link ctx} dirty. */
|
||||
protected drawSlots(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors, fromSlot?: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput, editorAlpha?: number): void {
|
||||
ctx.fillStyle = "#AAA"
|
||||
ctx.font = "12px Arial"
|
||||
ctx.textBaseline = "middle"
|
||||
|
||||
for (const slot of this.allSlots) {
|
||||
slot.draw({ ctx, colorContext, fromSlot, editorAlpha })
|
||||
}
|
||||
}
|
||||
|
||||
configure(data: ExportedSubgraphIONode): void {
|
||||
this.#boundingRect.set(data.bounding)
|
||||
this.pinned = data.pinned ?? false
|
||||
}
|
||||
|
||||
asSerialisable(): ExportedSubgraphIONode {
|
||||
return {
|
||||
id: this.id,
|
||||
bounding: this.boundingRect.export(),
|
||||
pinned: this.pinned ? true : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
234
src/lib/litegraph/src/subgraph/SubgraphInput.ts
Normal file
234
src/lib/litegraph/src/subgraph/SubgraphInput.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import type { SubgraphInputNode } from "./SubgraphInputNode"
|
||||
import type { SubgraphOutput } from "./SubgraphOutput"
|
||||
import type { SubgraphInputEventMap } from "@/infrastructure/SubgraphInputEventMap"
|
||||
import type { INodeInputSlot, INodeOutputSlot, Point, ReadOnlyRect } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { RerouteId } from "@/Reroute"
|
||||
import type { IBaseWidget } from "@/types/widgets"
|
||||
|
||||
import { CustomEventTarget } from "@/infrastructure/CustomEventTarget"
|
||||
import { LiteGraph } from "@/litegraph"
|
||||
import { LLink } from "@/LLink"
|
||||
import { NodeSlotType } from "@/types/globalEnums"
|
||||
|
||||
import { SubgraphSlot } from "./SubgraphSlotBase"
|
||||
import { isNodeSlot, isSubgraphOutput } from "./subgraphUtils"
|
||||
|
||||
/**
|
||||
* An input "slot" from a parent graph into a subgraph.
|
||||
*
|
||||
* IMPORTANT: A subgraph "input" is both an input AND an output. It creates an extra link connection point between
|
||||
* a parent graph and a subgraph, so is conceptually similar to a reroute.
|
||||
*
|
||||
* This can be a little confusing, but is easier to visualise when imagining editing a subgraph.
|
||||
* You have "Subgraph Inputs", because they are coming into the subgraph, which then connect to "node inputs".
|
||||
*
|
||||
* Functionally, however, when editing a subgraph, that "subgraph input" is the "origin" or "output side" of a link.
|
||||
*/
|
||||
export class SubgraphInput extends SubgraphSlot {
|
||||
declare parent: SubgraphInputNode
|
||||
|
||||
events = new CustomEventTarget<SubgraphInputEventMap>()
|
||||
|
||||
/** The linked widget that this slot is connected to. */
|
||||
#widgetRef?: WeakRef<IBaseWidget>
|
||||
|
||||
get _widget() {
|
||||
return this.#widgetRef?.deref()
|
||||
}
|
||||
|
||||
set _widget(widget) {
|
||||
this.#widgetRef = widget ? new WeakRef(widget) : undefined
|
||||
}
|
||||
|
||||
override connect(slot: INodeInputSlot, node: LGraphNode, afterRerouteId?: RerouteId): LLink | undefined {
|
||||
const { subgraph } = this.parent
|
||||
|
||||
// Allow nodes to block connection
|
||||
const inputIndex = node.inputs.indexOf(slot)
|
||||
if (node.onConnectInput?.(inputIndex, this.type, this, this.parent, -1) === false) return
|
||||
|
||||
// if (slot instanceof SubgraphOutput) {
|
||||
// // Subgraph IO nodes have no special handling at present.
|
||||
// return new LLink(
|
||||
// ++subgraph.state.lastLinkId,
|
||||
// this.type,
|
||||
// this.parent.id,
|
||||
// this.parent.slots.indexOf(this),
|
||||
// node.id,
|
||||
// inputIndex,
|
||||
// afterRerouteId,
|
||||
// )
|
||||
// }
|
||||
|
||||
// Disconnect target input, if it is already connected.
|
||||
if (slot.link != null) {
|
||||
subgraph.beforeChange()
|
||||
const link = subgraph.getLink(slot.link)
|
||||
this.parent._disconnectNodeInput(node, slot, link)
|
||||
}
|
||||
|
||||
const inputWidget = node.getWidgetFromSlot(slot)
|
||||
if (inputWidget) {
|
||||
if (!this.matchesWidget(inputWidget)) {
|
||||
console.warn("Target input has invalid widget.", slot, node)
|
||||
return
|
||||
}
|
||||
|
||||
this._widget ??= inputWidget
|
||||
this.events.dispatch("input-connected", { input: slot, widget: inputWidget })
|
||||
}
|
||||
|
||||
const link = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
slot.type,
|
||||
this.parent.id,
|
||||
this.parent.slots.indexOf(this),
|
||||
node.id,
|
||||
inputIndex,
|
||||
afterRerouteId,
|
||||
)
|
||||
|
||||
// Add to graph links list
|
||||
subgraph._links.set(link.id, link)
|
||||
|
||||
// Set link ID in each slot
|
||||
this.linkIds.push(link.id)
|
||||
slot.link = link.id
|
||||
|
||||
// Reroutes
|
||||
const reroutes = LLink.getReroutes(subgraph, link)
|
||||
for (const reroute of reroutes) {
|
||||
reroute.linkIds.add(link.id)
|
||||
if (reroute.floating) delete reroute.floating
|
||||
reroute._dragging = undefined
|
||||
}
|
||||
|
||||
// If this is the terminus of a floating link, remove it
|
||||
const lastReroute = reroutes.at(-1)
|
||||
if (lastReroute) {
|
||||
for (const linkId of lastReroute.floatingLinkIds) {
|
||||
const link = subgraph.floatingLinks.get(linkId)
|
||||
if (link?.parentId === lastReroute.id) {
|
||||
subgraph.removeFloatingLink(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
subgraph._version++
|
||||
|
||||
node.onConnectionsChange?.(
|
||||
NodeSlotType.INPUT,
|
||||
inputIndex,
|
||||
true,
|
||||
link,
|
||||
slot,
|
||||
)
|
||||
|
||||
subgraph.afterChange()
|
||||
|
||||
return link
|
||||
}
|
||||
|
||||
get labelPos(): Point {
|
||||
const [x, y, , height] = this.boundingRect
|
||||
return [x, y + height * 0.5]
|
||||
}
|
||||
|
||||
getConnectedWidgets(): IBaseWidget[] {
|
||||
const { subgraph } = this.parent
|
||||
const widgets: IBaseWidget[] = []
|
||||
|
||||
for (const linkId of this.linkIds) {
|
||||
const link = subgraph.getLink(linkId)
|
||||
if (!link) {
|
||||
console.error("Link not found", linkId)
|
||||
continue
|
||||
}
|
||||
|
||||
const resolved = link.resolve(subgraph)
|
||||
if (resolved.input && resolved.inputNode?.widgets) {
|
||||
// Has no widget
|
||||
const widgetNamePojo = resolved.input.widget
|
||||
if (!widgetNamePojo) continue
|
||||
|
||||
// Invalid widget name
|
||||
if (!widgetNamePojo.name) {
|
||||
console.warn("Invalid widget name", widgetNamePojo)
|
||||
continue
|
||||
}
|
||||
|
||||
const widget = resolved.inputNode.widgets.find(w => w.name === widgetNamePojo.name)
|
||||
if (!widget) {
|
||||
console.warn("Widget not found", widgetNamePojo)
|
||||
continue
|
||||
}
|
||||
|
||||
widgets.push(widget)
|
||||
} else {
|
||||
console.debug("No input found on link id", linkId, link)
|
||||
}
|
||||
}
|
||||
return widgets
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the connection between the new slot and the existing widget is valid.
|
||||
* Used to prevent connections between widgets that are not of the same type.
|
||||
* @param otherWidget The widget to compare to.
|
||||
* @returns `true` if the connection is valid, otherwise `false`.
|
||||
*/
|
||||
matchesWidget(otherWidget: IBaseWidget): boolean {
|
||||
const widget = this.#widgetRef?.deref()
|
||||
if (!widget) return true
|
||||
|
||||
if (
|
||||
otherWidget.type !== widget.type ||
|
||||
otherWidget.options.min !== widget.options.min ||
|
||||
otherWidget.options.max !== widget.options.max ||
|
||||
otherWidget.options.step !== widget.options.step ||
|
||||
otherWidget.options.step2 !== widget.options.step2 ||
|
||||
otherWidget.options.precision !== widget.options.precision
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override disconnect(): void {
|
||||
super.disconnect()
|
||||
|
||||
this.events.dispatch("input-disconnected", { input: this })
|
||||
}
|
||||
|
||||
/** For inputs, x is the right edge of the input node. */
|
||||
override arrange(rect: ReadOnlyRect): void {
|
||||
const [right, top, width, height] = rect
|
||||
const { boundingRect: b, pos } = this
|
||||
|
||||
b[0] = right - width
|
||||
b[1] = top
|
||||
b[2] = width
|
||||
b[3] = height
|
||||
|
||||
pos[0] = right - height * 0.5
|
||||
pos[1] = top + height * 0.5
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this slot is a valid target for a connection from the given slot.
|
||||
* For SubgraphInput (which acts as an output inside the subgraph),
|
||||
* the fromSlot should be an input slot.
|
||||
*/
|
||||
override isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput): boolean {
|
||||
if (isNodeSlot(fromSlot)) {
|
||||
return "link" in fromSlot && LiteGraph.isValidConnection(this.type, fromSlot.type)
|
||||
}
|
||||
|
||||
if (isSubgraphOutput(fromSlot)) {
|
||||
return LiteGraph.isValidConnection(this.type, fromSlot.type)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
200
src/lib/litegraph/src/subgraph/SubgraphInputNode.ts
Normal file
200
src/lib/litegraph/src/subgraph/SubgraphInputNode.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { SubgraphInput } from "./SubgraphInput"
|
||||
import type { SubgraphOutput } from "./SubgraphOutput"
|
||||
import type { LinkConnector } from "@/canvas/LinkConnector"
|
||||
import type { CanvasPointer } from "@/CanvasPointer"
|
||||
import type { DefaultConnectionColors, INodeInputSlot, INodeOutputSlot, ISlotType, Positionable } from "@/interfaces"
|
||||
import type { LGraphNode, NodeId } from "@/LGraphNode"
|
||||
import type { RerouteId } from "@/Reroute"
|
||||
import type { CanvasPointerEvent } from "@/types/events"
|
||||
import type { NodeLike } from "@/types/NodeLike"
|
||||
|
||||
import { SUBGRAPH_INPUT_ID } from "@/constants"
|
||||
import { Rectangle } from "@/infrastructure/Rectangle"
|
||||
import { LLink } from "@/LLink"
|
||||
import { NodeSlotType } from "@/types/globalEnums"
|
||||
import { findFreeSlotOfType } from "@/utils/collections"
|
||||
|
||||
import { EmptySubgraphInput } from "./EmptySubgraphInput"
|
||||
import { SubgraphIONodeBase } from "./SubgraphIONodeBase"
|
||||
|
||||
export class SubgraphInputNode extends SubgraphIONodeBase<SubgraphInput> implements Positionable {
|
||||
readonly id: NodeId = SUBGRAPH_INPUT_ID
|
||||
|
||||
readonly emptySlot: EmptySubgraphInput = new EmptySubgraphInput(this)
|
||||
|
||||
get slots() {
|
||||
return this.subgraph.inputs
|
||||
}
|
||||
|
||||
override get allSlots(): SubgraphInput[] {
|
||||
return [...this.slots, this.emptySlot]
|
||||
}
|
||||
|
||||
get slotAnchorX() {
|
||||
const [x, , width] = this.boundingRect
|
||||
return x + width - SubgraphIONodeBase.roundedRadius
|
||||
}
|
||||
|
||||
override onPointerDown(e: CanvasPointerEvent, pointer: CanvasPointer, linkConnector: LinkConnector): void {
|
||||
// Left-click handling for dragging connections
|
||||
if (e.button === 0) {
|
||||
for (const slot of this.allSlots) {
|
||||
const slotBounds = Rectangle.fromCentre(slot.pos, slot.boundingRect.height)
|
||||
|
||||
if (slotBounds.containsXy(e.canvasX, e.canvasY)) {
|
||||
pointer.onDragStart = () => {
|
||||
linkConnector.dragNewFromSubgraphInput(this.subgraph, this, slot)
|
||||
}
|
||||
pointer.onDragEnd = (eUp) => {
|
||||
linkConnector.dropLinks(this.subgraph, eUp)
|
||||
}
|
||||
pointer.finally = () => {
|
||||
linkConnector.reset(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check for right-click
|
||||
} else if (e.button === 2) {
|
||||
const slot = this.getSlotInPosition(e.canvasX, e.canvasY)
|
||||
if (slot) this.showSlotContextMenu(slot, e)
|
||||
}
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
override renameSlot(slot: SubgraphInput, name: string): void {
|
||||
this.subgraph.renameInput(slot, name)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
override removeSlot(slot: SubgraphInput): void {
|
||||
this.subgraph.removeInput(slot)
|
||||
}
|
||||
|
||||
canConnectTo(inputNode: NodeLike, input: INodeInputSlot, fromSlot: SubgraphInput): boolean {
|
||||
return inputNode.canConnectTo(this, input, fromSlot)
|
||||
}
|
||||
|
||||
connectSlots(fromSlot: SubgraphInput, inputNode: LGraphNode, input: INodeInputSlot, afterRerouteId: RerouteId | undefined): LLink {
|
||||
const { subgraph } = this
|
||||
|
||||
const outputIndex = this.slots.indexOf(fromSlot)
|
||||
const inputIndex = inputNode.inputs.indexOf(input)
|
||||
|
||||
if (outputIndex === -1 || inputIndex === -1) throw new Error("Invalid slot indices.")
|
||||
|
||||
return new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
input.type || fromSlot.type,
|
||||
this.id,
|
||||
outputIndex,
|
||||
inputNode.id,
|
||||
inputIndex,
|
||||
afterRerouteId,
|
||||
)
|
||||
}
|
||||
|
||||
// #region Legacy LGraphNode compatibility
|
||||
|
||||
connectByType(
|
||||
slot: number,
|
||||
target_node: LGraphNode,
|
||||
target_slotType: ISlotType,
|
||||
optsIn?: { afterRerouteId?: RerouteId },
|
||||
): LLink | undefined {
|
||||
const inputSlot = target_node.findInputByType(target_slotType)
|
||||
if (!inputSlot) return
|
||||
|
||||
if (slot === -1) {
|
||||
// This indicates a connection is being made from the "Empty" slot.
|
||||
// We need to create a new, concrete input on the subgraph that matches the target.
|
||||
const newSubgraphInput = this.subgraph.addInput(inputSlot.slot.name, String(inputSlot.slot.type ?? ""))
|
||||
const newSlotIndex = this.slots.indexOf(newSubgraphInput)
|
||||
if (newSlotIndex === -1) {
|
||||
console.error("Could not find newly created subgraph input slot.")
|
||||
return
|
||||
}
|
||||
slot = newSlotIndex
|
||||
}
|
||||
|
||||
return this.slots[slot].connect(inputSlot.slot, target_node, optsIn?.afterRerouteId)
|
||||
}
|
||||
|
||||
findOutputSlot(name: string): SubgraphInput | undefined {
|
||||
return this.slots.find(output => output.name === name)
|
||||
}
|
||||
|
||||
findOutputByType(type: ISlotType): SubgraphInput | undefined {
|
||||
return findFreeSlotOfType(this.slots, type, slot => slot.linkIds.length > 0)?.slot
|
||||
}
|
||||
|
||||
// #endregion Legacy LGraphNode compatibility
|
||||
|
||||
_disconnectNodeInput(node: LGraphNode, input: INodeInputSlot, link: LLink | undefined): void {
|
||||
const { subgraph } = this
|
||||
|
||||
// Break floating links
|
||||
if (input._floatingLinks?.size) {
|
||||
for (const link of input._floatingLinks) {
|
||||
subgraph.removeFloatingLink(link)
|
||||
}
|
||||
}
|
||||
|
||||
input.link = null
|
||||
subgraph.setDirtyCanvas(false, true)
|
||||
|
||||
if (!link) return
|
||||
|
||||
const subgraphInputIndex = link.origin_slot
|
||||
link.disconnect(subgraph, "output")
|
||||
subgraph._version++
|
||||
|
||||
const subgraphInput = this.slots.at(subgraphInputIndex)
|
||||
if (!subgraphInput) {
|
||||
console.debug("disconnectNodeInput: subgraphInput not found", this, subgraphInputIndex)
|
||||
return
|
||||
}
|
||||
|
||||
// search in the inputs list for this link
|
||||
const index = subgraphInput.linkIds.indexOf(link.id)
|
||||
if (index !== -1) {
|
||||
subgraphInput.linkIds.splice(index, 1)
|
||||
} else {
|
||||
console.debug("disconnectNodeInput: link ID not found in subgraphInput linkIds", link.id)
|
||||
}
|
||||
|
||||
node.onConnectionsChange?.(
|
||||
NodeSlotType.OUTPUT,
|
||||
index,
|
||||
false,
|
||||
link,
|
||||
subgraphInput,
|
||||
)
|
||||
}
|
||||
|
||||
override drawProtected(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors, fromSlot?: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput, editorAlpha?: number): void {
|
||||
const { roundedRadius } = SubgraphIONodeBase
|
||||
const transform = ctx.getTransform()
|
||||
|
||||
const [x, y, width, height] = this.boundingRect
|
||||
ctx.translate(x, y)
|
||||
|
||||
// Draw top rounded part
|
||||
ctx.strokeStyle = this.sideStrokeStyle
|
||||
ctx.lineWidth = this.sideLineWidth
|
||||
ctx.beginPath()
|
||||
ctx.arc(width - roundedRadius, roundedRadius, roundedRadius, Math.PI * 1.5, 0)
|
||||
|
||||
// Straight line to bottom
|
||||
ctx.moveTo(width, roundedRadius)
|
||||
ctx.lineTo(width, height - roundedRadius)
|
||||
|
||||
// Bottom rounded part
|
||||
ctx.arc(width - roundedRadius, height - roundedRadius, roundedRadius, 0, Math.PI * 0.5)
|
||||
ctx.stroke()
|
||||
|
||||
// Restore context
|
||||
ctx.setTransform(transform)
|
||||
|
||||
this.drawSlots(ctx, colorContext, fromSlot, editorAlpha)
|
||||
}
|
||||
}
|
||||
382
src/lib/litegraph/src/subgraph/SubgraphNode.ts
Normal file
382
src/lib/litegraph/src/subgraph/SubgraphNode.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import type { SubgraphInput } from "./SubgraphInput"
|
||||
import type { ISubgraphInput } from "@/interfaces"
|
||||
import type { BaseLGraph, LGraph } from "@/LGraph"
|
||||
import type { GraphOrSubgraph, Subgraph } from "@/subgraph/Subgraph"
|
||||
import type { ExportedSubgraphInstance } from "@/types/serialisation"
|
||||
import type { IBaseWidget } from "@/types/widgets"
|
||||
import type { UUID } from "@/utils/uuid"
|
||||
|
||||
import { RecursionError } from "@/infrastructure/RecursionError"
|
||||
import { LGraphButton } from "@/LGraphButton"
|
||||
import { LGraphCanvas } from "@/LGraphCanvas"
|
||||
import { LGraphNode } from "@/LGraphNode"
|
||||
import { type INodeInputSlot, type ISlotType, type NodeId } from "@/litegraph"
|
||||
import { LLink, type ResolvedConnection } from "@/LLink"
|
||||
import { NodeInputSlot } from "@/node/NodeInputSlot"
|
||||
import { NodeOutputSlot } from "@/node/NodeOutputSlot"
|
||||
import { toConcreteWidget } from "@/widgets/widgetMap"
|
||||
|
||||
import { type ExecutableLGraphNode, ExecutableNodeDTO, type ExecutionId } from "./ExecutableNodeDTO"
|
||||
|
||||
/**
|
||||
* An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph.
|
||||
*/
|
||||
export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
declare inputs: (INodeInputSlot & Partial<ISubgraphInput>)[]
|
||||
|
||||
override readonly type: UUID
|
||||
override readonly isVirtualNode = true as const
|
||||
|
||||
get rootGraph(): LGraph {
|
||||
return this.graph.rootGraph
|
||||
}
|
||||
|
||||
override get displayType(): string {
|
||||
return "Subgraph node"
|
||||
}
|
||||
|
||||
override isSubgraphNode(): this is SubgraphNode {
|
||||
return true
|
||||
}
|
||||
|
||||
override widgets: IBaseWidget[] = []
|
||||
|
||||
/** Manages lifecycle of all subgraph event listeners */
|
||||
#eventAbortController = new AbortController()
|
||||
|
||||
constructor(
|
||||
/** The (sub)graph that contains this subgraph instance. */
|
||||
override readonly graph: GraphOrSubgraph,
|
||||
/** The definition of this subgraph; how its nodes are configured, etc. */
|
||||
readonly subgraph: Subgraph,
|
||||
instanceData: ExportedSubgraphInstance,
|
||||
) {
|
||||
super(subgraph.name, subgraph.id)
|
||||
|
||||
// Update this node when the subgraph input / output slots are changed
|
||||
const subgraphEvents = this.subgraph.events
|
||||
const { signal } = this.#eventAbortController
|
||||
|
||||
subgraphEvents.addEventListener("input-added", (e) => {
|
||||
const subgraphInput = e.detail.input
|
||||
const { name, type } = subgraphInput
|
||||
const input = this.addInput(name, type)
|
||||
|
||||
this.#addSubgraphInputListeners(subgraphInput, input)
|
||||
}, { signal })
|
||||
|
||||
subgraphEvents.addEventListener("removing-input", (e) => {
|
||||
const widget = e.detail.input._widget
|
||||
if (widget) this.ensureWidgetRemoved(widget)
|
||||
|
||||
this.removeInput(e.detail.index)
|
||||
this.setDirtyCanvas(true, true)
|
||||
}, { signal })
|
||||
|
||||
subgraphEvents.addEventListener("output-added", (e) => {
|
||||
const { name, type } = e.detail.output
|
||||
this.addOutput(name, type)
|
||||
}, { signal })
|
||||
|
||||
subgraphEvents.addEventListener("removing-output", (e) => {
|
||||
this.removeOutput(e.detail.index)
|
||||
this.setDirtyCanvas(true, true)
|
||||
}, { signal })
|
||||
|
||||
subgraphEvents.addEventListener("renaming-input", (e) => {
|
||||
const { index, newName } = e.detail
|
||||
const input = this.inputs.at(index)
|
||||
if (!input) throw new Error("Subgraph input not found")
|
||||
|
||||
input.label = newName
|
||||
}, { signal })
|
||||
|
||||
subgraphEvents.addEventListener("renaming-output", (e) => {
|
||||
const { index, newName } = e.detail
|
||||
const output = this.outputs.at(index)
|
||||
if (!output) throw new Error("Subgraph output not found")
|
||||
|
||||
output.label = newName
|
||||
}, { signal })
|
||||
|
||||
this.type = subgraph.id
|
||||
this.configure(instanceData)
|
||||
|
||||
this.addTitleButton({
|
||||
name: "enter_subgraph",
|
||||
text: "\uE93B", // Unicode for pi-window-maximize
|
||||
yOffset: 0, // No vertical offset needed, button is centered
|
||||
xOffset: -10,
|
||||
fontSize: 16,
|
||||
})
|
||||
}
|
||||
|
||||
override onTitleButtonClick(button: LGraphButton, canvas: LGraphCanvas): void {
|
||||
if (button.name === "enter_subgraph") {
|
||||
canvas.openSubgraph(this.subgraph)
|
||||
} else {
|
||||
super.onTitleButtonClick(button, canvas)
|
||||
}
|
||||
}
|
||||
|
||||
#addSubgraphInputListeners(subgraphInput: SubgraphInput, input: INodeInputSlot & Partial<ISubgraphInput>) {
|
||||
input._listenerController?.abort()
|
||||
input._listenerController = new AbortController()
|
||||
const { signal } = input._listenerController
|
||||
|
||||
subgraphInput.events.addEventListener(
|
||||
"input-connected",
|
||||
() => {
|
||||
if (input._widget) return
|
||||
|
||||
const widget = subgraphInput._widget
|
||||
if (!widget) return
|
||||
|
||||
this.#setWidget(subgraphInput, input, widget)
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
|
||||
subgraphInput.events.addEventListener(
|
||||
"input-disconnected",
|
||||
() => {
|
||||
// If the input is connected to more than one widget, don't remove the widget
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
if (connectedWidgets.length > 0) return
|
||||
|
||||
this.removeWidgetByName(input.name)
|
||||
|
||||
delete input.pos
|
||||
delete input.widget
|
||||
input._widget = undefined
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
override configure(info: ExportedSubgraphInstance): void {
|
||||
for (const input of this.inputs) {
|
||||
input._listenerController?.abort()
|
||||
}
|
||||
|
||||
this.inputs.length = 0
|
||||
this.inputs.push(
|
||||
...this.subgraph.inputNode.slots.map(
|
||||
slot => new NodeInputSlot({ name: slot.name, localized_name: slot.localized_name, label: slot.label, type: slot.type, link: null }, this),
|
||||
),
|
||||
)
|
||||
|
||||
this.outputs.length = 0
|
||||
this.outputs.push(
|
||||
...this.subgraph.outputNode.slots.map(
|
||||
slot => new NodeOutputSlot({ name: slot.name, localized_name: slot.localized_name, label: slot.label, type: slot.type, links: null }, this),
|
||||
),
|
||||
)
|
||||
|
||||
super.configure(info)
|
||||
}
|
||||
|
||||
override _internalConfigureAfterSlots() {
|
||||
// Reset widgets
|
||||
this.widgets.length = 0
|
||||
|
||||
// Check all inputs for connected widgets
|
||||
for (const input of this.inputs) {
|
||||
const subgraphInput = this.subgraph.inputNode.slots.find(slot => slot.name === input.name)
|
||||
if (!subgraphInput) throw new Error(`[SubgraphNode.configure] No subgraph input found for input ${input.name}`)
|
||||
|
||||
this.#addSubgraphInputListeners(subgraphInput, input)
|
||||
|
||||
// Find the first widget that this slot is connected to
|
||||
for (const linkId of subgraphInput.linkIds) {
|
||||
const link = this.subgraph.getLink(linkId)
|
||||
if (!link) {
|
||||
console.warn(`[SubgraphNode.configure] No link found for link ID ${linkId}`, this)
|
||||
continue
|
||||
}
|
||||
|
||||
const resolved = link.resolve(this.subgraph)
|
||||
if (!resolved.input || !resolved.inputNode) {
|
||||
console.warn("Invalid resolved link", resolved, this)
|
||||
continue
|
||||
}
|
||||
|
||||
// No widget - ignore this link
|
||||
const widget = resolved.inputNode.getWidgetFromSlot(resolved.input)
|
||||
if (!widget) continue
|
||||
|
||||
this.#setWidget(subgraphInput, input, widget)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#setWidget(subgraphInput: Readonly<SubgraphInput>, input: INodeInputSlot, widget: Readonly<IBaseWidget>) {
|
||||
// Use the first matching widget
|
||||
const promotedWidget = toConcreteWidget(widget, this).createCopyForNode(this)
|
||||
|
||||
Object.assign(promotedWidget, {
|
||||
get name() {
|
||||
return subgraphInput.name
|
||||
},
|
||||
set name(value) {
|
||||
console.warn("Promoted widget: setting name is not allowed", this, value)
|
||||
},
|
||||
get localized_name() {
|
||||
return subgraphInput.localized_name
|
||||
},
|
||||
set localized_name(value) {
|
||||
console.warn("Promoted widget: setting localized_name is not allowed", this, value)
|
||||
},
|
||||
get label() {
|
||||
return subgraphInput.label
|
||||
},
|
||||
set label(value) {
|
||||
console.warn("Promoted widget: setting label is not allowed", this, value)
|
||||
},
|
||||
get tooltip() {
|
||||
// Preserve the original widget's tooltip for promoted widgets
|
||||
return widget.tooltip
|
||||
},
|
||||
set tooltip(value) {
|
||||
console.warn("Promoted widget: setting tooltip is not allowed", this, value)
|
||||
},
|
||||
})
|
||||
|
||||
this.widgets.push(promotedWidget)
|
||||
|
||||
// Dispatch widget-promoted event
|
||||
this.subgraph.events.dispatch("widget-promoted", { widget: promotedWidget, subgraphNode: this })
|
||||
|
||||
input.widget = { name: subgraphInput.name }
|
||||
input._widget = promotedWidget
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the subgraph slot is in the params before adding the input as normal.
|
||||
* @param name The name of the input slot.
|
||||
* @param type The type of the input slot.
|
||||
* @param inputProperties Properties that are directly assigned to the created input. Default: a new, empty object.
|
||||
* @returns The new input slot.
|
||||
* @remarks Assertion is required to instantiate empty generic POJO.
|
||||
*/
|
||||
override addInput<TInput extends Partial<ISubgraphInput>>(name: string, type: ISlotType, inputProperties: TInput = {} as TInput): INodeInputSlot & TInput {
|
||||
// Bypasses type narrowing on this.inputs
|
||||
return super.addInput(name, type, inputProperties)
|
||||
}
|
||||
|
||||
override getInputLink(slot: number): LLink | null {
|
||||
// Output side: the link from inside the subgraph
|
||||
const innerLink = this.subgraph.outputNode.slots[slot].getLinks().at(0)
|
||||
if (!innerLink) {
|
||||
console.warn(`SubgraphNode.getInputLink: no inner link found for slot ${slot}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const newLink = LLink.create(innerLink)
|
||||
newLink.origin_id = `${this.id}:${innerLink.origin_id}`
|
||||
newLink.origin_slot = innerLink.origin_slot
|
||||
|
||||
return newLink
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the internal links connected to the given input slot inside the subgraph, and resolves the nodes / slots.
|
||||
* @param slot The slot index
|
||||
* @returns The resolved connections, or undefined if no input node is found.
|
||||
* @remarks This is used to resolve the input links when dragging a link from a subgraph input slot.
|
||||
*/
|
||||
resolveSubgraphInputLinks(slot: number): ResolvedConnection[] {
|
||||
const inputSlot = this.subgraph.inputNode.slots[slot]
|
||||
const innerLinks = inputSlot.getLinks()
|
||||
if (innerLinks.length === 0) {
|
||||
console.debug(`[SubgraphNode.resolveSubgraphInputLinks] No inner links found for input slot [${slot}] ${inputSlot.name}`, this)
|
||||
return []
|
||||
}
|
||||
return innerLinks.map(link => link.resolve(this.subgraph))
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the internal link connected to the given output slot inside the subgraph, and resolves the nodes / slots.
|
||||
* @param slot The slot index
|
||||
* @returns The output node if found, otherwise undefined.
|
||||
*/
|
||||
resolveSubgraphOutputLink(slot: number): ResolvedConnection | undefined {
|
||||
const outputSlot = this.subgraph.outputNode.slots[slot]
|
||||
const innerLink = outputSlot.getLinks().at(0)
|
||||
if (innerLink) return innerLink.resolve(this.subgraph)
|
||||
|
||||
console.debug(`[SubgraphNode.resolveSubgraphOutputLink] No inner link found for output slot [${slot}] ${outputSlot.name}`, this)
|
||||
}
|
||||
|
||||
/** @internal Used to flatten the subgraph before execution. */
|
||||
getInnerNodes(
|
||||
/** The set of computed node DTOs for this execution. */
|
||||
executableNodes: Map<ExecutionId, ExecutableLGraphNode>,
|
||||
/** The path of subgraph node IDs. */
|
||||
subgraphNodePath: readonly NodeId[] = [],
|
||||
/** Internal recursion param. The list of nodes to add to. */
|
||||
nodes: ExecutableLGraphNode[] = [],
|
||||
/** Internal recursion param. The set of visited nodes. */
|
||||
visited = new Set<SubgraphNode>(),
|
||||
): ExecutableLGraphNode[] {
|
||||
if (visited.has(this)) {
|
||||
const nodeInfo = `${this.id}${this.title ? ` (${this.title})` : ""}`
|
||||
const subgraphInfo = `'${this.subgraph.name || "Unnamed Subgraph"}'`
|
||||
const depth = subgraphNodePath.length
|
||||
throw new RecursionError(
|
||||
`Circular reference detected at depth ${depth} in node ${nodeInfo} of subgraph ${subgraphInfo}. ` +
|
||||
`This creates an infinite loop in the subgraph hierarchy.`,
|
||||
)
|
||||
}
|
||||
visited.add(this)
|
||||
|
||||
const subgraphInstanceIdPath = [...subgraphNodePath, this.id]
|
||||
|
||||
// Store the subgraph node DTO
|
||||
const parentSubgraphNode = this.graph.rootGraph.resolveSubgraphIdPath(subgraphNodePath).at(-1)
|
||||
const subgraphNodeDto = new ExecutableNodeDTO(this, subgraphNodePath, executableNodes, parentSubgraphNode)
|
||||
executableNodes.set(subgraphNodeDto.id, subgraphNodeDto)
|
||||
|
||||
for (const node of this.subgraph.nodes) {
|
||||
if ("getInnerNodes" in node) {
|
||||
node.getInnerNodes(executableNodes, subgraphInstanceIdPath, nodes, new Set(visited))
|
||||
} else {
|
||||
// Create minimal DTOs rather than cloning the node
|
||||
const aVeryRealNode = new ExecutableNodeDTO(node, subgraphInstanceIdPath, executableNodes, this)
|
||||
executableNodes.set(aVeryRealNode.id, aVeryRealNode)
|
||||
nodes.push(aVeryRealNode)
|
||||
}
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
override removeWidgetByName(name: string): void {
|
||||
const widget = this.widgets.find(w => w.name === name)
|
||||
if (widget) {
|
||||
this.subgraph.events.dispatch("widget-demoted", { widget, subgraphNode: this })
|
||||
}
|
||||
super.removeWidgetByName(name)
|
||||
}
|
||||
|
||||
override ensureWidgetRemoved(widget: IBaseWidget): void {
|
||||
if (this.widgets.includes(widget)) {
|
||||
this.subgraph.events.dispatch("widget-demoted", { widget, subgraphNode: this })
|
||||
}
|
||||
super.ensureWidgetRemoved(widget)
|
||||
}
|
||||
|
||||
override onRemoved(): void {
|
||||
// Clean up all subgraph event listeners
|
||||
this.#eventAbortController.abort()
|
||||
|
||||
// Clean up all promoted widgets
|
||||
for (const widget of this.widgets) {
|
||||
this.subgraph.events.dispatch("widget-demoted", { widget, subgraphNode: this })
|
||||
}
|
||||
|
||||
for (const input of this.inputs) {
|
||||
input._listenerController?.abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
137
src/lib/litegraph/src/subgraph/SubgraphOutput.ts
Normal file
137
src/lib/litegraph/src/subgraph/SubgraphOutput.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { SubgraphInput } from "./SubgraphInput"
|
||||
import type { SubgraphOutputNode } from "./SubgraphOutputNode"
|
||||
import type { INodeInputSlot, INodeOutputSlot, Point, ReadOnlyRect } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { RerouteId } from "@/Reroute"
|
||||
|
||||
import { LiteGraph } from "@/litegraph"
|
||||
import { LLink } from "@/LLink"
|
||||
import { NodeSlotType } from "@/types/globalEnums"
|
||||
import { removeFromArray } from "@/utils/collections"
|
||||
|
||||
import { SubgraphSlot } from "./SubgraphSlotBase"
|
||||
import { isNodeSlot, isSubgraphInput } from "./subgraphUtils"
|
||||
|
||||
/**
|
||||
* An output "slot" from a subgraph to a parent graph.
|
||||
*
|
||||
* IMPORTANT: A subgraph "output" is both an output AND an input. It creates an extra link connection point between
|
||||
* a parent graph and a subgraph, so is conceptually similar to a reroute.
|
||||
*
|
||||
* This can be a little confusing, but is easier to visualise when imagining editing a subgraph.
|
||||
* You have "Subgraph Outputs", because they go from inside the subgraph and out, but links to them come from "node outputs".
|
||||
*
|
||||
* Functionally, however, when editing a subgraph, that "subgraph output" is the "target" or "input side" of a link.
|
||||
*/
|
||||
export class SubgraphOutput extends SubgraphSlot {
|
||||
declare parent: SubgraphOutputNode
|
||||
|
||||
override connect(slot: INodeOutputSlot, node: LGraphNode, afterRerouteId?: RerouteId): LLink | undefined {
|
||||
const { subgraph } = this.parent
|
||||
|
||||
// Validate type compatibility
|
||||
if (!LiteGraph.isValidConnection(slot.type, this.type)) return
|
||||
|
||||
// Allow nodes to block connection
|
||||
const outputIndex = node.outputs.indexOf(slot)
|
||||
if (outputIndex === -1) throw new Error("Slot is not an output of the given node")
|
||||
|
||||
if (node.onConnectOutput?.(outputIndex, this.type, this, this.parent, -1) === false) return
|
||||
|
||||
// Link should not be present, but just in case, disconnect it
|
||||
const existingLink = this.getLinks().at(0)
|
||||
if (existingLink != null) {
|
||||
subgraph.beforeChange()
|
||||
|
||||
existingLink.disconnect(subgraph, "input")
|
||||
const resolved = existingLink.resolve(subgraph)
|
||||
const links = resolved.output?.links
|
||||
if (links) removeFromArray(links, existingLink.id)
|
||||
}
|
||||
|
||||
const link = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
slot.type,
|
||||
node.id,
|
||||
outputIndex,
|
||||
this.parent.id,
|
||||
this.parent.slots.indexOf(this),
|
||||
afterRerouteId,
|
||||
)
|
||||
|
||||
// Add to graph links list
|
||||
subgraph._links.set(link.id, link)
|
||||
|
||||
// Set link ID in each slot
|
||||
this.linkIds[0] = link.id
|
||||
slot.links ??= []
|
||||
slot.links.push(link.id)
|
||||
|
||||
// Reroutes
|
||||
const reroutes = LLink.getReroutes(subgraph, link)
|
||||
for (const reroute of reroutes) {
|
||||
reroute.linkIds.add(link.id)
|
||||
if (reroute.floating) delete reroute.floating
|
||||
reroute._dragging = undefined
|
||||
}
|
||||
|
||||
// If this is the terminus of a floating link, remove it
|
||||
const lastReroute = reroutes.at(-1)
|
||||
if (lastReroute) {
|
||||
for (const linkId of lastReroute.floatingLinkIds) {
|
||||
const link = subgraph.floatingLinks.get(linkId)
|
||||
if (link?.parentId === lastReroute.id) {
|
||||
subgraph.removeFloatingLink(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
subgraph._version++
|
||||
|
||||
node.onConnectionsChange?.(
|
||||
NodeSlotType.OUTPUT,
|
||||
outputIndex,
|
||||
true,
|
||||
link,
|
||||
slot,
|
||||
)
|
||||
|
||||
subgraph.afterChange()
|
||||
|
||||
return link
|
||||
}
|
||||
|
||||
get labelPos(): Point {
|
||||
const [x, y, , height] = this.boundingRect
|
||||
return [x + height, y + height * 0.5]
|
||||
}
|
||||
|
||||
override arrange(rect: ReadOnlyRect): void {
|
||||
const [left, top, width, height] = rect
|
||||
const { boundingRect: b, pos } = this
|
||||
|
||||
b[0] = left
|
||||
b[1] = top
|
||||
b[2] = width
|
||||
b[3] = height
|
||||
|
||||
pos[0] = left + height * 0.5
|
||||
pos[1] = top + height * 0.5
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this slot is a valid target for a connection from the given slot.
|
||||
* For SubgraphOutput (which acts as an input inside the subgraph),
|
||||
* the fromSlot should be an output slot.
|
||||
*/
|
||||
override isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput): boolean {
|
||||
if (isNodeSlot(fromSlot)) {
|
||||
return "links" in fromSlot && LiteGraph.isValidConnection(fromSlot.type, this.type)
|
||||
}
|
||||
|
||||
if (isSubgraphInput(fromSlot)) {
|
||||
return LiteGraph.isValidConnection(fromSlot.type, this.type)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
119
src/lib/litegraph/src/subgraph/SubgraphOutputNode.ts
Normal file
119
src/lib/litegraph/src/subgraph/SubgraphOutputNode.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { SubgraphInput } from "./SubgraphInput"
|
||||
import type { SubgraphOutput } from "./SubgraphOutput"
|
||||
import type { LinkConnector } from "@/canvas/LinkConnector"
|
||||
import type { CanvasPointer } from "@/CanvasPointer"
|
||||
import type { DefaultConnectionColors, INodeInputSlot, INodeOutputSlot, ISlotType, Positionable } from "@/interfaces"
|
||||
import type { LGraphNode, NodeId } from "@/LGraphNode"
|
||||
import type { LLink } from "@/LLink"
|
||||
import type { RerouteId } from "@/Reroute"
|
||||
import type { CanvasPointerEvent } from "@/types/events"
|
||||
import type { NodeLike } from "@/types/NodeLike"
|
||||
import type { SubgraphIO } from "@/types/serialisation"
|
||||
|
||||
import { SUBGRAPH_OUTPUT_ID } from "@/constants"
|
||||
import { Rectangle } from "@/infrastructure/Rectangle"
|
||||
import { findFreeSlotOfType } from "@/utils/collections"
|
||||
|
||||
import { EmptySubgraphOutput } from "./EmptySubgraphOutput"
|
||||
import { SubgraphIONodeBase } from "./SubgraphIONodeBase"
|
||||
|
||||
export class SubgraphOutputNode extends SubgraphIONodeBase<SubgraphOutput> implements Positionable {
|
||||
readonly id: NodeId = SUBGRAPH_OUTPUT_ID
|
||||
|
||||
readonly emptySlot: EmptySubgraphOutput = new EmptySubgraphOutput(this)
|
||||
|
||||
get slots() {
|
||||
return this.subgraph.outputs
|
||||
}
|
||||
|
||||
override get allSlots(): SubgraphOutput[] {
|
||||
return [...this.slots, this.emptySlot]
|
||||
}
|
||||
|
||||
get slotAnchorX() {
|
||||
const [x] = this.boundingRect
|
||||
return x + SubgraphIONodeBase.roundedRadius
|
||||
}
|
||||
|
||||
override onPointerDown(e: CanvasPointerEvent, pointer: CanvasPointer, linkConnector: LinkConnector): void {
|
||||
// Left-click handling for dragging connections
|
||||
if (e.button === 0) {
|
||||
for (const slot of this.allSlots) {
|
||||
const slotBounds = Rectangle.fromCentre(slot.pos, slot.boundingRect.height)
|
||||
|
||||
if (slotBounds.containsXy(e.canvasX, e.canvasY)) {
|
||||
pointer.onDragStart = () => {
|
||||
linkConnector.dragNewFromSubgraphOutput(this.subgraph, this, slot)
|
||||
}
|
||||
pointer.onDragEnd = (eUp) => {
|
||||
linkConnector.dropLinks(this.subgraph, eUp)
|
||||
}
|
||||
pointer.finally = () => {
|
||||
linkConnector.reset(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check for right-click
|
||||
} else if (e.button === 2) {
|
||||
const slot = this.getSlotInPosition(e.canvasX, e.canvasY)
|
||||
if (slot) this.showSlotContextMenu(slot, e)
|
||||
}
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
override renameSlot(slot: SubgraphOutput, name: string): void {
|
||||
this.subgraph.renameOutput(slot, name)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
override removeSlot(slot: SubgraphOutput): void {
|
||||
this.subgraph.removeOutput(slot)
|
||||
}
|
||||
|
||||
canConnectTo(outputNode: NodeLike, fromSlot: SubgraphOutput, output: INodeOutputSlot | SubgraphIO): boolean {
|
||||
return outputNode.canConnectTo(this, fromSlot, output)
|
||||
}
|
||||
|
||||
connectByTypeOutput(
|
||||
slot: number,
|
||||
target_node: LGraphNode,
|
||||
target_slotType: ISlotType,
|
||||
optsIn?: { afterRerouteId?: RerouteId },
|
||||
): LLink | undefined {
|
||||
const outputSlot = target_node.findOutputByType(target_slotType)
|
||||
if (!outputSlot) return
|
||||
|
||||
return this.slots[slot].connect(outputSlot.slot, target_node, optsIn?.afterRerouteId)
|
||||
}
|
||||
|
||||
findInputByType(type: ISlotType): SubgraphOutput | undefined {
|
||||
return findFreeSlotOfType(this.slots, type, slot => slot.linkIds.length > 0)?.slot
|
||||
}
|
||||
|
||||
override drawProtected(ctx: CanvasRenderingContext2D, colorContext: DefaultConnectionColors, fromSlot?: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput, editorAlpha?: number): void {
|
||||
const { roundedRadius } = SubgraphIONodeBase
|
||||
const transform = ctx.getTransform()
|
||||
|
||||
const [x, y, , height] = this.boundingRect
|
||||
ctx.translate(x, y)
|
||||
|
||||
// Draw bottom rounded part
|
||||
ctx.strokeStyle = this.sideStrokeStyle
|
||||
ctx.lineWidth = this.sideLineWidth
|
||||
ctx.beginPath()
|
||||
ctx.arc(roundedRadius, roundedRadius, roundedRadius, Math.PI, Math.PI * 1.5)
|
||||
|
||||
// Straight line to bottom
|
||||
ctx.moveTo(0, roundedRadius)
|
||||
ctx.lineTo(0, height - roundedRadius)
|
||||
|
||||
// Bottom rounded part
|
||||
ctx.arc(roundedRadius, height - roundedRadius, roundedRadius, Math.PI, Math.PI * 0.5, true)
|
||||
ctx.stroke()
|
||||
|
||||
// Restore context
|
||||
ctx.setTransform(transform)
|
||||
|
||||
this.drawSlots(ctx, colorContext, fromSlot, editorAlpha)
|
||||
}
|
||||
}
|
||||
206
src/lib/litegraph/src/subgraph/SubgraphSlotBase.ts
Normal file
206
src/lib/litegraph/src/subgraph/SubgraphSlotBase.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { SubgraphInput } from "./SubgraphInput"
|
||||
import type { SubgraphInputNode } from "./SubgraphInputNode"
|
||||
import type { SubgraphOutput } from "./SubgraphOutput"
|
||||
import type { SubgraphOutputNode } from "./SubgraphOutputNode"
|
||||
import type { DefaultConnectionColors, Hoverable, INodeInputSlot, INodeOutputSlot, Point, ReadOnlyRect, ReadOnlySize } from "@/interfaces"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { LinkId, LLink } from "@/LLink"
|
||||
import type { RerouteId } from "@/Reroute"
|
||||
import type { CanvasPointerEvent } from "@/types/events"
|
||||
import type { Serialisable, SubgraphIO } from "@/types/serialisation"
|
||||
|
||||
import { SlotShape } from "@/draw"
|
||||
import { ConstrainedSize } from "@/infrastructure/ConstrainedSize"
|
||||
import { Rectangle } from "@/infrastructure/Rectangle"
|
||||
import { LGraphCanvas } from "@/LGraphCanvas"
|
||||
import { LiteGraph } from "@/litegraph"
|
||||
import { SlotBase } from "@/node/SlotBase"
|
||||
import { createUuidv4, type UUID } from "@/utils/uuid"
|
||||
|
||||
export interface SubgraphSlotDrawOptions {
|
||||
ctx: CanvasRenderingContext2D
|
||||
colorContext: DefaultConnectionColors
|
||||
lowQuality?: boolean
|
||||
fromSlot?: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput
|
||||
editorAlpha?: number
|
||||
}
|
||||
|
||||
/** Shared base class for the slots used on Subgraph . */
|
||||
export abstract class SubgraphSlot extends SlotBase implements SubgraphIO, Hoverable, Serialisable<SubgraphIO> {
|
||||
static get defaultHeight() {
|
||||
return LiteGraph.NODE_SLOT_HEIGHT
|
||||
}
|
||||
|
||||
readonly #pos: Point = new Float32Array(2)
|
||||
|
||||
readonly measurement: ConstrainedSize = new ConstrainedSize(SubgraphSlot.defaultHeight, SubgraphSlot.defaultHeight)
|
||||
|
||||
readonly id: UUID
|
||||
readonly parent: SubgraphInputNode | SubgraphOutputNode
|
||||
override type: string
|
||||
|
||||
readonly linkIds: LinkId[] = []
|
||||
|
||||
override readonly boundingRect: Rectangle = new Rectangle(0, 0, 0, SubgraphSlot.defaultHeight)
|
||||
|
||||
override get pos() {
|
||||
return this.#pos
|
||||
}
|
||||
|
||||
override set pos(value) {
|
||||
if (!value || value.length < 2) return
|
||||
|
||||
this.#pos[0] = value[0]
|
||||
this.#pos[1] = value[1]
|
||||
}
|
||||
|
||||
/** Whether this slot is connected to another slot. */
|
||||
override get isConnected() {
|
||||
return this.linkIds.length > 0
|
||||
}
|
||||
|
||||
/** The display name of this slot. */
|
||||
get displayName() {
|
||||
return this.label ?? this.localized_name ?? this.name
|
||||
}
|
||||
|
||||
abstract get labelPos(): Point
|
||||
|
||||
constructor(slot: SubgraphIO, parent: SubgraphInputNode | SubgraphOutputNode) {
|
||||
super(slot.name, slot.type)
|
||||
|
||||
Object.assign(this, slot)
|
||||
this.id = slot.id ?? createUuidv4()
|
||||
this.type = slot.type
|
||||
this.parent = parent
|
||||
}
|
||||
|
||||
isPointerOver: boolean = false
|
||||
|
||||
containsPoint(point: Point): boolean {
|
||||
return this.boundingRect.containsPoint(point)
|
||||
}
|
||||
|
||||
onPointerMove(e: CanvasPointerEvent): void {
|
||||
this.isPointerOver = this.boundingRect.containsXy(e.canvasX, e.canvasY)
|
||||
}
|
||||
|
||||
getLinks(): LLink[] {
|
||||
const links: LLink[] = []
|
||||
const { subgraph } = this.parent
|
||||
|
||||
for (const id of this.linkIds) {
|
||||
const link = subgraph.getLink(id)
|
||||
if (link) links.push(link)
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
decrementSlots(inputsOrOutputs: "inputs" | "outputs"): void {
|
||||
const { links } = this.parent.subgraph
|
||||
const linkProperty = inputsOrOutputs === "inputs" ? "origin_slot" : "target_slot"
|
||||
|
||||
for (const linkId of this.linkIds) {
|
||||
const link = links.get(linkId)
|
||||
if (link) link[linkProperty]--
|
||||
else console.warn("decrementSlots: link ID not found", linkId)
|
||||
}
|
||||
}
|
||||
|
||||
measure(): ReadOnlySize {
|
||||
const width = LGraphCanvas._measureText?.(this.displayName) ?? 0
|
||||
|
||||
const { defaultHeight } = SubgraphSlot
|
||||
this.measurement.setValues(width + defaultHeight, defaultHeight)
|
||||
return this.measurement.toSize()
|
||||
}
|
||||
|
||||
abstract arrange(rect: ReadOnlyRect): void
|
||||
|
||||
abstract connect(
|
||||
slot: INodeInputSlot | INodeOutputSlot,
|
||||
node: LGraphNode,
|
||||
afterRerouteId?: RerouteId,
|
||||
): LLink | undefined
|
||||
|
||||
/**
|
||||
* Disconnects all links connected to this slot.
|
||||
*/
|
||||
disconnect(): void {
|
||||
const { subgraph } = this.parent
|
||||
|
||||
for (const linkId of this.linkIds) {
|
||||
subgraph.removeLink(linkId)
|
||||
}
|
||||
|
||||
this.linkIds.length = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this slot is a valid target for a connection from the given slot.
|
||||
* @param fromSlot The slot that is being dragged to connect to this slot.
|
||||
* @returns true if the connection is valid, false otherwise.
|
||||
*/
|
||||
abstract isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput): boolean
|
||||
|
||||
/** @remarks Leaves the context dirty. */
|
||||
draw({ ctx, colorContext, lowQuality, fromSlot, editorAlpha = 1 }: SubgraphSlotDrawOptions): void {
|
||||
// Assertion: SlotShape is a subset of RenderShape
|
||||
const shape = this.shape as unknown as SlotShape
|
||||
const { isPointerOver, pos: [x, y] } = this
|
||||
|
||||
// Check if this slot is a valid target for the current dragging connection
|
||||
const isValidTarget = fromSlot ? this.isValidTarget(fromSlot) : true
|
||||
const isValid = !fromSlot || isValidTarget
|
||||
|
||||
// Only highlight if the slot is valid AND mouse is over it
|
||||
const highlight = isValid && isPointerOver
|
||||
|
||||
// Save current alpha
|
||||
const previousAlpha = ctx.globalAlpha
|
||||
|
||||
// Set opacity based on validity when dragging a connection
|
||||
ctx.globalAlpha = isValid ? editorAlpha : 0.4 * editorAlpha
|
||||
|
||||
ctx.beginPath()
|
||||
|
||||
// Default rendering for circle, hollow circle.
|
||||
const color = this.renderingColor(colorContext)
|
||||
if (lowQuality) {
|
||||
ctx.fillStyle = color
|
||||
|
||||
ctx.rect(x - 4, y - 4, 8, 8)
|
||||
ctx.fill()
|
||||
} else if (shape === SlotShape.HollowCircle) {
|
||||
ctx.lineWidth = 3
|
||||
ctx.strokeStyle = color
|
||||
|
||||
const radius = highlight ? 4 : 3
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
} else {
|
||||
// Normal circle
|
||||
ctx.fillStyle = color
|
||||
|
||||
const radius = highlight ? 5 : 4
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Draw label with current opacity
|
||||
if (this.displayName) {
|
||||
const [labelX, labelY] = this.labelPos
|
||||
// Also apply highlight logic to text color
|
||||
ctx.fillStyle = highlight ? "white" : (LiteGraph.NODE_TEXT_COLOR || "#AAA")
|
||||
ctx.fillText(this.displayName, labelX, labelY)
|
||||
}
|
||||
|
||||
// Restore alpha
|
||||
ctx.globalAlpha = previousAlpha
|
||||
}
|
||||
|
||||
asSerialisable(): SubgraphIO {
|
||||
const { id, name, type, linkIds, localized_name, label, dir, shape, color_off, color_on, pos } = this
|
||||
return { id, name, type, linkIds, localized_name, label, dir, shape, color_off, color_on, pos }
|
||||
}
|
||||
}
|
||||
420
src/lib/litegraph/src/subgraph/subgraphUtils.ts
Normal file
420
src/lib/litegraph/src/subgraph/subgraphUtils.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import type { GraphOrSubgraph } from "./Subgraph"
|
||||
import type { SubgraphInput } from "./SubgraphInput"
|
||||
import type { SubgraphOutput } from "./SubgraphOutput"
|
||||
import type { INodeInputSlot, INodeOutputSlot, Positionable } from "@/interfaces"
|
||||
import type { LGraph } from "@/LGraph"
|
||||
import type { ISerialisedNode, SerialisableLLink, SubgraphIO } from "@/types/serialisation"
|
||||
import type { UUID } from "@/utils/uuid"
|
||||
|
||||
import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID } from "@/constants"
|
||||
import { LGraphGroup } from "@/LGraphGroup"
|
||||
import { LGraphNode } from "@/LGraphNode"
|
||||
import { createUuidv4, LiteGraph } from "@/litegraph"
|
||||
import { LLink, type ResolvedConnection } from "@/LLink"
|
||||
import { Reroute } from "@/Reroute"
|
||||
import { nextUniqueName } from "@/strings"
|
||||
|
||||
import { SubgraphInputNode } from "./SubgraphInputNode"
|
||||
import { SubgraphOutputNode } from "./SubgraphOutputNode"
|
||||
|
||||
export interface FilteredItems {
|
||||
nodes: Set<LGraphNode>
|
||||
reroutes: Set<Reroute>
|
||||
groups: Set<LGraphGroup>
|
||||
subgraphInputNodes: Set<SubgraphInputNode>
|
||||
subgraphOutputNodes: Set<SubgraphOutputNode>
|
||||
unknown: Set<Positionable>
|
||||
}
|
||||
|
||||
export function splitPositionables(items: Iterable<Positionable>): FilteredItems {
|
||||
const nodes = new Set<LGraphNode>()
|
||||
const reroutes = new Set<Reroute>()
|
||||
const groups = new Set<LGraphGroup>()
|
||||
const subgraphInputNodes = new Set<SubgraphInputNode>()
|
||||
const subgraphOutputNodes = new Set<SubgraphOutputNode>()
|
||||
|
||||
const unknown = new Set<Positionable>()
|
||||
|
||||
for (const item of items) {
|
||||
switch (true) {
|
||||
case item instanceof LGraphNode:
|
||||
nodes.add(item)
|
||||
break
|
||||
case item instanceof LGraphGroup:
|
||||
groups.add(item)
|
||||
break
|
||||
case item instanceof Reroute:
|
||||
reroutes.add(item)
|
||||
break
|
||||
case item instanceof SubgraphInputNode:
|
||||
subgraphInputNodes.add(item)
|
||||
break
|
||||
case item instanceof SubgraphOutputNode:
|
||||
subgraphOutputNodes.add(item)
|
||||
break
|
||||
default:
|
||||
unknown.add(item)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
reroutes,
|
||||
groups,
|
||||
subgraphInputNodes,
|
||||
subgraphOutputNodes,
|
||||
unknown,
|
||||
}
|
||||
}
|
||||
|
||||
interface BoundaryLinks {
|
||||
boundaryLinks: LLink[]
|
||||
boundaryFloatingLinks: LLink[]
|
||||
internalLinks: LLink[]
|
||||
boundaryInputLinks: LLink[]
|
||||
boundaryOutputLinks: LLink[]
|
||||
}
|
||||
|
||||
export function getBoundaryLinks(graph: LGraph, items: Set<Positionable>): BoundaryLinks {
|
||||
const internalLinks: LLink[] = []
|
||||
const boundaryLinks: LLink[] = []
|
||||
const boundaryInputLinks: LLink[] = []
|
||||
const boundaryOutputLinks: LLink[] = []
|
||||
const boundaryFloatingLinks: LLink[] = []
|
||||
const visited = new WeakSet<Positionable>()
|
||||
|
||||
for (const item of items) {
|
||||
if (visited.has(item)) continue
|
||||
visited.add(item)
|
||||
|
||||
// Nodes
|
||||
if (item instanceof LGraphNode) {
|
||||
const node = item
|
||||
|
||||
// Inputs
|
||||
if (node.inputs) {
|
||||
for (const input of node.inputs) {
|
||||
addFloatingLinks(input._floatingLinks)
|
||||
|
||||
if (input.link == null) continue
|
||||
|
||||
const resolved = LLink.resolve(input.link, graph)
|
||||
if (!resolved) {
|
||||
console.debug(`Failed to resolve link ID [${input.link}]`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Output end of this link is outside the items set
|
||||
const { link, outputNode } = resolved
|
||||
if (outputNode) {
|
||||
if (!items.has(outputNode)) {
|
||||
boundaryInputLinks.push(link)
|
||||
} else {
|
||||
internalLinks.push(link)
|
||||
}
|
||||
} else if (link.origin_id === SUBGRAPH_INPUT_ID) {
|
||||
// Subgraph input node - always boundary
|
||||
boundaryInputLinks.push(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs
|
||||
if (node.outputs) {
|
||||
for (const output of node.outputs) {
|
||||
addFloatingLinks(output._floatingLinks)
|
||||
|
||||
if (!output.links) continue
|
||||
|
||||
const many = LLink.resolveMany(output.links, graph)
|
||||
for (const { link, inputNode } of many) {
|
||||
if (
|
||||
// Subgraph output node
|
||||
link.target_id === SUBGRAPH_OUTPUT_ID ||
|
||||
// Input end of this link is outside the items set
|
||||
(inputNode && !items.has(inputNode))
|
||||
) {
|
||||
boundaryOutputLinks.push(link)
|
||||
}
|
||||
// Internal links are discovered on input side.
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (item instanceof Reroute) {
|
||||
// Reroutes
|
||||
const reroute = item
|
||||
|
||||
// TODO: This reroute should be on one side of the boundary. We should mark the reroute that is on each side of the boundary.
|
||||
// TODO: This could occur any number of times on a link; each time should be marked as a separate boundary.
|
||||
// TODO: e.g. A link with 3 reroutes, the first and last reroute are in `items`, but the middle reroute is not. This will be two "in" and two "out" boundaries.
|
||||
const results = LLink.resolveMany(reroute.linkIds, graph)
|
||||
for (const { link } of results) {
|
||||
const reroutes = LLink.getReroutes(graph, link)
|
||||
const reroutesOutside = reroutes.filter(reroute => !items.has(reroute))
|
||||
|
||||
// for (const reroute of reroutes) {
|
||||
// // TODO: Do the checks here.
|
||||
// }
|
||||
|
||||
const { inputNode, outputNode } = link.resolve(graph)
|
||||
|
||||
if (
|
||||
reroutesOutside.length ||
|
||||
(inputNode && !items.has(inputNode)) ||
|
||||
(outputNode && !items.has(outputNode))
|
||||
) {
|
||||
boundaryLinks.push(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { boundaryLinks, boundaryFloatingLinks, internalLinks, boundaryInputLinks, boundaryOutputLinks }
|
||||
|
||||
/**
|
||||
* Adds any floating links that cross the boundary.
|
||||
* @param floatingLinks The floating links to check
|
||||
*/
|
||||
function addFloatingLinks(floatingLinks: Set<LLink> | undefined): void {
|
||||
if (!floatingLinks) return
|
||||
|
||||
for (const link of floatingLinks) {
|
||||
const crossesBoundary = LLink
|
||||
.getReroutes(graph, link)
|
||||
.some(reroute => !items.has(reroute))
|
||||
|
||||
if (crossesBoundary) boundaryFloatingLinks.push(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function multiClone(nodes: Iterable<LGraphNode>): ISerialisedNode[] {
|
||||
const clonedNodes: ISerialisedNode[] = []
|
||||
|
||||
// Selectively clone - keep IDs & links
|
||||
for (const node of nodes) {
|
||||
const newNode = LiteGraph.createNode(node.type)
|
||||
if (!newNode) {
|
||||
console.warn("Failed to create node", node.type)
|
||||
continue
|
||||
}
|
||||
|
||||
// Must be cloned; litegraph "serialize" is mostly shallow clone
|
||||
const data = LiteGraph.cloneObject(node.serialize())
|
||||
newNode.configure(data)
|
||||
|
||||
clonedNodes.push(newNode.serialize())
|
||||
}
|
||||
|
||||
return clonedNodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups resolved connections by output object. If the output is nullish, the connection will be in its own group.
|
||||
* @param resolvedConnections The resolved connections to group
|
||||
* @returns A map of grouped connections.
|
||||
*/
|
||||
export function groupResolvedByOutput(
|
||||
resolvedConnections: ResolvedConnection[],
|
||||
): Map<SubgraphIO | INodeOutputSlot | object, ResolvedConnection[]> {
|
||||
const groupedByOutput: ReturnType<typeof groupResolvedByOutput> = new Map()
|
||||
|
||||
for (const resolved of resolvedConnections) {
|
||||
// Force no group (unique object) if output is undefined; corruption or an error has occurred
|
||||
const groupBy = resolved.subgraphInput ?? resolved.output ?? {}
|
||||
const group = groupedByOutput.get(groupBy)
|
||||
if (group) {
|
||||
group.push(resolved)
|
||||
} else {
|
||||
groupedByOutput.set(groupBy, [resolved])
|
||||
}
|
||||
}
|
||||
|
||||
return groupedByOutput
|
||||
}
|
||||
|
||||
export function mapSubgraphInputsAndLinks(resolvedInputLinks: ResolvedConnection[], links: SerialisableLLink[]): SubgraphIO[] {
|
||||
// Group matching links
|
||||
const groupedByOutput = groupResolvedByOutput(resolvedInputLinks)
|
||||
|
||||
// Create one input for each output (outside subgraph)
|
||||
const inputs: SubgraphIO[] = []
|
||||
|
||||
for (const [, connections] of groupedByOutput) {
|
||||
const inputLinks: SerialisableLLink[] = []
|
||||
|
||||
// Create serialised links for all links (will be recreated in subgraph)
|
||||
for (const resolved of connections) {
|
||||
const { link, input } = resolved
|
||||
if (!input) continue
|
||||
|
||||
const linkData = link.asSerialisable()
|
||||
linkData.origin_id = SUBGRAPH_INPUT_ID
|
||||
linkData.origin_slot = inputs.length
|
||||
links.push(linkData)
|
||||
inputLinks.push(linkData)
|
||||
}
|
||||
|
||||
// Use first input link
|
||||
const { input } = connections[0]
|
||||
if (!input) continue
|
||||
|
||||
// Subgraph input slot
|
||||
const { color_off, color_on, dir, hasErrors, label, localized_name, name, shape, type } = input
|
||||
const uniqueName = nextUniqueName(name, inputs.map(input => input.name))
|
||||
const uniqueLocalizedName = localized_name ? nextUniqueName(localized_name, inputs.map(input => input.localized_name ?? "")) : undefined
|
||||
|
||||
const inputData: SubgraphIO = {
|
||||
id: createUuidv4(),
|
||||
type: String(type),
|
||||
linkIds: inputLinks.map(link => link.id),
|
||||
name: uniqueName,
|
||||
color_off,
|
||||
color_on,
|
||||
dir,
|
||||
label,
|
||||
localized_name: uniqueLocalizedName,
|
||||
hasErrors,
|
||||
shape,
|
||||
}
|
||||
|
||||
inputs.push(inputData)
|
||||
}
|
||||
|
||||
return inputs
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones the output slots, and updates existing links, when converting items to a subgraph.
|
||||
* @param resolvedOutputLinks The resolved output links.
|
||||
* @param links The links to add to the subgraph.
|
||||
* @returns The subgraph output slots.
|
||||
*/
|
||||
export function mapSubgraphOutputsAndLinks(resolvedOutputLinks: ResolvedConnection[], links: SerialisableLLink[]): SubgraphIO[] {
|
||||
// Group matching links
|
||||
const groupedByOutput = groupResolvedByOutput(resolvedOutputLinks)
|
||||
|
||||
const outputs: SubgraphIO[] = []
|
||||
|
||||
for (const [, connections] of groupedByOutput) {
|
||||
const outputLinks: SerialisableLLink[] = []
|
||||
|
||||
// Create serialised links for all links (will be recreated in subgraph)
|
||||
for (const resolved of connections) {
|
||||
const { link, output } = resolved
|
||||
if (!output) continue
|
||||
|
||||
// Link
|
||||
const linkData = link.asSerialisable()
|
||||
linkData.target_id = SUBGRAPH_OUTPUT_ID
|
||||
linkData.target_slot = outputs.length
|
||||
links.push(linkData)
|
||||
outputLinks.push(linkData)
|
||||
}
|
||||
|
||||
// Use first output link
|
||||
const { output } = connections[0]
|
||||
if (!output) continue
|
||||
|
||||
// Subgraph output slot
|
||||
const { color_off, color_on, dir, hasErrors, label, localized_name, name, shape, type } = output
|
||||
const uniqueName = nextUniqueName(name, outputs.map(output => output.name))
|
||||
const uniqueLocalizedName = localized_name ? nextUniqueName(localized_name, outputs.map(output => output.localized_name ?? "")) : undefined
|
||||
|
||||
const outputData = {
|
||||
id: createUuidv4(),
|
||||
type: String(type),
|
||||
linkIds: outputLinks.map(link => link.id),
|
||||
name: uniqueName,
|
||||
color_off,
|
||||
color_on,
|
||||
dir,
|
||||
label,
|
||||
localized_name: uniqueLocalizedName,
|
||||
hasErrors,
|
||||
shape,
|
||||
} satisfies SubgraphIO
|
||||
|
||||
outputs.push(structuredClone(outputData))
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all subgraph IDs used directly in a single graph (non-recursive).
|
||||
* @param graph The graph to check for subgraph nodes
|
||||
* @returns Set of subgraph IDs used in this graph
|
||||
*/
|
||||
export function getDirectSubgraphIds(graph: GraphOrSubgraph): Set<UUID> {
|
||||
const subgraphIds = new Set<UUID>()
|
||||
|
||||
for (const node of graph._nodes) {
|
||||
if (node.isSubgraphNode()) {
|
||||
subgraphIds.add(node.type)
|
||||
}
|
||||
}
|
||||
|
||||
return subgraphIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all subgraph IDs referenced in a graph hierarchy using BFS.
|
||||
* @param rootGraph The graph to start from
|
||||
* @param subgraphRegistry Map of all available subgraphs
|
||||
* @returns Set of all subgraph IDs found
|
||||
*/
|
||||
export function findUsedSubgraphIds(
|
||||
rootGraph: GraphOrSubgraph,
|
||||
subgraphRegistry: Map<UUID, GraphOrSubgraph>,
|
||||
): Set<UUID> {
|
||||
const usedSubgraphIds = new Set<UUID>()
|
||||
const toVisit: GraphOrSubgraph[] = [rootGraph]
|
||||
|
||||
while (toVisit.length > 0) {
|
||||
const graph = toVisit.shift()!
|
||||
const directIds = getDirectSubgraphIds(graph)
|
||||
|
||||
for (const id of directIds) {
|
||||
if (!usedSubgraphIds.has(id)) {
|
||||
usedSubgraphIds.add(id)
|
||||
const subgraph = subgraphRegistry.get(id)
|
||||
if (subgraph) {
|
||||
toVisit.push(subgraph)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return usedSubgraphIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a slot is a SubgraphInput.
|
||||
* @param slot The slot to check
|
||||
* @returns true if the slot is a SubgraphInput
|
||||
*/
|
||||
export function isSubgraphInput(slot: unknown): slot is SubgraphInput {
|
||||
return slot != null && typeof slot === "object" && "parent" in slot &&
|
||||
slot.parent instanceof SubgraphInputNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a slot is a SubgraphOutput.
|
||||
* @param slot The slot to check
|
||||
* @returns true if the slot is a SubgraphOutput
|
||||
*/
|
||||
export function isSubgraphOutput(slot: unknown): slot is SubgraphOutput {
|
||||
return slot != null && typeof slot === "object" && "parent" in slot &&
|
||||
slot.parent instanceof SubgraphOutputNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a slot is a regular node slot (INodeInputSlot or INodeOutputSlot).
|
||||
* @param slot The slot to check
|
||||
* @returns true if the slot is a regular node slot
|
||||
*/
|
||||
export function isNodeSlot(slot: unknown): slot is INodeInputSlot | INodeOutputSlot {
|
||||
return slot != null && typeof slot === "object" &&
|
||||
("link" in slot || "links" in slot)
|
||||
}
|
||||
13
src/lib/litegraph/src/types/NodeLike.ts
Normal file
13
src/lib/litegraph/src/types/NodeLike.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { INodeInputSlot, INodeOutputSlot } from "@/interfaces"
|
||||
import type { NodeId } from "@/LGraphNode"
|
||||
import type { SubgraphIO } from "@/types/serialisation"
|
||||
|
||||
export interface NodeLike {
|
||||
id: NodeId
|
||||
|
||||
canConnectTo(
|
||||
node: NodeLike,
|
||||
toSlot: INodeInputSlot | SubgraphIO,
|
||||
fromSlot: INodeOutputSlot | SubgraphIO,
|
||||
): boolean
|
||||
}
|
||||
23
src/lib/litegraph/src/types/disposable.d.ts
vendored
Normal file
23
src/lib/litegraph/src/types/disposable.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Polyfill for disposable type; symbol already registered in modern browsers.
|
||||
*/
|
||||
|
||||
interface SymbolConstructor {
|
||||
/**
|
||||
* A method that is used to release resources held by an object. Called by the semantics of the `using` statement.
|
||||
*/
|
||||
readonly dispose: unique symbol
|
||||
|
||||
/**
|
||||
* A method that is used to asynchronously release resources held by an object. Called by the semantics of the `await using` statement.
|
||||
*/
|
||||
readonly asyncDispose: unique symbol
|
||||
}
|
||||
|
||||
interface Disposable {
|
||||
[Symbol.dispose](): void
|
||||
}
|
||||
|
||||
interface AsyncDisposable {
|
||||
[Symbol.asyncDispose](): PromiseLike<void>
|
||||
}
|
||||
89
src/lib/litegraph/src/types/events.ts
Normal file
89
src/lib/litegraph/src/types/events.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Event interfaces for event extension
|
||||
*/
|
||||
|
||||
import type { LGraphGroup } from "../LGraphGroup"
|
||||
import type { LGraphNode } from "../LGraphNode"
|
||||
import type { LinkReleaseContextExtended } from "../litegraph"
|
||||
|
||||
/** For Canvas*Event - adds graph space co-ordinates (property names are shipped) */
|
||||
export interface ICanvasPosition {
|
||||
/** X co-ordinate of the event, in graph space (NOT canvas space) */
|
||||
canvasX: number
|
||||
/** Y co-ordinate of the event, in graph space (NOT canvas space) */
|
||||
canvasY: number
|
||||
}
|
||||
|
||||
/** For Canvas*Event */
|
||||
export interface IDeltaPosition {
|
||||
deltaX: number
|
||||
deltaY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Workaround for Firefox returning 0 on offsetX/Y props
|
||||
* See https://github.com/Comfy-Org/litegraph.js/issues/403 for details
|
||||
*/
|
||||
export interface IOffsetWorkaround {
|
||||
/** See {@link MouseEvent.offsetX}. This workaround is required (2024-12-31) to support Firefox, which always returns 0 */
|
||||
safeOffsetX: number
|
||||
/** See {@link MouseEvent.offsetY}. This workaround is required (2024-12-31) to support Firefox, which always returns 0 */
|
||||
safeOffsetY: number
|
||||
}
|
||||
|
||||
/** All properties added when converting a pointer event to a CanvasPointerEvent (via {@link LGraphCanvas.adjustMouseEvent}). */
|
||||
export type CanvasPointerExtensions = ICanvasPosition & IDeltaPosition & IOffsetWorkaround
|
||||
|
||||
interface LegacyMouseEvent {
|
||||
/** @deprecated Part of DragAndScale mouse API - incomplete / not maintained */
|
||||
dragging?: boolean
|
||||
click_time?: number
|
||||
}
|
||||
|
||||
/** PointerEvent with canvasX/Y and deltaX/Y properties */
|
||||
export interface CanvasPointerEvent extends PointerEvent, CanvasMouseEvent {}
|
||||
|
||||
/** MouseEvent with canvasX/Y and deltaX/Y properties */
|
||||
export interface CanvasMouseEvent extends
|
||||
MouseEvent,
|
||||
Readonly<CanvasPointerExtensions>,
|
||||
LegacyMouseEvent {}
|
||||
|
||||
/** DragEvent with canvasX/Y and deltaX/Y properties */
|
||||
export interface CanvasDragEvent extends
|
||||
DragEvent,
|
||||
CanvasPointerExtensions {}
|
||||
|
||||
export type CanvasEventDetail =
|
||||
| GenericEventDetail
|
||||
| GroupDoubleClickEventDetail
|
||||
| NodeDoubleClickEventDetail
|
||||
| EmptyDoubleClickEventDetail
|
||||
| EmptyReleaseEventDetail
|
||||
|
||||
export interface GenericEventDetail {
|
||||
subType: "before-change" | "after-change"
|
||||
}
|
||||
|
||||
export interface OriginalEvent {
|
||||
originalEvent: CanvasPointerEvent
|
||||
}
|
||||
|
||||
export interface EmptyReleaseEventDetail extends OriginalEvent {
|
||||
subType: "empty-release"
|
||||
linkReleaseContext: LinkReleaseContextExtended
|
||||
}
|
||||
|
||||
export interface EmptyDoubleClickEventDetail extends OriginalEvent {
|
||||
subType: "empty-double-click"
|
||||
}
|
||||
|
||||
export interface GroupDoubleClickEventDetail extends OriginalEvent {
|
||||
subType: "group-double-click"
|
||||
group: LGraphGroup
|
||||
}
|
||||
|
||||
export interface NodeDoubleClickEventDetail extends OriginalEvent {
|
||||
subType: "node-double-click"
|
||||
node: LGraphNode
|
||||
}
|
||||
142
src/lib/litegraph/src/types/globalEnums.ts
Normal file
142
src/lib/litegraph/src/types/globalEnums.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/** Node slot type - input or output */
|
||||
export enum NodeSlotType {
|
||||
INPUT = 1,
|
||||
OUTPUT = 2,
|
||||
}
|
||||
|
||||
/** Shape that an object will render as - used by nodes and slots */
|
||||
export enum RenderShape {
|
||||
/** Rectangle with square corners */
|
||||
BOX = 1,
|
||||
/** Rounded rectangle */
|
||||
ROUND = 2,
|
||||
/** Circle is circle */
|
||||
CIRCLE = 3,
|
||||
/** Two rounded corners: top left & bottom right */
|
||||
CARD = 4,
|
||||
/** Slot shape: Arrow */
|
||||
ARROW = 5,
|
||||
/** Slot shape: Grid */
|
||||
GRID = 6,
|
||||
/** Slot shape: Hollow circle */
|
||||
HollowCircle = 7,
|
||||
}
|
||||
|
||||
/** Bit flags used to indicate what the pointer is currently hovering over. */
|
||||
export enum CanvasItem {
|
||||
/** No items / none */
|
||||
Nothing = 0,
|
||||
/** At least one node */
|
||||
Node = 1,
|
||||
/** At least one group */
|
||||
Group = 1 << 1,
|
||||
/** A reroute (not its path) */
|
||||
Reroute = 1 << 2,
|
||||
/** The path of a link */
|
||||
Link = 1 << 3,
|
||||
/** A reroute slot */
|
||||
RerouteSlot = 1 << 5,
|
||||
/** A subgraph input or output node */
|
||||
SubgraphIoNode = 1 << 6,
|
||||
/** A subgraph input or output slot */
|
||||
SubgraphIoSlot = 1 << 7,
|
||||
}
|
||||
|
||||
/** The direction that a link point will flow towards - e.g. horizontal outputs are right by default */
|
||||
export enum LinkDirection {
|
||||
NONE = 0,
|
||||
UP = 1,
|
||||
DOWN = 2,
|
||||
LEFT = 3,
|
||||
RIGHT = 4,
|
||||
CENTER = 5,
|
||||
}
|
||||
|
||||
/** The path calculation that links follow */
|
||||
export enum LinkRenderType {
|
||||
HIDDEN_LINK = -1,
|
||||
/** Juts out from the input & output a little @see LinkDirection, then a straight line between them */
|
||||
STRAIGHT_LINK = 0,
|
||||
/** 90° angles, clean and box-like */
|
||||
LINEAR_LINK = 1,
|
||||
/** Smooth curved links - default */
|
||||
SPLINE_LINK = 2,
|
||||
}
|
||||
|
||||
/** The marker in the middle of a link */
|
||||
export enum LinkMarkerShape {
|
||||
/** Do not display markers */
|
||||
None = 0,
|
||||
/** Circles (default) */
|
||||
Circle = 1,
|
||||
/** Directional arrows */
|
||||
Arrow = 2,
|
||||
}
|
||||
|
||||
export enum TitleMode {
|
||||
NORMAL_TITLE = 0,
|
||||
NO_TITLE = 1,
|
||||
TRANSPARENT_TITLE = 2,
|
||||
AUTOHIDE_TITLE = 3,
|
||||
}
|
||||
|
||||
export enum LGraphEventMode {
|
||||
ALWAYS = 0,
|
||||
ON_EVENT = 1,
|
||||
NEVER = 2,
|
||||
ON_TRIGGER = 3,
|
||||
BYPASS = 4,
|
||||
}
|
||||
|
||||
export enum EaseFunction {
|
||||
LINEAR = "linear",
|
||||
EASE_IN_QUAD = "easeInQuad",
|
||||
EASE_OUT_QUAD = "easeOutQuad",
|
||||
EASE_IN_OUT_QUAD = "easeInOutQuad",
|
||||
}
|
||||
|
||||
/** Bit flags used to indicate what the pointer is currently hovering over. */
|
||||
export enum Alignment {
|
||||
/** No items / none */
|
||||
None = 0,
|
||||
/** Top */
|
||||
Top = 1,
|
||||
/** Bottom */
|
||||
Bottom = 1 << 1,
|
||||
/** Vertical middle */
|
||||
Middle = 1 << 2,
|
||||
/** Left */
|
||||
Left = 1 << 3,
|
||||
/** Right */
|
||||
Right = 1 << 4,
|
||||
/** Horizontal centre */
|
||||
Centre = 1 << 5,
|
||||
/** Top left */
|
||||
TopLeft = Top | Left,
|
||||
/** Top side, horizontally centred */
|
||||
TopCentre = Top | Centre,
|
||||
/** Top right */
|
||||
TopRight = Top | Right,
|
||||
/** Left side, vertically centred */
|
||||
MidLeft = Left | Middle,
|
||||
/** Middle centre */
|
||||
MidCentre = Middle | Centre,
|
||||
/** Right side, vertically centred */
|
||||
MidRight = Right | Middle,
|
||||
/** Bottom left */
|
||||
BottomLeft = Bottom | Left,
|
||||
/** Bottom side, horizontally centred */
|
||||
BottomCentre = Bottom | Centre,
|
||||
/** Bottom right */
|
||||
BottomRight = Bottom | Right,
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the bitwise {@link flag} is set in the {@link flagSet}.
|
||||
* @param flagSet The unknown set of flags - will be checked for the presence of {@link flag}
|
||||
* @param flag The flag to check for
|
||||
* @returns `true` if the flag is set, `false` otherwise.
|
||||
*/
|
||||
export function hasFlag(flagSet: number, flag: number): boolean {
|
||||
return (flagSet & flag) === flag
|
||||
}
|
||||
221
src/lib/litegraph/src/types/serialisation.ts
Normal file
221
src/lib/litegraph/src/types/serialisation.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type {
|
||||
Dictionary,
|
||||
INodeFlags,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
INodeSlot,
|
||||
ISlotType,
|
||||
Point,
|
||||
Size,
|
||||
} from "../interfaces"
|
||||
import type { LGraphConfig, LGraphExtra, LGraphState } from "../LGraph"
|
||||
import type { IGraphGroupFlags } from "../LGraphGroup"
|
||||
import type { NodeId, NodeProperty } from "../LGraphNode"
|
||||
import type { LiteGraph } from "../litegraph"
|
||||
import type { LinkId, SerialisedLLinkArray } from "../LLink"
|
||||
import type { FloatingRerouteSlot, RerouteId } from "../Reroute"
|
||||
import type { TWidgetValue } from "../types/widgets"
|
||||
import type { RenderShape } from "./globalEnums"
|
||||
import type { UUID } from "@/utils/uuid"
|
||||
|
||||
/**
|
||||
* An object that implements custom pre-serialization logic via {@link Serialisable.asSerialisable}.
|
||||
*/
|
||||
export interface Serialisable<SerialisableObject> {
|
||||
/**
|
||||
* Prepares this object for serialization.
|
||||
* 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.
|
||||
*/
|
||||
asSerialisable(): SerialisableObject
|
||||
}
|
||||
|
||||
export interface BaseExportedGraph {
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: UUID
|
||||
/** The revision number of this graph. Not automatically incremented; intended for use by a downstream save function. */
|
||||
revision: number
|
||||
config?: LGraphConfig
|
||||
/** Details of the appearance and location of subgraphs shown in this graph. Similar to */
|
||||
subgraphs?: ExportedSubgraphInstance[]
|
||||
/** Definitions of re-usable objects that are referenced elsewhere in this exported graph. */
|
||||
definitions?: {
|
||||
/** The base definition of subgraphs used in this workflow. That is, what you see when you open / edit a subgraph. */
|
||||
subgraphs?: ExportedSubgraph[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface SerialisableGraph extends BaseExportedGraph {
|
||||
/** Schema version. @remarks Version bump should add to const union, which is used to narrow type during deserialise. */
|
||||
version: 0 | 1
|
||||
state: LGraphState
|
||||
groups?: ISerialisedGroup[]
|
||||
nodes?: ISerialisedNode[]
|
||||
links?: SerialisableLLink[]
|
||||
floatingLinks?: SerialisableLLink[]
|
||||
reroutes?: SerialisableReroute[]
|
||||
extra?: LGraphExtra
|
||||
}
|
||||
|
||||
export type ISerialisableNodeInput = Omit<INodeInputSlot, "boundingRect" | "widget"> & {
|
||||
widget?: { name: string }
|
||||
}
|
||||
export type ISerialisableNodeOutput = Omit<INodeOutputSlot, "boundingRect" | "_data"> & {
|
||||
widget?: { name: string }
|
||||
}
|
||||
|
||||
/** Serialised LGraphNode */
|
||||
export interface ISerialisedNode {
|
||||
title?: string
|
||||
id: NodeId
|
||||
type: string
|
||||
pos: Point
|
||||
size: Size
|
||||
flags: INodeFlags
|
||||
order: number
|
||||
mode: number
|
||||
outputs?: ISerialisableNodeOutput[]
|
||||
inputs?: ISerialisableNodeInput[]
|
||||
properties?: Dictionary<NodeProperty | undefined>
|
||||
shape?: RenderShape
|
||||
boxcolor?: string
|
||||
color?: string
|
||||
bgcolor?: string
|
||||
showAdvanced?: boolean
|
||||
/**
|
||||
* Note: Some custom nodes overrides the `widgets_values` property to an
|
||||
* object that has `length` property and index access. It is not safe to call
|
||||
* any array methods on it.
|
||||
* See example in https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite/blob/8629188458dc6cb832f871ece3bd273507e8a766/web/js/VHS.core.js#L59-L84
|
||||
*/
|
||||
widgets_values?: TWidgetValue[]
|
||||
}
|
||||
|
||||
/** Properties of nodes that are used by subgraph instances. */
|
||||
type NodeSubgraphSharedProps = Omit<ISerialisedNode, "properties" | "showAdvanced">
|
||||
|
||||
/** A single instance of a subgraph; where it is used on a graph, any customisation to shape / colour etc. */
|
||||
export interface ExportedSubgraphInstance extends NodeSubgraphSharedProps {
|
||||
/**
|
||||
* The ID of the actual subgraph definition.
|
||||
* @see {@link ExportedSubgraph.subgraphs}
|
||||
*/
|
||||
type: UUID
|
||||
}
|
||||
|
||||
/**
|
||||
* Original implementation from static litegraph.d.ts
|
||||
* Maintained for backwards compat
|
||||
*/
|
||||
export interface ISerialisedGraph extends BaseExportedGraph {
|
||||
last_node_id: NodeId
|
||||
last_link_id: number
|
||||
nodes: ISerialisedNode[]
|
||||
links: SerialisedLLinkArray[]
|
||||
floatingLinks?: SerialisableLLink[]
|
||||
groups: ISerialisedGroup[]
|
||||
version: typeof LiteGraph.VERSION
|
||||
extra?: LGraphExtra
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a subgraph and its contents.
|
||||
* Can be referenced multiple times in a schema.
|
||||
*/
|
||||
export interface ExportedSubgraph extends SerialisableGraph {
|
||||
/** The display name of the subgraph. */
|
||||
name: string
|
||||
inputNode: ExportedSubgraphIONode
|
||||
outputNode: ExportedSubgraphIONode
|
||||
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
||||
inputs?: SubgraphIO[]
|
||||
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
|
||||
outputs?: SubgraphIO[]
|
||||
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
|
||||
widgets?: ExposedWidget[]
|
||||
}
|
||||
|
||||
/** Properties shared by subgraph and node I/O slots. */
|
||||
type SubgraphIOShared = Omit<INodeSlot, "boundingRect" | "nameLocked" | "locked" | "removable" | "_floatingLinks">
|
||||
|
||||
/** Subgraph I/O slots */
|
||||
export interface SubgraphIO extends SubgraphIOShared {
|
||||
/** Slot ID (internal; never changes once instantiated). */
|
||||
id: UUID
|
||||
/** The data type this slot uses. Unlike nodes, this does not support legacy numeric types. */
|
||||
type: string
|
||||
/** Links connected to this slot, or `undefined` if not connected. An ouptut slot should only ever have one link. */
|
||||
linkIds?: LinkId[]
|
||||
}
|
||||
|
||||
/** A reference to a node widget shown in the parent graph */
|
||||
export interface ExposedWidget {
|
||||
/** The ID of the node (inside the subgraph) that the widget belongs to. */
|
||||
id: NodeId
|
||||
/** The name of the widget to show in the parent graph. */
|
||||
name: string
|
||||
}
|
||||
|
||||
/** Serialised LGraphGroup */
|
||||
export interface ISerialisedGroup {
|
||||
id: number
|
||||
title: string
|
||||
bounding: number[]
|
||||
color?: string
|
||||
font_size?: number
|
||||
flags?: IGraphGroupFlags
|
||||
}
|
||||
|
||||
export type TClipboardLink = [
|
||||
targetRelativeIndex: number,
|
||||
originSlot: number,
|
||||
nodeRelativeIndex: number,
|
||||
targetSlot: number,
|
||||
targetNodeId: NodeId,
|
||||
]
|
||||
|
||||
/** Items copied from the canvas */
|
||||
export interface ClipboardItems {
|
||||
nodes?: ISerialisedNode[]
|
||||
groups?: ISerialisedGroup[]
|
||||
reroutes?: SerialisableReroute[]
|
||||
links?: SerialisableLLink[]
|
||||
subgraphs?: ExportedSubgraph[]
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export interface IClipboardContents {
|
||||
nodes?: ISerialisedNode[]
|
||||
links?: TClipboardLink[]
|
||||
}
|
||||
|
||||
export interface SerialisableReroute {
|
||||
id: RerouteId
|
||||
parentId?: RerouteId
|
||||
pos: Point
|
||||
linkIds: LinkId[]
|
||||
floating?: FloatingRerouteSlot
|
||||
}
|
||||
|
||||
export interface SerialisableLLink {
|
||||
/** Link ID */
|
||||
id: LinkId
|
||||
/** Output node ID */
|
||||
origin_id: NodeId
|
||||
/** Output slot index */
|
||||
origin_slot: number
|
||||
/** Input node ID */
|
||||
target_id: NodeId
|
||||
/** Input slot index */
|
||||
target_slot: number
|
||||
/** Data type of the link */
|
||||
type: ISlotType
|
||||
/** ID of the last reroute (from input to output) that this link passes through, otherwise `undefined` */
|
||||
parentId?: RerouteId
|
||||
}
|
||||
|
||||
export interface ExportedSubgraphIONode {
|
||||
id: NodeId
|
||||
bounding: [number, number, number, number]
|
||||
pinned?: boolean
|
||||
}
|
||||
13
src/lib/litegraph/src/types/utility.ts
Normal file
13
src/lib/litegraph/src/types/utility.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* General-purpose, TypeScript utility types.
|
||||
*/
|
||||
|
||||
/** {@link Pick} only properties that evaluate to `never`. */
|
||||
export type PickNevers<T> = {
|
||||
[K in keyof T as T[K] extends never ? K : never]: T[K]
|
||||
}
|
||||
|
||||
/** {@link Omit} all properties that evaluate to `never`. */
|
||||
export type NeverNever<T> = {
|
||||
[K in keyof T as T[K] extends never ? never : K]: T[K]
|
||||
}
|
||||
276
src/lib/litegraph/src/types/widgets.ts
Normal file
276
src/lib/litegraph/src/types/widgets.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import type { CanvasColour, Point, RequiredProps, Size } from "../interfaces"
|
||||
import type { CanvasPointer, LGraphCanvas, LGraphNode } from "../litegraph"
|
||||
import type { CanvasPointerEvent } from "./events"
|
||||
|
||||
export interface IWidgetOptions<TValues = unknown[]> {
|
||||
on?: string
|
||||
off?: string
|
||||
max?: number
|
||||
min?: number
|
||||
slider_color?: CanvasColour
|
||||
marker_color?: CanvasColour
|
||||
precision?: number
|
||||
read_only?: boolean
|
||||
/**
|
||||
* @deprecated Use {@link IWidgetOptions.step2} instead.
|
||||
* The legacy step is scaled up by 10x in the legacy frontend logic.
|
||||
*/
|
||||
step?: number
|
||||
/** The step value for numeric widgets. */
|
||||
step2?: number
|
||||
|
||||
y?: number
|
||||
multiline?: boolean
|
||||
// TODO: Confirm this
|
||||
property?: string
|
||||
/** If `true`, an input socket will not be created for this widget. */
|
||||
socketless?: boolean
|
||||
|
||||
values?: TValues
|
||||
callback?: IWidget["callback"]
|
||||
}
|
||||
|
||||
export interface IWidgetSliderOptions extends IWidgetOptions<number[]> {
|
||||
min: number
|
||||
max: number
|
||||
step2: number
|
||||
slider_color?: CanvasColour
|
||||
marker_color?: CanvasColour
|
||||
}
|
||||
|
||||
export interface IWidgetKnobOptions extends IWidgetOptions<number[]> {
|
||||
min: number
|
||||
max: number
|
||||
step2: number
|
||||
slider_color?: CanvasColour // TODO: Replace with knob color
|
||||
marker_color?: CanvasColour
|
||||
gradient_stops?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A widget for a node.
|
||||
* All types are based on IBaseWidget - additions can be made there or directly on individual types.
|
||||
*
|
||||
* Implemented as a discriminative union of widget types, so this type itself cannot be extended.
|
||||
* Recommend declaration merging any properties that use IWidget (e.g. {@link LGraphNode.widgets}) with a new type alias.
|
||||
* @see ICustomWidget
|
||||
*/
|
||||
export type IWidget =
|
||||
| IBooleanWidget
|
||||
| INumericWidget
|
||||
| IStringWidget
|
||||
| IComboWidget
|
||||
| IStringComboWidget
|
||||
| ICustomWidget
|
||||
| ISliderWidget
|
||||
| IButtonWidget
|
||||
| IKnobWidget
|
||||
|
||||
export interface IBooleanWidget extends IBaseWidget<boolean, "toggle"> {
|
||||
type: "toggle"
|
||||
value: boolean
|
||||
}
|
||||
|
||||
/** Any widget that uses a numeric backing */
|
||||
export interface INumericWidget extends IBaseWidget<number, "number"> {
|
||||
type: "number"
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface ISliderWidget extends IBaseWidget<number, "slider", IWidgetSliderOptions> {
|
||||
type: "slider"
|
||||
value: number
|
||||
marker?: number
|
||||
}
|
||||
|
||||
export interface IKnobWidget extends IBaseWidget<number, "knob", IWidgetKnobOptions> {
|
||||
type: "knob"
|
||||
value: number
|
||||
options: IWidgetKnobOptions
|
||||
}
|
||||
|
||||
/** Avoids the type issues with the legacy IComboWidget type */
|
||||
export interface IStringComboWidget extends IBaseWidget<string, "combo", RequiredProps<IWidgetOptions<string[]>, "values">> {
|
||||
type: "combo"
|
||||
value: string
|
||||
}
|
||||
|
||||
type ComboWidgetValues = string[] | Record<string, string> | ((widget?: IComboWidget, node?: LGraphNode) => string[])
|
||||
|
||||
/** A combo-box widget (dropdown, select, etc) */
|
||||
export interface IComboWidget extends IBaseWidget<
|
||||
string | number,
|
||||
"combo",
|
||||
RequiredProps<IWidgetOptions<ComboWidgetValues>, "values">
|
||||
> {
|
||||
type: "combo"
|
||||
value: string | number
|
||||
}
|
||||
|
||||
/** A widget with a string value */
|
||||
export interface IStringWidget extends IBaseWidget<string, "string" | "text", IWidgetOptions<string[]>> {
|
||||
type: "string" | "text"
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface IButtonWidget extends IBaseWidget<string | undefined, "button"> {
|
||||
type: "button"
|
||||
value: string | undefined
|
||||
clicked: boolean
|
||||
}
|
||||
|
||||
/** A custom widget - accepts any value and has no built-in special handling */
|
||||
export interface ICustomWidget extends IBaseWidget<string | object, "custom"> {
|
||||
type: "custom"
|
||||
value: string | object
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
|
||||
* Override linkedWidgets[]
|
||||
* Values not in this list will not result in litegraph errors, however they will be treated the same as "custom".
|
||||
*/
|
||||
export type TWidgetType = IWidget["type"]
|
||||
export type TWidgetValue = IWidget["value"]
|
||||
|
||||
/**
|
||||
* The base type for all widgets. Should not be implemented directly.
|
||||
* @template TValue The type of value this widget holds.
|
||||
* @template TType A string designating the type of widget, e.g. "toggle" or "string".
|
||||
* @template TOptions The options for this widget.
|
||||
* @see IWidget
|
||||
*/
|
||||
export interface IBaseWidget<
|
||||
TValue = boolean | number | string | object | undefined,
|
||||
TType extends string = string,
|
||||
TOptions extends IWidgetOptions<unknown> = IWidgetOptions<unknown>,
|
||||
> {
|
||||
linkedWidgets?: IBaseWidget[]
|
||||
|
||||
name: string
|
||||
options: TOptions
|
||||
|
||||
label?: string
|
||||
/** Widget type (see {@link TWidgetType}) */
|
||||
type: TType
|
||||
value?: TValue
|
||||
|
||||
/**
|
||||
* Whether the widget value should be serialized on node serialization.
|
||||
* @default true
|
||||
*/
|
||||
serialize?: boolean
|
||||
|
||||
/**
|
||||
* The computed height of the widget. Used by customized node resize logic.
|
||||
* See scripts/domWidget.ts for more details.
|
||||
* @readonly [Computed] This property is computed by the node.
|
||||
*/
|
||||
computedHeight?: number
|
||||
|
||||
/**
|
||||
* The starting y position of the widget after layout.
|
||||
* @readonly [Computed] This property is computed by the node.
|
||||
*/
|
||||
y: number
|
||||
|
||||
/**
|
||||
* The y position of the widget after drawing (rendering).
|
||||
* @readonly [Computed] This property is computed by the node.
|
||||
* @deprecated There is no longer dynamic y adjustment on rendering anymore.
|
||||
* Use {@link IBaseWidget.y} instead.
|
||||
*/
|
||||
last_y?: number
|
||||
|
||||
width?: number
|
||||
/**
|
||||
* Whether the widget is disabled. Disabled widgets are rendered at half opacity.
|
||||
* See also {@link IBaseWidget.computedDisabled}.
|
||||
*/
|
||||
disabled?: boolean
|
||||
|
||||
/**
|
||||
* The disabled state used for rendering based on various conditions including
|
||||
* {@link IBaseWidget.disabled}.
|
||||
* @readonly [Computed] This property is computed by the node.
|
||||
*/
|
||||
computedDisabled?: boolean
|
||||
|
||||
hidden?: boolean
|
||||
advanced?: boolean
|
||||
|
||||
tooltip?: string
|
||||
|
||||
// TODO: Confirm this format
|
||||
callback?(
|
||||
value: any,
|
||||
canvas?: LGraphCanvas,
|
||||
node?: LGraphNode,
|
||||
pos?: Point,
|
||||
e?: CanvasPointerEvent,
|
||||
): void
|
||||
|
||||
/**
|
||||
* Simple callback for pointer events, allowing custom widgets to events relevant to them.
|
||||
* @param event The pointer event that triggered this callback
|
||||
* @param pointerOffset Offset of the pointer relative to {@link node.pos}
|
||||
* @param node The node this widget belongs to
|
||||
* @todo Expose CanvasPointer API to custom widgets
|
||||
*/
|
||||
mouse?(event: CanvasPointerEvent, pointerOffset: Point, node: LGraphNode): boolean
|
||||
/**
|
||||
* Draw the widget.
|
||||
* @param ctx The canvas context to draw on.
|
||||
* @param node The node this widget belongs to.
|
||||
* @param widget_width The width of the widget.
|
||||
* @param y The y position of the widget.
|
||||
* @param H The height of the widget.
|
||||
* @param lowQuality Whether to draw the widget in low quality.
|
||||
*/
|
||||
draw?(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: LGraphNode,
|
||||
widget_width: number,
|
||||
y: number,
|
||||
H: number,
|
||||
lowQuality?: boolean,
|
||||
): void
|
||||
|
||||
/**
|
||||
* Compute the size of the widget. Overrides {@link IBaseWidget.computeSize}.
|
||||
* @param width The width of the widget.
|
||||
* @deprecated Use {@link IBaseWidget.computeLayoutSize} instead.
|
||||
* @returns The size of the widget.
|
||||
*/
|
||||
computeSize?(width?: number): Size
|
||||
|
||||
/**
|
||||
* Compute the layout size of the widget.
|
||||
* @param node The node this widget belongs to.
|
||||
* @returns The layout size of the widget.
|
||||
*/
|
||||
computeLayoutSize?(
|
||||
this: IBaseWidget,
|
||||
node: LGraphNode
|
||||
): {
|
||||
minHeight: number
|
||||
maxHeight?: number
|
||||
minWidth: number
|
||||
maxWidth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for pointerdown events, allowing custom widgets to register callbacks to occur
|
||||
* for all {@link CanvasPointer} events.
|
||||
*
|
||||
* This callback is operated early in the pointerdown logic; actions that prevent it from firing are:
|
||||
* - `Ctrl + Drag` Multi-select
|
||||
* - `Alt + Click/Drag` Clone node
|
||||
* @param pointer The CanvasPointer handling this event
|
||||
* @param node The node this widget belongs to
|
||||
* @param canvas The LGraphCanvas where this event originated
|
||||
* @returns Returning `true` from this callback forces Litegraph to ignore the event and
|
||||
* not process it any further.
|
||||
*/
|
||||
onPointerDown?(pointer: CanvasPointer, node: LGraphNode, canvas: LGraphCanvas): boolean
|
||||
}
|
||||
105
src/lib/litegraph/src/utils/arrange.ts
Normal file
105
src/lib/litegraph/src/utils/arrange.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { Direction, IBoundaryNodes } from "../interfaces"
|
||||
import type { LGraphNode } from "../LGraphNode"
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @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 {
|
||||
const valid = nodes?.find(x => x)
|
||||
if (!valid) return null
|
||||
|
||||
let top = valid
|
||||
let right = valid
|
||||
let bottom = valid
|
||||
let left = valid
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!node) continue
|
||||
const [x, y] = node.pos
|
||||
const [width, height] = node.size
|
||||
|
||||
if (y < top.pos[1]) top = node
|
||||
if (x + width > right.pos[0] + right.size[0]) right = node
|
||||
if (y + height > bottom.pos[1] + bottom.size[1]) bottom = node
|
||||
if (x < left.pos[0]) left = node
|
||||
}
|
||||
|
||||
return {
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
left,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Distributes nodes evenly along a horizontal or vertical plane.
|
||||
* @param nodes The nodes to distribute
|
||||
* @param horizontal If true, distributes along the horizontal plane. Otherwise, the vertical plane.
|
||||
*/
|
||||
export function distributeNodes(nodes: LGraphNode[], horizontal?: boolean): void {
|
||||
const nodeCount = nodes?.length
|
||||
if (!(nodeCount > 1)) return
|
||||
|
||||
const index = horizontal ? 0 : 1
|
||||
|
||||
let total = 0
|
||||
let highest = -Infinity
|
||||
|
||||
for (const node of nodes) {
|
||||
total += node.size[index]
|
||||
|
||||
const high = node.pos[index] + node.size[index]
|
||||
if (high > highest) highest = high
|
||||
}
|
||||
const sorted = [...nodes].sort((a, b) => a.pos[index] - b.pos[index])
|
||||
const lowest = sorted[0].pos[index]
|
||||
|
||||
const gap = (highest - lowest - total) / (nodeCount - 1)
|
||||
let startAt = lowest
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const node = sorted[i]
|
||||
node.pos[index] = startAt + gap * i
|
||||
startAt += node.size[index]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aligns all nodes along the edge of a node.
|
||||
* @param nodes The nodes to align
|
||||
* @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.
|
||||
*/
|
||||
export function alignNodes(
|
||||
nodes: LGraphNode[],
|
||||
direction: Direction,
|
||||
align_to?: LGraphNode,
|
||||
): void {
|
||||
if (!nodes) return
|
||||
|
||||
const boundary = align_to === undefined
|
||||
? getBoundaryNodes(nodes)
|
||||
: { top: align_to, right: align_to, bottom: align_to, left: align_to }
|
||||
|
||||
if (boundary === null) return
|
||||
|
||||
for (const node of nodes) {
|
||||
switch (direction) {
|
||||
case "right":
|
||||
node.pos[0] = boundary.right.pos[0] + boundary.right.size[0] - node.size[0]
|
||||
break
|
||||
case "left":
|
||||
node.pos[0] = boundary.left.pos[0]
|
||||
break
|
||||
case "top":
|
||||
node.pos[1] = boundary.top.pos[1]
|
||||
break
|
||||
case "bottom":
|
||||
node.pos[1] = boundary.bottom.pos[1] + boundary.bottom.size[1] - node.size[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
110
src/lib/litegraph/src/utils/collections.ts
Normal file
110
src/lib/litegraph/src/utils/collections.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { ConnectingLink, ISlotType, Positionable } from "../interfaces"
|
||||
import type { LinkId } from "@/LLink"
|
||||
|
||||
import { LGraphNode } from "@/LGraphNode"
|
||||
import { parseSlotTypes } from "@/strings"
|
||||
|
||||
/**
|
||||
* Creates a flat set of all positionable items by recursively iterating through all child items.
|
||||
*
|
||||
* Does not include or recurse into pinned items.
|
||||
* @param items The original set of items to iterate through
|
||||
* @returns All unpinned items in the original set, and recursively, their children
|
||||
*/
|
||||
export function getAllNestedItems(items: ReadonlySet<Positionable>): Set<Positionable> {
|
||||
const allItems = new Set<Positionable>()
|
||||
if (items) {
|
||||
for (const item of items) addRecursively(item, allItems)
|
||||
}
|
||||
return allItems
|
||||
|
||||
function addRecursively(item: Positionable, flatSet: Set<Positionable>): void {
|
||||
if (flatSet.has(item) || item.pinned) return
|
||||
flatSet.add(item)
|
||||
if (item.children) {
|
||||
for (const child of item.children) addRecursively(child, flatSet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates through a collection of {@link Positionable} items, returning the first {@link LGraphNode}.
|
||||
* @param items The items to search through
|
||||
* @returns The first node found in {@link items}, otherwise `undefined`
|
||||
*/
|
||||
export function findFirstNode(items: Iterable<Positionable>): LGraphNode | undefined {
|
||||
for (const item of items) {
|
||||
if (item instanceof LGraphNode) return item
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns `true` if the provided link ID is currently being dragged. */
|
||||
export function isDraggingLink(linkId: LinkId, connectingLinks: ConnectingLink[] | null | undefined): ConnectingLink | undefined {
|
||||
if (connectingLinks == null) return
|
||||
|
||||
for (const connectingLink of connectingLinks) {
|
||||
if (connectingLink.link == null) continue
|
||||
if (linkId === connectingLink.link.id) return connectingLink
|
||||
}
|
||||
}
|
||||
|
||||
type FreeSlotResult<T extends { type: ISlotType }> = { index: number, slot: T } | undefined
|
||||
|
||||
/**
|
||||
* Finds the first free in/out slot with any of the comma-delimited types in {@link type}.
|
||||
*
|
||||
* If no slots are free, falls back in order to:
|
||||
* - The first free wildcard slot
|
||||
* - The first occupied slot
|
||||
* - The first occupied wildcard slot
|
||||
* @param slots The iterable of node slots slots to search through
|
||||
* @param type The {@link ISlotType type} of slot to find
|
||||
* @param hasNoLinks A predicate that returns `true` if the slot is free.
|
||||
* @returns The index and slot if found, otherwise `undefined`.
|
||||
*/
|
||||
export function findFreeSlotOfType<T extends { type: ISlotType }>(
|
||||
slots: T[],
|
||||
type: ISlotType,
|
||||
hasNoLinks: (slot: T) => boolean,
|
||||
) {
|
||||
if (!slots?.length) return
|
||||
|
||||
let occupiedSlot: FreeSlotResult<T>
|
||||
let wildSlot: FreeSlotResult<T>
|
||||
let occupiedWildSlot: FreeSlotResult<T>
|
||||
|
||||
const validTypes = parseSlotTypes(type)
|
||||
|
||||
for (const [index, slot] of slots.entries()) {
|
||||
const slotTypes = parseSlotTypes(slot.type)
|
||||
|
||||
for (const validType of validTypes) {
|
||||
for (const slotType of slotTypes) {
|
||||
if (slotType === validType) {
|
||||
if (hasNoLinks(slot)) {
|
||||
// Exact match - short circuit
|
||||
return { index, slot }
|
||||
}
|
||||
// In case we can't find a free slot.
|
||||
occupiedSlot ??= { index, slot }
|
||||
} else if (!wildSlot && (validType === "*" || slotType === "*")) {
|
||||
// Save the first free wildcard slot as a fallback
|
||||
if (hasNoLinks(slot)) {
|
||||
wildSlot = { index, slot }
|
||||
} else {
|
||||
occupiedWildSlot ??= { index, slot }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return wildSlot ?? occupiedSlot ?? occupiedWildSlot
|
||||
}
|
||||
|
||||
export function removeFromArray<T>(array: T[], value: T): boolean {
|
||||
const index = array.indexOf(value)
|
||||
const found = index !== -1
|
||||
|
||||
if (found) array.splice(index, 1)
|
||||
return found
|
||||
}
|
||||
27
src/lib/litegraph/src/utils/feedback.ts
Normal file
27
src/lib/litegraph/src/utils/feedback.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { LiteGraph } from "@/litegraph"
|
||||
|
||||
/** Guard against unbound allocation. */
|
||||
const UNIQUE_MESSAGE_LIMIT = 10_000
|
||||
const sentWarnings: Set<string> = new Set()
|
||||
|
||||
/**
|
||||
* Warns that a deprecated function has been used via the public
|
||||
* {@link onDeprecationWarning} / {@link onEveryDeprecationWarning} callback arrays.
|
||||
* @param message Plain-language detail about what has been deprecated. This **should not** include unique data; use {@link source}.
|
||||
* @param source A reference object to include alongside the message, e.g. `this`.
|
||||
*/
|
||||
export function warnDeprecated(message: string, source?: object): void {
|
||||
if (!LiteGraph.alwaysRepeatWarnings) {
|
||||
// Do not repeat
|
||||
if (sentWarnings.has(message)) return
|
||||
|
||||
// Hard limit of unique messages per session
|
||||
if (sentWarnings.size > UNIQUE_MESSAGE_LIMIT) return
|
||||
|
||||
sentWarnings.add(message)
|
||||
}
|
||||
|
||||
for (const callback of LiteGraph.onDeprecationWarning) {
|
||||
callback(message, source)
|
||||
}
|
||||
}
|
||||
5
src/lib/litegraph/src/utils/object.ts
Normal file
5
src/lib/litegraph/src/utils/object.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function omitBy<T extends object>(obj: T, predicate: (value: any) => boolean): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([_key, value]) => !predicate(value)),
|
||||
) as Partial<T>
|
||||
}
|
||||
77
src/lib/litegraph/src/utils/spaceDistribution.ts
Normal file
77
src/lib/litegraph/src/utils/spaceDistribution.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export interface SpaceRequest {
|
||||
minSize: number
|
||||
maxSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Distributes available space among items with min/max size constraints
|
||||
* @param totalSpace Total space available to distribute
|
||||
* @param requests Array of space requests with size constraints
|
||||
* @returns Array of space allocations
|
||||
*/
|
||||
export function distributeSpace(
|
||||
totalSpace: number,
|
||||
requests: SpaceRequest[],
|
||||
): number[] {
|
||||
// Handle edge cases
|
||||
if (requests.length === 0) return []
|
||||
|
||||
// Calculate total minimum space needed
|
||||
const totalMinSize = requests.reduce((sum, req) => sum + req.minSize, 0)
|
||||
|
||||
// If we can't meet minimum requirements, return the minimum sizes
|
||||
if (totalSpace < totalMinSize) {
|
||||
return requests.map(req => req.minSize)
|
||||
}
|
||||
|
||||
// Initialize allocations with minimum sizes
|
||||
let allocations = requests.map(req => ({
|
||||
computedSize: req.minSize,
|
||||
maxSize: req.maxSize ?? Infinity,
|
||||
remaining: (req.maxSize ?? Infinity) - req.minSize,
|
||||
}))
|
||||
|
||||
// Calculate remaining space to distribute
|
||||
let remainingSpace = totalSpace - totalMinSize
|
||||
|
||||
// Distribute remaining space iteratively
|
||||
while (
|
||||
remainingSpace > 0 &&
|
||||
allocations.some(alloc => alloc.remaining > 0)
|
||||
) {
|
||||
// Count items that can still grow
|
||||
const growableItems = allocations.filter(
|
||||
alloc => alloc.remaining > 0,
|
||||
).length
|
||||
|
||||
if (growableItems === 0) break
|
||||
|
||||
// Calculate fair share per item
|
||||
const sharePerItem = remainingSpace / growableItems
|
||||
|
||||
// Track how much space was actually used in this iteration
|
||||
let spaceUsedThisRound = 0
|
||||
|
||||
// Distribute space
|
||||
allocations = allocations.map((alloc) => {
|
||||
if (alloc.remaining <= 0) return alloc
|
||||
|
||||
const growth = Math.min(sharePerItem, alloc.remaining)
|
||||
spaceUsedThisRound += growth
|
||||
|
||||
return {
|
||||
...alloc,
|
||||
computedSize: alloc.computedSize + growth,
|
||||
remaining: alloc.remaining - growth,
|
||||
}
|
||||
})
|
||||
|
||||
remainingSpace -= spaceUsedThisRound
|
||||
|
||||
// Break if we couldn't distribute any more space
|
||||
if (spaceUsedThisRound === 0) break
|
||||
}
|
||||
|
||||
// Return only the computed sizes
|
||||
return allocations.map(({ computedSize }) => computedSize)
|
||||
}
|
||||
47
src/lib/litegraph/src/utils/textUtils.ts
Normal file
47
src/lib/litegraph/src/utils/textUtils.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Truncates text to fit within a given width using binary search for optimal performance.
|
||||
* @param ctx The canvas rendering context used for text measurement
|
||||
* @param text The text to truncate
|
||||
* @param maxWidth The maximum width the text should occupy
|
||||
* @param ellipsis The ellipsis string to append (default: "...")
|
||||
* @returns The truncated text with ellipsis if needed
|
||||
*/
|
||||
export function truncateText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
ellipsis: string = "...",
|
||||
): string {
|
||||
const textWidth = ctx.measureText(text).width
|
||||
|
||||
if (textWidth <= maxWidth || maxWidth <= 0) {
|
||||
return text
|
||||
}
|
||||
|
||||
const ellipsisWidth = ctx.measureText(ellipsis).width
|
||||
const availableWidth = maxWidth - ellipsisWidth
|
||||
|
||||
if (availableWidth <= 0) {
|
||||
return ellipsis
|
||||
}
|
||||
|
||||
// Binary search for the right length
|
||||
let low = 0
|
||||
let high = text.length
|
||||
let bestFit = 0
|
||||
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2)
|
||||
const testText = text.substring(0, mid)
|
||||
const testWidth = ctx.measureText(testText).width
|
||||
|
||||
if (testWidth <= availableWidth) {
|
||||
bestFit = mid
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
return text.substring(0, bestFit) + ellipsis
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user