Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c02dfca6 | ||
|
|
e6534f17e6 | ||
|
|
7e3c04399a | ||
|
|
2599136296 | ||
|
|
d7796fcda4 | ||
|
|
4404c0461d | ||
|
|
4cb03cf052 | ||
|
|
eeb0977738 | ||
|
|
9a505100ac | ||
|
|
21873d40d5 | ||
|
|
cbbbadf438 | ||
|
|
d2972220bb | ||
|
|
4e08ed64f0 | ||
|
|
13db1e484b | ||
|
|
8b7bc5eb89 | ||
|
|
fd474fe2aa | ||
|
|
b6b6455189 | ||
|
|
1455845a30 | ||
|
|
6b3a4d214b | ||
|
|
06b0eecfe4 |
4
.github/workflows/claude-pr-review.yml
vendored
@@ -11,6 +11,10 @@ on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
wait-for-ci:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
4
.github/workflows/lint-and-format.yaml
vendored
@@ -4,6 +4,10 @@ on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
12
.github/workflows/tests-ci.yaml
vendored
@@ -7,6 +7,10 @@ on:
|
||||
branches-ignore:
|
||||
[wip/*, draft/*, temp/*, vue-nodes-migration, sno-playwright-*]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -19,12 +23,10 @@ jobs:
|
||||
# Setup Test Environment, build frontend but do not start server yet
|
||||
- name: Setup ComfyUI server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: 'false'
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: 'true'
|
||||
include_build_step: true
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright # Setup Playwright and cache browsers
|
||||
|
||||
@@ -63,6 +65,8 @@ jobs:
|
||||
# Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
|
||||
- name: Setup ComfyUI server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup nodejs, pnpm, reuse built frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
- name: Setup Playwright
|
||||
@@ -108,6 +112,8 @@ jobs:
|
||||
# Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
|
||||
- name: Setup ComfyUI server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup nodejs, pnpm, reuse built frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
- name: Setup Playwright
|
||||
|
||||
@@ -27,8 +27,6 @@ jobs:
|
||||
# Setup playwright environment with custom node repository
|
||||
- name: Setup ComfyUI Server (without launching)
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: 'false'
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
|
||||
4
.github/workflows/update-locales.yaml
vendored
@@ -20,10 +20,12 @@ jobs:
|
||||
# Setup playwright environment
|
||||
- name: Setup ComfyUI Server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup ComfyUI Frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: 'true'
|
||||
include_build_step: true
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
|
||||
@@ -18,10 +18,12 @@ jobs:
|
||||
# Setup playwright environment
|
||||
- name: Setup ComfyUI Server (and start)
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: 'true'
|
||||
include_build_step: true
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
@@ -50,4 +52,3 @@ jobs:
|
||||
branch: update-locales-node-defs-${{ github.event.inputs.trigger_type }}-${{ github.run_id }}
|
||||
base: main
|
||||
labels: dependencies
|
||||
path: ComfyUI_frontend
|
||||
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -21,22 +25,44 @@ jobs:
|
||||
) &&
|
||||
startsWith(github.event.comment.body, '/update-playwright') )
|
||||
steps:
|
||||
- name: Find Update Comment
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
|
||||
id: "find-update-comment"
|
||||
with:
|
||||
issue-number: ${{ github.event.number || github.event.issue.number }}
|
||||
comment-author: "github-actions[bot]"
|
||||
body-includes: "Updating Playwright Expectations"
|
||||
|
||||
- name: Add Starting Reaction
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
|
||||
with:
|
||||
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.number || github.event.issue.number }}
|
||||
body: |
|
||||
Updating Playwright Expectations
|
||||
edit-mode: replace
|
||||
reactions: eyes
|
||||
|
||||
- name: Get Branch SHA
|
||||
id: "get-branch"
|
||||
run: echo ::set-output name=branch::$(gh pr view $PR_NO --repo $REPO --json headRefName --jq '.headRefName')
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NO: ${{ github.event.number || github.event.issue.number }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Initial Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Pull Request Checkout (from comment)
|
||||
run: gh pr checkout ${{ github.event.issue.number }}
|
||||
if: github.event_name == 'issue_comment'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Pull Request Checkout (from label)
|
||||
run: |
|
||||
git fetch origin ${{ github.head_ref }}
|
||||
git checkout ${{ github.head_ref }}
|
||||
if: github.event_name == 'pull_request'
|
||||
- name: Setup ComfyUI Server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
ref: ${{ steps.get-branch.outputs.branch }}
|
||||
- name: Setup Frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: true
|
||||
- name: Setup ComfyUI Server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
- name: Run Playwright tests and update snapshots
|
||||
@@ -52,6 +78,7 @@ jobs:
|
||||
- name: Debugging info
|
||||
run: |
|
||||
echo "PR: ${{ github.event.issue.number }}"
|
||||
echo "Branch: ${{ steps.get-branch.outputs.branch }}"
|
||||
git status
|
||||
- name: Commit updated expectations
|
||||
run: |
|
||||
@@ -62,11 +89,20 @@ jobs:
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "[automated] Update test expectations"
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
git push origin HEAD:${{ github.head_ref }}
|
||||
else
|
||||
git push
|
||||
fi
|
||||
git push origin ${{ steps.get-branch.outputs.branch }}
|
||||
fi
|
||||
|
||||
- name: Add Done Reaction
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
|
||||
if: github.event_name == 'issue_comment'
|
||||
with:
|
||||
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.number || github.event.issue.number }}
|
||||
reactions: +1
|
||||
reactions-edit-mode: replace
|
||||
|
||||
- name: Remove New Browser Test Expectations label
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
run: gh pr edit ${{ github.event.pull_request.number }} --remove-label "New Browser Test Expectations"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
4
.github/workflows/vitest-tests.yaml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -54,3 +54,10 @@
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
.cursor/
|
||||
.cursorrules
|
||||
**/AGENTS.md
|
||||
**/CLAUDE.md
|
||||
90
browser_tests/assets/vueNodes/linked-int-widget.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"id": "95ea19ba-456c-46e8-aa40-dc3ff135b746",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "KSampler",
|
||||
"pos": [494.3333740234375, 142.3333282470703],
|
||||
"size": [444, 399],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [67, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [24.333343505859375, 149.6666717529297],
|
||||
"size": [444, 125],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": [10]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [67, "randomize"]
|
||||
}
|
||||
],
|
||||
"links": [[10, 11, 0, 10, 4, "INT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"frontendVersion": "1.28.6"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -119,4 +119,24 @@ export class VueNodeHelpers {
|
||||
await this.page.waitForSelector('[data-node-id]')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific widget by node title and widget name
|
||||
*/
|
||||
getWidgetByName(nodeTitle: string, widgetName: string): Locator {
|
||||
return this.getNodeByTitle(nodeTitle).locator(
|
||||
`_vue=[widget.name="${widgetName}"]`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get controls for input number widgets (increment/decrement buttons and input)
|
||||
*/
|
||||
getInputNumberControls(widget: Locator) {
|
||||
return {
|
||||
input: widget.locator('input'),
|
||||
incrementButton: widget.locator('button').first(),
|
||||
decrementButton: widget.locator('button').last()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
@@ -788,4 +788,171 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
targetSlot: 2
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Release actions (Shift-drop)', () => {
|
||||
test('Context menu opens and endpoint is pinned on Shift-drop', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'context menu'
|
||||
)
|
||||
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
|
||||
const dropPos = { x: outputCenter.x + 180, y: outputCenter.y - 140 }
|
||||
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
try {
|
||||
await comfyMouse.drag(dropPos)
|
||||
await comfyMouse.drop()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
|
||||
// Context menu should be visible
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Pinned endpoint should not change with mouse movement while menu is open
|
||||
const before = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(before).not.toBeNull()
|
||||
|
||||
// Move mouse elsewhere and verify snap position is unchanged
|
||||
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
|
||||
const after = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
|
||||
test('Context menu -> Search pre-filters by link type and connects after selection', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'context menu'
|
||||
)
|
||||
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const dropPos = { x: outputCenter.x + 200, y: outputCenter.y - 120 }
|
||||
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
try {
|
||||
await comfyMouse.drag(dropPos)
|
||||
await comfyMouse.drop()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
|
||||
// Open Search from the context menu
|
||||
await comfyPage.clickContextMenuItem('Search')
|
||||
|
||||
// Search box opens with prefilled type filter based on link type (LATENT)
|
||||
await expect(comfyPage.searchBox.input).toBeVisible()
|
||||
const chips = comfyPage.searchBox.filterChips
|
||||
// Ensure at least one filter chip exists and it matches the link type
|
||||
const chipCount = await chips.count()
|
||||
expect(chipCount).toBeGreaterThan(0)
|
||||
await expect(chips.first()).toContainText('LATENT')
|
||||
|
||||
// Choose a compatible node and verify it auto-connects
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('VAEDecode')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// KSampler output should now have an outgoing link
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
expect(await samplerOutput.getLinkCount()).toBe(1)
|
||||
|
||||
// One of the VAEDecode nodes should have an incoming link on input[0]
|
||||
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
|
||||
let linked = false
|
||||
for (const vae of vaeNodes) {
|
||||
const details = await getInputLinkDetails(comfyPage.page, vae.id, 0)
|
||||
if (details) {
|
||||
expect(details.originId).toBe(samplerNode.id)
|
||||
linked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(linked).toBe(true)
|
||||
})
|
||||
|
||||
test('Search box opens on Shift-drop and connects after selection', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'search box')
|
||||
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const dropPos = { x: outputCenter.x + 140, y: outputCenter.y - 100 }
|
||||
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
try {
|
||||
await comfyMouse.drag(dropPos)
|
||||
await comfyMouse.drop()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
|
||||
// Search box should open directly
|
||||
await expect(comfyPage.searchBox.input).toBeVisible()
|
||||
await expect(comfyPage.searchBox.filterChips.first()).toContainText(
|
||||
'LATENT'
|
||||
)
|
||||
|
||||
// Select a compatible node and verify connection
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('VAEDecode')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
expect(await samplerOutput.getLinkCount()).toBe(1)
|
||||
|
||||
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
|
||||
let linked = false
|
||||
for (const vae of vaeNodes) {
|
||||
const details = await getInputLinkDetails(comfyPage.page, vae.id, 0)
|
||||
if (details) {
|
||||
expect(details.originId).toBe(samplerNode.id)
|
||||
linked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(linked).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.3 KiB |
@@ -49,4 +49,36 @@ test.describe('Vue Node Selection', () => {
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
|
||||
})
|
||||
}
|
||||
|
||||
test('should select pinned node without dragging', async ({ comfyPage }) => {
|
||||
const PIN_HOTKEY = 'p'
|
||||
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
|
||||
|
||||
// Select a node by clicking its title
|
||||
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
|
||||
await checkpointNodeHeader.click()
|
||||
|
||||
// Pin it using the hotkey (as a user would)
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const pinIndicator = checkpointNode.locator(PIN_INDICATOR)
|
||||
await expect(pinIndicator).toBeVisible()
|
||||
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
|
||||
const initialPos = await checkpointNodeHeader.boundingBox()
|
||||
if (!initialPos) throw new Error('Failed to get header position')
|
||||
|
||||
await comfyPage.dragAndDrop(
|
||||
{ x: initialPos.x + 10, y: initialPos.y + 10 },
|
||||
{ x: initialPos.x + 100, y: initialPos.y + 100 }
|
||||
)
|
||||
|
||||
const finalPos = await checkpointNodeHeader.boundingBox()
|
||||
if (!finalPos) throw new Error('Failed to get header position after drag')
|
||||
expect(finalPos).toEqual(initialPos)
|
||||
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 94 KiB |
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Integer Widget', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('should be disabled and not allow changing value when link connected to slot', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('vueNodes/linked-int-widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const seedWidget = comfyPage.vueNodes.getWidgetByName('KSampler', 'seed')
|
||||
const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
|
||||
const initialValue = Number(await controls.input.inputValue())
|
||||
|
||||
// Verify widget is disabled when linked
|
||||
await controls.incrementButton.click({ force: true })
|
||||
await expect(controls.input).toHaveValue(initialValue.toString())
|
||||
|
||||
await controls.decrementButton.click({ force: true })
|
||||
await expect(controls.input).toHaveValue(initialValue.toString())
|
||||
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Delete the node that is linked to the slot (freeing up the widget)
|
||||
await comfyPage.vueNodes.getNodeByTitle('Int').click()
|
||||
await comfyPage.vueNodes.deleteSelected()
|
||||
|
||||
// Test widget works when unlinked
|
||||
await controls.incrementButton.click()
|
||||
await expect(controls.input).toHaveValue((initialValue + 1).toString())
|
||||
|
||||
await controls.decrementButton.click()
|
||||
await expect(controls.input).toHaveValue(initialValue.toString())
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 66 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.29.0",
|
||||
"version": "1.29.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -1046,6 +1046,11 @@ audio.comfy-audio.empty-audio-widget {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-header {
|
||||
border-radius: 0px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-widgets {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -113,7 +113,6 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slotLinkPreviewRenderer'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
@@ -401,7 +400,6 @@ onMounted(async () => {
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
await comfyApp.setup(canvasRef.value)
|
||||
attachSlotLinkPreviewRenderer(comfyApp.canvas)
|
||||
canvasStore.canvas = comfyApp.canvas
|
||||
canvasStore.canvas.render_canvas_border = false
|
||||
workspaceStore.spinner = false
|
||||
|
||||
@@ -12,10 +12,23 @@ import type {
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerEvent,
|
||||
LGraphTriggerParam
|
||||
} from '../../lib/litegraph/src/litegraph'
|
||||
import { NodeSlotType } from '../../lib/litegraph/src/types/globalEnums'
|
||||
|
||||
export interface WidgetSlotMetadata {
|
||||
index: number
|
||||
linked: boolean
|
||||
}
|
||||
|
||||
export interface SafeWidgetData {
|
||||
name: string
|
||||
@@ -25,6 +38,8 @@ export interface SafeWidgetData {
|
||||
options?: Record<string, unknown>
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
spec?: InputSpec
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
isDOMWidget?: boolean
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
@@ -68,6 +83,37 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Non-reactive storage for original LiteGraph nodes
|
||||
const nodeRefs = new Map<string, LGraphNode>()
|
||||
|
||||
const refreshNodeSlots = (nodeId: string) => {
|
||||
const nodeRef = nodeRefs.get(nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
if (!nodeRef || !currentData) return
|
||||
|
||||
// Only extract slot-related data instead of full node re-extraction
|
||||
const slotMetadata = new Map<string, WidgetSlotMetadata>()
|
||||
|
||||
nodeRef.inputs?.forEach((input, index) => {
|
||||
if (!input?.widget?.name) return
|
||||
slotMetadata.set(input.widget.name, {
|
||||
index,
|
||||
linked: input.link != null
|
||||
})
|
||||
})
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
const updatedWidgets = currentData.widgets?.map((widget) => {
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
return slotInfo ? { ...widget, slotMetadata: slotInfo } : widget
|
||||
})
|
||||
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
widgets: updatedWidgets,
|
||||
inputs: nodeRef.inputs ? [...nodeRef.inputs] : undefined,
|
||||
outputs: nodeRef.outputs ? [...nodeRef.outputs] : undefined
|
||||
})
|
||||
}
|
||||
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
const extractVueNodeData = (node: LGraphNode): VueNodeData => {
|
||||
// Determine subgraph ID - null for root graph, string for subgraphs
|
||||
@@ -76,6 +122,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
? String(node.graph.id)
|
||||
: null
|
||||
// Extract safe widget data
|
||||
const slotMetadata = new Map<string, WidgetSlotMetadata>()
|
||||
|
||||
node.inputs?.forEach((input, index) => {
|
||||
if (!input?.widget?.name) return
|
||||
slotMetadata.set(input.widget.name, {
|
||||
index,
|
||||
linked: input.link != null
|
||||
})
|
||||
})
|
||||
|
||||
const safeWidgets = node.widgets?.map((widget) => {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
@@ -92,6 +148,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
value = widget.options.values[0]
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
@@ -100,7 +157,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
label: widget.label,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
callback: widget.callback,
|
||||
spec
|
||||
spec,
|
||||
slotMetadata: slotInfo,
|
||||
isDOMWidget: isDOMWidget(widget)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -375,7 +434,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
const createCleanupFunction = (
|
||||
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnTrigger: ((action: string, param: unknown) => void) | undefined
|
||||
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined
|
||||
) => {
|
||||
return () => {
|
||||
// Restore original callbacks
|
||||
@@ -407,29 +466,19 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
handleNodeRemoved(node, originalOnNodeRemoved)
|
||||
}
|
||||
|
||||
// Listen for property change events from instrumented nodes
|
||||
graph.onTrigger = (action: string, param: unknown) => {
|
||||
if (
|
||||
action === 'node:property:changed' &&
|
||||
param &&
|
||||
typeof param === 'object'
|
||||
) {
|
||||
const event = param as {
|
||||
nodeId: string | number
|
||||
property: string
|
||||
oldValue: unknown
|
||||
newValue: unknown
|
||||
}
|
||||
|
||||
const nodeId = String(event.nodeId)
|
||||
const triggerHandlers: {
|
||||
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
|
||||
} = {
|
||||
'node:property:changed': (propertyEvent) => {
|
||||
const nodeId = String(propertyEvent.nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
if (currentData) {
|
||||
switch (event.property) {
|
||||
switch (propertyEvent.property) {
|
||||
case 'title':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
title: String(event.newValue)
|
||||
title: String(propertyEvent.newValue)
|
||||
})
|
||||
break
|
||||
case 'flags.collapsed':
|
||||
@@ -437,7 +486,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
collapsed: Boolean(event.newValue)
|
||||
collapsed: Boolean(propertyEvent.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
@@ -446,22 +495,25 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
pinned: Boolean(event.newValue)
|
||||
pinned: Boolean(propertyEvent.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'mode':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
mode: typeof event.newValue === 'number' ? event.newValue : 0
|
||||
mode:
|
||||
typeof propertyEvent.newValue === 'number'
|
||||
? propertyEvent.newValue
|
||||
: 0
|
||||
})
|
||||
break
|
||||
case 'color':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
color:
|
||||
typeof event.newValue === 'string'
|
||||
? event.newValue
|
||||
typeof propertyEvent.newValue === 'string'
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
break
|
||||
@@ -469,40 +521,38 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
bgcolor:
|
||||
typeof event.newValue === 'string'
|
||||
? event.newValue
|
||||
typeof propertyEvent.newValue === 'string'
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
action === 'node:slot-errors:changed' &&
|
||||
param &&
|
||||
typeof param === 'object'
|
||||
) {
|
||||
const event = param as { nodeId: string | number }
|
||||
const nodeId = String(event.nodeId)
|
||||
const litegraphNode = nodeRefs.get(nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
if (litegraphNode && currentData) {
|
||||
// Re-extract slot data with updated hasErrors properties
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
inputs: litegraphNode.inputs
|
||||
? [...litegraphNode.inputs]
|
||||
: undefined,
|
||||
outputs: litegraphNode.outputs
|
||||
? [...litegraphNode.outputs]
|
||||
: undefined
|
||||
})
|
||||
},
|
||||
'node:slot-errors:changed': (slotErrorsEvent) => {
|
||||
refreshNodeSlots(String(slotErrorsEvent.nodeId))
|
||||
},
|
||||
'node:slot-links:changed': (slotLinksEvent) => {
|
||||
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
|
||||
refreshNodeSlots(String(slotLinksEvent.nodeId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call original trigger handler if it exists
|
||||
if (originalOnTrigger) {
|
||||
originalOnTrigger(action, param)
|
||||
graph.onTrigger = (event: LGraphTriggerEvent) => {
|
||||
switch (event.type) {
|
||||
case 'node:property:changed':
|
||||
triggerHandlers['node:property:changed'](event)
|
||||
break
|
||||
case 'node:slot-errors:changed':
|
||||
triggerHandlers['node:slot-errors:changed'](event)
|
||||
break
|
||||
case 'node:slot-links:changed':
|
||||
triggerHandlers['node:slot-links:changed'](event)
|
||||
break
|
||||
}
|
||||
|
||||
// Chain to original handler
|
||||
originalOnTrigger?.(event)
|
||||
}
|
||||
|
||||
// Initialize state
|
||||
|
||||
@@ -64,15 +64,21 @@ const activeNode = computed(() => {
|
||||
|
||||
const activeWidgets = computed<WidgetItem[]>({
|
||||
get() {
|
||||
if (!activeNode.value) return []
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return proxyWidgets.value.flatMap(([id, name]: [string, string]) => {
|
||||
function mapWidgets([id, name]: [string, string]): WidgetItem[] {
|
||||
if (id === '-1') {
|
||||
const widget = node.widgets.find((w) => w.name === name)
|
||||
if (!widget) return []
|
||||
return [[{ id: -1, title: '(Linked)', type: '' }, widget]]
|
||||
}
|
||||
const wNode = node.subgraph._nodes_by_id[id]
|
||||
if (!wNode?.widgets) return []
|
||||
const w = wNode.widgets.find((w) => w.name === name)
|
||||
if (!w) return []
|
||||
return [[wNode, w]]
|
||||
})
|
||||
const widget = wNode.widgets.find((w) => w.name === name)
|
||||
if (!widget) return []
|
||||
return [[wNode, widget]]
|
||||
}
|
||||
return proxyWidgets.value.flatMap(mapWidgets)
|
||||
},
|
||||
set(value: WidgetItem[]) {
|
||||
const node = activeNode.value
|
||||
@@ -80,9 +86,7 @@ const activeWidgets = computed<WidgetItem[]>({
|
||||
console.error('Attempted to toggle widgets with no node selected')
|
||||
return
|
||||
}
|
||||
//map back to id/name
|
||||
const widgets: ProxyWidgetsProperty = value.map(widgetItemToProperty)
|
||||
proxyWidgets.value = widgets
|
||||
proxyWidgets.value = value.map(widgetItemToProperty)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -165,10 +169,10 @@ function showAll() {
|
||||
function hideAll() {
|
||||
const node = activeNode.value
|
||||
if (!node) return //Not reachable
|
||||
//Not great from a nesting perspective, but path is cold
|
||||
//and it cleans up potential error states
|
||||
proxyWidgets.value = proxyWidgets.value.filter(
|
||||
(widgetItem) => !filteredActive.value.some(matchesWidgetItem(widgetItem))
|
||||
(propertyItem) =>
|
||||
!filteredActive.value.some(matchesWidgetItem(propertyItem)) ||
|
||||
propertyItem[0] === '-1'
|
||||
)
|
||||
}
|
||||
function showRecommended() {
|
||||
@@ -258,20 +262,16 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
</div>
|
||||
<div ref="draggableItems">
|
||||
<div
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredActive"
|
||||
:key="toKey([node, widget])"
|
||||
class="draggable-item w-full"
|
||||
style=""
|
||||
>
|
||||
<SubgraphNodeWidget
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
:is-shown="true"
|
||||
:is-draggable="!debouncedQuery"
|
||||
@toggle-visibility="demote([node, widget])"
|
||||
/>
|
||||
</div>
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
:is-shown="true"
|
||||
:is-draggable="!debouncedQuery"
|
||||
:is-physical="node.id === -1"
|
||||
@toggle-visibility="demote([node, widget])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filteredCandidates.length" class="pt-1 pb-4">
|
||||
@@ -286,17 +286,13 @@ onBeforeUnmount(() => {
|
||||
{{ $t('subgraphStore.showAll') }}</a
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredCandidates"
|
||||
:key="toKey([node, widget])"
|
||||
class="w-full"
|
||||
>
|
||||
<SubgraphNodeWidget
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
@toggle-visibility="promote([node, widget])"
|
||||
/>
|
||||
</div>
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
@toggle-visibility="promote([node, widget])"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="recommendedWidgets.length"
|
||||
|
||||
@@ -8,6 +8,7 @@ const props = defineProps<{
|
||||
widgetName: string
|
||||
isShown?: boolean
|
||||
isDraggable?: boolean
|
||||
isPhysical?: boolean
|
||||
}>()
|
||||
defineEmits<{
|
||||
(e: 'toggleVisibility'): void
|
||||
@@ -17,11 +18,17 @@ function classes() {
|
||||
return cn(
|
||||
'flex py-1 pr-4 pl-0 break-all rounded items-center gap-1',
|
||||
'bg-node-component-surface',
|
||||
props.isDraggable
|
||||
? 'drag-handle cursor-grab [.is-draggable]:cursor-grabbing'
|
||||
: ''
|
||||
props.isDraggable &&
|
||||
'draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing'
|
||||
)
|
||||
}
|
||||
function getIcon() {
|
||||
return props.isPhysical
|
||||
? 'icon-[lucide--link]'
|
||||
: props.isDraggable
|
||||
? 'icon-[lucide--eye]'
|
||||
: 'icon-[lucide--eye-off]'
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div :class="classes()">
|
||||
@@ -40,7 +47,8 @@ function classes() {
|
||||
<Button
|
||||
size="small"
|
||||
text
|
||||
:icon="isDraggable ? 'icon-[lucide--eye]' : 'icon-[lucide--eye-off]'"
|
||||
:icon="getIcon()"
|
||||
:disabled="isPhysical"
|
||||
severity="secondary"
|
||||
@click.stop="$emit('toggleVisibility')"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { demoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
@@ -75,15 +76,17 @@ const onConfigure = function (
|
||||
const canvasStore = useCanvasStore()
|
||||
//Must give value to proxyWidgets prior to defining or it won't serialize
|
||||
this.properties.proxyWidgets ??= []
|
||||
let proxyWidgets = this.properties.proxyWidgets
|
||||
|
||||
originalOnConfigure?.call(this, serialisedNode)
|
||||
|
||||
Object.defineProperty(this.properties, 'proxyWidgets', {
|
||||
get: () => {
|
||||
return proxyWidgets
|
||||
},
|
||||
set: (property: string) => {
|
||||
get: () =>
|
||||
this.widgets.map((w) =>
|
||||
isProxyWidget(w)
|
||||
? [w._overlay.nodeId, w._overlay.widgetName]
|
||||
: ['-1', w.name]
|
||||
),
|
||||
set: (property: NodeProperty) => {
|
||||
const parsed = parseProxyWidgets(property)
|
||||
const { deactivateWidget, setWidget } = useDomWidgetStore()
|
||||
const isActiveGraph = useCanvasStore().canvas?.graph === this.graph
|
||||
@@ -92,21 +95,34 @@ const onConfigure = function (
|
||||
if (w instanceof DOMWidgetImpl) deactivateWidget(w.id)
|
||||
}
|
||||
}
|
||||
this.widgets = this.widgets.filter((w) => !isProxyWidget(w))
|
||||
for (const [nodeId, widgetName] of parsed) {
|
||||
const w = addProxyWidget(this, `${nodeId}`, widgetName)
|
||||
|
||||
const newWidgets = parsed.flatMap(([nodeId, widgetName]) => {
|
||||
if (nodeId === '-1') {
|
||||
const widget = this.widgets.find((w) => w.name === widgetName)
|
||||
return widget ? [widget] : []
|
||||
}
|
||||
const w = newProxyWidget(this, nodeId, widgetName)
|
||||
if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
|
||||
}
|
||||
proxyWidgets = property
|
||||
return [w]
|
||||
})
|
||||
this.widgets = this.widgets.filter(
|
||||
(w) => !isProxyWidget(w) && !parsed.some(([, name]) => w.name === name)
|
||||
)
|
||||
this.widgets.push(...newWidgets)
|
||||
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
this._setConcreteSlots()
|
||||
this.arrange()
|
||||
}
|
||||
})
|
||||
this.properties.proxyWidgets = proxyWidgets
|
||||
if (serialisedNode.properties?.proxyWidgets)
|
||||
this.properties.proxyWidgets = serialisedNode.properties.proxyWidgets
|
||||
serialisedNode.widgets_values?.forEach((v, index) => {
|
||||
if (v !== null) this.widgets[index].value = v
|
||||
})
|
||||
}
|
||||
|
||||
function addProxyWidget(
|
||||
function newProxyWidget(
|
||||
subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
@@ -130,7 +146,7 @@ function addProxyWidget(
|
||||
width: undefined,
|
||||
y: 0
|
||||
}
|
||||
return addProxyFromOverlay(subgraphNode, overlay)
|
||||
return newProxyFromOverlay(subgraphNode, overlay)
|
||||
}
|
||||
function resolveLinkedWidget(
|
||||
overlay: Overlay
|
||||
@@ -141,7 +157,7 @@ function resolveLinkedWidget(
|
||||
return [n, n.widgets?.find((w: IBaseWidget) => w.name === widgetName)]
|
||||
}
|
||||
|
||||
function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
||||
function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
||||
const { updatePreviews } = useLitegraphService()
|
||||
let [linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
|
||||
let backingWidget = linkedWidget ?? disconnectedWidget
|
||||
@@ -213,6 +229,5 @@ function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
||||
}
|
||||
}
|
||||
const w = new Proxy(disconnectedWidget, handler)
|
||||
subgraphNode.widgets.push(w)
|
||||
return w
|
||||
}
|
||||
|
||||
@@ -9,13 +9,15 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
|
||||
export type WidgetItem = [LGraphNode, IBaseWidget]
|
||||
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
|
||||
export type WidgetItem = [PartialNode, IBaseWidget]
|
||||
|
||||
function getProxyWidgets(node: SubgraphNode) {
|
||||
return parseProxyWidgets(node.properties.proxyWidgets)
|
||||
}
|
||||
export function promoteWidget(
|
||||
node: LGraphNode,
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget,
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
@@ -30,7 +32,7 @@ export function promoteWidget(
|
||||
}
|
||||
|
||||
export function demoteWidget(
|
||||
node: LGraphNode,
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget,
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
|
||||
@@ -11,7 +11,10 @@ import type {
|
||||
IStringWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import {
|
||||
getResourceURL,
|
||||
splitFilePath
|
||||
} from '@/renderer/extensions/vueNodes/widgets/utils/audioUtils'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { DOMWidget } from '@/scripts/domWidget'
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
@@ -21,32 +24,6 @@ import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
import { api } from '../../scripts/api'
|
||||
import { app } from '../../scripts/app'
|
||||
|
||||
function splitFilePath(path: string): [string, string] {
|
||||
const folder_separator = path.lastIndexOf('/')
|
||||
if (folder_separator === -1) {
|
||||
return ['', path]
|
||||
}
|
||||
return [
|
||||
path.substring(0, folder_separator),
|
||||
path.substring(folder_separator + 1)
|
||||
]
|
||||
}
|
||||
|
||||
function getResourceURL(
|
||||
subfolder: string,
|
||||
filename: string,
|
||||
type: ResultItemType = 'input'
|
||||
): string {
|
||||
const params = [
|
||||
'filename=' + encodeURIComponent(filename),
|
||||
'type=' + type,
|
||||
'subfolder=' + subfolder,
|
||||
app.getRandParam().substring(1)
|
||||
].join('&')
|
||||
|
||||
return `/view?${params}`
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
audioWidget: IStringWidget,
|
||||
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
|
||||
@@ -123,7 +100,6 @@ app.registerExtension({
|
||||
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
|
||||
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
|
||||
audioUIWidget.serialize = false
|
||||
|
||||
const { nodeData } = node.constructor
|
||||
if (nodeData == null) throw new TypeError('nodeData is null')
|
||||
|
||||
@@ -199,6 +175,7 @@ app.registerExtension({
|
||||
const audioUIWidget = node.widgets.find(
|
||||
(w) => w.name === 'audioUI'
|
||||
) as unknown as DOMWidget<HTMLAudioElement, string>
|
||||
audioUIWidget.options.canvasOnly = true
|
||||
|
||||
const onAudioWidgetUpdate = () => {
|
||||
audioUIWidget.element.src = api.apiURL(
|
||||
@@ -273,9 +250,9 @@ app.registerExtension({
|
||||
audio.controls = true
|
||||
audio.classList.add('comfy-audio')
|
||||
audio.setAttribute('name', 'media')
|
||||
|
||||
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
|
||||
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
|
||||
audioUIWidget.options.canvasOnly = true
|
||||
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let isRecording = false
|
||||
|
||||
@@ -57,6 +57,12 @@ import {
|
||||
splitPositionables
|
||||
} from './subgraph/subgraphUtils'
|
||||
import { Alignment, LGraphEventMode } from './types/globalEnums'
|
||||
import type {
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerEvent,
|
||||
LGraphTriggerHandler,
|
||||
LGraphTriggerParam
|
||||
} from './types/graphTriggers'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ExposedWidget,
|
||||
@@ -68,6 +74,11 @@ import type {
|
||||
} from './types/serialisation'
|
||||
import { getAllNestedItems } from './utils/collections'
|
||||
|
||||
export type {
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerParam
|
||||
} from './types/graphTriggers'
|
||||
|
||||
export interface LGraphState {
|
||||
lastGroupId: number
|
||||
lastNodeId: number
|
||||
@@ -257,7 +268,7 @@ export class LGraph
|
||||
onExecuteStep?(): void
|
||||
onNodeAdded?(node: LGraphNode): void
|
||||
onNodeRemoved?(node: LGraphNode): void
|
||||
onTrigger?(action: string, param: unknown): void
|
||||
onTrigger?: LGraphTriggerHandler
|
||||
onBeforeChange?(graph: LGraph, info?: LGraphNode): void
|
||||
onAfterChange?(graph: LGraph, info?: LGraphNode | null): void
|
||||
onConnectionChange?(node: LGraphNode): void
|
||||
@@ -1183,8 +1194,23 @@ export class LGraph
|
||||
}
|
||||
|
||||
// ********** GLOBALS *****************
|
||||
trigger<A extends LGraphTriggerAction>(
|
||||
action: A,
|
||||
param: LGraphTriggerParam<A>
|
||||
): void
|
||||
trigger(action: string, param: unknown): void
|
||||
trigger(action: string, param: unknown) {
|
||||
this.onTrigger?.(action, param)
|
||||
// Convert to discriminated union format for typed handlers
|
||||
const validEventTypes = new Set([
|
||||
'node:slot-links:changed',
|
||||
'node:slot-errors:changed',
|
||||
'node:property:changed'
|
||||
])
|
||||
|
||||
if (validEventTypes.has(action) && param && typeof param === 'object') {
|
||||
this.onTrigger?.({ type: action, ...param } as LGraphTriggerEvent)
|
||||
}
|
||||
// Don't handle unknown events - just ignore them
|
||||
}
|
||||
|
||||
/** @todo Clean up - never implemented. */
|
||||
|
||||
@@ -3319,7 +3319,15 @@ export class LGraphCanvas
|
||||
|
||||
if (slot && linkConnector.isInputValidDrop(node, slot)) {
|
||||
highlightInput = slot
|
||||
highlightPos = node.getInputSlotPos(slot)
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
const idx = node.inputs.indexOf(slot)
|
||||
highlightPos =
|
||||
idx !== -1
|
||||
? getSlotPosition(node, idx, true)
|
||||
: node.getInputSlotPos(slot)
|
||||
} else {
|
||||
highlightPos = node.getInputSlotPos(slot)
|
||||
}
|
||||
linkConnector.overWidget = overWidget
|
||||
}
|
||||
}
|
||||
@@ -3331,7 +3339,9 @@ export class LGraphCanvas
|
||||
const result = node.findInputByType(firstLink.fromSlot.type)
|
||||
if (result) {
|
||||
highlightInput = result.slot
|
||||
highlightPos = node.getInputSlotPos(result.slot)
|
||||
highlightPos = LiteGraph.vueNodesMode
|
||||
? getSlotPosition(node, result.index, true)
|
||||
: node.getInputSlotPos(result.slot)
|
||||
}
|
||||
} else if (
|
||||
inputId != -1 &&
|
||||
@@ -3356,7 +3366,9 @@ export class LGraphCanvas
|
||||
if (inputId === -1 && outputId === -1) {
|
||||
const result = node.findOutputByType(firstLink.fromSlot.type)
|
||||
if (result) {
|
||||
highlightPos = node.getOutputPos(result.index)
|
||||
highlightPos = LiteGraph.vueNodesMode
|
||||
? getSlotPosition(node, result.index, false)
|
||||
: node.getOutputPos(result.index)
|
||||
}
|
||||
} else {
|
||||
// check if I have a slot below de mouse
|
||||
@@ -4696,7 +4708,9 @@ export class LGraphCanvas
|
||||
|
||||
// draw nodes
|
||||
const { visible_nodes } = this
|
||||
const drawSnapGuides = this.#snapToGrid && this.isDragging
|
||||
const drawSnapGuides =
|
||||
this.#snapToGrid &&
|
||||
(this.isDragging || layoutStore.isDraggingVueNodes.value)
|
||||
|
||||
for (const node of visible_nodes) {
|
||||
ctx.save()
|
||||
@@ -5728,7 +5742,9 @@ export class LGraphCanvas
|
||||
if (!node) continue
|
||||
|
||||
const startPos = firstReroute.pos
|
||||
const endPos = node.getInputPos(link.target_slot)
|
||||
const endPos: Point = LiteGraph.vueNodesMode
|
||||
? getSlotPosition(node, link.target_slot, true)
|
||||
: node.getInputPos(link.target_slot)
|
||||
const endDirection = node.inputs[link.target_slot]?.dir
|
||||
|
||||
firstReroute._dragging = true
|
||||
@@ -5747,7 +5763,9 @@ export class LGraphCanvas
|
||||
const node = graph.getNodeById(link.origin_id)
|
||||
if (!node) continue
|
||||
|
||||
const startPos = node.getOutputPos(link.origin_slot)
|
||||
const startPos: Point = LiteGraph.vueNodesMode
|
||||
? getSlotPosition(node, link.origin_slot, false)
|
||||
: node.getOutputPos(link.origin_slot)
|
||||
const endPos = reroute.pos
|
||||
const startDirection = node.outputs[link.origin_slot]?.dir
|
||||
|
||||
@@ -6074,7 +6092,9 @@ export class LGraphCanvas
|
||||
|
||||
ctx.save()
|
||||
ctx.globalAlpha = 0.5 * this.editor_alpha
|
||||
const drawSnapGuides = this.#snapToGrid && this.isDragging
|
||||
const drawSnapGuides =
|
||||
this.#snapToGrid &&
|
||||
(this.isDragging || layoutStore.isDraggingVueNodes.value)
|
||||
|
||||
for (const group of groups) {
|
||||
// out of the visible area
|
||||
|
||||
@@ -2851,7 +2851,17 @@ export class LGraphNode
|
||||
output.links ??= []
|
||||
output.links.push(link.id)
|
||||
// connect in input
|
||||
inputNode.inputs[inputIndex].link = link.id
|
||||
const targetInput = inputNode.inputs[inputIndex]
|
||||
targetInput.link = link.id
|
||||
if (targetInput.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: inputNode.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: inputIndex,
|
||||
connected: true,
|
||||
linkId: link.id
|
||||
})
|
||||
}
|
||||
|
||||
// Reroutes
|
||||
const reroutes = LLink.getReroutes(graph, link)
|
||||
@@ -3008,6 +3018,15 @@ export class LGraphNode
|
||||
const input = target.inputs[link_info.target_slot]
|
||||
// remove there
|
||||
input.link = null
|
||||
if (input.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: target.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: link_info.target_slot,
|
||||
connected: false,
|
||||
linkId: link_info.id
|
||||
})
|
||||
}
|
||||
|
||||
// remove the link from the links pool
|
||||
link_info.disconnect(graph, 'input')
|
||||
@@ -3044,6 +3063,15 @@ export class LGraphNode
|
||||
const input = target.inputs[link_info.target_slot]
|
||||
// remove other side link
|
||||
input.link = null
|
||||
if (input.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: target.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: link_info.target_slot,
|
||||
connected: false,
|
||||
linkId: link_info.id
|
||||
})
|
||||
}
|
||||
|
||||
// link_info hasn't been modified so its ok
|
||||
target.onConnectionsChange?.(
|
||||
@@ -3113,6 +3141,15 @@ export class LGraphNode
|
||||
const link_id = this.inputs[slot].link
|
||||
if (link_id != null) {
|
||||
this.inputs[slot].link = null
|
||||
if (input.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: this.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: slot,
|
||||
connected: false,
|
||||
linkId: link_id
|
||||
})
|
||||
}
|
||||
|
||||
// remove other side
|
||||
const link_info = graph._links.get(link_id)
|
||||
@@ -3289,11 +3326,14 @@ export class LGraphNode
|
||||
* Gets the position of an output slot, in graph co-ordinates.
|
||||
*
|
||||
* This method is preferred over the legacy {@link getConnectionPos} method.
|
||||
* @param slot Output slot index
|
||||
* @param outputSlotIndex Output slot index
|
||||
* @returns Position of the output slot
|
||||
*/
|
||||
getOutputPos(slot: number): Point {
|
||||
return calculateOutputSlotPos(this.#getSlotPositionContext(), slot)
|
||||
getOutputPos(outputSlotIndex: number): Point {
|
||||
return calculateOutputSlotPos(
|
||||
this.#getSlotPositionContext(),
|
||||
outputSlotIndex
|
||||
)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
|
||||
@@ -102,7 +102,12 @@ export type {
|
||||
Positionable,
|
||||
Size
|
||||
} from './interfaces'
|
||||
export { LGraph } from './LGraph'
|
||||
export {
|
||||
LGraph,
|
||||
type LGraphTriggerAction,
|
||||
type LGraphTriggerParam
|
||||
} from './LGraph'
|
||||
export type { LGraphTriggerEvent } from './types/graphTriggers'
|
||||
export { BadgePosition, LGraphBadge } from './LGraphBadge'
|
||||
export { LGraphCanvas } from './LGraphCanvas'
|
||||
export { LGraphGroup } from './LGraphGroup'
|
||||
|
||||
38
src/lib/litegraph/src/types/graphTriggers.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { NodeId } from '../LGraphNode'
|
||||
import type { NodeSlotType } from './globalEnums'
|
||||
|
||||
interface NodePropertyChangedEvent {
|
||||
type: 'node:property:changed'
|
||||
nodeId: NodeId
|
||||
property: string
|
||||
oldValue: unknown
|
||||
newValue: unknown
|
||||
}
|
||||
|
||||
interface NodeSlotErrorsChangedEvent {
|
||||
type: 'node:slot-errors:changed'
|
||||
nodeId: NodeId
|
||||
}
|
||||
|
||||
interface NodeSlotLinksChangedEvent {
|
||||
type: 'node:slot-links:changed'
|
||||
nodeId: NodeId
|
||||
slotType: NodeSlotType
|
||||
slotIndex: number
|
||||
connected: boolean
|
||||
linkId: number
|
||||
}
|
||||
|
||||
export type LGraphTriggerEvent =
|
||||
| NodePropertyChangedEvent
|
||||
| NodeSlotErrorsChangedEvent
|
||||
| NodeSlotLinksChangedEvent
|
||||
|
||||
export type LGraphTriggerAction = LGraphTriggerEvent['type']
|
||||
|
||||
export type LGraphTriggerParam<A extends LGraphTriggerAction> = Extract<
|
||||
LGraphTriggerEvent,
|
||||
{ type: A }
|
||||
>
|
||||
|
||||
export type LGraphTriggerHandler = (event: LGraphTriggerEvent) => void
|
||||
@@ -79,6 +79,7 @@ export type IWidget =
|
||||
| ISelectButtonWidget
|
||||
| ITextareaWidget
|
||||
| IAssetWidget
|
||||
| IAudioRecordWidget
|
||||
|
||||
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
|
||||
type: 'toggle'
|
||||
@@ -227,6 +228,11 @@ export interface ITextareaWidget extends IBaseWidget<string, 'textarea'> {
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface IAudioRecordWidget extends IBaseWidget<string, 'audiorecord'> {
|
||||
type: 'audiorecord'
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface IAssetWidget
|
||||
extends IBaseWidget<string, 'asset', IWidgetOptions<string[]>> {
|
||||
type: 'asset'
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"logs": "Logs",
|
||||
"videoFailedToLoad": "Video failed to load",
|
||||
"audioFailedToLoad": "Audio failed to load",
|
||||
"liveSamplingPreview": "Live sampling preview",
|
||||
"extensionName": "Extension Name",
|
||||
"reloadToApplyChanges": "Reload to apply changes",
|
||||
"insert": "Insert",
|
||||
@@ -182,7 +183,17 @@
|
||||
"nodeHeaderError": "Node Header Error",
|
||||
"nodeSlotsError": "Node Slots Error",
|
||||
"nodeWidgetsError": "Node Widgets Error",
|
||||
"frameNodes": "Frame Nodes"
|
||||
"frameNodes": "Frame Nodes",
|
||||
"listening": "Listening...",
|
||||
"ready": "Ready",
|
||||
"playRecording": "Play Recording",
|
||||
"playing": "Playing",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"playbackSpeed": "Playback Speed",
|
||||
"volume": "Volume",
|
||||
"halfSpeed": "0.5x",
|
||||
"1x": "1x",
|
||||
"2x": "2x"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Custom Nodes Manager",
|
||||
|
||||
74
src/renderer/core/canvas/interaction/canvasPointerEvent.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import type {
|
||||
CanvasPointerEvent,
|
||||
CanvasPointerExtensions
|
||||
} from '@/lib/litegraph/src/types/events'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
type PointerOffsets = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const pointerHistory = new Map<number, PointerOffsets>()
|
||||
|
||||
const defineEnhancements = (
|
||||
event: PointerEvent,
|
||||
enhancement: CanvasPointerExtensions
|
||||
) => {
|
||||
Object.defineProperties(event, {
|
||||
canvasX: { value: enhancement.canvasX, configurable: true, writable: true },
|
||||
canvasY: { value: enhancement.canvasY, configurable: true, writable: true },
|
||||
deltaX: { value: enhancement.deltaX, configurable: true, writable: true },
|
||||
deltaY: { value: enhancement.deltaY, configurable: true, writable: true },
|
||||
safeOffsetX: {
|
||||
value: enhancement.safeOffsetX,
|
||||
configurable: true,
|
||||
writable: true
|
||||
},
|
||||
safeOffsetY: {
|
||||
value: enhancement.safeOffsetY,
|
||||
configurable: true,
|
||||
writable: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const createEnhancement = (event: PointerEvent): CanvasPointerExtensions => {
|
||||
const conversion = useSharedCanvasPositionConversion()
|
||||
conversion.update()
|
||||
|
||||
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
|
||||
event.clientX,
|
||||
event.clientY
|
||||
])
|
||||
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
const { offset, scale } = canvas.ds
|
||||
|
||||
const [originClientX, originClientY] = conversion.canvasPosToClientPos([0, 0])
|
||||
const left = originClientX - offset[0] * scale
|
||||
const top = originClientY - offset[1] * scale
|
||||
|
||||
const safeOffsetX = event.clientX - left
|
||||
const safeOffsetY = event.clientY - top
|
||||
|
||||
const previous = pointerHistory.get(event.pointerId)
|
||||
const deltaX = previous ? safeOffsetX - previous.x : 0
|
||||
const deltaY = previous ? safeOffsetY - previous.y : 0
|
||||
pointerHistory.set(event.pointerId, { x: safeOffsetX, y: safeOffsetY })
|
||||
|
||||
return { canvasX, canvasY, deltaX, deltaY, safeOffsetX, safeOffsetY }
|
||||
}
|
||||
|
||||
export const toCanvasPointerEvent = <T extends PointerEvent>(
|
||||
event: T
|
||||
): T & CanvasPointerEvent => {
|
||||
const enhancement = createEnhancement(event)
|
||||
defineEnhancements(event, enhancement)
|
||||
return event as T & CanvasPointerEvent
|
||||
}
|
||||
|
||||
export const clearCanvasPointerHistory = (pointerId: number) => {
|
||||
pointerHistory.delete(pointerId)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
|
||||
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
// Keep one adapter per graph so rendering and interaction share state.
|
||||
@@ -17,16 +17,11 @@ const adapterByGraph = new WeakMap<LGraph, LinkConnectorAdapter>()
|
||||
* - Preserves existing Vue composable behavior.
|
||||
*/
|
||||
export class LinkConnectorAdapter {
|
||||
readonly linkConnector: LinkConnector
|
||||
|
||||
constructor(
|
||||
/** Network the links belong to (typically `app.canvas.graph`). */
|
||||
readonly network: LGraph
|
||||
) {
|
||||
// No-op legacy setter to avoid side effects when connectors update
|
||||
const setConnectingLinks: (value: ConnectingLink[]) => void = () => {}
|
||||
this.linkConnector = new LinkConnector(setConnectingLinks)
|
||||
}
|
||||
readonly network: LGraph,
|
||||
readonly linkConnector: LinkConnector
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The currently rendered/dragged links, typed for consumer use.
|
||||
@@ -133,6 +128,11 @@ export class LinkConnectorAdapter {
|
||||
this.linkConnector.disconnectLinks()
|
||||
}
|
||||
|
||||
/** Drops moving links onto the canvas (no target). */
|
||||
dropOnCanvas(event: CanvasPointerEvent): void {
|
||||
this.linkConnector.dropOnNothing(event)
|
||||
}
|
||||
|
||||
/** Resets connector state and clears any temporary flags. */
|
||||
reset(): void {
|
||||
this.linkConnector.reset()
|
||||
@@ -141,11 +141,12 @@ export class LinkConnectorAdapter {
|
||||
|
||||
/** Convenience creator using the current app canvas graph. */
|
||||
export function createLinkConnectorAdapter(): LinkConnectorAdapter | null {
|
||||
const graph = app.canvas?.graph as LGraph | undefined
|
||||
if (!graph) return null
|
||||
const graph = app.canvas?.graph
|
||||
const connector = app.canvas?.linkConnector
|
||||
if (!graph || !connector) return null
|
||||
let adapter = adapterByGraph.get(graph)
|
||||
if (!adapter) {
|
||||
adapter = new LinkConnectorAdapter(graph)
|
||||
if (!adapter || adapter.linkConnector !== connector) {
|
||||
adapter = new LinkConnectorAdapter(graph, connector)
|
||||
adapterByGraph.set(graph, adapter)
|
||||
}
|
||||
return adapter
|
||||
|
||||
117
src/renderer/core/canvas/links/linkDropOrchestrator.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
|
||||
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
|
||||
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragUIState'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { SlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext'
|
||||
|
||||
interface DropResolutionContext {
|
||||
adapter: LinkConnectorAdapter | null
|
||||
graph: LGraph | null
|
||||
session: SlotLinkDragContext
|
||||
}
|
||||
|
||||
export const resolveSlotTargetCandidate = (
|
||||
target: EventTarget | null,
|
||||
{ adapter, graph }: DropResolutionContext
|
||||
): SlotDropCandidate | null => {
|
||||
const { state: dragState, setCompatibleForKey } = useSlotLinkDragUIState()
|
||||
if (!(target instanceof HTMLElement)) return null
|
||||
|
||||
const elWithKey = target.closest<HTMLElement>('[data-slot-key]')
|
||||
const key = elWithKey?.dataset['slotKey']
|
||||
if (!key) return null
|
||||
|
||||
const layout = layoutStore.getSlotLayout(key)
|
||||
if (!layout) return null
|
||||
|
||||
const candidate: SlotDropCandidate = { layout, compatible: false }
|
||||
|
||||
if (adapter && graph) {
|
||||
const cached = dragState.compatible.get(key)
|
||||
if (cached != null) {
|
||||
candidate.compatible = cached
|
||||
} else {
|
||||
const nodeId: NodeId = layout.nodeId
|
||||
const compatible =
|
||||
layout.type === 'input'
|
||||
? adapter.isInputValidDrop(nodeId, layout.index)
|
||||
: adapter.isOutputValidDrop(nodeId, layout.index)
|
||||
|
||||
setCompatibleForKey(key, compatible)
|
||||
candidate.compatible = compatible
|
||||
}
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
export const resolveNodeSurfaceSlotCandidate = (
|
||||
target: EventTarget | null,
|
||||
{ adapter, graph, session }: DropResolutionContext
|
||||
): SlotDropCandidate | null => {
|
||||
const { setCompatibleForKey } = useSlotLinkDragUIState()
|
||||
if (!(target instanceof HTMLElement)) return null
|
||||
|
||||
const elWithNode = target.closest<HTMLElement>('[data-node-id]')
|
||||
const nodeIdAttr = elWithNode?.dataset['nodeId']
|
||||
if (!nodeIdAttr) return null
|
||||
|
||||
if (!adapter || !graph) return null
|
||||
|
||||
const nodeId: NodeId = nodeIdAttr
|
||||
|
||||
const cachedPreferredSlotForNode = session.preferredSlotForNode.get(nodeId)
|
||||
if (cachedPreferredSlotForNode !== undefined) {
|
||||
return cachedPreferredSlotForNode
|
||||
? { layout: cachedPreferredSlotForNode.layout, compatible: true }
|
||||
: null
|
||||
}
|
||||
|
||||
const node = graph.getNodeById(nodeId)
|
||||
if (!node) return null
|
||||
|
||||
const firstLink = adapter.renderLinks[0]
|
||||
if (!firstLink) return null
|
||||
|
||||
const connectingTo = adapter.linkConnector.state.connectingTo
|
||||
if (connectingTo !== 'input' && connectingTo !== 'output') return null
|
||||
|
||||
const isInput = connectingTo === 'input'
|
||||
const slotType = firstLink.fromSlot.type
|
||||
|
||||
const result = isInput
|
||||
? node.findInputByType(slotType)
|
||||
: node.findOutputByType(slotType)
|
||||
|
||||
const index = result?.index
|
||||
if (index == null) {
|
||||
session.preferredSlotForNode.set(nodeId, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const key = getSlotKey(String(nodeId), index, isInput)
|
||||
const layout = layoutStore.getSlotLayout(key)
|
||||
if (!layout) {
|
||||
session.preferredSlotForNode.set(nodeId, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const compatible = isInput
|
||||
? adapter.isInputValidDrop(nodeId, index)
|
||||
: adapter.isOutputValidDrop(nodeId, index)
|
||||
|
||||
setCompatibleForKey(key, compatible)
|
||||
|
||||
if (!compatible) {
|
||||
session.preferredSlotForNode.set(nodeId, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const preferred = { index, key, layout }
|
||||
session.preferredSlotForNode.set(nodeId, preferred)
|
||||
|
||||
return { layout, compatible: true }
|
||||
}
|
||||
@@ -5,6 +5,14 @@ import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { Point, SlotLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Slot link drag UI state
|
||||
*
|
||||
* Reactive, shared state for a single drag interaction that UI components subscribe to.
|
||||
* Tracks pointer position, source slot, and resolved drop candidate. Also exposes
|
||||
* a compatibility map used to dim incompatible slots during drag.
|
||||
*/
|
||||
|
||||
type SlotDragType = 'input' | 'output'
|
||||
|
||||
interface SlotDragSource {
|
||||
@@ -33,6 +41,7 @@ interface SlotDragState {
|
||||
source: SlotDragSource | null
|
||||
pointer: PointerPosition
|
||||
candidate: SlotDropCandidate | null
|
||||
compatible: Map<string, boolean>
|
||||
}
|
||||
|
||||
const state = reactive<SlotDragState>({
|
||||
@@ -43,7 +52,8 @@ const state = reactive<SlotDragState>({
|
||||
client: { x: 0, y: 0 },
|
||||
canvas: { x: 0, y: 0 }
|
||||
},
|
||||
candidate: null
|
||||
candidate: null,
|
||||
compatible: new Map<string, boolean>()
|
||||
})
|
||||
|
||||
function updatePointerPosition(
|
||||
@@ -67,6 +77,7 @@ function beginDrag(source: SlotDragSource, pointerId: number) {
|
||||
state.source = source
|
||||
state.pointerId = pointerId
|
||||
state.candidate = null
|
||||
state.compatible.clear()
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
@@ -78,6 +89,7 @@ function endDrag() {
|
||||
state.pointer.canvas.x = 0
|
||||
state.pointer.canvas.y = 0
|
||||
state.candidate = null
|
||||
state.compatible.clear()
|
||||
}
|
||||
|
||||
function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) {
|
||||
@@ -85,13 +97,21 @@ function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) {
|
||||
return layoutStore.getSlotLayout(slotKey)
|
||||
}
|
||||
|
||||
export function useSlotLinkDragState() {
|
||||
export function useSlotLinkDragUIState() {
|
||||
return {
|
||||
state: readonly(state),
|
||||
beginDrag,
|
||||
endDrag,
|
||||
updatePointerPosition,
|
||||
setCandidate,
|
||||
getSlotLayout
|
||||
getSlotLayout,
|
||||
setCompatibleMap: (entries: Iterable<[string, boolean]>) => {
|
||||
state.compatible.clear()
|
||||
for (const [key, value] of entries) state.compatible.set(key, value)
|
||||
},
|
||||
setCompatibleForKey: (key: string, value: boolean) => {
|
||||
state.compatible.set(key, value)
|
||||
},
|
||||
clearCompatible: () => state.compatible.clear()
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { resolveConnectingLinkColor } from '@/lib/litegraph/src/utils/linkColors'
|
||||
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
|
||||
import { useSlotLinkDragState } from '@/renderer/core/canvas/links/slotLinkDragState'
|
||||
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
function buildContext(canvas: LGraphCanvas): LinkRenderContext {
|
||||
return {
|
||||
renderMode: canvas.links_render_mode,
|
||||
connectionWidth: canvas.connections_width,
|
||||
renderBorder: canvas.render_connections_border,
|
||||
lowQuality: canvas.low_quality,
|
||||
highQualityRender: canvas.highquality_render,
|
||||
scale: canvas.ds.scale,
|
||||
linkMarkerShape: canvas.linkMarkerShape,
|
||||
renderConnectionArrows: canvas.render_connection_arrows,
|
||||
highlightedLinks: new Set(Object.keys(canvas.highlighted_links)),
|
||||
defaultLinkColor: canvas.default_link_color,
|
||||
linkTypeColors: (canvas.constructor as typeof LGraphCanvas)
|
||||
.link_type_colors,
|
||||
disabledPattern: canvas._pattern
|
||||
}
|
||||
}
|
||||
|
||||
export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) {
|
||||
const originalOnDrawForeground = canvas.onDrawForeground?.bind(canvas)
|
||||
const patched = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
area: LGraphCanvas['visible_area']
|
||||
) => {
|
||||
originalOnDrawForeground?.(ctx, area)
|
||||
|
||||
const { state } = useSlotLinkDragState()
|
||||
// If LiteGraph's own connector is active, let it handle rendering to avoid double-draw
|
||||
if (canvas.linkConnector?.isConnecting) return
|
||||
if (!state.active || !state.source) return
|
||||
|
||||
const { pointer } = state
|
||||
|
||||
const linkRenderer = canvas.linkRenderer
|
||||
if (!linkRenderer) return
|
||||
const context = buildContext(canvas)
|
||||
|
||||
const renderLinks = createLinkConnectorAdapter()?.renderLinks
|
||||
if (!renderLinks || renderLinks.length === 0) return
|
||||
|
||||
const to: Readonly<Point> = state.candidate?.compatible
|
||||
? [state.candidate.layout.position.x, state.candidate.layout.position.y]
|
||||
: [pointer.canvas.x, pointer.canvas.y]
|
||||
ctx.save()
|
||||
for (const link of renderLinks) {
|
||||
const startDir = link.fromDirection ?? LinkDirection.RIGHT
|
||||
const endDir = link.dragDirection ?? LinkDirection.CENTER
|
||||
const colour = resolveConnectingLinkColor(link.fromSlot.type)
|
||||
|
||||
const fromPoint = resolveRenderLinkOrigin(link)
|
||||
|
||||
linkRenderer.renderDraggingLink(
|
||||
ctx,
|
||||
fromPoint,
|
||||
to,
|
||||
colour,
|
||||
startDir,
|
||||
endDir,
|
||||
context
|
||||
)
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
canvas.onDrawForeground = patched
|
||||
}
|
||||
|
||||
function resolveRenderLinkOrigin(link: RenderLink): Readonly<Point> {
|
||||
if (link.fromReroute) {
|
||||
const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id)
|
||||
if (rerouteLayout) {
|
||||
return [rerouteLayout.position.x, rerouteLayout.position.y]
|
||||
}
|
||||
|
||||
const [x, y] = link.fromReroute.pos
|
||||
return [x, y]
|
||||
}
|
||||
|
||||
const nodeId = getRenderLinkNodeId(link)
|
||||
if (nodeId != null) {
|
||||
const isInputFrom = link.toType === 'output'
|
||||
const key = getSlotKey(String(nodeId), link.fromSlotIndex, isInputFrom)
|
||||
const layout = layoutStore.getSlotLayout(key)
|
||||
if (layout) {
|
||||
return [layout.position.x, layout.position.y]
|
||||
}
|
||||
}
|
||||
|
||||
return link.fromPos
|
||||
}
|
||||
|
||||
function getRenderLinkNodeId(link: RenderLink): number | null {
|
||||
const node = link.node
|
||||
if (typeof node === 'object' && node !== null && 'id' in node) {
|
||||
const maybeId = node.id
|
||||
if (typeof maybeId === 'number') return maybeId
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import * as Y from 'yjs'
|
||||
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type {
|
||||
BatchUpdateBoundsOperation,
|
||||
Bounds,
|
||||
CreateLinkOperation,
|
||||
CreateNodeOperation,
|
||||
@@ -577,6 +578,14 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
return this.rerouteLayouts.get(rerouteId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all slot layout keys currently tracked by the store.
|
||||
* Useful for global passes without relying on spatial queries.
|
||||
*/
|
||||
getAllSlotKeys(): string[] {
|
||||
return Array.from(this.slotLayouts.keys())
|
||||
}
|
||||
|
||||
/**
|
||||
* Update link segment layout data
|
||||
*/
|
||||
@@ -863,6 +872,12 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
case 'deleteNode':
|
||||
this.handleDeleteNode(operation as DeleteNodeOperation, change)
|
||||
break
|
||||
case 'batchUpdateBounds':
|
||||
this.handleBatchUpdateBounds(
|
||||
operation as BatchUpdateBoundsOperation,
|
||||
change
|
||||
)
|
||||
break
|
||||
case 'createLink':
|
||||
this.handleCreateLink(operation as CreateLinkOperation, change)
|
||||
break
|
||||
@@ -1091,6 +1106,38 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
change.nodeIds.push(operation.nodeId)
|
||||
}
|
||||
|
||||
private handleBatchUpdateBounds(
|
||||
operation: BatchUpdateBoundsOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
const spatialUpdates: Array<{ nodeId: NodeId; bounds: Bounds }> = []
|
||||
|
||||
for (const nodeId of operation.nodeIds) {
|
||||
const data = operation.bounds[nodeId]
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (!ynode || !data) continue
|
||||
|
||||
ynode.set('position', { x: data.bounds.x, y: data.bounds.y })
|
||||
ynode.set('size', {
|
||||
width: data.bounds.width,
|
||||
height: data.bounds.height
|
||||
})
|
||||
ynode.set('bounds', data.bounds)
|
||||
|
||||
spatialUpdates.push({ nodeId, bounds: data.bounds })
|
||||
change.nodeIds.push(nodeId)
|
||||
}
|
||||
|
||||
// Batch update spatial index for better performance
|
||||
if (spatialUpdates.length > 0) {
|
||||
this.spatialIndex.batchUpdate(spatialUpdates)
|
||||
}
|
||||
|
||||
if (change.nodeIds.length) {
|
||||
change.type = 'update'
|
||||
}
|
||||
}
|
||||
|
||||
private handleCreateLink(
|
||||
operation: CreateLinkOperation,
|
||||
change: LayoutChange
|
||||
@@ -1371,19 +1418,38 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
const originalSource = this.currentSource
|
||||
this.currentSource = LayoutSource.Vue
|
||||
|
||||
this.ydoc.transact(() => {
|
||||
for (const { nodeId, bounds } of updates) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (!ynode) continue
|
||||
const nodeIds: NodeId[] = []
|
||||
const boundsRecord: BatchUpdateBoundsOperation['bounds'] = {}
|
||||
|
||||
this.spatialIndex.update(nodeId, bounds)
|
||||
ynode.set('bounds', bounds)
|
||||
ynode.set('position', { x: bounds.x, y: bounds.y })
|
||||
ynode.set('size', { width: bounds.width, height: bounds.height })
|
||||
for (const { nodeId, bounds } of updates) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (!ynode) continue
|
||||
const currentLayout = yNodeToLayout(ynode)
|
||||
|
||||
boundsRecord[nodeId] = {
|
||||
bounds,
|
||||
previousBounds: currentLayout.bounds
|
||||
}
|
||||
}, this.currentActor)
|
||||
nodeIds.push(nodeId)
|
||||
}
|
||||
|
||||
if (!nodeIds.length) {
|
||||
this.currentSource = originalSource
|
||||
return
|
||||
}
|
||||
|
||||
const operation: BatchUpdateBoundsOperation = {
|
||||
type: 'batchUpdateBounds',
|
||||
entity: 'node',
|
||||
nodeIds,
|
||||
bounds: boundsRecord,
|
||||
timestamp: Date.now(),
|
||||
source: this.currentSource,
|
||||
actor: this.currentActor
|
||||
}
|
||||
|
||||
this.applyOperation(operation)
|
||||
|
||||
// Restore original source
|
||||
this.currentSource = originalSource
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +267,11 @@ export function useLinkLayoutSync() {
|
||||
case 'resizeNode':
|
||||
recomputeLinksForNode(parseInt(change.operation.nodeId))
|
||||
break
|
||||
case 'batchUpdateBounds':
|
||||
for (const nodeId of change.operation.nodeIds) {
|
||||
recomputeLinksForNode(parseInt(nodeId))
|
||||
}
|
||||
break
|
||||
case 'createLink':
|
||||
recomputeLinkById(change.operation.linkId)
|
||||
break
|
||||
|
||||
@@ -95,19 +95,19 @@ export function useSlotLayoutSync() {
|
||||
}
|
||||
}
|
||||
|
||||
graph.onTrigger = (action: string, param: any) => {
|
||||
graph.onTrigger = (event) => {
|
||||
if (
|
||||
action === 'node:property:changed' &&
|
||||
param?.property === 'flags.collapsed'
|
||||
event.type === 'node:property:changed' &&
|
||||
event.property === 'flags.collapsed'
|
||||
) {
|
||||
const node = graph.getNodeById(parseInt(String(param.nodeId)))
|
||||
const node = graph.getNodeById(parseInt(String(event.nodeId)))
|
||||
if (node) {
|
||||
computeAndRegisterSlots(node)
|
||||
}
|
||||
}
|
||||
if (origTrigger) {
|
||||
origTrigger.call(graph, action, param)
|
||||
}
|
||||
|
||||
// Chain to original handler
|
||||
origTrigger?.(event)
|
||||
}
|
||||
|
||||
graph.onAfterChange = (graph: any, node?: any) => {
|
||||
|
||||
@@ -122,7 +122,7 @@ type OperationType =
|
||||
| 'createNode'
|
||||
| 'deleteNode'
|
||||
| 'setNodeVisibility'
|
||||
| 'batchUpdate'
|
||||
| 'batchUpdateBounds'
|
||||
| 'createLink'
|
||||
| 'deleteLink'
|
||||
| 'createReroute'
|
||||
@@ -184,10 +184,11 @@ interface SetNodeVisibilityOperation extends NodeOpBase {
|
||||
/**
|
||||
* Batch update operation for atomic multi-property changes
|
||||
*/
|
||||
interface BatchUpdateOperation extends NodeOpBase {
|
||||
type: 'batchUpdate'
|
||||
updates: Partial<NodeLayout>
|
||||
previousValues: Partial<NodeLayout>
|
||||
export interface BatchUpdateBoundsOperation extends OperationMeta {
|
||||
entity: 'node'
|
||||
type: 'batchUpdateBounds'
|
||||
nodeIds: NodeId[]
|
||||
bounds: Record<NodeId, { bounds: Bounds; previousBounds: Bounds }>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,7 +245,7 @@ export type LayoutOperation =
|
||||
| CreateNodeOperation
|
||||
| DeleteNodeOperation
|
||||
| SetNodeVisibilityOperation
|
||||
| BatchUpdateOperation
|
||||
| BatchUpdateBoundsOperation
|
||||
| CreateLinkOperation
|
||||
| DeleteLinkOperation
|
||||
| CreateRerouteOperation
|
||||
@@ -309,6 +310,9 @@ export interface LayoutStore {
|
||||
getSlotLayout(key: string): SlotLayout | null
|
||||
getRerouteLayout(rerouteId: RerouteId): RerouteLayout | null
|
||||
|
||||
// Returns all slot layout keys currently tracked by the store
|
||||
getAllSlotKeys(): string[]
|
||||
|
||||
// Direct mutation API (CRDT-ready)
|
||||
applyOperation(operation: LayoutOperation): void
|
||||
|
||||
|
||||
@@ -55,6 +55,17 @@ export class SpatialIndexManager {
|
||||
this.invalidateCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update multiple nodes' bounds in the spatial index
|
||||
* More efficient than calling update() multiple times as it only invalidates cache once
|
||||
*/
|
||||
batchUpdate(updates: Array<{ nodeId: NodeId; bounds: Bounds }>): void {
|
||||
for (const { nodeId, bounds } of updates) {
|
||||
this.quadTree.update(nodeId, bounds)
|
||||
}
|
||||
this.invalidateCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a node from the spatial index
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="imageUrls.length > 0"
|
||||
class="video-preview group relative flex flex-col items-center"
|
||||
class="video-preview group relative flex size-full min-h-16 min-w-16 flex-col"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
:aria-label="$t('g.videoPreview')"
|
||||
@@ -12,12 +12,12 @@
|
||||
>
|
||||
<!-- Video Wrapper -->
|
||||
<div
|
||||
class="relative w-full max-w-[352px] overflow-hidden rounded-[5px] bg-[#262729]"
|
||||
class="relative h-88 w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-if="videoError"
|
||||
class="flex h-[352px] w-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
|
||||
class="flex size-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
|
||||
>
|
||||
<i class="mb-2 icon-[lucide--video-off] h-12 w-12 text-gray-400" />
|
||||
<p class="text-sm text-gray-300">{{ $t('g.videoFailedToLoad') }}</p>
|
||||
@@ -27,17 +27,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<Skeleton
|
||||
v-else-if="isLoading"
|
||||
class="h-[352px] w-full"
|
||||
border-radius="5px"
|
||||
/>
|
||||
<Skeleton v-else-if="isLoading" class="size-full" border-radius="5px" />
|
||||
|
||||
<!-- Main Video -->
|
||||
<video
|
||||
v-else
|
||||
:src="currentVideoUrl"
|
||||
class="block h-[352px] w-full object-contain"
|
||||
class="block size-full object-contain"
|
||||
controls
|
||||
loop
|
||||
playsinline
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="imageUrls.length > 0"
|
||||
class="image-preview group relative flex flex-col items-center"
|
||||
class="image-preview group relative flex size-full min-h-16 min-w-16 flex-col"
|
||||
data-capture-node="true"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
@@ -12,12 +12,12 @@
|
||||
>
|
||||
<!-- Image Wrapper -->
|
||||
<div
|
||||
class="relative w-full max-w-[352px] overflow-hidden rounded-[5px] bg-[#262729]"
|
||||
class="relative h-88 w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-if="imageError"
|
||||
class="flex h-[352px] w-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
|
||||
class="flex size-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
|
||||
>
|
||||
<i class="mb-2 icon-[lucide--image-off] h-12 w-12 text-gray-400" />
|
||||
<p class="text-sm text-gray-300">{{ $t('g.imageFailedToLoad') }}</p>
|
||||
@@ -27,18 +27,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<Skeleton
|
||||
v-else-if="isLoading"
|
||||
class="h-[352px] w-full"
|
||||
border-radius="5px"
|
||||
/>
|
||||
<Skeleton v-else-if="isLoading" class="size-full" border-radius="5px" />
|
||||
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-else
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
class="block h-[352px] w-full object-contain"
|
||||
class="block size-full object-contain"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
@@ -31,6 +31,8 @@ import type { ComponentPublicInstance } from 'vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
@@ -103,6 +105,15 @@ const slotColor = computed(() => {
|
||||
return getSlotColor(props.slotData.type)
|
||||
})
|
||||
|
||||
const { state: dragState } = useSlotLinkDragUIState()
|
||||
const slotKey = computed(() =>
|
||||
getSlotKey(props.nodeId ?? '', props.index, true)
|
||||
)
|
||||
const shouldDim = computed(() => {
|
||||
if (!dragState.active) return false
|
||||
return !dragState.compatible.get(slotKey.value)
|
||||
})
|
||||
|
||||
const slotWrapperClass = computed(() =>
|
||||
cn(
|
||||
'lg-slot lg-slot--input flex items-center group rounded-r-lg h-6',
|
||||
@@ -112,7 +123,8 @@ const slotWrapperClass = computed(() =>
|
||||
: 'pr-6 hover:bg-black/5 hover:dark:bg-white/5',
|
||||
{
|
||||
'lg-slot--connected': props.connected,
|
||||
'lg-slot--compatible': props.compatible
|
||||
'lg-slot--compatible': props.compatible,
|
||||
'opacity-40': shouldDim.value
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
:class="
|
||||
cn(
|
||||
'bg-node-component-surface',
|
||||
'lg-node absolute rounded-2xl touch-none',
|
||||
'lg-node absolute rounded-2xl touch-none flex flex-col',
|
||||
'border-1 border-solid border-node-component-border',
|
||||
// hover (only when node should handle events)
|
||||
shouldHandleNodePointerEvents &&
|
||||
@@ -88,7 +88,7 @@
|
||||
|
||||
<!-- Node Body - rendered based on LOD level and collapsed state -->
|
||||
<div
|
||||
class="flex flex-col gap-4 pb-4"
|
||||
class="flex min-h-0 flex-1 flex-col gap-4 pb-4"
|
||||
:data-testid="`node-body-${nodeData.id}`"
|
||||
>
|
||||
<!-- Slots only rendered at full detail -->
|
||||
@@ -98,18 +98,12 @@
|
||||
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
|
||||
|
||||
<!-- Custom content at reduced+ detail -->
|
||||
<NodeContent
|
||||
v-if="hasCustomContent"
|
||||
:node-data="nodeData"
|
||||
:media="nodeMedia"
|
||||
/>
|
||||
<!-- Live preview image -->
|
||||
<div v-if="shouldShowPreviewImg" class="px-4">
|
||||
<img
|
||||
:src="latestPreviewUrl"
|
||||
alt="preview"
|
||||
class="max-h-64 w-full object-contain"
|
||||
/>
|
||||
<div v-if="hasCustomContent" class="min-h-0 flex-1">
|
||||
<NodeContent :node-data="nodeData" :media="nodeMedia" />
|
||||
</div>
|
||||
<!-- Live mid-execution preview images -->
|
||||
<div v-if="shouldShowPreviewImg" class="min-h-0 flex-1 px-4">
|
||||
<LivePreview :image-url="latestPreviewUrl || null" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -124,6 +118,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, inject, onErrorCaptured, onMounted, ref } from 'vue'
|
||||
|
||||
@@ -153,6 +148,8 @@ import {
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useNodeResize } from '../composables/useNodeResize'
|
||||
import { calculateIntrinsicSize } from '../utils/calculateIntrinsicSize'
|
||||
import LivePreview from './LivePreview.vue'
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
@@ -211,6 +208,7 @@ const hasAnyError = computed((): boolean => {
|
||||
)
|
||||
})
|
||||
|
||||
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
|
||||
const bypassed = computed((): boolean => nodeData.mode === 4)
|
||||
const muted = computed((): boolean => nodeData.mode === 2) // NEVER mode
|
||||
|
||||
@@ -245,7 +243,7 @@ onErrorCaptured((error) => {
|
||||
})
|
||||
|
||||
// Use layout system for node position and dragging
|
||||
const { position, size, zIndex, resize } = useNodeLayout(() => nodeData.id)
|
||||
const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
|
||||
const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions(
|
||||
() => nodeData,
|
||||
handleNodeSelect
|
||||
@@ -267,13 +265,19 @@ const handleContextMenu = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (size.value && transformState?.camera) {
|
||||
const scale = transformState.camera.z
|
||||
const screenSize = {
|
||||
width: size.value.width * scale,
|
||||
height: size.value.height * scale
|
||||
}
|
||||
resize(screenSize)
|
||||
// Set initial DOM size from layout store, but respect intrinsic content minimum
|
||||
if (size.value && nodeContainerRef.value && transformState) {
|
||||
const intrinsicMin = calculateIntrinsicSize(
|
||||
nodeContainerRef.value,
|
||||
transformState.camera.z
|
||||
)
|
||||
|
||||
// Use the larger of stored size or intrinsic minimum
|
||||
const finalWidth = Math.max(size.value.width, intrinsicMin.width)
|
||||
const finalHeight = Math.max(size.value.height, intrinsicMin.height)
|
||||
|
||||
nodeContainerRef.value.style.width = `${finalWidth}px`
|
||||
nodeContainerRef.value.style.height = `${finalHeight}px`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -290,8 +294,12 @@ const { startResize } = useNodeResize(
|
||||
}
|
||||
)
|
||||
|
||||
// Track collapsed state
|
||||
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
|
||||
whenever(isCollapsed, () => {
|
||||
const element = nodeContainerRef.value
|
||||
if (!element) return
|
||||
element.style.width = ''
|
||||
element.style.height = ''
|
||||
})
|
||||
|
||||
// Check if node has custom content (like image/video outputs)
|
||||
const hasCustomContent = computed(() => {
|
||||
@@ -395,5 +403,5 @@ const nodeMedia = computed(() => {
|
||||
return { type, urls } as const
|
||||
})
|
||||
|
||||
const nodeContainerRef = ref()
|
||||
const nodeContainerRef = ref<HTMLDivElement>()
|
||||
</script>
|
||||
|
||||
73
src/renderer/extensions/vueNodes/components/LivePreview.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div v-if="imageUrl" class="flex h-full min-h-16 w-full min-w-16 flex-col">
|
||||
<!-- Image Container -->
|
||||
<div
|
||||
class="relative h-88 w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-if="imageError"
|
||||
class="flex h-full w-full flex-col items-center justify-center text-center text-pure-white"
|
||||
>
|
||||
<i-lucide:image-off class="mb-1 size-8 text-gray-500" />
|
||||
<p class="text-xs text-gray-400">{{ $t('g.imageFailedToLoad') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-else
|
||||
:src="imageUrl"
|
||||
:alt="$t('g.liveSamplingPreview')"
|
||||
class="pointer-events-none h-full w-full object-contain object-center"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Image Dimensions -->
|
||||
<div class="text-node-component-header-text mt-1 text-center text-xs">
|
||||
{{
|
||||
imageError
|
||||
? $t('g.errorLoadingImage')
|
||||
: actualDimensions || $t('g.calculatingDimensions')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
interface LivePreviewProps {
|
||||
/** Image URL to display */
|
||||
imageUrl: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<LivePreviewProps>()
|
||||
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const imageError = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.imageUrl,
|
||||
() => {
|
||||
// Reset states when URL changes
|
||||
actualDimensions.value = null
|
||||
imageError.value = false
|
||||
}
|
||||
)
|
||||
|
||||
const handleImageLoad = (event: Event) => {
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
const img = event.target
|
||||
imageError.value = false
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
imageError.value = true
|
||||
actualDimensions.value = null
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
|
||||
{{ $t('Node Content Error') }}
|
||||
</div>
|
||||
<div v-else class="lg-node-content">
|
||||
<div v-else class="lg-node-content flex h-full flex-col">
|
||||
<!-- Default slot for custom content -->
|
||||
<slot>
|
||||
<VideoPreview
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
boundingRect: [0, 0, 0, 0]
|
||||
}"
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
:index="getWidgetInputIndex(widget)"
|
||||
:index="widget.slotMetadata?.index ?? 0"
|
||||
:dot-only="true"
|
||||
/>
|
||||
</div>
|
||||
@@ -56,12 +56,13 @@ import { computed, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
SafeWidgetData,
|
||||
VueNodeData
|
||||
VueNodeData,
|
||||
WidgetSlotMetadata
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
// Import widget components directly
|
||||
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
|
||||
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
|
||||
import {
|
||||
getComponent,
|
||||
@@ -110,6 +111,7 @@ interface ProcessedWidget {
|
||||
value: WidgetValue
|
||||
updateHandler: (value: unknown) => void
|
||||
tooltipConfig: any
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
}
|
||||
|
||||
const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
@@ -119,24 +121,42 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
const result: ProcessedWidget[] = []
|
||||
|
||||
for (const widget of widgets) {
|
||||
// Skip if widget is in the hidden list for this node type
|
||||
if (widget.options?.hidden) continue
|
||||
if (widget.options?.canvasOnly) continue
|
||||
if (!widget.type) continue
|
||||
if (!shouldRenderAsVue(widget)) continue
|
||||
|
||||
const vueComponent = getComponent(widget.type) || WidgetInputText
|
||||
const vueComponent =
|
||||
getComponent(widget.type, widget.name) ||
|
||||
(widget.isDOMWidget ? WidgetDOM : WidgetInputText)
|
||||
|
||||
const slotMetadata = widget.slotMetadata
|
||||
|
||||
let widgetOptions = widget.options
|
||||
// Core feature: Disable Vue widgets when their input slots are connected
|
||||
// This prevents conflicting input sources - when a slot is linked to another
|
||||
// node's output, the widget should be read-only to avoid data conflicts
|
||||
if (slotMetadata?.linked) {
|
||||
widgetOptions = widget.options
|
||||
? { ...widget.options, disabled: true }
|
||||
: { disabled: true }
|
||||
}
|
||||
|
||||
const simplified: SimplifiedWidget = {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: widget.value,
|
||||
label: widget.label,
|
||||
options: widget.options,
|
||||
options: widgetOptions,
|
||||
callback: widget.callback,
|
||||
spec: widget.spec
|
||||
}
|
||||
|
||||
const updateHandler = (value: unknown) => {
|
||||
// Update the widget value directly
|
||||
widget.value = value as WidgetValue
|
||||
|
||||
if (widget.callback) {
|
||||
widget.callback(value)
|
||||
}
|
||||
@@ -152,25 +172,11 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
simplified,
|
||||
value: widget.value,
|
||||
updateHandler,
|
||||
tooltipConfig
|
||||
tooltipConfig,
|
||||
slotMetadata
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// TODO: Refactor to avoid O(n) lookup - consider storing input index on widget creation
|
||||
// or restructuring data model to unify widgets and inputs
|
||||
// Map a widget to its corresponding input slot index
|
||||
const getWidgetInputIndex = (widget: ProcessedWidget): number => {
|
||||
const inputs = nodeData?.inputs
|
||||
if (!inputs) return 0
|
||||
|
||||
const idx = inputs.findIndex((input: any) => {
|
||||
if (!input || typeof input !== 'object') return false
|
||||
if (!('name' in input && 'type' in input)) return false
|
||||
return 'widget' in input && input.widget?.name === widget.name
|
||||
})
|
||||
return idx >= 0 ? idx : 0
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -28,6 +28,8 @@ import type { ComponentPublicInstance } from 'vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
@@ -73,6 +75,15 @@ onErrorCaptured((error) => {
|
||||
// Get slot color based on type
|
||||
const slotColor = computed(() => getSlotColor(props.slotData.type))
|
||||
|
||||
const { state: dragState } = useSlotLinkDragUIState()
|
||||
const slotKey = computed(() =>
|
||||
getSlotKey(props.nodeId ?? '', props.index, false)
|
||||
)
|
||||
const shouldDim = computed(() => {
|
||||
if (!dragState.active) return false
|
||||
return !dragState.compatible.get(slotKey.value)
|
||||
})
|
||||
|
||||
const slotWrapperClass = computed(() =>
|
||||
cn(
|
||||
'lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6',
|
||||
@@ -82,7 +93,8 @@ const slotWrapperClass = computed(() =>
|
||||
: 'pl-6 hover:bg-black/5 hover:dark:bg-white/5',
|
||||
{
|
||||
'lg-slot--connected': props.connected,
|
||||
'lg-slot--compatible': props.compatible
|
||||
'lg-slot--compatible': props.compatible,
|
||||
'opacity-40': shouldDim.value
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { SlotLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Slot link drag context
|
||||
*
|
||||
* Non-reactive, per-drag ephemeral caches and RAF batching used during
|
||||
* link drag interactions. Keeps high-churn data out of the reactive UI state.
|
||||
*/
|
||||
|
||||
interface PendingPointerMoveData {
|
||||
clientX: number
|
||||
clientY: number
|
||||
target: EventTarget | null
|
||||
}
|
||||
|
||||
export interface SlotLinkDragContext {
|
||||
preferredSlotForNode: Map<
|
||||
NodeId,
|
||||
{ index: number; key: string; layout: SlotLayout } | null
|
||||
>
|
||||
lastHoverSlotKey: string | null
|
||||
lastHoverNodeId: NodeId | null
|
||||
lastCandidateKey: string | null
|
||||
pendingPointerMove: PendingPointerMoveData | null
|
||||
lastPointerEventTarget: EventTarget | null
|
||||
lastPointerTargetSlotKey: string | null
|
||||
lastPointerTargetNodeId: NodeId | null
|
||||
reset: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export function createSlotLinkDragContext(): SlotLinkDragContext {
|
||||
const state: SlotLinkDragContext = {
|
||||
preferredSlotForNode: new Map(),
|
||||
lastHoverSlotKey: null,
|
||||
lastHoverNodeId: null,
|
||||
lastCandidateKey: null,
|
||||
pendingPointerMove: null,
|
||||
lastPointerEventTarget: null,
|
||||
lastPointerTargetSlotKey: null,
|
||||
lastPointerTargetNodeId: null,
|
||||
reset: () => {
|
||||
state.preferredSlotForNode = new Map()
|
||||
state.lastHoverSlotKey = null
|
||||
state.lastHoverNodeId = null
|
||||
state.lastCandidateKey = null
|
||||
state.pendingPointerMove = null
|
||||
state.lastPointerEventTarget = null
|
||||
state.lastPointerTargetSlotKey = null
|
||||
state.lastPointerTargetNodeId = null
|
||||
},
|
||||
dispose: () => {
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { SlotLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
interface PendingMoveData {
|
||||
clientX: number
|
||||
clientY: number
|
||||
target: EventTarget | null
|
||||
}
|
||||
|
||||
interface SlotLinkDragSession {
|
||||
compatCache: Map<string, boolean>
|
||||
nodePreferred: Map<
|
||||
number,
|
||||
{ index: number; key: string; layout: SlotLayout } | null
|
||||
>
|
||||
lastHoverSlotKey: string | null
|
||||
lastHoverNodeId: number | null
|
||||
lastCandidateKey: string | null
|
||||
pendingMove: PendingMoveData | null
|
||||
reset: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export function createSlotLinkDragSession(): SlotLinkDragSession {
|
||||
const state: SlotLinkDragSession = {
|
||||
compatCache: new Map(),
|
||||
nodePreferred: new Map(),
|
||||
lastHoverSlotKey: null,
|
||||
lastHoverNodeId: null,
|
||||
lastCandidateKey: null,
|
||||
pendingMove: null,
|
||||
reset: () => {
|
||||
state.compatCache = new Map()
|
||||
state.nodePreferred = new Map()
|
||||
state.lastHoverSlotKey = null
|
||||
state.lastHoverNodeId = null
|
||||
state.lastCandidateKey = null
|
||||
state.pendingMove = null
|
||||
},
|
||||
dispose: () => {
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -15,6 +15,24 @@ import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
/**
|
||||
* Check if multiple nodes are selected
|
||||
* Optimized to return early when 2+ nodes found
|
||||
*/
|
||||
function hasMultipleNodesSelected(selectedItems: unknown[]): boolean {
|
||||
let count = 0
|
||||
for (let i = 0; i < selectedItems.length; i++) {
|
||||
if (isLGraphNode(selectedItems[i])) {
|
||||
count++
|
||||
if (count >= 2) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function useNodeEventHandlersIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -26,11 +44,7 @@ function useNodeEventHandlersIndividual() {
|
||||
* Handle node selection events
|
||||
* Supports single selection and multi-select with Ctrl/Cmd
|
||||
*/
|
||||
const handleNodeSelect = (
|
||||
event: PointerEvent,
|
||||
nodeData: VueNodeData,
|
||||
wasDragging: boolean
|
||||
) => {
|
||||
const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
@@ -48,12 +62,14 @@ function useNodeEventHandlersIndividual() {
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
} else {
|
||||
// If it wasn't a drag: single-select the node
|
||||
if (!wasDragging) {
|
||||
const selectedMultipleNodes = hasMultipleNodesSelected(
|
||||
canvasStore.selectedItems
|
||||
)
|
||||
if (!selectedMultipleNodes) {
|
||||
// Single-select the node
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
// Regular click -> single select
|
||||
}
|
||||
|
||||
// Bring node to front when clicked (similar to LiteGraph behavior)
|
||||
@@ -122,7 +138,7 @@ function useNodeEventHandlersIndividual() {
|
||||
// TODO: add custom double-click behavior here
|
||||
// For now, ensure node is selected
|
||||
if (!node.selected) {
|
||||
handleNodeSelect(event, nodeData, false)
|
||||
handleNodeSelect(event, nodeData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +159,7 @@ function useNodeEventHandlersIndividual() {
|
||||
|
||||
// Select the node if not already selected
|
||||
if (!node.selected) {
|
||||
handleNodeSelect(event, nodeData, false)
|
||||
handleNodeSelect(event, nodeData)
|
||||
}
|
||||
|
||||
// Let LiteGraph handle the context menu
|
||||
@@ -170,7 +186,7 @@ function useNodeEventHandlersIndividual() {
|
||||
metaKey: event.metaKey,
|
||||
bubbles: true
|
||||
})
|
||||
handleNodeSelect(syntheticEvent, nodeData, false)
|
||||
handleNodeSelect(syntheticEvent, nodeData)
|
||||
}
|
||||
|
||||
// Set drag data for potential drop operations
|
||||
|
||||
@@ -69,125 +69,85 @@ const createMouseEvent = (
|
||||
}
|
||||
|
||||
describe('useNodePointerInteractions', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
forwardEventToCanvasMock.mockClear()
|
||||
// Reset layout store state between tests
|
||||
const { layoutStore } = await import(
|
||||
'@/renderer/core/layout/store/layoutStore'
|
||||
)
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
})
|
||||
|
||||
it('should only start drag on left-click', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
// Right-click should not start drag
|
||||
// Right-click should not trigger selection
|
||||
const rightClickEvent = createPointerEvent('pointerdown', { button: 2 })
|
||||
pointerHandlers.onPointerdown(rightClickEvent)
|
||||
|
||||
expect(mockOnPointerUp).not.toHaveBeenCalled()
|
||||
expect(mockOnNodeSelect).not.toHaveBeenCalled()
|
||||
|
||||
// Left-click should start drag and emit callback
|
||||
// Left-click should trigger selection on pointer down
|
||||
const leftClickEvent = createPointerEvent('pointerdown', { button: 0 })
|
||||
pointerHandlers.onPointerdown(leftClickEvent)
|
||||
|
||||
const pointerUpEvent = createPointerEvent('pointerup')
|
||||
pointerHandlers.onPointerup(pointerUpEvent)
|
||||
|
||||
expect(mockOnPointerUp).toHaveBeenCalledWith(
|
||||
pointerUpEvent,
|
||||
mockNodeData,
|
||||
false // wasDragging = false (same position)
|
||||
)
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledWith(leftClickEvent, mockNodeData)
|
||||
})
|
||||
|
||||
it('forwards middle mouse interactions to the canvas', () => {
|
||||
it('should call onNodeSelect on pointer down', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
const middlePointerDown = createPointerEvent('pointerdown', { button: 1 })
|
||||
pointerHandlers.onPointerdown(middlePointerDown)
|
||||
expect(forwardEventToCanvasMock).toHaveBeenCalledWith(middlePointerDown)
|
||||
|
||||
forwardEventToCanvasMock.mockClear()
|
||||
|
||||
const middlePointerMove = createPointerEvent('pointermove', { buttons: 4 })
|
||||
pointerHandlers.onPointermove(middlePointerMove)
|
||||
expect(forwardEventToCanvasMock).toHaveBeenCalledWith(middlePointerMove)
|
||||
|
||||
forwardEventToCanvasMock.mockClear()
|
||||
|
||||
const middlePointerUp = createPointerEvent('pointerup', { button: 1 })
|
||||
pointerHandlers.onPointerup(middlePointerUp)
|
||||
expect(forwardEventToCanvasMock).toHaveBeenCalledWith(middlePointerUp)
|
||||
|
||||
expect(mockOnPointerUp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should distinguish drag from click based on distance threshold', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// Test drag (distance > 4px)
|
||||
pointerHandlers.onPointerdown(
|
||||
createPointerEvent('pointerdown', { clientX: 100, clientY: 100 })
|
||||
)
|
||||
|
||||
const dragUpEvent = createPointerEvent('pointerup', {
|
||||
clientX: 200,
|
||||
clientY: 200
|
||||
// Selection should happen on pointer down
|
||||
const downEvent = createPointerEvent('pointerdown', {
|
||||
clientX: 100,
|
||||
clientY: 100
|
||||
})
|
||||
pointerHandlers.onPointerup(dragUpEvent)
|
||||
pointerHandlers.onPointerdown(downEvent)
|
||||
|
||||
expect(mockOnPointerUp).toHaveBeenCalledWith(
|
||||
dragUpEvent,
|
||||
mockNodeData,
|
||||
true
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
|
||||
|
||||
mockOnNodeSelect.mockClear()
|
||||
|
||||
// Even if we drag, selection already happened on pointer down
|
||||
pointerHandlers.onPointerup(
|
||||
createPointerEvent('pointerup', { clientX: 200, clientY: 200 })
|
||||
)
|
||||
|
||||
mockOnPointerUp.mockClear()
|
||||
|
||||
// Test click (same position)
|
||||
const samePos = { clientX: 100, clientY: 100 }
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown', samePos))
|
||||
|
||||
const clickUpEvent = createPointerEvent('pointerup', samePos)
|
||||
pointerHandlers.onPointerup(clickUpEvent)
|
||||
|
||||
expect(mockOnPointerUp).toHaveBeenCalledWith(
|
||||
clickUpEvent,
|
||||
mockNodeData,
|
||||
false
|
||||
)
|
||||
// onNodeSelect should not be called again on pointer up
|
||||
expect(mockOnNodeSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle drag termination via cancel and context menu', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
// Test pointer cancel
|
||||
// Test pointer cancel - selection happens on pointer down
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
|
||||
pointerHandlers.onPointercancel(createPointerEvent('pointercancel'))
|
||||
|
||||
// Should not emit callback on cancel
|
||||
expect(mockOnPointerUp).not.toHaveBeenCalled()
|
||||
// Selection should have been called on pointer down only
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
|
||||
mockOnNodeSelect.mockClear()
|
||||
|
||||
// Test context menu during drag prevents default
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
@@ -200,36 +160,35 @@ describe('useNodePointerInteractions', () => {
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not emit callback when nodeData becomes null', async () => {
|
||||
it('should not call onNodeSelect when nodeData is null', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
const nodeDataRef = ref<VueNodeData | null>(mockNodeData)
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
nodeDataRef,
|
||||
mockOnPointerUp
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
// Clear nodeData before pointer down
|
||||
nodeDataRef.value = null
|
||||
await nextTick()
|
||||
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
|
||||
// Clear nodeData before pointerup
|
||||
nodeDataRef.value = null
|
||||
|
||||
pointerHandlers.onPointerup(createPointerEvent('pointerup'))
|
||||
|
||||
expect(mockOnPointerUp).not.toHaveBeenCalled()
|
||||
expect(mockOnNodeSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should integrate with layout store dragging state', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
const { layoutStore } = await import(
|
||||
'@/renderer/core/layout/store/layoutStore'
|
||||
)
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
// Start drag
|
||||
@@ -242,4 +201,93 @@ describe('useNodePointerInteractions', () => {
|
||||
await nextTick()
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should select node on pointer down with ctrl key for multi-select', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
// Pointer down with ctrl key should pass the event with ctrl key set
|
||||
const ctrlDownEvent = createPointerEvent('pointerdown', {
|
||||
ctrlKey: true,
|
||||
clientX: 100,
|
||||
clientY: 100
|
||||
})
|
||||
pointerHandlers.onPointerdown(ctrlDownEvent)
|
||||
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledWith(ctrlDownEvent, mockNodeData)
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should select pinned node on pointer down but not start drag', async () => {
|
||||
const mockNodeData = createMockVueNodeData({
|
||||
flags: { pinned: true }
|
||||
})
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
const { layoutStore } = await import(
|
||||
'@/renderer/core/layout/store/layoutStore'
|
||||
)
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
// Pointer down on pinned node
|
||||
const downEvent = createPointerEvent('pointerdown')
|
||||
pointerHandlers.onPointerdown(downEvent)
|
||||
|
||||
// Should select the node
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
|
||||
|
||||
// But should not start dragging
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should select node immediately when drag starts', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
const { layoutStore } = await import(
|
||||
'@/renderer/core/layout/store/layoutStore'
|
||||
)
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
// Pointer down should select node immediately
|
||||
const downEvent = createPointerEvent('pointerdown', {
|
||||
clientX: 100,
|
||||
clientY: 100
|
||||
})
|
||||
pointerHandlers.onPointerdown(downEvent)
|
||||
|
||||
// Selection should happen on pointer down (before move)
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Dragging state should be active
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(true)
|
||||
|
||||
// Move the pointer (start dragging)
|
||||
pointerHandlers.onPointermove(
|
||||
createPointerEvent('pointermove', { clientX: 150, clientY: 150 })
|
||||
)
|
||||
|
||||
// Selection should still only have been called once (on pointer down)
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
|
||||
// End drag
|
||||
pointerHandlers.onPointerup(
|
||||
createPointerEvent('pointerup', { clientX: 150, clientY: 150 })
|
||||
)
|
||||
|
||||
// Selection should still only have been called once
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,16 +7,9 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
|
||||
// Treat tiny pointer jitter as a click, not a drag
|
||||
const DRAG_THRESHOLD_PX = 4
|
||||
|
||||
export function useNodePointerInteractions(
|
||||
nodeDataMaybe: MaybeRefOrGetter<VueNodeData | null>,
|
||||
onPointerUp: (
|
||||
event: PointerEvent,
|
||||
nodeData: VueNodeData,
|
||||
wasDragging: boolean
|
||||
) => void
|
||||
onNodeSelect: (event: PointerEvent, nodeData: VueNodeData) => void
|
||||
) {
|
||||
const nodeData = computed(() => {
|
||||
const value = toValue(nodeDataMaybe)
|
||||
@@ -84,8 +77,11 @@ export function useNodePointerInteractions(
|
||||
return
|
||||
}
|
||||
|
||||
// Don't allow dragging if node is pinned (but still record position for selection)
|
||||
// Record position for drag threshold calculation
|
||||
startPosition.value = { x: event.clientX, y: event.clientY }
|
||||
|
||||
onNodeSelect(event, nodeData.value)
|
||||
|
||||
if (nodeData.value.flags?.pinned) {
|
||||
return
|
||||
}
|
||||
@@ -147,19 +143,11 @@ export function useNodePointerInteractions(
|
||||
handleDragTermination(event, 'drag end')
|
||||
}
|
||||
|
||||
// Don't emit node-click when canvas is in panning mode - forward to canvas instead
|
||||
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
|
||||
if (!shouldHandleNodePointerEvents.value) {
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Emit node-click for selection handling in GraphCanvas
|
||||
const dx = event.clientX - startPosition.value.x
|
||||
const dy = event.clientY - startPosition.value.y
|
||||
const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
|
||||
|
||||
if (!nodeData?.value) return
|
||||
onPointerUp(event, nodeData.value, wasDragging)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useEventListener } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { TransformState } from '@/renderer/core/layout/injectionKeys'
|
||||
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
|
||||
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||
import { calculateIntrinsicSize } from '@/renderer/extensions/vueNodes/utils/calculateIntrinsicSize'
|
||||
|
||||
interface Size {
|
||||
width: number
|
||||
@@ -35,6 +38,12 @@ export function useNodeResize(
|
||||
const resizeStartSize = ref<Size | null>(null)
|
||||
const intrinsicMinSize = ref<Size | null>(null)
|
||||
|
||||
// Snap-to-grid functionality
|
||||
const { shouldSnap, applySnapToSize } = useNodeSnap()
|
||||
|
||||
// Shift key sync for LiteGraph canvas preview
|
||||
const { trackShiftKey } = useShiftKeySync()
|
||||
|
||||
const startResize = (event: PointerEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -42,6 +51,9 @@ export function useNodeResize(
|
||||
const target = event.currentTarget
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
|
||||
// Track shift key state and sync to canvas for snap preview
|
||||
const stopShiftSync = trackShiftKey(event)
|
||||
|
||||
// Capture pointer to ensure we get all move/up events
|
||||
target.setPointerCapture(event.pointerId)
|
||||
|
||||
@@ -53,29 +65,16 @@ export function useNodeResize(
|
||||
if (!(nodeElement instanceof HTMLElement)) return
|
||||
|
||||
const rect = nodeElement.getBoundingClientRect()
|
||||
|
||||
// Calculate intrinsic content size once at start
|
||||
const originalWidth = nodeElement.style.width
|
||||
const originalHeight = nodeElement.style.height
|
||||
nodeElement.style.width = 'auto'
|
||||
nodeElement.style.height = 'auto'
|
||||
|
||||
const intrinsicRect = nodeElement.getBoundingClientRect()
|
||||
|
||||
// Restore original size
|
||||
nodeElement.style.width = originalWidth
|
||||
nodeElement.style.height = originalHeight
|
||||
|
||||
// Convert to canvas coordinates using transform state
|
||||
const scale = transformState.camera.z
|
||||
|
||||
// Calculate current size in canvas coordinates
|
||||
resizeStartSize.value = {
|
||||
width: rect.width / scale,
|
||||
height: rect.height / scale
|
||||
}
|
||||
intrinsicMinSize.value = {
|
||||
width: intrinsicRect.width / scale,
|
||||
height: intrinsicRect.height / scale
|
||||
}
|
||||
|
||||
// Calculate intrinsic content size (minimum based on content)
|
||||
intrinsicMinSize.value = calculateIntrinsicSize(nodeElement, scale)
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
if (
|
||||
@@ -95,19 +94,26 @@ export function useNodeResize(
|
||||
const scaledDy = dy / scale
|
||||
|
||||
// Apply constraints: only minimum size based on content, no maximum
|
||||
const newWidth = Math.max(
|
||||
intrinsicMinSize.value.width,
|
||||
resizeStartSize.value.width + scaledDx
|
||||
)
|
||||
const newHeight = Math.max(
|
||||
intrinsicMinSize.value.height,
|
||||
resizeStartSize.value.height + scaledDy
|
||||
)
|
||||
const constrainedSize = {
|
||||
width: Math.max(
|
||||
intrinsicMinSize.value.width,
|
||||
resizeStartSize.value.width + scaledDx
|
||||
),
|
||||
height: Math.max(
|
||||
intrinsicMinSize.value.height,
|
||||
resizeStartSize.value.height + scaledDy
|
||||
)
|
||||
}
|
||||
|
||||
// Apply snap-to-grid if shift is held or always snap is enabled
|
||||
const finalSize = shouldSnap(moveEvent)
|
||||
? applySnapToSize(constrainedSize)
|
||||
: constrainedSize
|
||||
|
||||
// Get the node element to apply size directly
|
||||
const nodeElement = target.closest('[data-node-id]')
|
||||
if (nodeElement instanceof HTMLElement) {
|
||||
resizeCallback({ width: newWidth, height: newHeight }, nodeElement)
|
||||
resizeCallback(finalSize, nodeElement)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +124,9 @@ export function useNodeResize(
|
||||
resizeStartSize.value = null
|
||||
intrinsicMinSize.value = null
|
||||
|
||||
// Stop tracking shift key state
|
||||
stopShiftSync()
|
||||
|
||||
target.releasePointerCapture(upEvent.pointerId)
|
||||
stopMoveListen()
|
||||
stopUpListen()
|
||||
|
||||
73
src/renderer/extensions/vueNodes/composables/useNodeSnap.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { snapPoint } from '@/lib/litegraph/src/measure'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
/**
|
||||
* Composable for node snap-to-grid functionality
|
||||
*
|
||||
* Provides reactive access to snap settings and utilities for applying
|
||||
* snap-to-grid behavior to Vue nodes during drag and resize operations.
|
||||
*/
|
||||
export function useNodeSnap() {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// Reactive snap settings
|
||||
const gridSize = computed(() => settingStore.get('Comfy.SnapToGrid.GridSize'))
|
||||
const alwaysSnap = computed(() => settingStore.get('pysssss.SnapToGrid'))
|
||||
|
||||
/**
|
||||
* Determines if snap-to-grid should be applied based on shift key and settings
|
||||
* @param event - The pointer event to check for shift key
|
||||
* @returns true if snapping should be applied
|
||||
*/
|
||||
function shouldSnap(event: PointerEvent): boolean {
|
||||
return event.shiftKey || alwaysSnap.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies snap-to-grid to a position
|
||||
* @param position - Position object with x, y coordinates
|
||||
* @returns The snapped position as a new object
|
||||
*/
|
||||
function applySnapToPosition(position: { x: number; y: number }): {
|
||||
x: number
|
||||
y: number
|
||||
} {
|
||||
const size = gridSize.value
|
||||
if (!size) return { ...position }
|
||||
|
||||
const posArray: [number, number] = [position.x, position.y]
|
||||
if (snapPoint(posArray, size)) {
|
||||
return { x: posArray[0], y: posArray[1] }
|
||||
}
|
||||
return { ...position }
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies snap-to-grid to a size (width/height)
|
||||
* @param size - Size object with width, height
|
||||
* @returns The snapped size as a new object
|
||||
*/
|
||||
function applySnapToSize(size: { width: number; height: number }): {
|
||||
width: number
|
||||
height: number
|
||||
} {
|
||||
const gridSizeValue = gridSize.value
|
||||
if (!gridSizeValue) return { ...size }
|
||||
|
||||
const sizeArray: [number, number] = [size.width, size.height]
|
||||
if (snapPoint(sizeArray, gridSizeValue)) {
|
||||
return { width: sizeArray[0], height: sizeArray[1] }
|
||||
}
|
||||
return { ...size }
|
||||
}
|
||||
|
||||
return {
|
||||
gridSize,
|
||||
alwaysSnap,
|
||||
shouldSnap,
|
||||
applySnapToPosition,
|
||||
applySnapToSize
|
||||
}
|
||||
}
|
||||
107
src/renderer/extensions/vueNodes/composables/useShiftKeySync.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { tryOnScopeDispose, useEventListener } from '@vueuse/core'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
/**
|
||||
* Composable for synchronizing shift key state from Vue nodes to LiteGraph canvas.
|
||||
*
|
||||
* Enables snap-to-grid preview rendering in LiteGraph during Vue node drag/resize operations
|
||||
* by dispatching synthetic keyboard events to the canvas element.
|
||||
*
|
||||
* @returns Object containing trackShiftKey function for shift state synchronization lifecycle
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { trackShiftKey } = useShiftKeySync()
|
||||
*
|
||||
* function startDrag(event: PointerEvent) {
|
||||
* const stopTracking = trackShiftKey(event)
|
||||
* // ... drag logic
|
||||
* // Call stopTracking() on pointerup to cleanup listeners
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useShiftKeySync() {
|
||||
const shiftKeyState = shallowRef(false)
|
||||
let canvasEl: HTMLCanvasElement | null = null
|
||||
|
||||
/**
|
||||
* Synchronizes shift key state to LiteGraph canvas by dispatching synthetic keyboard events.
|
||||
*
|
||||
* Only dispatches events when shift state actually changes to minimize overhead.
|
||||
* Canvas reference is lazily initialized on first sync.
|
||||
*
|
||||
* @param isShiftPressed - Current shift key state to synchronize
|
||||
*/
|
||||
function syncShiftState(isShiftPressed: boolean) {
|
||||
if (isShiftPressed === shiftKeyState.value) return
|
||||
|
||||
// Lazy-initialize canvas reference on first use
|
||||
if (!canvasEl) {
|
||||
canvasEl = app.canvas?.canvas ?? null
|
||||
if (!canvasEl) return // Canvas not ready yet
|
||||
}
|
||||
|
||||
shiftKeyState.value = isShiftPressed
|
||||
canvasEl.dispatchEvent(
|
||||
new KeyboardEvent(isShiftPressed ? 'keydown' : 'keyup', {
|
||||
key: 'Shift',
|
||||
shiftKey: isShiftPressed,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks shift key state during drag/resize operations and synchronizes to canvas.
|
||||
*
|
||||
* Attaches window-level keyboard event listeners for the duration of the operation.
|
||||
* Listeners are automatically cleaned up when the returned function is called.
|
||||
*
|
||||
* @param initialEvent - Initial pointer event containing shift key state at drag/resize start
|
||||
* @returns Cleanup function that removes event listeners - must be called when operation ends
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* function startDrag(event: PointerEvent) {
|
||||
* const stopTracking = trackShiftKey(event)
|
||||
*
|
||||
* const handlePointerUp = () => {
|
||||
* stopTracking() // Cleanup listeners
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
function trackShiftKey(initialEvent: PointerEvent): () => void {
|
||||
// Sync initial shift state
|
||||
syncShiftState(initialEvent.shiftKey)
|
||||
|
||||
// Listen for shift key press/release during the operation
|
||||
const handleKeyEvent = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Shift') return
|
||||
syncShiftState(e.shiftKey)
|
||||
}
|
||||
|
||||
const stopKeydown = useEventListener(window, 'keydown', handleKeyEvent, {
|
||||
passive: true
|
||||
})
|
||||
const stopKeyup = useEventListener(window, 'keyup', handleKeyEvent, {
|
||||
passive: true
|
||||
})
|
||||
|
||||
// Return cleanup function that stops both listeners
|
||||
return () => {
|
||||
stopKeydown()
|
||||
stopKeyup()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on component unmount
|
||||
tryOnScopeDispose(() => {
|
||||
shiftKeyState.value = false
|
||||
canvasEl = null
|
||||
})
|
||||
|
||||
return { trackShiftKey }
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { onBeforeUnmount } from 'vue'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { Reroute } from '@/lib/litegraph/src/Reroute'
|
||||
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
|
||||
@@ -13,15 +13,23 @@ import type {
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import {
|
||||
clearCanvasPointerHistory,
|
||||
toCanvasPointerEvent
|
||||
} from '@/renderer/core/canvas/interaction/canvasPointerEvent'
|
||||
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
|
||||
import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
|
||||
import { useSlotLinkDragState } from '@/renderer/core/canvas/links/slotLinkDragState'
|
||||
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragState'
|
||||
import {
|
||||
resolveNodeSurfaceSlotCandidate,
|
||||
resolveSlotTargetCandidate
|
||||
} from '@/renderer/core/canvas/links/linkDropOrchestrator'
|
||||
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
|
||||
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragUIState'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { Point } from '@/renderer/core/layout/types'
|
||||
import { toPoint } from '@/renderer/core/layout/utils/geometry'
|
||||
import { createSlotLinkDragSession } from '@/renderer/extensions/vueNodes/composables/slotLinkDragSession'
|
||||
import { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext'
|
||||
import { app } from '@/scripts/app'
|
||||
import { createRafBatch } from '@/utils/rafBatch'
|
||||
|
||||
@@ -80,111 +88,54 @@ export function useSlotLinkInteraction({
|
||||
index,
|
||||
type
|
||||
}: SlotInteractionOptions): SlotInteractionHandlers {
|
||||
const { state, beginDrag, endDrag, updatePointerPosition, setCandidate } =
|
||||
useSlotLinkDragState()
|
||||
const {
|
||||
state,
|
||||
beginDrag,
|
||||
endDrag,
|
||||
updatePointerPosition,
|
||||
setCandidate,
|
||||
setCompatibleForKey,
|
||||
clearCompatible
|
||||
} = useSlotLinkDragUIState()
|
||||
const conversion = useSharedCanvasPositionConversion()
|
||||
const pointerSession = createPointerSession()
|
||||
let activeAdapter: LinkConnectorAdapter | null = null
|
||||
|
||||
// Per-drag drag-state cache
|
||||
const dragSession = createSlotLinkDragSession()
|
||||
// Per-drag drag-state context (non-reactive caches + RAF batching)
|
||||
const dragContext = createSlotLinkDragContext()
|
||||
|
||||
function candidateFromTarget(
|
||||
target: EventTarget | null
|
||||
): SlotDropCandidate | null {
|
||||
if (!(target instanceof HTMLElement)) return null
|
||||
const elWithKey = target.closest<HTMLElement>('[data-slot-key]')
|
||||
const key = elWithKey?.dataset['slotKey']
|
||||
if (!key) return null
|
||||
const resolveRenderLinkSource = (link: RenderLink): Point | null => {
|
||||
if (link.fromReroute) {
|
||||
const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id)
|
||||
if (rerouteLayout) return rerouteLayout.position
|
||||
const [x, y] = link.fromReroute.pos
|
||||
return toPoint(x, y)
|
||||
}
|
||||
|
||||
const layout = layoutStore.getSlotLayout(key)
|
||||
if (!layout) return null
|
||||
const nodeId = link.node.id
|
||||
if (nodeId != null) {
|
||||
const isInputFrom = link.toType === 'output'
|
||||
const key = getSlotKey(String(nodeId), link.fromSlotIndex, isInputFrom)
|
||||
const layout = layoutStore.getSlotLayout(key)
|
||||
if (layout) return layout.position
|
||||
}
|
||||
|
||||
const candidate: SlotDropCandidate = { layout, compatible: false }
|
||||
const pos = link.fromPos
|
||||
return toPoint(pos[0], pos[1])
|
||||
}
|
||||
|
||||
const graph = app.canvas?.graph
|
||||
const adapter = ensureActiveAdapter()
|
||||
if (graph && adapter) {
|
||||
const cached = dragSession.compatCache.get(key)
|
||||
if (cached != null) {
|
||||
candidate.compatible = cached
|
||||
} else {
|
||||
const compatible =
|
||||
layout.type === 'input'
|
||||
? adapter.isInputValidDrop(layout.nodeId, layout.index)
|
||||
: adapter.isOutputValidDrop(layout.nodeId, layout.index)
|
||||
dragSession.compatCache.set(key, compatible)
|
||||
candidate.compatible = compatible
|
||||
const syncRenderLinkOrigins = () => {
|
||||
if (!activeAdapter) return
|
||||
for (const link of activeAdapter.renderLinks) {
|
||||
const origin = resolveRenderLinkSource(link)
|
||||
if (!origin) continue
|
||||
const x = origin.x
|
||||
const y = origin.y
|
||||
if (link.fromPos[0] !== x || link.fromPos[1] !== y) {
|
||||
link.fromPos[0] = x
|
||||
link.fromPos[1] = y
|
||||
}
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
function candidateFromNodeTarget(
|
||||
target: EventTarget | null
|
||||
): SlotDropCandidate | null {
|
||||
if (!(target instanceof HTMLElement)) return null
|
||||
const elWithNode = target.closest<HTMLElement>('[data-node-id]')
|
||||
const nodeIdStr = elWithNode?.dataset['nodeId']
|
||||
if (!nodeIdStr) return null
|
||||
|
||||
const adapter = ensureActiveAdapter()
|
||||
const graph = app.canvas?.graph
|
||||
if (!adapter || !graph) return null
|
||||
|
||||
const nodeId = Number(nodeIdStr)
|
||||
|
||||
// Cached preferred slot for this node within this drag
|
||||
const cachedPreferred = dragSession.nodePreferred.get(nodeId)
|
||||
if (cachedPreferred !== undefined) {
|
||||
return cachedPreferred
|
||||
? { layout: cachedPreferred.layout, compatible: true }
|
||||
: null
|
||||
}
|
||||
|
||||
const node = graph.getNodeById(nodeId)
|
||||
if (!node) return null
|
||||
|
||||
const firstLink = adapter.renderLinks[0]
|
||||
if (!firstLink) return null
|
||||
const connectingTo = adapter.linkConnector.state.connectingTo
|
||||
|
||||
if (connectingTo !== 'input' && connectingTo !== 'output') return null
|
||||
|
||||
const isInput = connectingTo === 'input'
|
||||
const slotType = firstLink.fromSlot.type
|
||||
|
||||
const res = isInput
|
||||
? node.findInputByType(slotType)
|
||||
: node.findOutputByType(slotType)
|
||||
|
||||
const index = res?.index
|
||||
if (index == null) return null
|
||||
|
||||
const key = getSlotKey(String(nodeId), index, isInput)
|
||||
const layout = layoutStore.getSlotLayout(key)
|
||||
if (!layout) return null
|
||||
|
||||
const compatible = isInput
|
||||
? adapter.isInputValidDrop(nodeId, index)
|
||||
: adapter.isOutputValidDrop(nodeId, index)
|
||||
|
||||
if (compatible) {
|
||||
dragSession.compatCache.set(key, true)
|
||||
const preferred = { index, key, layout }
|
||||
dragSession.nodePreferred.set(nodeId, preferred)
|
||||
return { layout, compatible: true }
|
||||
} else {
|
||||
dragSession.compatCache.set(key, false)
|
||||
dragSession.nodePreferred.set(nodeId, null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const ensureActiveAdapter = (): LinkConnectorAdapter | null => {
|
||||
if (!activeAdapter) activeAdapter = createLinkConnectorAdapter()
|
||||
return activeAdapter
|
||||
}
|
||||
|
||||
function hasCanConnectToReroute(
|
||||
@@ -308,12 +259,16 @@ export function useSlotLinkInteraction({
|
||||
}
|
||||
|
||||
const cleanupInteraction = () => {
|
||||
if (state.pointerId != null) {
|
||||
clearCanvasPointerHistory(state.pointerId)
|
||||
}
|
||||
activeAdapter?.reset()
|
||||
pointerSession.clear()
|
||||
endDrag()
|
||||
activeAdapter = null
|
||||
raf.cancel()
|
||||
dragSession.dispose()
|
||||
dragContext.dispose()
|
||||
clearCompatible()
|
||||
}
|
||||
|
||||
const updatePointerState = (event: PointerEvent) => {
|
||||
@@ -328,9 +283,9 @@ export function useSlotLinkInteraction({
|
||||
}
|
||||
|
||||
const processPointerMoveFrame = () => {
|
||||
const data = dragSession.pendingMove
|
||||
const data = dragContext.pendingPointerMove
|
||||
if (!data) return
|
||||
dragSession.pendingMove = null
|
||||
dragContext.pendingPointerMove = null
|
||||
|
||||
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
|
||||
data.clientX,
|
||||
@@ -338,34 +293,61 @@ export function useSlotLinkInteraction({
|
||||
])
|
||||
updatePointerPosition(data.clientX, data.clientY, canvasX, canvasY)
|
||||
|
||||
syncRenderLinkOrigins()
|
||||
|
||||
let hoveredSlotKey: string | null = null
|
||||
let hoveredNodeId: number | null = null
|
||||
let hoveredNodeId: NodeId | null = null
|
||||
const target = data.target
|
||||
if (target instanceof HTMLElement) {
|
||||
hoveredSlotKey =
|
||||
target.closest<HTMLElement>('[data-slot-key]')?.dataset['slotKey'] ??
|
||||
null
|
||||
if (!hoveredSlotKey) {
|
||||
const nodeIdStr =
|
||||
target.closest<HTMLElement>('[data-node-id]')?.dataset['nodeId']
|
||||
hoveredNodeId = nodeIdStr != null ? Number(nodeIdStr) : null
|
||||
}
|
||||
if (target === dragContext.lastPointerEventTarget) {
|
||||
hoveredSlotKey = dragContext.lastPointerTargetSlotKey
|
||||
hoveredNodeId = dragContext.lastPointerTargetNodeId
|
||||
} else if (target instanceof HTMLElement) {
|
||||
const elWithSlot = target.closest<HTMLElement>('[data-slot-key]')
|
||||
const elWithNode = elWithSlot
|
||||
? null
|
||||
: target.closest<HTMLElement>('[data-node-id]')
|
||||
hoveredSlotKey = elWithSlot?.dataset['slotKey'] ?? null
|
||||
hoveredNodeId = hoveredSlotKey
|
||||
? null
|
||||
: elWithNode?.dataset['nodeId'] ?? null
|
||||
dragContext.lastPointerEventTarget = target
|
||||
dragContext.lastPointerTargetSlotKey = hoveredSlotKey
|
||||
dragContext.lastPointerTargetNodeId = hoveredNodeId
|
||||
}
|
||||
|
||||
const hoverChanged =
|
||||
hoveredSlotKey !== dragSession.lastHoverSlotKey ||
|
||||
hoveredNodeId !== dragSession.lastHoverNodeId
|
||||
hoveredSlotKey !== dragContext.lastHoverSlotKey ||
|
||||
hoveredNodeId !== dragContext.lastHoverNodeId
|
||||
|
||||
let candidate: SlotDropCandidate | null = state.candidate
|
||||
|
||||
if (hoverChanged) {
|
||||
const slotCandidate = candidateFromTarget(target)
|
||||
const adapter = activeAdapter
|
||||
const graph = app.canvas?.graph ?? null
|
||||
const context = { adapter, graph, session: dragContext }
|
||||
const slotCandidate = resolveSlotTargetCandidate(target, context)
|
||||
const nodeCandidate = slotCandidate
|
||||
? null
|
||||
: candidateFromNodeTarget(target)
|
||||
: resolveNodeSurfaceSlotCandidate(target, context)
|
||||
candidate = slotCandidate ?? nodeCandidate
|
||||
dragSession.lastHoverSlotKey = hoveredSlotKey
|
||||
dragSession.lastHoverNodeId = hoveredNodeId
|
||||
dragContext.lastHoverSlotKey = hoveredSlotKey
|
||||
dragContext.lastHoverNodeId = hoveredNodeId
|
||||
|
||||
if (slotCandidate) {
|
||||
const key = getSlotKey(
|
||||
slotCandidate.layout.nodeId,
|
||||
slotCandidate.layout.index,
|
||||
slotCandidate.layout.type === 'input'
|
||||
)
|
||||
setCompatibleForKey(key, !!slotCandidate.compatible)
|
||||
} else if (nodeCandidate) {
|
||||
const key = getSlotKey(
|
||||
nodeCandidate.layout.nodeId,
|
||||
nodeCandidate.layout.index,
|
||||
nodeCandidate.layout.type === 'input'
|
||||
)
|
||||
setCompatibleForKey(key, !!nodeCandidate.compatible)
|
||||
}
|
||||
}
|
||||
|
||||
const newCandidate = candidate?.compatible ? candidate : null
|
||||
@@ -377,18 +359,36 @@ export function useSlotLinkInteraction({
|
||||
)
|
||||
: null
|
||||
|
||||
if (newCandidateKey !== dragSession.lastCandidateKey) {
|
||||
const candidateChanged = newCandidateKey !== dragContext.lastCandidateKey
|
||||
if (candidateChanged) {
|
||||
setCandidate(newCandidate)
|
||||
dragSession.lastCandidateKey = newCandidateKey
|
||||
dragContext.lastCandidateKey = newCandidateKey
|
||||
}
|
||||
|
||||
app.canvas?.setDirty(true)
|
||||
let snapPosChanged = false
|
||||
if (activeAdapter) {
|
||||
const snapX = newCandidate
|
||||
? newCandidate.layout.position.x
|
||||
: state.pointer.canvas.x
|
||||
const snapY = newCandidate
|
||||
? newCandidate.layout.position.y
|
||||
: state.pointer.canvas.y
|
||||
const currentSnap = activeAdapter.linkConnector.state.snapLinksPos
|
||||
snapPosChanged =
|
||||
!currentSnap || currentSnap[0] !== snapX || currentSnap[1] !== snapY
|
||||
if (snapPosChanged) {
|
||||
activeAdapter.linkConnector.state.snapLinksPos = [snapX, snapY]
|
||||
}
|
||||
}
|
||||
|
||||
const shouldRedraw = candidateChanged || snapPosChanged
|
||||
if (shouldRedraw) app.canvas?.setDirty(true, true)
|
||||
}
|
||||
const raf = createRafBatch(processPointerMoveFrame)
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (!pointerSession.matches(event)) return
|
||||
dragSession.pendingMove = {
|
||||
dragContext.pendingPointerMove = {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
target: event.target
|
||||
@@ -402,10 +402,10 @@ export function useSlotLinkInteraction({
|
||||
): boolean => {
|
||||
if (!candidate?.compatible) return false
|
||||
const graph = app.canvas?.graph
|
||||
const adapter = ensureActiveAdapter()
|
||||
const adapter = activeAdapter
|
||||
if (!graph || !adapter) return false
|
||||
|
||||
const nodeId = Number(candidate.layout.nodeId)
|
||||
const nodeId: NodeId = candidate.layout.nodeId
|
||||
const targetNode = graph.getNodeById(nodeId)
|
||||
if (!targetNode) return false
|
||||
|
||||
@@ -435,7 +435,7 @@ export function useSlotLinkInteraction({
|
||||
y: state.pointer.canvas.y
|
||||
})
|
||||
const graph = app.canvas?.graph
|
||||
const adapter = ensureActiveAdapter()
|
||||
const adapter = activeAdapter
|
||||
if (!rerouteLayout || !graph || !adapter) return false
|
||||
|
||||
const reroute = graph.getReroute(rerouteLayout.id)
|
||||
@@ -483,43 +483,31 @@ export function useSlotLinkInteraction({
|
||||
|
||||
const finishInteraction = (event: PointerEvent) => {
|
||||
if (!pointerSession.matches(event)) return
|
||||
event.preventDefault()
|
||||
const canvasEvent = toCanvasPointerEvent(event)
|
||||
canvasEvent.preventDefault()
|
||||
|
||||
raf.flush()
|
||||
|
||||
raf.flush()
|
||||
|
||||
if (!state.source) {
|
||||
cleanupInteraction()
|
||||
app.canvas?.setDirty(true)
|
||||
app.canvas?.setDirty(true, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer using the snapped candidate captured during hover for perf + consistency
|
||||
const snappedCandidate = state.candidate?.compatible
|
||||
? state.candidate
|
||||
: null
|
||||
|
||||
let connected = tryConnectToCandidate(snappedCandidate)
|
||||
const hasConnected = connectByPriority(canvasEvent.target, snappedCandidate)
|
||||
|
||||
// Fallback to DOM slot under pointer (if any), then node fallback, then reroute
|
||||
if (!connected) {
|
||||
const domCandidate = candidateFromTarget(event.target)
|
||||
connected = tryConnectToCandidate(domCandidate)
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
const nodeCandidate = candidateFromNodeTarget(event.target)
|
||||
connected = tryConnectToCandidate(nodeCandidate)
|
||||
}
|
||||
|
||||
if (!connected) connected = tryConnectViaRerouteAtPointer() || connected
|
||||
|
||||
// Drop on canvas: disconnect moving input link(s)
|
||||
if (!connected && !snappedCandidate && state.source.type === 'input') {
|
||||
ensureActiveAdapter()?.disconnectMovingLinks()
|
||||
if (!hasConnected) {
|
||||
activeAdapter?.dropOnCanvas(canvasEvent)
|
||||
}
|
||||
|
||||
cleanupInteraction()
|
||||
app.canvas?.setDirty(true)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
@@ -530,8 +518,37 @@ export function useSlotLinkInteraction({
|
||||
if (!pointerSession.matches(event)) return
|
||||
|
||||
raf.flush()
|
||||
toCanvasPointerEvent(event)
|
||||
cleanupInteraction()
|
||||
app.canvas?.setDirty(true)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function connectByPriority(
|
||||
target: EventTarget | null,
|
||||
snappedCandidate: SlotDropCandidate | null
|
||||
): boolean {
|
||||
const adapter = activeAdapter
|
||||
const graph = app.canvas?.graph ?? null
|
||||
const context = { adapter, graph, session: dragContext }
|
||||
|
||||
const attemptSnapped = () => tryConnectToCandidate(snappedCandidate)
|
||||
|
||||
const domSlotCandidate = resolveSlotTargetCandidate(target, context)
|
||||
const attemptDomSlot = () => tryConnectToCandidate(domSlotCandidate)
|
||||
|
||||
const nodeSurfaceSlotCandidate = resolveNodeSurfaceSlotCandidate(
|
||||
target,
|
||||
context
|
||||
)
|
||||
const attemptNodeSurface = () =>
|
||||
tryConnectToCandidate(nodeSurfaceSlotCandidate)
|
||||
const attemptReroute = () => tryConnectViaRerouteAtPointer()
|
||||
|
||||
if (attemptSnapped()) return true
|
||||
if (attemptDomSlot()) return true
|
||||
if (attemptNodeSurface()) return true
|
||||
if (attemptReroute()) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
@@ -543,20 +560,21 @@ export function useSlotLinkInteraction({
|
||||
const graph = canvas?.graph
|
||||
if (!canvas || !graph) return
|
||||
|
||||
ensureActiveAdapter()
|
||||
activeAdapter = createLinkConnectorAdapter()
|
||||
if (!activeAdapter) return
|
||||
raf.cancel()
|
||||
dragSession.reset()
|
||||
dragContext.reset()
|
||||
|
||||
const layout = layoutStore.getSlotLayout(
|
||||
getSlotKey(nodeId, index, type === 'input')
|
||||
)
|
||||
if (!layout) return
|
||||
|
||||
const numericNodeId = Number(nodeId)
|
||||
const localNodeId: NodeId = nodeId
|
||||
const isInputSlot = type === 'input'
|
||||
const isOutputSlot = type === 'output'
|
||||
|
||||
const resolvedNode = graph.getNodeById(numericNodeId)
|
||||
const resolvedNode = graph.getNodeById(localNodeId)
|
||||
const inputSlot = isInputSlot ? resolvedNode?.inputs?.[index] : undefined
|
||||
const outputSlot = isOutputSlot ? resolvedNode?.outputs?.[index] : undefined
|
||||
|
||||
@@ -601,19 +619,24 @@ export function useSlotLinkInteraction({
|
||||
const shouldMoveExistingInput =
|
||||
isInputSlot && !shouldBreakExistingInputLink && hasExistingInputLink
|
||||
|
||||
const adapter = ensureActiveAdapter()
|
||||
if (adapter) {
|
||||
if (activeAdapter) {
|
||||
if (isOutputSlot) {
|
||||
adapter.beginFromOutput(numericNodeId, index, {
|
||||
activeAdapter.beginFromOutput(localNodeId, index, {
|
||||
moveExisting: shouldMoveExistingOutput
|
||||
})
|
||||
} else {
|
||||
adapter.beginFromInput(numericNodeId, index, {
|
||||
activeAdapter.beginFromInput(localNodeId, index, {
|
||||
moveExisting: shouldMoveExistingInput
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldMoveExistingInput && existingInputLink) {
|
||||
existingInputLink._dragging = true
|
||||
}
|
||||
}
|
||||
|
||||
syncRenderLinkOrigins()
|
||||
|
||||
const direction = existingAnchor?.direction ?? baseDirection
|
||||
const startPosition = existingAnchor?.position ?? {
|
||||
x: layout.position.x,
|
||||
@@ -637,8 +660,16 @@ export function useSlotLinkInteraction({
|
||||
|
||||
pointerSession.begin(event.pointerId)
|
||||
|
||||
toCanvasPointerEvent(event)
|
||||
updatePointerState(event)
|
||||
|
||||
if (activeAdapter) {
|
||||
activeAdapter.linkConnector.state.snapLinksPos = [
|
||||
state.pointer.canvas.x,
|
||||
state.pointer.canvas.y
|
||||
]
|
||||
}
|
||||
|
||||
pointerSession.register(
|
||||
useEventListener(window, 'pointermove', handlePointerMove, {
|
||||
capture: true
|
||||
@@ -650,7 +681,21 @@ export function useSlotLinkInteraction({
|
||||
capture: true
|
||||
})
|
||||
)
|
||||
app.canvas?.setDirty(true)
|
||||
const targetType: 'input' | 'output' = type === 'input' ? 'output' : 'input'
|
||||
const allKeys = layoutStore.getAllSlotKeys()
|
||||
clearCompatible()
|
||||
for (const key of allKeys) {
|
||||
const slotLayout = layoutStore.getSlotLayout(key)
|
||||
if (!slotLayout) continue
|
||||
if (slotLayout.type !== targetType) continue
|
||||
const idx = slotLayout.index
|
||||
const ok =
|
||||
targetType === 'input'
|
||||
? activeAdapter.isInputValidDrop(slotLayout.nodeId, idx)
|
||||
: activeAdapter.isOutputValidDrop(slotLayout.nodeId, idx)
|
||||
setCompatibleForKey(key, ok)
|
||||
}
|
||||
app.canvas?.setDirty(true, true)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { Point } from '@/renderer/core/layout/types'
|
||||
import type { NodeBoundsUpdate, Point } from '@/renderer/core/layout/types'
|
||||
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
|
||||
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||
|
||||
/**
|
||||
* Composable for individual Vue node components
|
||||
@@ -21,6 +23,12 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
// Get transform utilities from TransformPane if available
|
||||
const transformState = inject(TransformStateKey)
|
||||
|
||||
// Snap-to-grid functionality
|
||||
const { shouldSnap, applySnapToPosition } = useNodeSnap()
|
||||
|
||||
// Shift key sync for LiteGraph canvas preview
|
||||
const { trackShiftKey } = useShiftKeySync()
|
||||
|
||||
// Get the customRef for this node (shared write access)
|
||||
const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
|
||||
@@ -50,6 +58,8 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
let dragStartPos: Point | null = null
|
||||
let dragStartMouse: Point | null = null
|
||||
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
|
||||
let rafId: number | null = null
|
||||
let stopShiftSync: (() => void) | null = null
|
||||
|
||||
/**
|
||||
* Start dragging the node
|
||||
@@ -57,6 +67,9 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
function startDrag(event: PointerEvent) {
|
||||
if (!layoutRef.value || !transformState) return
|
||||
|
||||
// Track shift key state and sync to canvas for snap preview
|
||||
stopShiftSync = trackShiftKey(event)
|
||||
|
||||
isDragging.value = true
|
||||
dragStartPos = { ...position.value }
|
||||
dragStartMouse = { x: event.clientX, y: event.clientY }
|
||||
@@ -100,42 +113,54 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate mouse delta in screen coordinates
|
||||
const mouseDelta = {
|
||||
x: event.clientX - dragStartMouse.x,
|
||||
y: event.clientY - dragStartMouse.y
|
||||
}
|
||||
// Throttle position updates using requestAnimationFrame for better performance
|
||||
if (rafId !== null) return // Skip if frame already scheduled
|
||||
|
||||
// Convert to canvas coordinates
|
||||
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
|
||||
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
|
||||
const canvasDelta = {
|
||||
x: canvasWithDelta.x - canvasOrigin.x,
|
||||
y: canvasWithDelta.y - canvasOrigin.y
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
|
||||
// Calculate new position for the current node
|
||||
const newPosition = {
|
||||
x: dragStartPos.x + canvasDelta.x,
|
||||
y: dragStartPos.y + canvasDelta.y
|
||||
}
|
||||
if (!dragStartPos || !dragStartMouse || !transformState) return
|
||||
|
||||
// Apply mutation through the layout system
|
||||
mutations.moveNode(nodeId, newPosition)
|
||||
|
||||
// If we're dragging multiple selected nodes, move them all together
|
||||
if (
|
||||
otherSelectedNodesStartPositions &&
|
||||
otherSelectedNodesStartPositions.size > 0
|
||||
) {
|
||||
for (const [otherNodeId, startPos] of otherSelectedNodesStartPositions) {
|
||||
const newOtherPosition = {
|
||||
x: startPos.x + canvasDelta.x,
|
||||
y: startPos.y + canvasDelta.y
|
||||
}
|
||||
mutations.moveNode(otherNodeId, newOtherPosition)
|
||||
// Calculate mouse delta in screen coordinates
|
||||
const mouseDelta = {
|
||||
x: event.clientX - dragStartMouse.x,
|
||||
y: event.clientY - dragStartMouse.y
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to canvas coordinates
|
||||
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
|
||||
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
|
||||
const canvasDelta = {
|
||||
x: canvasWithDelta.x - canvasOrigin.x,
|
||||
y: canvasWithDelta.y - canvasOrigin.y
|
||||
}
|
||||
|
||||
// Calculate new position for the current node
|
||||
const newPosition = {
|
||||
x: dragStartPos.x + canvasDelta.x,
|
||||
y: dragStartPos.y + canvasDelta.y
|
||||
}
|
||||
|
||||
// Apply mutation through the layout system (Vue batches DOM updates automatically)
|
||||
mutations.moveNode(nodeId, newPosition)
|
||||
|
||||
// If we're dragging multiple selected nodes, move them all together
|
||||
if (
|
||||
otherSelectedNodesStartPositions &&
|
||||
otherSelectedNodesStartPositions.size > 0
|
||||
) {
|
||||
for (const [
|
||||
otherNodeId,
|
||||
startPos
|
||||
] of otherSelectedNodesStartPositions) {
|
||||
const newOtherPosition = {
|
||||
x: startPos.x + canvasDelta.x,
|
||||
y: startPos.y + canvasDelta.y
|
||||
}
|
||||
mutations.moveNode(otherNodeId, newOtherPosition)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,11 +169,82 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
function endDrag(event: PointerEvent) {
|
||||
if (!isDragging.value) return
|
||||
|
||||
// Apply snap to final position if snap was active (matches LiteGraph behavior)
|
||||
if (shouldSnap(event)) {
|
||||
const boundsUpdates: NodeBoundsUpdate[] = []
|
||||
|
||||
// Snap main node
|
||||
const currentLayout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (currentLayout) {
|
||||
const currentPos = currentLayout.position
|
||||
const snappedPos = applySnapToPosition({ ...currentPos })
|
||||
|
||||
// Only add update if position actually changed
|
||||
if (snappedPos.x !== currentPos.x || snappedPos.y !== currentPos.y) {
|
||||
boundsUpdates.push({
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: snappedPos.x,
|
||||
y: snappedPos.y,
|
||||
width: currentLayout.size.width,
|
||||
height: currentLayout.size.height
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Also snap other selected nodes
|
||||
// Capture all positions at the start to ensure consistent state
|
||||
if (
|
||||
otherSelectedNodesStartPositions &&
|
||||
otherSelectedNodesStartPositions.size > 0
|
||||
) {
|
||||
for (const otherNodeId of otherSelectedNodesStartPositions.keys()) {
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(otherNodeId).value
|
||||
if (nodeLayout) {
|
||||
const currentPos = { ...nodeLayout.position }
|
||||
const snappedPos = applySnapToPosition(currentPos)
|
||||
|
||||
// Only add update if position actually changed
|
||||
if (
|
||||
snappedPos.x !== currentPos.x ||
|
||||
snappedPos.y !== currentPos.y
|
||||
) {
|
||||
boundsUpdates.push({
|
||||
nodeId: otherNodeId,
|
||||
bounds: {
|
||||
x: snappedPos.x,
|
||||
y: snappedPos.y,
|
||||
width: nodeLayout.size.width,
|
||||
height: nodeLayout.size.height
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all snap updates in a single batched transaction
|
||||
if (boundsUpdates.length > 0) {
|
||||
layoutStore.batchUpdateNodeBounds(boundsUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
isDragging.value = false
|
||||
dragStartPos = null
|
||||
dragStartMouse = null
|
||||
otherSelectedNodesStartPositions = null
|
||||
|
||||
// Stop tracking shift key state
|
||||
stopShiftSync?.()
|
||||
stopShiftSync = null
|
||||
|
||||
// Cancel any pending animation frame
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
|
||||
// Release pointer
|
||||
if (!(event.target instanceof HTMLElement)) return
|
||||
event.target.releasePointerCapture(event.pointerId)
|
||||
@@ -162,14 +258,6 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
mutations.moveNode(nodeId, position)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node size
|
||||
*/
|
||||
function resize(newSize: { width: number; height: number }) {
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
mutations.resizeNode(nodeId, newSize)
|
||||
}
|
||||
|
||||
return {
|
||||
// Reactive state (via customRef)
|
||||
layoutRef,
|
||||
@@ -182,7 +270,6 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
|
||||
// Mutations
|
||||
moveTo,
|
||||
resize,
|
||||
|
||||
// Drag handlers
|
||||
startDrag,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { calculateIntrinsicSize } from './calculateIntrinsicSize'
|
||||
|
||||
describe('calculateIntrinsicSize', () => {
|
||||
let element: HTMLElement
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a test element
|
||||
element = document.createElement('div')
|
||||
element.style.width = '200px'
|
||||
element.style.height = '100px'
|
||||
document.body.appendChild(element)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(element)
|
||||
})
|
||||
|
||||
it('should calculate intrinsic size and convert to canvas coordinates', () => {
|
||||
// Mock getBoundingClientRect to return specific dimensions
|
||||
const originalGetBoundingClientRect = element.getBoundingClientRect
|
||||
element.getBoundingClientRect = () => ({
|
||||
width: 300,
|
||||
height: 150,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 150,
|
||||
right: 300,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
|
||||
const scale = 2
|
||||
const result = calculateIntrinsicSize(element, scale)
|
||||
|
||||
// Should divide by scale to convert from screen to canvas coordinates
|
||||
expect(result).toEqual({
|
||||
width: 150, // 300 / 2
|
||||
height: 75 // 150 / 2
|
||||
})
|
||||
|
||||
element.getBoundingClientRect = originalGetBoundingClientRect
|
||||
})
|
||||
|
||||
it('should restore original size after measuring', () => {
|
||||
const originalWidth = element.style.width
|
||||
const originalHeight = element.style.height
|
||||
|
||||
element.getBoundingClientRect = () => ({
|
||||
width: 300,
|
||||
height: 150,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 150,
|
||||
right: 300,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
|
||||
calculateIntrinsicSize(element, 1)
|
||||
|
||||
// Should restore original styles
|
||||
expect(element.style.width).toBe(originalWidth)
|
||||
expect(element.style.height).toBe(originalHeight)
|
||||
})
|
||||
|
||||
it('should handle scale of 1 correctly', () => {
|
||||
element.getBoundingClientRect = () => ({
|
||||
width: 400,
|
||||
height: 200,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 200,
|
||||
right: 400,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
|
||||
const result = calculateIntrinsicSize(element, 1)
|
||||
|
||||
expect(result).toEqual({
|
||||
width: 400,
|
||||
height: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle fractional scales', () => {
|
||||
element.getBoundingClientRect = () => ({
|
||||
width: 300,
|
||||
height: 150,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 150,
|
||||
right: 300,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
|
||||
const result = calculateIntrinsicSize(element, 0.5)
|
||||
|
||||
expect(result).toEqual({
|
||||
width: 600, // 300 / 0.5
|
||||
height: 300 // 150 / 0.5
|
||||
})
|
||||
})
|
||||
|
||||
it('should temporarily set width and height to auto during measurement', () => {
|
||||
let widthDuringMeasurement = ''
|
||||
let heightDuringMeasurement = ''
|
||||
|
||||
element.getBoundingClientRect = function (this: HTMLElement) {
|
||||
widthDuringMeasurement = this.style.width
|
||||
heightDuringMeasurement = this.style.height
|
||||
return {
|
||||
width: 300,
|
||||
height: 150,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 150,
|
||||
right: 300,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
}
|
||||
}
|
||||
|
||||
calculateIntrinsicSize(element, 1)
|
||||
|
||||
// During measurement, styles should be set to 'auto'
|
||||
expect(widthDuringMeasurement).toBe('auto')
|
||||
expect(heightDuringMeasurement).toBe('auto')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Calculate the intrinsic (minimum content-based) size of a node element
|
||||
*
|
||||
* Temporarily sets the element to auto-size to measure its natural content dimensions,
|
||||
* then converts from screen coordinates to canvas coordinates using the camera scale.
|
||||
*
|
||||
* @param element - The node element to measure
|
||||
* @param scale - Camera zoom scale for coordinate conversion
|
||||
* @returns The intrinsic minimum size in canvas coordinates
|
||||
*/
|
||||
export function calculateIntrinsicSize(
|
||||
element: HTMLElement,
|
||||
scale: number
|
||||
): { width: number; height: number } {
|
||||
// Store original size to restore later
|
||||
const originalWidth = element.style.width
|
||||
const originalHeight = element.style.height
|
||||
|
||||
// Temporarily set to auto to measure natural content size
|
||||
element.style.width = 'auto'
|
||||
element.style.height = 'auto'
|
||||
|
||||
const intrinsicRect = element.getBoundingClientRect()
|
||||
|
||||
// Restore original size
|
||||
element.style.width = originalWidth
|
||||
element.style.height = originalHeight
|
||||
|
||||
// Convert from screen coordinates to canvas coordinates
|
||||
return {
|
||||
width: intrinsicRect.width / scale,
|
||||
height: intrinsicRect.height / scale
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<WidgetSelect v-model="modelValue" :widget />
|
||||
<div class="my-4">
|
||||
<AudioPreviewPlayer
|
||||
:audio-url="audioUrlFromWidget"
|
||||
:readonly="readonly"
|
||||
:hide-when-empty="isOutputNodeRef"
|
||||
:show-options-button="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { isOutputNode } from '@/utils/nodeFilterUtil'
|
||||
|
||||
import { getAudioUrlFromPath } from '../utils/audioUtils'
|
||||
import WidgetSelect from './WidgetSelect.vue'
|
||||
import AudioPreviewPlayer from './audio/AudioPreviewPlayer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
readonly?: boolean
|
||||
nodeId: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>('modelValue')
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// Get litegraph node
|
||||
const litegraphNode = computed(() => {
|
||||
if (!props.nodeId || !app.rootGraph) return null
|
||||
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
|
||||
})
|
||||
|
||||
// Check if this is an output node (PreviewAudio, SaveAudio, etc)
|
||||
const isOutputNodeRef = computed(() => {
|
||||
const node = litegraphNode.value
|
||||
if (!node) return false
|
||||
return isOutputNode(node)
|
||||
})
|
||||
|
||||
const audioFilePath = computed(() => props.widget.value as string)
|
||||
|
||||
// Computed audio URL from widget value (for input files)
|
||||
const audioUrlFromWidget = computed(() => {
|
||||
const path = audioFilePath.value
|
||||
if (!path) return ''
|
||||
return getAudioUrlFromPath(path, 'input')
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
// Button widgets don't have a v-model value, they trigger actions
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<void>
|
||||
nodeId: string
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const domEl = ref<HTMLElement>()
|
||||
|
||||
const { canvas } = useCanvasStore()
|
||||
onMounted(() => {
|
||||
if (!domEl.value) return
|
||||
const node = canvas?.graph?.getNodeById(props.nodeId) ?? undefined
|
||||
if (!node) return
|
||||
const widget = node.widgets?.find((w) => w.name === props.widget.name)
|
||||
if (!widget || !isDOMWidget(widget)) return
|
||||
domEl.value.replaceChildren(widget.element)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div ref="domEl" />
|
||||
</template>
|
||||
@@ -90,6 +90,7 @@ const buttonTooltip = computed(() => {
|
||||
:step="stepValue"
|
||||
:use-grouping="useGrouping"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
:aria-label="widget.name"
|
||||
:pt="{
|
||||
incrementButton:
|
||||
'!rounded-r-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40',
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="mb-4">
|
||||
<Button
|
||||
class="w-[413px] border-0 bg-zinc-500/10 text-zinc-400 dark-theme:bg-charcoal-600 dark-theme:text-white"
|
||||
:disabled="isRecording || readonly"
|
||||
@click="handleStartRecording"
|
||||
>
|
||||
{{ t('g.startRecording', 'Start Recording') }}
|
||||
<i-lucide:mic class="ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="isRecording || isPlaying || recordedURL"
|
||||
class="flex h-14 w-[413px] items-center gap-4 rounded-lg bg-zinc-500/10 px-4 text-zinc-400 dark-theme:bg-node-component-surface dark-theme:text-white"
|
||||
>
|
||||
<!-- Recording Status -->
|
||||
<div class="flex min-w-30 items-center gap-2">
|
||||
<span class="min-w-20 text-xs">
|
||||
{{
|
||||
isRecording
|
||||
? t('g.listening', 'Listening...')
|
||||
: isPlaying
|
||||
? t('g.playing', 'Playing...')
|
||||
: recordedURL
|
||||
? t('g.ready', 'Ready')
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
<span class="min-w-10 text-sm">{{ formatTime(timer) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Waveform Visualization -->
|
||||
<div class="flex h-8 flex-1 items-center gap-2 overflow-x-clip">
|
||||
<div
|
||||
v-for="(bar, index) in waveformBars"
|
||||
:key="index"
|
||||
class="max-h-8 min-h-1 w-0.75 rounded-[1.5px] bg-slate-100 transition-all duration-100"
|
||||
:style="{ height: bar.height + 'px' }"
|
||||
:title="`Bar ${index + 1}: ${bar.height}px`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Control Button -->
|
||||
<button
|
||||
v-if="isRecording"
|
||||
:title="t('g.stopRecording', 'Stop Recording')"
|
||||
class="flex size-8 animate-pulse items-center justify-center rounded-full border-0 bg-gray-500/33 transition-colors"
|
||||
@click="handleStopRecording"
|
||||
>
|
||||
<div class="size-2.5 rounded-sm bg-[#C02323]" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else-if="!isRecording && recordedURL && !isPlaying"
|
||||
:title="t('g.playRecording') || 'Play Recording'"
|
||||
class="flex size-8 items-center justify-center rounded-full border-0 bg-gray-500/33 transition-colors"
|
||||
@click="handlePlayRecording"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--play] size-4 text-zinc-400 dark-theme:text-white"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else-if="isPlaying"
|
||||
:title="t('g.stopPlayback') || 'Stop Playback'"
|
||||
class="flex size-8 items-center justify-center rounded-full border-0 bg-gray-500/33 transition-colors"
|
||||
@click="handleStopPlayback"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--square] size-4 text-zinc-400 dark-theme:text-white"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<audio
|
||||
v-if="recordedURL"
|
||||
ref="audioRef"
|
||||
:key="audioElementKey"
|
||||
:src="recordedURL"
|
||||
class="hidden"
|
||||
@ended="playback.onPlaybackEnded"
|
||||
@loadedmetadata="playback.onMetadataLoaded"
|
||||
/>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { Button } from 'primevue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import { useAudioPlayback } from '../composables/audio/useAudioPlayback'
|
||||
import { useAudioRecorder } from '../composables/audio/useAudioRecorder'
|
||||
import { useAudioWaveform } from '../composables/audio/useAudioWaveform'
|
||||
import { formatTime } from '../utils/audioUtils'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
readonly?: boolean
|
||||
modelValue: string
|
||||
nodeId: string
|
||||
}>()
|
||||
|
||||
// Audio element ref
|
||||
const audioRef = ref<HTMLAudioElement>()
|
||||
|
||||
// Keep track of the last uploaded path as a backup
|
||||
let lastUploadedPath = ''
|
||||
|
||||
// Composables
|
||||
const recorder = useAudioRecorder({
|
||||
onRecordingComplete: handleRecordingComplete,
|
||||
onError: () => {
|
||||
useToastStore().addAlert(
|
||||
t('g.micPermissionDenied') || 'Microphone permission denied'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const waveform = useAudioWaveform({
|
||||
barCount: 18,
|
||||
minHeight: 4,
|
||||
maxHeight: 32
|
||||
})
|
||||
|
||||
const playback = useAudioPlayback(audioRef, {
|
||||
onPlaybackEnded: handlePlaybackEnded,
|
||||
onMetadataLoaded: (duration) => {
|
||||
if (!isPlaying.value && !isRecording.value) {
|
||||
timer.value = Math.floor(duration)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Timer for recording
|
||||
const timer = ref(0)
|
||||
const { pause: pauseTimer, resume: resumeTimer } = useIntervalFn(
|
||||
() => {
|
||||
timer.value += 1
|
||||
},
|
||||
1000,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// Destructure for template access
|
||||
const { isRecording, recordedURL } = recorder
|
||||
const { waveformBars } = waveform
|
||||
const { isPlaying, audioElementKey } = playback
|
||||
|
||||
// Computed for waveform animation
|
||||
const isWaveformActive = computed(() => isRecording.value || isPlaying.value)
|
||||
const { localValue, onChange } = useStringWidgetValue(
|
||||
props.widget as SimplifiedWidget<string, Record<string, string>>,
|
||||
props.modelValue,
|
||||
emit
|
||||
)
|
||||
const litegraphNode = computed(() => {
|
||||
if (!props.nodeId || !app.rootGraph) return null
|
||||
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
|
||||
})
|
||||
|
||||
async function handleRecordingComplete(blob: Blob) {
|
||||
try {
|
||||
const path = await useAudioService().convertBlobToFileAndSubmit(blob)
|
||||
localValue.value = path
|
||||
lastUploadedPath = path
|
||||
onChange(path)
|
||||
} catch (e) {
|
||||
useToastStore().addAlert('Failed to upload recorded audio')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartRecording() {
|
||||
if (props.readonly) return
|
||||
|
||||
try {
|
||||
await waveform.setupAudioContext()
|
||||
await recorder.startRecording()
|
||||
|
||||
// Setup waveform visualization for recording
|
||||
if (recorder.mediaRecorder.value) {
|
||||
const stream = recorder.mediaRecorder.value.stream
|
||||
if (stream) {
|
||||
await waveform.setupRecordingVisualization(stream)
|
||||
}
|
||||
}
|
||||
|
||||
// Start timer
|
||||
timer.value = 0
|
||||
resumeTimer()
|
||||
waveform.initWaveform()
|
||||
waveform.updateWaveform(isWaveformActive)
|
||||
} catch (err) {
|
||||
console.error('Failed to start recording:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleStopRecording() {
|
||||
recorder.stopRecording()
|
||||
pauseTimer()
|
||||
waveform.stopWaveform()
|
||||
}
|
||||
|
||||
async function handlePlayRecording() {
|
||||
if (!recordedURL.value) return
|
||||
|
||||
// Reset timer
|
||||
timer.value = 0
|
||||
|
||||
// Reset and setup audio element
|
||||
await playback.resetAudioElement()
|
||||
|
||||
// Wait for audio element to be ready
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
if (!audioRef.value) return
|
||||
|
||||
// Setup waveform visualization for playback
|
||||
const setupSuccess = await waveform.setupPlaybackVisualization(audioRef.value)
|
||||
if (!setupSuccess) return
|
||||
|
||||
// Start playback
|
||||
await playback.play()
|
||||
|
||||
// Update waveform
|
||||
waveform.initWaveform()
|
||||
waveform.updateWaveform(isWaveformActive)
|
||||
|
||||
// Update timer from audio current time
|
||||
const timerInterval = setInterval(() => {
|
||||
timer.value = Math.floor(playback.getCurrentTime())
|
||||
}, 100)
|
||||
|
||||
// Store interval for cleanup
|
||||
playback.playbackTimerInterval.value = timerInterval
|
||||
}
|
||||
|
||||
function handleStopPlayback() {
|
||||
playback.stop()
|
||||
handlePlaybackEnded()
|
||||
}
|
||||
|
||||
function handlePlaybackEnded() {
|
||||
waveform.stopWaveform()
|
||||
|
||||
// Clear playback timer interval
|
||||
if (playback.playbackTimerInterval.value !== null) {
|
||||
clearInterval(playback.playbackTimerInterval.value)
|
||||
playback.playbackTimerInterval.value = null
|
||||
}
|
||||
|
||||
const duration = playback.getDuration()
|
||||
if (duration) {
|
||||
timer.value = Math.floor(duration)
|
||||
} else {
|
||||
timer.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Serialization function for workflow execution
|
||||
async function serializeValue() {
|
||||
if (isRecording.value && recorder.mediaRecorder.value) {
|
||||
recorder.mediaRecorder.value.stop()
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
let attempts = 0
|
||||
const maxAttempts = 50 // 5 seconds max (50 * 100ms)
|
||||
const checkRecording = () => {
|
||||
if (!isRecording.value && props.modelValue) {
|
||||
resolve(undefined)
|
||||
} else if (++attempts >= maxAttempts) {
|
||||
reject(new Error('Recording serialization timeout after 5 seconds'))
|
||||
} else {
|
||||
setTimeout(checkRecording, 100)
|
||||
}
|
||||
}
|
||||
checkRecording()
|
||||
})
|
||||
}
|
||||
|
||||
return props.modelValue || lastUploadedPath || ''
|
||||
}
|
||||
|
||||
function registerWidgetSerialization() {
|
||||
const node = litegraphNode.value
|
||||
if (!node?.widgets) return
|
||||
const targetWidget = node.widgets.find((w: IBaseWidget) => w.name === 'audio')
|
||||
if (targetWidget) {
|
||||
targetWidget.serializeValue = serializeValue
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
waveform.initWaveform()
|
||||
registerWidgetSerialization()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (playback.playbackTimerInterval.value !== null) {
|
||||
clearInterval(playback.playbackTimerInterval.value)
|
||||
playback.playbackTimerInterval.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
v-if="!hidden"
|
||||
:class="
|
||||
cn(
|
||||
'bg-zinc-500/10 dark-theme:bg-charcoal-600 box-border flex gap-4 items-center justify-start relative rounded-lg w-full h-16 px-4 py-0',
|
||||
{ hidden: hideWhenEmpty && !hasAudio }
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Hidden audio element -->
|
||||
<audio
|
||||
ref="audioRef"
|
||||
@loadedmetadata="handleLoadedMetadata"
|
||||
@timeupdate="handleTimeUpdate"
|
||||
@ended="handleEnded"
|
||||
/>
|
||||
|
||||
<!-- Left Actions -->
|
||||
<div class="relative flex shrink-0 items-center justify-start gap-2">
|
||||
<!-- Play/Pause Button -->
|
||||
<div
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
aria-label="Play/Pause"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
|
||||
@click="togglePlayPause"
|
||||
>
|
||||
<i
|
||||
v-if="!isPlaying"
|
||||
class="icon-[lucide--play] size-4 text-gray-600 dark-theme:text-gray-800"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="icon-[lucide--pause] size-4 text-gray-600 dark-theme:text-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Time Display -->
|
||||
<div
|
||||
class="text-sm font-normal text-nowrap text-black dark-theme:text-white"
|
||||
>
|
||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div
|
||||
class="relative h-0.5 flex-1 rounded-full bg-gray-300 dark-theme:bg-stone-200"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full rounded-full bg-gray-600 transition-all dark-theme:bg-white/50"
|
||||
:style="{ width: `${progressPercentage}%` }"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
:value="progressPercentage"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="absolute inset-0 w-full cursor-pointer opacity-0"
|
||||
@input="handleSeek"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right Actions -->
|
||||
<div class="relative flex shrink-0 items-center justify-start gap-2">
|
||||
<!-- Volume Button -->
|
||||
<div
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
aria-label="Volume"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<i
|
||||
v-if="showVolumeTwo"
|
||||
class="icon-[lucide--volume-2] size-4 text-gray-600 dark-theme:text-gray-800"
|
||||
/>
|
||||
<i
|
||||
v-else-if="showVolumeOne"
|
||||
class="icon-[lucide--volume-1] size-4 text-gray-600 dark-theme:text-gray-800"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="icon-[lucide--volume-x] size-4 text-gray-600 dark-theme:text-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Options Button -->
|
||||
<div
|
||||
v-if="showOptionsButton"
|
||||
ref="optionsButtonRef"
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
aria-label="More Options"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
|
||||
@click="toggleOptionsMenu"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--more-vertical] size-4 text-gray-600 dark-theme:text-gray-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options Menu -->
|
||||
<TieredMenu
|
||||
ref="optionsMenu"
|
||||
:model="menuItems"
|
||||
popup
|
||||
class="audio-player-menu"
|
||||
pt:root:class="!bg-white dark-theme:!bg-charcoal-800 !border-sand-100 dark-theme:!border-charcoal-600"
|
||||
pt:submenu:class="!bg-white dark-theme:!bg-charcoal-800"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div v-if="item.key === 'volume'" class="w-48 px-4 py-2">
|
||||
<label
|
||||
class="mb-2 block text-xs text-black dark-theme:text-white"
|
||||
>{{ item.label }}</label
|
||||
>
|
||||
<Slider
|
||||
:model-value="volume * 10"
|
||||
:min="0"
|
||||
:max="10"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
@update:model-value="handleVolumeChange"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex cursor-pointer items-center px-4 py-2 text-xs hover:bg-white/10"
|
||||
@click="item.onClick?.()"
|
||||
>
|
||||
<span class="text-black dark-theme:text-white">{{
|
||||
item.label
|
||||
}}</span>
|
||||
<i
|
||||
v-if="item.selected"
|
||||
class="ml-auto icon-[lucide--check] size-4 text-black dark-theme:text-white"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</TieredMenu>
|
||||
</div>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
import TieredMenu from 'primevue/tieredmenu'
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { getLocatorIdFromNodeData } from '@/utils/graphTraversalUtil'
|
||||
import { isOutputNode } from '@/utils/nodeFilterUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { formatTime, getResourceURL } from '../../utils/audioUtils'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
readonly?: boolean
|
||||
hideWhenEmpty?: boolean
|
||||
showOptionsButton?: boolean
|
||||
modelValue?: string
|
||||
nodeId?: string
|
||||
audioUrl?: string
|
||||
}>(),
|
||||
{
|
||||
hideWhenEmpty: true
|
||||
}
|
||||
)
|
||||
|
||||
// Refs
|
||||
const audioRef = ref<HTMLAudioElement>()
|
||||
const optionsMenu = ref()
|
||||
const optionsButtonRef = ref<HTMLElement>()
|
||||
const isPlaying = ref(false)
|
||||
const isMuted = ref(false)
|
||||
const volume = ref(1)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const hasAudio = ref(false)
|
||||
const playbackRate = ref(1)
|
||||
|
||||
// Computed
|
||||
const progressPercentage = computed(() => {
|
||||
if (!duration.value || duration.value === 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
})
|
||||
|
||||
const showVolumeTwo = computed(() => !isMuted.value && volume.value > 0.5)
|
||||
const showVolumeOne = computed(() => isMuted.value && volume.value > 0)
|
||||
|
||||
const litegraphNode = computed(() => {
|
||||
if (!props.nodeId || !app.rootGraph) return null
|
||||
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
|
||||
})
|
||||
|
||||
const hidden = computed(() => {
|
||||
if (!litegraphNode.value) return false
|
||||
// dont show if its a LoadAudio and we have nodeId
|
||||
const isLoadAudio =
|
||||
litegraphNode.value.constructor?.comfyClass === 'LoadAudio'
|
||||
return isLoadAudio && !!props.nodeId
|
||||
})
|
||||
|
||||
// Check if this is an output node
|
||||
const isOutputNodeRef = computed(() => {
|
||||
const node = litegraphNode.value
|
||||
return !!node && isOutputNode(node)
|
||||
})
|
||||
|
||||
const nodeLocatorId = computed(() => {
|
||||
const node = litegraphNode.value
|
||||
if (!node) return null
|
||||
return getLocatorIdFromNodeData(node)
|
||||
})
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
// Computed audio URL from node output (for output nodes)
|
||||
const audioUrlFromOutput = computed(() => {
|
||||
if (!isOutputNodeRef.value || !nodeLocatorId.value) return ''
|
||||
|
||||
const nodeOutput = nodeOutputStore.nodeOutputs[nodeLocatorId.value]
|
||||
if (!nodeOutput?.audio || nodeOutput.audio.length === 0) return ''
|
||||
|
||||
const audio = nodeOutput.audio[0]
|
||||
if (!audio.filename) return ''
|
||||
|
||||
return api.apiURL(
|
||||
getResourceURL(
|
||||
audio.subfolder || '',
|
||||
audio.filename,
|
||||
audio.type || 'output'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
// Combined audio URL (output takes precedence for output nodes)
|
||||
const finalAudioUrl = computed(() => {
|
||||
return audioUrlFromOutput.value || props.audioUrl || ''
|
||||
})
|
||||
|
||||
// Playback controls
|
||||
const togglePlayPause = () => {
|
||||
if (!audioRef.value || !audioRef.value.src) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlaying.value) {
|
||||
audioRef.value.pause()
|
||||
} else {
|
||||
void audioRef.value.play()
|
||||
}
|
||||
isPlaying.value = !isPlaying.value
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
if (audioRef.value) {
|
||||
isMuted.value = !isMuted.value
|
||||
audioRef.value.muted = isMuted.value
|
||||
}
|
||||
}
|
||||
|
||||
const handleSeek = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = parseFloat(target.value)
|
||||
if (audioRef.value && duration.value > 0) {
|
||||
const newTime = (value / 100) * duration.value
|
||||
audioRef.value.currentTime = newTime
|
||||
currentTime.value = newTime
|
||||
}
|
||||
}
|
||||
|
||||
// Audio events
|
||||
const handleLoadedMetadata = () => {
|
||||
if (audioRef.value) {
|
||||
duration.value = audioRef.value.duration
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (audioRef.value) {
|
||||
currentTime.value = audioRef.value.currentTime
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
isPlaying.value = false
|
||||
currentTime.value = 0
|
||||
}
|
||||
|
||||
// Options menu
|
||||
const toggleOptionsMenu = (event: Event) => {
|
||||
optionsMenu.value?.toggle(event)
|
||||
}
|
||||
|
||||
const setPlaybackSpeed = (speed: number) => {
|
||||
playbackRate.value = speed
|
||||
if (audioRef.value) {
|
||||
audioRef.value.playbackRate = speed
|
||||
}
|
||||
}
|
||||
|
||||
const handleVolumeChange = (value: number | number[]) => {
|
||||
const numValue = Array.isArray(value) ? value[0] : value
|
||||
volume.value = numValue / 10
|
||||
if (audioRef.value) {
|
||||
audioRef.value.volume = volume.value
|
||||
if (volume.value > 0 && isMuted.value) {
|
||||
isMuted.value = false
|
||||
audioRef.value.muted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
label: t('g.playbackSpeed'),
|
||||
items: [
|
||||
{
|
||||
label: t('g.halfSpeed'),
|
||||
onClick: () => setPlaybackSpeed(0.5),
|
||||
selected: playbackRate.value === 0.5
|
||||
},
|
||||
{
|
||||
label: t('g.1x'),
|
||||
onClick: () => setPlaybackSpeed(1),
|
||||
selected: playbackRate.value === 1
|
||||
},
|
||||
{
|
||||
label: t('g.2x'),
|
||||
onClick: () => setPlaybackSpeed(2),
|
||||
selected: playbackRate.value === 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('g.volume'),
|
||||
key: 'volume'
|
||||
}
|
||||
])
|
||||
|
||||
// Load audio from URL
|
||||
const loadAudioFromUrl = (url: string) => {
|
||||
if (!audioRef.value) return
|
||||
isPlaying.value = false
|
||||
audioRef.value.pause()
|
||||
audioRef.value.src = url
|
||||
void audioRef.value.load()
|
||||
hasAudio.value = !!url
|
||||
}
|
||||
|
||||
// Watch for finalAudioUrl changes
|
||||
watch(
|
||||
finalAudioUrl,
|
||||
(newUrl) => {
|
||||
if (newUrl) {
|
||||
void nextTick(() => {
|
||||
loadAudioFromUrl(newUrl)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Cleanup
|
||||
onUnmounted(() => {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.pause()
|
||||
audioRef.value.src = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audio-player-menu {
|
||||
--p-tieredmenu-item-focus-background: rgba(255, 255, 255, 0.1);
|
||||
--p-tieredmenu-item-active-background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { nextTick, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
interface AudioPlaybackOptions {
|
||||
onPlaybackEnded?: () => void
|
||||
onMetadataLoaded?: (duration: number) => void
|
||||
}
|
||||
|
||||
export function useAudioPlayback(
|
||||
audioRef: Ref<HTMLAudioElement | undefined>,
|
||||
options: AudioPlaybackOptions = {}
|
||||
) {
|
||||
const isPlaying = ref(false)
|
||||
const audioElementKey = ref(0)
|
||||
const playbackTimerInterval = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
async function play() {
|
||||
if (!audioRef.value) return false
|
||||
|
||||
try {
|
||||
await audioRef.value.play()
|
||||
isPlaying.value = true
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('Audio playback failed:', error)
|
||||
isPlaying.value = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.pause()
|
||||
audioRef.value.currentTime = 0
|
||||
}
|
||||
isPlaying.value = false
|
||||
if (options.onPlaybackEnded) {
|
||||
options.onPlaybackEnded()
|
||||
}
|
||||
}
|
||||
|
||||
function onPlaybackEnded() {
|
||||
isPlaying.value = false
|
||||
if (options.onPlaybackEnded) {
|
||||
options.onPlaybackEnded()
|
||||
}
|
||||
}
|
||||
|
||||
function onMetadataLoaded() {
|
||||
if (audioRef.value?.duration && options.onMetadataLoaded) {
|
||||
options.onMetadataLoaded(audioRef.value.duration)
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAudioElement() {
|
||||
audioElementKey.value += 1
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function getCurrentTime() {
|
||||
return audioRef.value?.currentTime || 0
|
||||
}
|
||||
|
||||
function getDuration() {
|
||||
return audioRef.value?.duration || 0
|
||||
}
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
audioElementKey,
|
||||
play,
|
||||
stop,
|
||||
onPlaybackEnded,
|
||||
onMetadataLoaded,
|
||||
resetAudioElement,
|
||||
getCurrentTime,
|
||||
getDuration,
|
||||
playbackTimerInterval
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
|
||||
interface AudioRecorderOptions {
|
||||
onRecordingComplete?: (audioBlob: Blob) => Promise<void>
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export function useAudioRecorder(options: AudioRecorderOptions = {}) {
|
||||
const isRecording = ref(false)
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null)
|
||||
const audioChunks = ref<Blob[]>([])
|
||||
const stream = ref<MediaStream | null>(null)
|
||||
const recordedURL = ref<string | null>(null)
|
||||
|
||||
async function startRecording() {
|
||||
try {
|
||||
// Clean up previous recording
|
||||
if (recordedURL.value?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(recordedURL.value)
|
||||
}
|
||||
|
||||
// Initialize
|
||||
audioChunks.value = []
|
||||
recordedURL.value = null
|
||||
|
||||
// Register wav encoder and get media stream
|
||||
await useAudioService().registerWavEncoder()
|
||||
stream.value = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
|
||||
// Create media recorder
|
||||
mediaRecorder.value = new ExtendableMediaRecorder(stream.value, {
|
||||
mimeType: 'audio/wav'
|
||||
}) as unknown as MediaRecorder
|
||||
|
||||
mediaRecorder.value.ondataavailable = (e) => {
|
||||
audioChunks.value.push(e.data)
|
||||
}
|
||||
|
||||
mediaRecorder.value.onstop = async () => {
|
||||
const blob = new Blob(audioChunks.value, { type: 'audio/wav' })
|
||||
|
||||
// Create blob URL for preview
|
||||
if (recordedURL.value?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(recordedURL.value)
|
||||
}
|
||||
recordedURL.value = URL.createObjectURL(blob)
|
||||
|
||||
// Notify completion
|
||||
if (options.onRecordingComplete) {
|
||||
await options.onRecordingComplete(blob)
|
||||
}
|
||||
|
||||
cleanup()
|
||||
}
|
||||
|
||||
// Start recording
|
||||
mediaRecorder.value.start(100)
|
||||
isRecording.value = true
|
||||
} catch (err) {
|
||||
if (options.onError) {
|
||||
options.onError(err as Error)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') {
|
||||
mediaRecorder.value.stop()
|
||||
} else {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
isRecording.value = false
|
||||
|
||||
if (stream.value) {
|
||||
stream.value.getTracks().forEach((track) => track.stop())
|
||||
stream.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
stopRecording()
|
||||
if (recordedURL.value) {
|
||||
URL.revokeObjectURL(recordedURL.value)
|
||||
recordedURL.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
dispose()
|
||||
})
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
recordedURL,
|
||||
mediaRecorder,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
dispose
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
interface WaveformBar {
|
||||
height: number
|
||||
}
|
||||
|
||||
interface AudioWaveformOptions {
|
||||
barCount?: number
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
export function useAudioWaveform(options: AudioWaveformOptions = {}) {
|
||||
const { barCount = 18, minHeight = 4, maxHeight = 32 } = options
|
||||
|
||||
const waveformBars = ref<WaveformBar[]>(
|
||||
Array.from({ length: barCount }, () => ({ height: 16 }))
|
||||
)
|
||||
const audioContext = ref<AudioContext | null>(null)
|
||||
const analyser = ref<AnalyserNode | null>(null)
|
||||
const dataArray = ref<Uint8Array | null>(null)
|
||||
const animationId = ref<number | null>(null)
|
||||
const mediaElementSource = ref<MediaElementAudioSourceNode | null>(null)
|
||||
|
||||
function initWaveform() {
|
||||
waveformBars.value = Array.from({ length: barCount }, () => ({
|
||||
height: Math.random() * (maxHeight - minHeight) + minHeight
|
||||
}))
|
||||
}
|
||||
|
||||
function updateWaveform(isActive: Ref<boolean>) {
|
||||
if (!isActive.value) return
|
||||
|
||||
if (analyser.value && dataArray.value) {
|
||||
updateWaveformFromAudio()
|
||||
} else {
|
||||
updateWaveformRandom()
|
||||
}
|
||||
|
||||
animationId.value = requestAnimationFrame(() => updateWaveform(isActive))
|
||||
}
|
||||
|
||||
function updateWaveformFromAudio() {
|
||||
if (!analyser.value || !dataArray.value) return
|
||||
|
||||
analyser.value.getByteFrequencyData(
|
||||
dataArray.value as Uint8Array<ArrayBuffer>
|
||||
)
|
||||
const samplesPerBar = Math.floor(dataArray.value.length / barCount)
|
||||
|
||||
waveformBars.value = waveformBars.value.map((_, i) => {
|
||||
let sum = 0
|
||||
for (let j = 0; j < samplesPerBar; j++) {
|
||||
sum += dataArray.value![i * samplesPerBar + j] || 0
|
||||
}
|
||||
const average = sum / samplesPerBar
|
||||
const normalizedHeight =
|
||||
(average / 255) * (maxHeight - minHeight) + minHeight
|
||||
return { height: normalizedHeight }
|
||||
})
|
||||
}
|
||||
|
||||
function updateWaveformRandom() {
|
||||
waveformBars.value = waveformBars.value.map((bar) => ({
|
||||
height: Math.max(
|
||||
minHeight,
|
||||
Math.min(maxHeight, bar.height + (Math.random() - 0.5) * 4)
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
async function setupAudioContext() {
|
||||
if (audioContext.value && audioContext.value.state !== 'closed') {
|
||||
await audioContext.value.close()
|
||||
}
|
||||
audioContext.value = null
|
||||
mediaElementSource.value = null
|
||||
}
|
||||
|
||||
async function setupRecordingVisualization(stream: MediaStream) {
|
||||
audioContext.value = new window.AudioContext()
|
||||
analyser.value = audioContext.value.createAnalyser()
|
||||
const source = audioContext.value.createMediaStreamSource(stream)
|
||||
source.connect(analyser.value)
|
||||
|
||||
analyser.value.fftSize = 256
|
||||
dataArray.value = new Uint8Array(analyser.value.frequencyBinCount)
|
||||
}
|
||||
|
||||
async function setupPlaybackVisualization(audioElement: HTMLAudioElement) {
|
||||
if (audioContext.value && audioContext.value.state !== 'closed') {
|
||||
await audioContext.value.close()
|
||||
}
|
||||
|
||||
mediaElementSource.value = null
|
||||
|
||||
if (!audioElement) return false
|
||||
|
||||
audioContext.value = new window.AudioContext()
|
||||
analyser.value = audioContext.value.createAnalyser()
|
||||
|
||||
mediaElementSource.value =
|
||||
audioContext.value.createMediaElementSource(audioElement)
|
||||
|
||||
mediaElementSource.value.connect(analyser.value)
|
||||
analyser.value.connect(audioContext.value.destination)
|
||||
|
||||
analyser.value.fftSize = 256
|
||||
dataArray.value = new Uint8Array(analyser.value.frequencyBinCount)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function stopWaveform() {
|
||||
if (animationId.value) {
|
||||
cancelAnimationFrame(animationId.value)
|
||||
animationId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
stopWaveform()
|
||||
if (audioContext.value && audioContext.value.state !== 'closed') {
|
||||
void audioContext.value.close()
|
||||
}
|
||||
audioContext.value = null
|
||||
mediaElementSource.value = null
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
dispose()
|
||||
})
|
||||
|
||||
return {
|
||||
waveformBars,
|
||||
initWaveform,
|
||||
updateWaveform,
|
||||
setupAudioContext,
|
||||
setupRecordingVisualization,
|
||||
setupPlaybackVisualization,
|
||||
stopWaveform,
|
||||
dispose
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IAudioRecordWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
AudioRecordInputSpec,
|
||||
InputSpec as InputSpecV2
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
export const useAudioRecordWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IAudioRecordWidget => {
|
||||
const {
|
||||
name,
|
||||
default: defaultValue = '',
|
||||
options = {}
|
||||
} = inputSpec as AudioRecordInputSpec
|
||||
|
||||
const widget = node.addWidget('audiorecord', name, defaultValue, () => {}, {
|
||||
serialize: true,
|
||||
...options
|
||||
}) as IAudioRecordWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
*/
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
|
||||
|
||||
import WidgetAudioUI from '../components/WidgetAudioUI.vue'
|
||||
import WidgetButton from '../components/WidgetButton.vue'
|
||||
import WidgetChart from '../components/WidgetChart.vue'
|
||||
import WidgetColorPicker from '../components/WidgetColorPicker.vue'
|
||||
@@ -13,11 +16,13 @@ import WidgetInputNumber from '../components/WidgetInputNumber.vue'
|
||||
import WidgetInputText from '../components/WidgetInputText.vue'
|
||||
import WidgetMarkdown from '../components/WidgetMarkdown.vue'
|
||||
import WidgetMultiSelect from '../components/WidgetMultiSelect.vue'
|
||||
import WidgetRecordAudio from '../components/WidgetRecordAudio.vue'
|
||||
import WidgetSelect from '../components/WidgetSelect.vue'
|
||||
import WidgetSelectButton from '../components/WidgetSelectButton.vue'
|
||||
import WidgetTextarea from '../components/WidgetTextarea.vue'
|
||||
import WidgetToggleSwitch from '../components/WidgetToggleSwitch.vue'
|
||||
import WidgetTreeSelect from '../components/WidgetTreeSelect.vue'
|
||||
import AudioPreviewPlayer from '../components/audio/AudioPreviewPlayer.vue'
|
||||
|
||||
interface WidgetDefinition {
|
||||
component: Component
|
||||
@@ -108,9 +113,29 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
[
|
||||
'markdown',
|
||||
{ component: WidgetMarkdown, aliases: ['MARKDOWN'], essential: false }
|
||||
],
|
||||
[
|
||||
'audiorecord',
|
||||
{
|
||||
component: WidgetRecordAudio,
|
||||
aliases: ['AUDIO_RECORD', 'AUDIORECORD'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'audioUI',
|
||||
{
|
||||
component: AudioPreviewPlayer,
|
||||
aliases: ['AUDIOUI', 'AUDIO_UI'],
|
||||
essential: false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
const getComboWidgetAdditions = (): Map<string, Component> => {
|
||||
return new Map([['audio', WidgetAudioUI]])
|
||||
}
|
||||
|
||||
// Build lookup maps
|
||||
const widgets = new Map<string, WidgetDefinition>()
|
||||
const aliasMap = new Map<string, string>()
|
||||
@@ -125,7 +150,13 @@ for (const [type, def] of coreWidgetDefinitions) {
|
||||
// Utility functions
|
||||
const getCanonicalType = (type: string): string => aliasMap.get(type) || type
|
||||
|
||||
export const getComponent = (type: string): Component | null => {
|
||||
export const getComponent = (type: string, name: string): Component | null => {
|
||||
if (type == 'combo') {
|
||||
const comboAdditions = getComboWidgetAdditions()
|
||||
if (comboAdditions.has(name)) {
|
||||
return comboAdditions.get(name) || null
|
||||
}
|
||||
}
|
||||
const canonicalType = getCanonicalType(type)
|
||||
return widgets.get(canonicalType)?.component || null
|
||||
}
|
||||
@@ -140,11 +171,9 @@ export const isEssential = (type: string): boolean => {
|
||||
return widgets.get(canonicalType)?.essential || false
|
||||
}
|
||||
|
||||
export const shouldRenderAsVue = (widget: {
|
||||
type?: string
|
||||
options?: Record<string, unknown>
|
||||
}): boolean => {
|
||||
export const shouldRenderAsVue = (widget: Partial<SafeWidgetData>): boolean => {
|
||||
if (widget.options?.canvasOnly) return false
|
||||
if (widget.isDOMWidget) return true
|
||||
if (!widget.type) return false
|
||||
return isSupported(widget.type)
|
||||
}
|
||||
|
||||
54
src/renderer/extensions/vueNodes/widgets/utils/audioUtils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
/**
|
||||
* Format time in MM:SS format
|
||||
*/
|
||||
export function formatTime(seconds: number): string {
|
||||
if (isNaN(seconds) || seconds === 0) return '0:00'
|
||||
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full audio URL from path
|
||||
*/
|
||||
export function getAudioUrlFromPath(
|
||||
path: string,
|
||||
type: ResultItemType = 'input'
|
||||
): string {
|
||||
const [subfolder, filename] = splitFilePath(path)
|
||||
return api.apiURL(getResourceURL(subfolder, filename, type))
|
||||
}
|
||||
|
||||
function getRandParam() {
|
||||
return '&rand=' + Math.random()
|
||||
}
|
||||
|
||||
export function getResourceURL(
|
||||
subfolder: string,
|
||||
filename: string,
|
||||
type: ResultItemType = 'input'
|
||||
): string {
|
||||
const params = [
|
||||
'filename=' + encodeURIComponent(filename),
|
||||
'type=' + type,
|
||||
'subfolder=' + subfolder,
|
||||
getRandParam().substring(1)
|
||||
].join('&')
|
||||
|
||||
return `/view?${params}`
|
||||
}
|
||||
|
||||
export function splitFilePath(path: string): [string, string] {
|
||||
const folder_separator = path.lastIndexOf('/')
|
||||
if (folder_separator === -1) {
|
||||
return ['', path]
|
||||
}
|
||||
return [
|
||||
path.substring(0, folder_separator),
|
||||
path.substring(folder_separator + 1)
|
||||
]
|
||||
}
|
||||
@@ -152,6 +152,13 @@ const zTextareaInputSpec = zBaseInputOptions.extend({
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zAudioRecordInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('AUDIORECORD'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
const zCustomInputSpec = zBaseInputOptions.extend({
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
@@ -167,6 +174,7 @@ const zInputSpec = z.union([
|
||||
zColorInputSpec,
|
||||
zFileUploadInputSpec,
|
||||
zImageInputSpec,
|
||||
zAudioRecordInputSpec,
|
||||
zImageCompareInputSpec,
|
||||
zMarkdownInputSpec,
|
||||
zTreeSelectInputSpec,
|
||||
@@ -222,6 +230,7 @@ export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
|
||||
export type SelectButtonInputSpec = z.infer<typeof zSelectButtonInputSpec>
|
||||
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
|
||||
export type CustomInputSpec = z.infer<typeof zCustomInputSpec>
|
||||
export type AudioRecordInputSpec = z.infer<typeof zAudioRecordInputSpec>
|
||||
|
||||
export type InputSpec = z.infer<typeof zInputSpec>
|
||||
export type OutputSpec = z.infer<typeof zOutputSpec>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { shallowRef } from 'vue'
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { st, t } from '@/i18n'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
@@ -1667,6 +1668,28 @@ export class ComfyApp {
|
||||
useExtensionService().registerExtension(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects context menu items from all extensions for canvas menus
|
||||
* @param canvas The canvas instance
|
||||
* @returns Array of context menu items from all extensions
|
||||
*/
|
||||
collectCanvasMenuItems(canvas: LGraphCanvas): IContextMenuValue[] {
|
||||
return useExtensionService()
|
||||
.invokeExtensions('getCanvasMenuItems', canvas)
|
||||
.flat() as IContextMenuValue[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects context menu items from all extensions for node menus
|
||||
* @param node The node being right-clicked
|
||||
* @returns Array of context menu items from all extensions
|
||||
*/
|
||||
collectNodeMenuItems(node: LGraphNode): IContextMenuValue[] {
|
||||
return useExtensionService()
|
||||
.invokeExtensions('getNodeMenuItems', node)
|
||||
.flat() as IContextMenuValue[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh combo list on whole nodes
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
IStringWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useAudioRecordWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useAudioRecordWidget'
|
||||
import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget'
|
||||
import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
|
||||
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
|
||||
@@ -304,5 +305,6 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
|
||||
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
|
||||
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
|
||||
SELECTBUTTON: transformWidgetConstructorV2ToV1(useSelectButtonWidget()),
|
||||
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget())
|
||||
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
|
||||
AUDIO_RECORD: transformWidgetConstructorV2ToV1(useAudioRecordWidget())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IContextMenuValue,
|
||||
Positionable
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { Keybinding } from '@/schemas/keyBindingSchema'
|
||||
@@ -106,6 +109,20 @@ export interface ComfyExtension {
|
||||
*/
|
||||
getSelectionToolboxCommands?(selectedItem: Positionable): string[]
|
||||
|
||||
/**
|
||||
* Allows the extension to add context menu items to canvas right-click menus
|
||||
* @param canvas The canvas instance
|
||||
* @returns An array of context menu items to add
|
||||
*/
|
||||
getCanvasMenuItems?(canvas: LGraphCanvas): IContextMenuValue[]
|
||||
|
||||
/**
|
||||
* Allows the extension to add context menu items to node right-click menus
|
||||
* @param node The node being right-clicked
|
||||
* @returns An array of context menu items to add
|
||||
*/
|
||||
getNodeMenuItems?(node: LGraphNode): IContextMenuValue[]
|
||||
|
||||
/**
|
||||
* Allows the extension to add additional handling to the node before it is registered with **LGraph**
|
||||
* @param nodeType The node class (not an instance)
|
||||
|
||||
200
tests-ui/tests/extensions/contextMenuExtension.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
describe('Context Menu Extension API', () => {
|
||||
let mockCanvas: LGraphCanvas
|
||||
let mockNode: LGraphNode
|
||||
let extensionStore: ReturnType<typeof useExtensionStore>
|
||||
let extensionService: ReturnType<typeof useExtensionService>
|
||||
|
||||
// Mock menu items
|
||||
const canvasMenuItem1: IContextMenuValue = {
|
||||
content: 'Canvas Item 1',
|
||||
callback: () => {}
|
||||
}
|
||||
const canvasMenuItem2: IContextMenuValue = {
|
||||
content: 'Canvas Item 2',
|
||||
callback: () => {}
|
||||
}
|
||||
const nodeMenuItem1: IContextMenuValue = {
|
||||
content: 'Node Item 1',
|
||||
callback: () => {}
|
||||
}
|
||||
const nodeMenuItem2: IContextMenuValue = {
|
||||
content: 'Node Item 2',
|
||||
callback: () => {}
|
||||
}
|
||||
|
||||
// Mock extensions
|
||||
const createCanvasMenuExtension = (
|
||||
name: string,
|
||||
items: IContextMenuValue[]
|
||||
): ComfyExtension => ({
|
||||
name,
|
||||
getCanvasMenuItems: () => items
|
||||
})
|
||||
|
||||
const createNodeMenuExtension = (
|
||||
name: string,
|
||||
items: IContextMenuValue[]
|
||||
): ComfyExtension => ({
|
||||
name,
|
||||
getNodeMenuItems: () => items
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
extensionStore = useExtensionStore()
|
||||
extensionService = useExtensionService()
|
||||
|
||||
mockCanvas = {
|
||||
graph_mouse: [100, 100],
|
||||
selectedItems: new Set()
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
mockNode = {
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
pos: [0, 0]
|
||||
} as unknown as LGraphNode
|
||||
})
|
||||
|
||||
describe('collectCanvasMenuItems', () => {
|
||||
it('should call getCanvasMenuItems and collect into flat array', () => {
|
||||
const ext1 = createCanvasMenuExtension('Extension 1', [canvasMenuItem1])
|
||||
const ext2 = createCanvasMenuExtension('Extension 2', [
|
||||
canvasMenuItem2,
|
||||
{ content: 'Item 3', callback: () => {} }
|
||||
])
|
||||
|
||||
extensionStore.registerExtension(ext1)
|
||||
extensionStore.registerExtension(ext2)
|
||||
|
||||
const items = extensionService
|
||||
.invokeExtensions('getCanvasMenuItems', mockCanvas)
|
||||
.flat() as IContextMenuValue[]
|
||||
|
||||
expect(items).toHaveLength(3)
|
||||
expect(items[0]).toMatchObject({ content: 'Canvas Item 1' })
|
||||
expect(items[1]).toMatchObject({ content: 'Canvas Item 2' })
|
||||
expect(items[2]).toMatchObject({ content: 'Item 3' })
|
||||
})
|
||||
|
||||
it('should support submenus and separators', () => {
|
||||
const extension = createCanvasMenuExtension('Test Extension', [
|
||||
{
|
||||
content: 'Menu with Submenu',
|
||||
has_submenu: true,
|
||||
submenu: {
|
||||
options: [
|
||||
{ content: 'Submenu Item 1', callback: () => {} },
|
||||
{ content: 'Submenu Item 2', callback: () => {} }
|
||||
]
|
||||
}
|
||||
},
|
||||
null as unknown as IContextMenuValue,
|
||||
{ content: 'After Separator', callback: () => {} }
|
||||
])
|
||||
|
||||
extensionStore.registerExtension(extension)
|
||||
|
||||
const items = extensionService
|
||||
.invokeExtensions('getCanvasMenuItems', mockCanvas)
|
||||
.flat() as IContextMenuValue[]
|
||||
|
||||
expect(items).toHaveLength(3)
|
||||
expect(items[0].content).toBe('Menu with Submenu')
|
||||
expect(items[0].submenu?.options).toHaveLength(2)
|
||||
expect(items[1]).toBeNull()
|
||||
expect(items[2].content).toBe('After Separator')
|
||||
})
|
||||
|
||||
it('should skip extensions without getCanvasMenuItems', () => {
|
||||
const canvasExtension = createCanvasMenuExtension('Canvas Ext', [
|
||||
canvasMenuItem1
|
||||
])
|
||||
const extensionWithoutCanvasMenu: ComfyExtension = {
|
||||
name: 'No Canvas Menu'
|
||||
}
|
||||
|
||||
extensionStore.registerExtension(canvasExtension)
|
||||
extensionStore.registerExtension(extensionWithoutCanvasMenu)
|
||||
|
||||
const items = extensionService
|
||||
.invokeExtensions('getCanvasMenuItems', mockCanvas)
|
||||
.flat() as IContextMenuValue[]
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].content).toBe('Canvas Item 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectNodeMenuItems', () => {
|
||||
it('should call getNodeMenuItems and collect into flat array', () => {
|
||||
const ext1 = createNodeMenuExtension('Extension 1', [nodeMenuItem1])
|
||||
const ext2 = createNodeMenuExtension('Extension 2', [
|
||||
nodeMenuItem2,
|
||||
{ content: 'Item 3', callback: () => {} }
|
||||
])
|
||||
|
||||
extensionStore.registerExtension(ext1)
|
||||
extensionStore.registerExtension(ext2)
|
||||
|
||||
const items = extensionService
|
||||
.invokeExtensions('getNodeMenuItems', mockNode)
|
||||
.flat() as IContextMenuValue[]
|
||||
|
||||
expect(items).toHaveLength(3)
|
||||
expect(items[0]).toMatchObject({ content: 'Node Item 1' })
|
||||
expect(items[1]).toMatchObject({ content: 'Node Item 2' })
|
||||
})
|
||||
|
||||
it('should support submenus', () => {
|
||||
const extension = createNodeMenuExtension('Submenu Extension', [
|
||||
{
|
||||
content: 'Node Menu with Submenu',
|
||||
has_submenu: true,
|
||||
submenu: {
|
||||
options: [
|
||||
{ content: 'Node Submenu 1', callback: () => {} },
|
||||
{ content: 'Node Submenu 2', callback: () => {} }
|
||||
]
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
extensionStore.registerExtension(extension)
|
||||
|
||||
const items = extensionService
|
||||
.invokeExtensions('getNodeMenuItems', mockNode)
|
||||
.flat() as IContextMenuValue[]
|
||||
|
||||
expect(items[0].content).toBe('Node Menu with Submenu')
|
||||
expect(items[0].submenu?.options).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should skip extensions without getNodeMenuItems', () => {
|
||||
const nodeExtension = createNodeMenuExtension('Node Ext', [nodeMenuItem1])
|
||||
const extensionWithoutNodeMenu: ComfyExtension = {
|
||||
name: 'No Node Menu'
|
||||
}
|
||||
|
||||
extensionStore.registerExtension(nodeExtension)
|
||||
extensionStore.registerExtension(extensionWithoutNodeMenu)
|
||||
|
||||
const items = extensionService
|
||||
.invokeExtensions('getNodeMenuItems', mockNode)
|
||||
.flat() as IContextMenuValue[]
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].content).toBe('Node Item 1')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -283,6 +283,7 @@ LGraph {
|
||||
"nodes_actioning": [],
|
||||
"nodes_executedAction": [],
|
||||
"nodes_executing": [],
|
||||
"onTrigger": undefined,
|
||||
"revision": 0,
|
||||
"runningtime": 0,
|
||||
"starttime": 0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import {
|
||||
@@ -165,10 +165,11 @@ describe('layoutStore CRDT operations', () => {
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
|
||||
// Wait for async notification
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
// Wait for onChange callback to be called (uses setTimeout internally)
|
||||
await vi.waitFor(() => {
|
||||
expect(changes.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
expect(changes.length).toBeGreaterThanOrEqual(1)
|
||||
const lastChange = changes[changes.length - 1]
|
||||
expect(lastChange.source).toBe('vue')
|
||||
expect(lastChange.operation.actor).toBe('user-123')
|
||||
@@ -176,6 +177,48 @@ describe('layoutStore CRDT operations', () => {
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('should emit change when batch updating node bounds', async () => {
|
||||
const nodeId = 'test-node-6'
|
||||
const layout = createTestNode(nodeId)
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
const changes: LayoutChange[] = []
|
||||
const unsubscribe = layoutStore.onChange((change) => {
|
||||
changes.push(change)
|
||||
})
|
||||
|
||||
const newBounds = { x: 40, y: 60, width: 220, height: 120 }
|
||||
layoutStore.batchUpdateNodeBounds([{ nodeId, bounds: newBounds }])
|
||||
|
||||
// Wait for onChange callback to be called (uses setTimeout internally)
|
||||
await vi.waitFor(() => {
|
||||
expect(changes.length).toBeGreaterThan(0)
|
||||
const lastChange = changes[changes.length - 1]
|
||||
expect(lastChange.operation.type).toBe('batchUpdateBounds')
|
||||
})
|
||||
|
||||
const lastChange = changes[changes.length - 1]
|
||||
if (lastChange.operation.type === 'batchUpdateBounds') {
|
||||
expect(lastChange.nodeIds).toContain(nodeId)
|
||||
expect(lastChange.operation.bounds[nodeId]?.bounds).toEqual(newBounds)
|
||||
}
|
||||
|
||||
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
expect(nodeRef.value?.position).toEqual({ x: 40, y: 60 })
|
||||
expect(nodeRef.value?.size).toEqual({ width: 220, height: 120 })
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('should query nodes by spatial bounds', () => {
|
||||
const nodes = [
|
||||
{ id: 'node-a', position: { x: 0, y: 0 } },
|
||||
|
||||
@@ -8,7 +8,6 @@ import { createI18n } from 'vue-i18n'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
|
||||
const mockData = vi.hoisted(() => ({
|
||||
@@ -205,18 +204,4 @@ describe('LGraphNode', () => {
|
||||
|
||||
expect(wrapper.classes()).toContain('animate-pulse')
|
||||
})
|
||||
|
||||
it('should emit node-click event on pointer up', async () => {
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
await wrapper.trigger('pointerup')
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledOnce()
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith(
|
||||
expect.any(PointerEvent),
|
||||
mockNodeData,
|
||||
expect.any(Boolean)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import LivePreview from '@/renderer/extensions/vueNodes/components/LivePreview.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
liveSamplingPreview: 'Live sampling preview',
|
||||
imageFailedToLoad: 'Image failed to load',
|
||||
errorLoadingImage: 'Error loading image',
|
||||
calculatingDimensions: 'Calculating dimensions'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('LivePreview', () => {
|
||||
const defaultProps = {
|
||||
imageUrl: '/api/view?filename=test_sample.png&type=temp'
|
||||
}
|
||||
|
||||
const mountLivePreview = (props = {}) => {
|
||||
return mount(LivePreview, {
|
||||
props: { ...defaultProps, ...props },
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
stubs: {
|
||||
'i-lucide:image-off': true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders preview when imageUrl provided', () => {
|
||||
const wrapper = mountLivePreview()
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
expect(wrapper.find('img').attributes('src')).toBe(defaultProps.imageUrl)
|
||||
})
|
||||
|
||||
it('does not render when no imageUrl provided', () => {
|
||||
const wrapper = mountLivePreview({ imageUrl: null })
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.text()).toBe('')
|
||||
})
|
||||
|
||||
it('displays calculating dimensions text initially', () => {
|
||||
const wrapper = mountLivePreview()
|
||||
|
||||
expect(wrapper.text()).toContain('Calculating dimensions')
|
||||
})
|
||||
|
||||
it('has proper accessibility attributes', () => {
|
||||
const wrapper = mountLivePreview()
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('alt')).toBe('Live sampling preview')
|
||||
})
|
||||
|
||||
it('handles image load event', async () => {
|
||||
const wrapper = mountLivePreview()
|
||||
const img = wrapper.find('img')
|
||||
|
||||
// Mock the naturalWidth and naturalHeight properties on the img element
|
||||
Object.defineProperty(img.element, 'naturalWidth', {
|
||||
writable: false,
|
||||
value: 512
|
||||
})
|
||||
Object.defineProperty(img.element, 'naturalHeight', {
|
||||
writable: false,
|
||||
value: 512
|
||||
})
|
||||
|
||||
// Trigger the load event
|
||||
await img.trigger('load')
|
||||
|
||||
expect(wrapper.text()).toContain('512 x 512')
|
||||
})
|
||||
|
||||
it('handles image error state', async () => {
|
||||
const wrapper = mountLivePreview()
|
||||
const img = wrapper.find('img')
|
||||
|
||||
// Trigger the error event
|
||||
await img.trigger('error')
|
||||
|
||||
// Check that the image is hidden and error content is shown
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('Image failed to load')
|
||||
})
|
||||
|
||||
it('resets state when imageUrl changes', async () => {
|
||||
const wrapper = mountLivePreview()
|
||||
const img = wrapper.find('img')
|
||||
|
||||
// Set error state via event
|
||||
await img.trigger('error')
|
||||
expect(wrapper.text()).toContain('Error loading image')
|
||||
|
||||
// Change imageUrl prop
|
||||
await wrapper.setProps({ imageUrl: '/new-image.png' })
|
||||
await nextTick()
|
||||
|
||||
// State should be reset - dimensions text should show calculating
|
||||
expect(wrapper.text()).toContain('Calculating dimensions')
|
||||
expect(wrapper.text()).not.toContain('Error loading image')
|
||||
})
|
||||
|
||||
it('shows error state when image fails to load', async () => {
|
||||
const wrapper = mountLivePreview()
|
||||
const img = wrapper.find('img')
|
||||
|
||||
// Trigger error event
|
||||
await img.trigger('error')
|
||||
|
||||
// Should show error state instead of image
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('Image failed to load')
|
||||
expect(wrapper.text()).toContain('Error loading image')
|
||||
})
|
||||
})
|
||||