Compare commits
1 Commits
v1.9.14
...
preview-sc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84c5f7bf16 |
2
.github/workflows/i18n-node-defs.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
update-locales:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
2
.github/workflows/i18n.yaml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
2
.github/workflows/test-browser-exp.yaml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.label.name == 'New Browser Test Expectations'
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
16
.github/workflows/test-ui.yaml
vendored
@@ -15,9 +15,7 @@ jobs:
|
||||
jest-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
|
||||
with:
|
||||
devtools_ref: 7b81139e904519db8e5481899ef36bbb4393cb6b
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||
- name: Run Jest tests
|
||||
run: |
|
||||
npm run test:generate
|
||||
@@ -27,9 +25,7 @@ jobs:
|
||||
playwright-tests-chromium:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
|
||||
with:
|
||||
devtools_ref: 7b81139e904519db8e5481899ef36bbb4393cb6b
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
@@ -46,9 +42,7 @@ jobs:
|
||||
playwright-tests-chromium-2x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
|
||||
with:
|
||||
devtools_ref: 7b81139e904519db8e5481899ef36bbb4393cb6b
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
@@ -65,9 +59,7 @@ jobs:
|
||||
playwright-tests-mobile-chrome:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
|
||||
with:
|
||||
devtools_ref: 7b81139e904519db8e5481899ef36bbb4393cb6b
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "DevToolsNodeWithSeedInput",
|
||||
"pos": [
|
||||
20,
|
||||
50
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
82
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "DevToolsNodeWithSeedInput"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { SettingParams } from '../src/types/settingTypes'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
|
||||
test.describe('Topbar commands', () => {
|
||||
@@ -135,90 +134,6 @@ test.describe('Topbar commands', () => {
|
||||
expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(true)
|
||||
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2)
|
||||
})
|
||||
|
||||
test.describe('Passing through attrs to setting components', () => {
|
||||
const testCases: Array<{
|
||||
config: Partial<SettingParams>
|
||||
selector: string
|
||||
}> = [
|
||||
{
|
||||
config: {
|
||||
type: 'boolean',
|
||||
defaultValue: true
|
||||
},
|
||||
selector: '.p-toggleswitch.p-component'
|
||||
},
|
||||
{
|
||||
config: {
|
||||
type: 'number',
|
||||
defaultValue: 10
|
||||
},
|
||||
selector: '.p-inputnumber input'
|
||||
},
|
||||
{
|
||||
config: {
|
||||
type: 'slider',
|
||||
defaultValue: 10
|
||||
},
|
||||
selector: '.p-slider.p-component'
|
||||
},
|
||||
{
|
||||
config: {
|
||||
type: 'combo',
|
||||
defaultValue: 'foo',
|
||||
options: ['foo', 'bar', 'baz']
|
||||
},
|
||||
selector: '.p-select.p-component'
|
||||
},
|
||||
{
|
||||
config: {
|
||||
type: 'text',
|
||||
defaultValue: 'Hello'
|
||||
},
|
||||
selector: '.p-inputtext'
|
||||
},
|
||||
{
|
||||
config: {
|
||||
type: 'color',
|
||||
defaultValue: '#000000'
|
||||
},
|
||||
selector: '.p-colorpicker-preview'
|
||||
}
|
||||
] as const
|
||||
|
||||
for (const { config, selector } of testCases) {
|
||||
test(`${config.type} component should respect disabled attr`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate((config) => {
|
||||
window['app'].registerExtension({
|
||||
name: 'TestExtension1',
|
||||
settings: [
|
||||
{
|
||||
id: 'Comfy.TestSetting',
|
||||
name: 'Test',
|
||||
// The `disabled` attr is common to all settings components
|
||||
attrs: { disabled: true },
|
||||
...config
|
||||
}
|
||||
]
|
||||
})
|
||||
}, config)
|
||||
|
||||
await comfyPage.settingDialog.open()
|
||||
const component = comfyPage.settingDialog.root
|
||||
.getByText('TestSetting Test')
|
||||
.locator(selector)
|
||||
|
||||
const isDisabled = await component.evaluate((el) =>
|
||||
el.tagName === 'INPUT'
|
||||
? (el as HTMLInputElement).disabled
|
||||
: el.classList.contains('p-disabled')
|
||||
)
|
||||
expect(isDisabled).toBe(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('About panel', () => {
|
||||
|
||||
@@ -3,10 +3,6 @@ import { Page } from '@playwright/test'
|
||||
export class SettingDialog {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
get root() {
|
||||
return this.page.locator('div.settings-container')
|
||||
}
|
||||
|
||||
async open() {
|
||||
const button = this.page.locator('button.comfy-settings-btn:visible')
|
||||
await button.click()
|
||||
|
||||
@@ -62,9 +62,6 @@ export class NodeWidgetReference {
|
||||
readonly node: NodeReference
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @returns The position of the widget's center
|
||||
*/
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
|
||||
([id, index]) => {
|
||||
@@ -92,22 +89,6 @@ export class NodeWidgetReference {
|
||||
position: await this.getPosition()
|
||||
})
|
||||
}
|
||||
|
||||
async dragHorizontal(delta: number) {
|
||||
const pos = await this.getPosition()
|
||||
const canvas = this.node.comfyPage.canvas
|
||||
const canvasPos = (await canvas.boundingBox())!
|
||||
this.node.comfyPage.dragAndDrop(
|
||||
{
|
||||
x: canvasPos.x + pos.x,
|
||||
y: canvasPos.y + pos.y
|
||||
},
|
||||
{
|
||||
x: canvasPos.x + pos.x + delta,
|
||||
y: canvasPos.y + pos.y
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeReference {
|
||||
|
||||
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
@@ -35,20 +35,4 @@ test.describe('Keybindings', () => {
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Should not trigger keybinding reserved by text input when typing in input fields', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.registerKeybinding({ key: 'Ctrl+v' }, () => {
|
||||
window['TestCommand'] = true
|
||||
})
|
||||
|
||||
const textBox = comfyPage.widgetTextBox
|
||||
await textBox.click()
|
||||
await textBox.press('Control+v')
|
||||
await expect(textBox).toBeFocused()
|
||||
expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,24 +30,9 @@ test.describe('Remote COMBO Widget', () => {
|
||||
}, nodeName)
|
||||
}
|
||||
|
||||
const getWidgetValue = async (comfyPage: ComfyPage, nodeName: string) => {
|
||||
return await comfyPage.page.evaluate((name) => {
|
||||
const node = window['app'].graph.nodes.find((node) => node.title === name)
|
||||
return node.widgets[0].value
|
||||
}, nodeName)
|
||||
}
|
||||
|
||||
const clickRefreshButton = (comfyPage: ComfyPage, nodeName: string) => {
|
||||
return comfyPage.page.evaluate((name) => {
|
||||
const node = window['app'].graph.nodes.find((node) => node.title === name)
|
||||
const buttonWidget = node.widgets.find((w) => w.name === 'refresh')
|
||||
return buttonWidget?.callback()
|
||||
}, nodeName)
|
||||
}
|
||||
|
||||
const waitForWidgetUpdate = async (comfyPage: ComfyPage) => {
|
||||
// Force re-render to trigger first access of widget's options
|
||||
await comfyPage.page.mouse.click(400, 300)
|
||||
await comfyPage.page.mouse.click(100, 100)
|
||||
await comfyPage.page.waitForTimeout(256)
|
||||
}
|
||||
|
||||
@@ -194,7 +179,7 @@ test.describe('Remote COMBO Widget', () => {
|
||||
const initialOptions = await getWidgetOptions(comfyPage, nodeName)
|
||||
|
||||
// Wait for the refresh (TTL) to expire
|
||||
await comfyPage.page.waitForTimeout(512)
|
||||
await comfyPage.page.waitForTimeout(302)
|
||||
await comfyPage.page.mouse.click(100, 100)
|
||||
|
||||
const refreshedOptions = await getWidgetOptions(comfyPage, nodeName)
|
||||
@@ -236,79 +221,14 @@ test.describe('Remote COMBO Widget', () => {
|
||||
|
||||
const nodeName = 'Remote Widget Node'
|
||||
await addRemoteWidgetNode(comfyPage, nodeName)
|
||||
await waitForWidgetUpdate(comfyPage)
|
||||
|
||||
// Wait for timeout and backoff, then force re-render, repeat
|
||||
const requestTimeout = 512
|
||||
await comfyPage.page.waitForTimeout(requestTimeout)
|
||||
await waitForWidgetUpdate(comfyPage)
|
||||
await comfyPage.page.waitForTimeout(requestTimeout * 2)
|
||||
await waitForWidgetUpdate(comfyPage)
|
||||
await comfyPage.page.waitForTimeout(requestTimeout * 3)
|
||||
// Wait for a few retries
|
||||
await comfyPage.page.waitForTimeout(1024)
|
||||
|
||||
// Verify exponential backoff between retries
|
||||
const intervals = timestamps.slice(1).map((t, i) => t - timestamps[i])
|
||||
expect(intervals[1]).toBeGreaterThan(intervals[0])
|
||||
})
|
||||
|
||||
test('clicking refresh button forces a refresh', async ({ comfyPage }) => {
|
||||
await comfyPage.page.route(
|
||||
'**/api/models/checkpoints**',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
body: JSON.stringify([`${Date.now()}`]),
|
||||
status: 200
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const nodeName = 'Remote Widget Node With Refresh Button'
|
||||
|
||||
// Trigger initial fetch when adding node to the graph
|
||||
await addRemoteWidgetNode(comfyPage, nodeName)
|
||||
await waitForWidgetUpdate(comfyPage)
|
||||
const initialOptions = await getWidgetOptions(comfyPage, nodeName)
|
||||
|
||||
// Click refresh button
|
||||
await clickRefreshButton(comfyPage, nodeName)
|
||||
|
||||
// Verify refresh occurred
|
||||
const refreshedOptions = await getWidgetOptions(comfyPage, nodeName)
|
||||
expect(refreshedOptions).not.toEqual(initialOptions)
|
||||
})
|
||||
|
||||
test('control_after_refresh is applied after refresh', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const options = [
|
||||
['first option', 'second option', 'third option'],
|
||||
['new first option', 'first option', 'second option', 'third option']
|
||||
]
|
||||
await comfyPage.page.route(
|
||||
'**/api/models/checkpoints**',
|
||||
async (route) => {
|
||||
const next = options.shift()
|
||||
await route.fulfill({
|
||||
body: JSON.stringify(next),
|
||||
status: 200
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const nodeName =
|
||||
'Remote Widget Node With Refresh Button and Control After Refresh'
|
||||
|
||||
// Trigger initial fetch when adding node to the graph
|
||||
await addRemoteWidgetNode(comfyPage, nodeName)
|
||||
await waitForWidgetUpdate(comfyPage)
|
||||
|
||||
// Click refresh button
|
||||
await clickRefreshButton(comfyPage, nodeName)
|
||||
|
||||
// Verify the selected value of the widget is the first option in the refreshed list
|
||||
const refreshedValue = await getWidgetValue(comfyPage, nodeName)
|
||||
expect(refreshedValue).toEqual('new first option')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cache Behavior', () => {
|
||||
|
||||
@@ -40,47 +40,3 @@ test.describe('Boolean widget', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Slider widget', () => {
|
||||
test('Can drag adjust value', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('simple_slider')
|
||||
await comfyPage.page.waitForTimeout(300)
|
||||
const node = (await comfyPage.getFirstNodeRef())!
|
||||
const widget = await node.getWidget(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const widget = window['app'].graph.nodes[0].widgets[0]
|
||||
widget.callback = (value: number) => {
|
||||
window['widgetValue'] = value
|
||||
}
|
||||
})
|
||||
await widget.dragHorizontal(50)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('slider_widget_dragged.png')
|
||||
|
||||
expect(
|
||||
await comfyPage.page.evaluate(() => window['widgetValue'])
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Number widget', () => {
|
||||
test('Can drag adjust value', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/seed_widget')
|
||||
await comfyPage.page.waitForTimeout(300)
|
||||
|
||||
const node = (await comfyPage.getFirstNodeRef())!
|
||||
const widget = await node.getWidget(0)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const widget = window['app'].graph.nodes[0].widgets[0]
|
||||
widget.callback = (value: number) => {
|
||||
window['widgetValue'] = value
|
||||
}
|
||||
})
|
||||
await widget.dragHorizontal(50)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('seed_widget_dragged.png')
|
||||
|
||||
expect(
|
||||
await comfyPage.page.evaluate(() => window['widgetValue'])
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB |
12
package-lock.json
generated
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.9.14",
|
||||
"version": "1.9.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.9.14",
|
||||
"version": "1.9.6",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.16",
|
||||
"@comfyorg/litegraph": "^0.8.77",
|
||||
"@comfyorg/litegraph": "^0.8.70",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
@@ -1944,9 +1944,9 @@
|
||||
"license": "GPL-3.0-only"
|
||||
},
|
||||
"node_modules/@comfyorg/litegraph": {
|
||||
"version": "0.8.77",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.77.tgz",
|
||||
"integrity": "sha512-PMMJBishdaB/Nz3wYV/iC0qO8lqzoG5r+Zky/hCFt2LLCrRhktn+HciT4Kimk6ekldWiridNY165sazQLKQWGQ==",
|
||||
"version": "0.8.70",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.70.tgz",
|
||||
"integrity": "sha512-YZSMVzr/gUn7Xoe4orFjypq58faDy1kMvF1/kGTzmukF6w7WZ3tPjkng1ZBAWIztjcGDSmeTLYRayj5hfaDavA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.9.14",
|
||||
"version": "1.9.6",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -84,7 +84,7 @@
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.16",
|
||||
"@comfyorg/litegraph": "^0.8.77",
|
||||
"@comfyorg/litegraph": "^0.8.70",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
|
||||
@@ -26,9 +26,8 @@ try {
|
||||
|
||||
// Create the PR
|
||||
console.log('Creating PR...')
|
||||
const prBody = `Automated update of litegraph to version ${newVersion}. Ref: https://github.com/Comfy-Org/litegraph.js/releases/tag/v${newVersion}`
|
||||
execSync(
|
||||
`gh pr create --title "Update litegraph ${newVersion}" --label "dependencies" --body "${prBody}"`,
|
||||
`gh pr create --title "Update litegraph ${newVersion}" --label "dependencies" --body "Automated update of litegraph to version ${newVersion}"`,
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ body {
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
background: var(--bg-color) var(--bg-img);
|
||||
color: var(--fg-color);
|
||||
min-height: -webkit-fill-available;
|
||||
@@ -54,6 +56,87 @@ body {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
/**
|
||||
+------------------+------------------+------------------+
|
||||
| |
|
||||
| .comfyui-body- |
|
||||
| top |
|
||||
| (spans all cols) |
|
||||
| |
|
||||
+------------------+------------------+------------------+
|
||||
| | | |
|
||||
| .comfyui-body- | #graph-canvas | .comfyui-body- |
|
||||
| left | | right |
|
||||
| | | |
|
||||
| | | |
|
||||
+------------------+------------------+------------------+
|
||||
| |
|
||||
| .comfyui-body- |
|
||||
| bottom |
|
||||
| (spans all cols) |
|
||||
| |
|
||||
+------------------+------------------+------------------+
|
||||
*/
|
||||
|
||||
.comfyui-body-top {
|
||||
order: -5;
|
||||
/* Span across all columns */
|
||||
grid-column: 1/-1;
|
||||
/* Position at the first row */
|
||||
grid-row: 1;
|
||||
/* Top menu bar dropdown needs to be above of graph canvas splitter overlay which is z-index: 999 */
|
||||
/* Top menu bar z-index needs to be higher than bottom menu bar z-index as by default
|
||||
pysssss's image feed is located at body-bottom, and it can overlap with the queue button, which
|
||||
is located in body-top. */
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.comfyui-body-left {
|
||||
order: -4;
|
||||
/* Position in the first column */
|
||||
grid-column: 1;
|
||||
/* Position below the top element */
|
||||
grid-row: 2;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.graph-canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
order: -3;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#graph-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.comfyui-body-right {
|
||||
order: -2;
|
||||
z-index: 10;
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.comfyui-body-bottom {
|
||||
order: 4;
|
||||
/* Span across all columns */
|
||||
grid-column: 1/-1;
|
||||
grid-row: 3;
|
||||
/* Bottom menu bar dropdown needs to be above of graph canvas splitter overlay which is z-index: 999 */
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.comfy-multiline-input {
|
||||
background-color: var(--comfy-input-bg);
|
||||
color: var(--input-text);
|
||||
@@ -468,6 +551,82 @@ dialog::backdrop {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#comfy-settings-dialog {
|
||||
padding: 0;
|
||||
width: 41rem;
|
||||
}
|
||||
|
||||
#comfy-settings-dialog tr > td:first-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#comfy-settings-dialog tbody button,
|
||||
#comfy-settings-dialog table > button {
|
||||
background-color: var(--bg-color);
|
||||
border: 1px var(--border-color) solid;
|
||||
border-radius: 0;
|
||||
color: var(--input-text);
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
#comfy-settings-dialog button:hover {
|
||||
background-color: var(--tr-odd-bg-color);
|
||||
}
|
||||
|
||||
/* General CSS for tables */
|
||||
|
||||
.comfy-table {
|
||||
border-collapse: collapse;
|
||||
color: var(--input-text);
|
||||
font-family: Arial, sans-serif;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.comfy-table caption {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--input-text);
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.comfy-table caption .comfy-btn {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
aspect-ratio: 1/1;
|
||||
user-select: none;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.comfy-table caption .comfy-btn:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.comfy-table tr:nth-child(even) {
|
||||
background-color: var(--tr-even-bg-color);
|
||||
}
|
||||
|
||||
.comfy-table tr:nth-child(odd) {
|
||||
background-color: var(--tr-odd-bg-color);
|
||||
}
|
||||
|
||||
.comfy-table td,
|
||||
.comfy-table th {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Context menu */
|
||||
|
||||
.litegraph .dialog {
|
||||
@@ -566,6 +725,24 @@ dialog::backdrop {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 450px) {
|
||||
#comfy-settings-dialog .comfy-table tbody {
|
||||
display: grid;
|
||||
}
|
||||
#comfy-settings-dialog .comfy-table tr {
|
||||
display: grid;
|
||||
}
|
||||
#comfy-settings-dialog tr > td:first-child {
|
||||
text-align: center;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
#comfy-settings-dialog tr > td:not(:first-child) {
|
||||
text-align: center;
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
audio.comfy-audio.empty-audio-widget {
|
||||
display: none;
|
||||
}
|
||||
@@ -576,6 +753,7 @@ audio.comfy-audio.empty-audio-widget {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Set auto complete panel's width as it is not accessible within vue-root */
|
||||
|
||||
@@ -57,6 +57,6 @@ const positionCSS = computed<CSSProperties>(() =>
|
||||
|
||||
<style scoped>
|
||||
.comfy-menu-hamburger {
|
||||
@apply fixed z-[9999] flex flex-row;
|
||||
@apply pointer-events-auto fixed z-[9999] flex flex-row;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
:style="style"
|
||||
:class="{ 'is-dragging': isDragging, 'is-docked': isDocked }"
|
||||
>
|
||||
<div class="actionbar-content flex items-center select-none" ref="panelRef">
|
||||
<div class="actionbar-content flex items-center" ref="panelRef">
|
||||
<span class="drag-handle cursor-move mr-2 p-0!" ref="dragHandleRef">
|
||||
</span>
|
||||
<ComfyQueueButton />
|
||||
@@ -239,8 +239,4 @@ watch([isDragging, isOverlappingWithTopMenu], ([dragging, overlapping]) => {
|
||||
:deep(.p-panel-header) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
@apply w-3 h-max;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="color-picker-wrapper flex items-center gap-2">
|
||||
<ColorPicker v-model="modelValue" v-bind="$attrs" />
|
||||
<ColorPicker v-model="modelValue" />
|
||||
<InputText v-model="modelValue" class="w-28" :placeholder="label" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,8 +14,4 @@ defineProps<{
|
||||
defaultValue?: string
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex flex-grow items-center">
|
||||
<span
|
||||
class="text-muted"
|
||||
:class="props.labelClass"
|
||||
:id="`${props.id}-label`"
|
||||
>
|
||||
<span class="text-muted" :class="props.labelClass">
|
||||
<slot name="name-prefix"></slot>
|
||||
{{ props.item.name }}
|
||||
<i
|
||||
@@ -21,7 +17,6 @@
|
||||
<component
|
||||
:is="markRaw(getFormComponent(props.item))"
|
||||
:id="props.id"
|
||||
:aria-labelledby="`${props.id}-label`"
|
||||
v-model:modelValue="formValue"
|
||||
v-bind="getFormAttrs(props.item)"
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<InputNumber
|
||||
:modelValue="modelValue"
|
||||
@@ -71,8 +70,4 @@ const updateValue = (newValue: number | null) => {
|
||||
localValue.value = newValue
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -4,15 +4,6 @@
|
||||
<ul v-if="itemList?.length" class="pl-4 m-0 flex flex-col gap-2">
|
||||
<li v-for="item of itemList" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
<Message
|
||||
v-if="hint"
|
||||
icon="pi pi-info-circle"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ hint }}
|
||||
</Message>
|
||||
<div class="flex gap-4 justify-end">
|
||||
<Button
|
||||
:label="$t('g.cancel')"
|
||||
@@ -72,7 +63,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
|
||||
import type { ConfirmationDialogType } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -82,7 +72,6 @@ const props = defineProps<{
|
||||
type: ConfirmationDialogType
|
||||
onConfirm: (value?: boolean) => void
|
||||
itemList?: string[]
|
||||
hint?: string
|
||||
}>()
|
||||
|
||||
const onCancel = () => useDialogStore().closeDialog()
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
<template>
|
||||
<!-- Load splitter overlay only after comfyApp is ready. -->
|
||||
<!-- If load immediately, the top-level splitter stateKey won't be correctly
|
||||
synced with the stateStorage (localStorage). -->
|
||||
<LiteGraphCanvasSplitterOverlay
|
||||
v-if="comfyAppReady && betaMenuEnabled && !workspaceStore.focusMode"
|
||||
>
|
||||
<template #side-bar-panel>
|
||||
<SideToolbar />
|
||||
</template>
|
||||
<template #bottom-panel>
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
/>
|
||||
<GraphCanvasMenu v-if="canvasMenuEnabled" />
|
||||
</template>
|
||||
</LiteGraphCanvasSplitterOverlay>
|
||||
<TitleEditor />
|
||||
<GraphCanvasMenu v-if="!betaMenuEnabled && canvasMenuEnabled" />
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
id="graph-canvas"
|
||||
tabindex="1"
|
||||
class="w-full h-full touch-none"
|
||||
/>
|
||||
<teleport to=".graph-canvas-container">
|
||||
<!-- Load splitter overlay only after comfyApp is ready. -->
|
||||
<!-- If load immediately, the top-level splitter stateKey won't be correctly
|
||||
synced with the stateStorage (localStorage). -->
|
||||
<LiteGraphCanvasSplitterOverlay
|
||||
v-if="comfyAppReady && betaMenuEnabled && !workspaceStore.focusMode"
|
||||
>
|
||||
<template #side-bar-panel>
|
||||
<SideToolbar />
|
||||
</template>
|
||||
<template #bottom-panel>
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
/>
|
||||
<GraphCanvasMenu v-if="canvasMenuEnabled" />
|
||||
</template>
|
||||
</LiteGraphCanvasSplitterOverlay>
|
||||
<TitleEditor />
|
||||
<GraphCanvasMenu v-if="!betaMenuEnabled && canvasMenuEnabled" />
|
||||
<canvas ref="canvasRef" id="graph-canvas" tabindex="1" />
|
||||
</teleport>
|
||||
<NodeSearchboxPopover />
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeBadge />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ButtonGroup
|
||||
class="p-buttongroup-vertical absolute bottom-[10px] right-[10px] z-[1000]"
|
||||
class="p-buttongroup-vertical absolute bottom-[10px] right-[10px] z-[1000] pointer-events-auto"
|
||||
>
|
||||
<Button
|
||||
severity="secondary"
|
||||
|
||||
@@ -17,11 +17,10 @@
|
||||
class="w-full"
|
||||
:class="{ 'p-invalid': pathError }"
|
||||
@update:modelValue="validatePath"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
<InputIcon
|
||||
class="pi pi-info-circle"
|
||||
v-tooltip.top="$t('install.installLocationTooltip')"
|
||||
v-tooltip="$t('install.installLocationTooltip')"
|
||||
/>
|
||||
</IconField>
|
||||
<Button icon="pi pi-folder" @click="browsePath" class="w-12" />
|
||||
@@ -82,7 +81,6 @@ const pathError = defineModel<string>('pathError', { required: true })
|
||||
const pathExists = ref(false)
|
||||
const appData = ref('')
|
||||
const appPath = ref('')
|
||||
const inputTouched = ref(false)
|
||||
|
||||
const electron = electronAPI()
|
||||
|
||||
@@ -134,13 +132,4 @@ const browsePath = async () => {
|
||||
pathError.value = t('install.failedToSelectDirectory')
|
||||
}
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
if (!inputTouched.value) {
|
||||
inputTouched.value = true
|
||||
return
|
||||
}
|
||||
// Refresh validation on re-focus
|
||||
validatePath(installPath.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -80,28 +80,11 @@ const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
|
||||
}
|
||||
}
|
||||
|
||||
const userIsInChina = ref(false)
|
||||
onMounted(async () => {
|
||||
userIsInChina.value = await isInChina()
|
||||
})
|
||||
|
||||
const useFallbackMirror = (mirror: UVMirror) => ({
|
||||
...mirror,
|
||||
mirror: mirror.fallbackMirror
|
||||
})
|
||||
|
||||
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
|
||||
(
|
||||
[
|
||||
[PYTHON_MIRROR, pythonMirror],
|
||||
[PYPI_MIRROR, pypiMirror],
|
||||
[getTorchMirrorItem(device), torchMirror]
|
||||
] as [UVMirror, ModelRef<string>][]
|
||||
).map(([item, modelValue]) => [
|
||||
userIsInChina.value ? useFallbackMirror(item) : item,
|
||||
modelValue
|
||||
])
|
||||
)
|
||||
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() => [
|
||||
[PYTHON_MIRROR, pythonMirror],
|
||||
[PYPI_MIRROR, pypiMirror],
|
||||
[getTorchMirrorItem(device), torchMirror]
|
||||
])
|
||||
|
||||
const validationStates = ref<ValidationState[]>(
|
||||
mirrors.value.map(() => ValidationState.IDLE)
|
||||
@@ -119,4 +102,13 @@ const validationStateTooltip = computed(() => {
|
||||
return t('install.settings.checkingMirrors')
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Check if user is in China and set fallback mirrors directly
|
||||
if (await isInChina()) {
|
||||
for (const [item, modelValue] of mirrors.value) {
|
||||
modelValue.value = item.fallbackMirror
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -44,7 +44,10 @@ const normalizedSettingId = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
modelValue.value = item.mirror
|
||||
// Set mirror value if not already set
|
||||
if (!modelValue.value) {
|
||||
modelValue.value = item.mirror
|
||||
}
|
||||
})
|
||||
|
||||
watch(validationState, (newState) => {
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
<template>
|
||||
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
|
||||
<Load3DControls
|
||||
:backgroundColor="backgroundColor"
|
||||
:showGrid="showGrid"
|
||||
:showPreview="showPreview"
|
||||
:lightIntensity="lightIntensity"
|
||||
:showLightIntensityButton="showLightIntensityButton"
|
||||
:fov="fov"
|
||||
:showFOVButton="showFOVButton"
|
||||
:showPreviewButton="showPreviewButton"
|
||||
@toggleCamera="onToggleCamera"
|
||||
@toggleGrid="onToggleGrid"
|
||||
@togglePreview="onTogglePreview"
|
||||
@updateBackgroundColor="onUpdateBackgroundColor"
|
||||
@updateLightIntensity="onUpdateLightIntensity"
|
||||
@updateFOV="onUpdateFOV"
|
||||
ref="load3dControlsRef"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="animations && animations.length > 0"
|
||||
class="absolute top-0 left-0 w-full flex justify-center pt-2 gap-2 items-center z-10"
|
||||
>
|
||||
<Button class="p-button-rounded p-button-text" @click="togglePlay">
|
||||
<i
|
||||
:class="[
|
||||
'pi',
|
||||
playing ? 'pi-pause' : 'pi-play',
|
||||
'text-white text-lg'
|
||||
]"
|
||||
></i>
|
||||
</Button>
|
||||
|
||||
<Select
|
||||
v-model="selectedSpeed"
|
||||
:options="speedOptions"
|
||||
optionLabel="name"
|
||||
optionValue="value"
|
||||
@change="speedChange"
|
||||
class="w-24"
|
||||
/>
|
||||
|
||||
<Select
|
||||
v-model="selectedAnimation"
|
||||
:options="animations"
|
||||
optionLabel="name"
|
||||
optionValue="index"
|
||||
@change="animationChange"
|
||||
class="w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Select from 'primevue/select'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import Load3DControls from '@/components/load3d/Load3DControls.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
animations: Array<{ name: string; index: number }>
|
||||
playing: boolean
|
||||
backgroundColor: string
|
||||
showGrid: boolean
|
||||
showPreview: boolean
|
||||
lightIntensity: number
|
||||
showLightIntensityButton: boolean
|
||||
fov: number
|
||||
showFOVButton: boolean
|
||||
showPreviewButton: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggleCamera'): void
|
||||
(e: 'toggleGrid', value: boolean): void
|
||||
(e: 'togglePreview', value: boolean): void
|
||||
(e: 'updateBackgroundColor', color: string): void
|
||||
(e: 'togglePlay', value: boolean): void
|
||||
(e: 'speedChange', value: number): void
|
||||
(e: 'animationChange', value: number): void
|
||||
(e: 'updateLightIntensity', value: number): void
|
||||
(e: 'updateFOV', value: number): void
|
||||
}>()
|
||||
|
||||
const animations = ref(props.animations)
|
||||
const playing = ref(props.playing)
|
||||
const selectedSpeed = ref(1)
|
||||
const selectedAnimation = ref(0)
|
||||
const backgroundColor = ref(props.backgroundColor)
|
||||
const showGrid = ref(props.showGrid)
|
||||
const showPreview = ref(props.showPreview)
|
||||
const lightIntensity = ref(props.lightIntensity)
|
||||
const showLightIntensityButton = ref(props.showLightIntensityButton)
|
||||
const fov = ref(props.fov)
|
||||
const showFOVButton = ref(props.showFOVButton)
|
||||
const showPreviewButton = ref(props.showPreviewButton)
|
||||
const load3dControlsRef = ref(null)
|
||||
|
||||
const speedOptions = [
|
||||
{ name: '0.1x', value: 0.1 },
|
||||
{ name: '0.5x', value: 0.5 },
|
||||
{ name: '1x', value: 1 },
|
||||
{ name: '1.5x', value: 1.5 },
|
||||
{ name: '2x', value: 2 }
|
||||
]
|
||||
|
||||
watch(backgroundColor, (newValue) => {
|
||||
load3dControlsRef.value.backgroundColor = newValue
|
||||
})
|
||||
|
||||
watch(showLightIntensityButton, (newValue) => {
|
||||
load3dControlsRef.value.showLightIntensityButton = newValue
|
||||
})
|
||||
|
||||
watch(showFOVButton, (newValue) => {
|
||||
load3dControlsRef.value.showFOVButton = newValue
|
||||
})
|
||||
|
||||
watch(showPreviewButton, (newValue) => {
|
||||
load3dControlsRef.value.showPreviewButton = newValue
|
||||
})
|
||||
|
||||
const onToggleCamera = () => {
|
||||
emit('toggleCamera')
|
||||
}
|
||||
const onToggleGrid = (value: boolean) => emit('toggleGrid', value)
|
||||
const onTogglePreview = (value: boolean) => {
|
||||
emit('togglePreview', value)
|
||||
}
|
||||
const onUpdateBackgroundColor = (color: string) =>
|
||||
emit('updateBackgroundColor', color)
|
||||
|
||||
const onUpdateLightIntensity = (lightIntensity: number) => {
|
||||
emit('updateLightIntensity', lightIntensity)
|
||||
}
|
||||
|
||||
const onUpdateFOV = (fov: number) => {
|
||||
emit('updateFOV', fov)
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
playing.value = !playing.value
|
||||
emit('togglePlay', playing.value)
|
||||
}
|
||||
|
||||
const speedChange = () => {
|
||||
emit('speedChange', selectedSpeed.value)
|
||||
}
|
||||
|
||||
const animationChange = () => {
|
||||
emit('animationChange', selectedAnimation.value)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
animations,
|
||||
selectedAnimation,
|
||||
playing,
|
||||
backgroundColor,
|
||||
showGrid,
|
||||
lightIntensity,
|
||||
showLightIntensityButton,
|
||||
fov,
|
||||
showFOVButton
|
||||
})
|
||||
</script>
|
||||
@@ -1,205 +0,0 @@
|
||||
<template>
|
||||
<div class="absolute top-2 left-2 flex flex-col gap-2 z-20">
|
||||
<Button class="p-button-rounded p-button-text" @click="toggleCamera">
|
||||
<i
|
||||
class="pi pi-camera text-white text-lg"
|
||||
v-tooltip.right="{ value: t('load3d.switchCamera'), showDelay: 300 }"
|
||||
></i>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
class="p-button-rounded p-button-text"
|
||||
:class="{ 'p-button-outlined': showGrid }"
|
||||
@click="toggleGrid"
|
||||
v-tooltip.right="{ value: t('load3d.showGrid'), showDelay: 300 }"
|
||||
>
|
||||
<i class="pi pi-table text-white text-lg"></i>
|
||||
</Button>
|
||||
|
||||
<Button class="p-button-rounded p-button-text" @click="openColorPicker">
|
||||
<i
|
||||
class="pi pi-palette text-white text-lg"
|
||||
v-tooltip.right="{ value: t('load3d.backgroundColor'), showDelay: 300 }"
|
||||
></i>
|
||||
<input
|
||||
type="color"
|
||||
ref="colorPickerRef"
|
||||
:value="backgroundColor"
|
||||
@input="
|
||||
updateBackgroundColor(($event.target as HTMLInputElement).value)
|
||||
"
|
||||
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<div class="relative" v-if="showLightIntensityButton">
|
||||
<Button
|
||||
class="p-button-rounded p-button-text"
|
||||
@click="toggleLightIntensity"
|
||||
>
|
||||
<i
|
||||
class="pi pi-sun text-white text-lg"
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.lightIntensity'),
|
||||
showDelay: 300
|
||||
}"
|
||||
></i>
|
||||
</Button>
|
||||
<div
|
||||
v-show="showLightIntensity"
|
||||
class="absolute left-12 top-0 bg-black bg-opacity-50 p-4 rounded-lg shadow-lg"
|
||||
style="width: 150px"
|
||||
>
|
||||
<Slider
|
||||
v-model="lightIntensity"
|
||||
class="w-full"
|
||||
@change="updateLightIntensity"
|
||||
:min="1"
|
||||
:max="20"
|
||||
:step="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative" v-if="showFOVButton">
|
||||
<Button class="p-button-rounded p-button-text" @click="toggleFOV">
|
||||
<i
|
||||
class="pi pi-expand text-white text-lg"
|
||||
v-tooltip.right="{ value: t('load3d.fov'), showDelay: 300 }"
|
||||
></i>
|
||||
</Button>
|
||||
<div
|
||||
v-show="showFOV"
|
||||
class="absolute left-12 top-0 bg-black bg-opacity-50 p-4 rounded-lg shadow-lg"
|
||||
style="width: 150px"
|
||||
>
|
||||
<Slider
|
||||
v-model="fov"
|
||||
class="w-full"
|
||||
@change="updateFOV"
|
||||
:min="10"
|
||||
:max="150"
|
||||
:step="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showPreviewButton">
|
||||
<Button class="p-button-rounded p-button-text" @click="togglePreview">
|
||||
<i
|
||||
:class="[
|
||||
'pi',
|
||||
showPreview ? 'pi-eye' : 'pi-eye-slash',
|
||||
'text-white text-lg'
|
||||
]"
|
||||
v-tooltip.right="{ value: t('load3d.previewOutput'), showDelay: 300 }"
|
||||
></i>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Slider from 'primevue/slider'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
backgroundColor: string
|
||||
showGrid: boolean
|
||||
showPreview: boolean
|
||||
lightIntensity: number
|
||||
showLightIntensityButton: boolean
|
||||
fov: number
|
||||
showFOVButton: boolean
|
||||
showPreviewButton: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggleCamera'): void
|
||||
(e: 'toggleGrid', value: boolean): void
|
||||
(e: 'updateBackgroundColor', color: string): void
|
||||
(e: 'updateLightIntensity', value: number): void
|
||||
(e: 'updateFOV', value: number): void
|
||||
(e: 'togglePreview', value: boolean): void
|
||||
}>()
|
||||
|
||||
const backgroundColor = ref(props.backgroundColor)
|
||||
const showGrid = ref(props.showGrid)
|
||||
const showPreview = ref(props.showPreview)
|
||||
const colorPickerRef = ref<HTMLInputElement | null>(null)
|
||||
const lightIntensity = ref(props.lightIntensity)
|
||||
const showLightIntensity = ref(false)
|
||||
const showLightIntensityButton = ref(props.showLightIntensityButton)
|
||||
const fov = ref(props.fov)
|
||||
const showFOV = ref(false)
|
||||
const showFOVButton = ref(props.showFOVButton)
|
||||
const showPreviewButton = ref(props.showPreviewButton)
|
||||
|
||||
const toggleCamera = () => {
|
||||
emit('toggleCamera')
|
||||
}
|
||||
|
||||
const toggleGrid = () => {
|
||||
showGrid.value = !showGrid.value
|
||||
emit('toggleGrid', showGrid.value)
|
||||
}
|
||||
|
||||
const togglePreview = () => {
|
||||
showPreview.value = !showPreview.value
|
||||
emit('togglePreview', showPreview.value)
|
||||
}
|
||||
|
||||
const updateBackgroundColor = (color: string) => {
|
||||
emit('updateBackgroundColor', color)
|
||||
}
|
||||
|
||||
const openColorPicker = () => {
|
||||
colorPickerRef.value?.click()
|
||||
}
|
||||
|
||||
const toggleLightIntensity = () => {
|
||||
showLightIntensity.value = !showLightIntensity.value
|
||||
}
|
||||
|
||||
const updateLightIntensity = () => {
|
||||
emit('updateLightIntensity', lightIntensity.value)
|
||||
}
|
||||
|
||||
const toggleFOV = () => {
|
||||
showFOV.value = !showFOV.value
|
||||
}
|
||||
|
||||
const updateFOV = () => {
|
||||
emit('updateFOV', fov.value)
|
||||
}
|
||||
|
||||
const closeSlider = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
if (!target.closest('.relative')) {
|
||||
showLightIntensity.value = false
|
||||
showFOV.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeSlider)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeSlider)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
backgroundColor,
|
||||
showGrid,
|
||||
lightIntensity,
|
||||
showLightIntensityButton,
|
||||
fov,
|
||||
showFOVButton,
|
||||
showPreviewButton
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="comfy-vue-node-search-container flex justify-center items-center w-full min-w-96"
|
||||
class="comfy-vue-node-search-container flex justify-center items-center w-full min-w-96 pointer-events-auto"
|
||||
>
|
||||
<div
|
||||
class="comfy-vue-node-preview-container absolute left-[-350px] top-[50px]"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="absolute top-0 left-0 w-auto max-w-full">
|
||||
<div class="absolute top-0 left-0 w-auto max-w-full pointer-events-auto">
|
||||
<WorkflowTabs />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
<template>
|
||||
<div
|
||||
ref="topMenuRef"
|
||||
class="comfyui-menu flex items-center"
|
||||
v-show="showTopMenu"
|
||||
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
|
||||
>
|
||||
<h1 class="comfyui-logo mx-2 app-drag">ComfyUI</h1>
|
||||
<CommandMenubar />
|
||||
<div class="flex-grow min-w-0 app-drag h-full">
|
||||
<WorkflowTabs v-if="workflowTabsPosition === 'Topbar'" />
|
||||
</div>
|
||||
<div class="comfyui-menu-right flex-shrink-0" ref="menuRight"></div>
|
||||
<Actionbar />
|
||||
<BottomPanelToggleButton class="flex-shrink-0" />
|
||||
<Button
|
||||
class="flex-shrink-0"
|
||||
icon="pi pi-bars"
|
||||
severity="secondary"
|
||||
text
|
||||
v-tooltip="{ value: $t('menu.hideMenu'), showDelay: 300 }"
|
||||
:aria-label="$t('menu.hideMenu')"
|
||||
@click="workspaceState.focusMode = true"
|
||||
@contextmenu="showNativeMenu"
|
||||
/>
|
||||
<teleport :to="teleportTarget">
|
||||
<div
|
||||
v-show="menuSetting !== 'Bottom'"
|
||||
class="window-actions-spacer flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
ref="topMenuRef"
|
||||
class="comfyui-menu flex items-center"
|
||||
v-show="showTopMenu"
|
||||
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
|
||||
>
|
||||
<h1 class="comfyui-logo mx-2 app-drag">ComfyUI</h1>
|
||||
<CommandMenubar />
|
||||
<div class="flex-grow min-w-0 app-drag h-full">
|
||||
<WorkflowTabs v-if="workflowTabsPosition === 'Topbar'" />
|
||||
</div>
|
||||
<div class="comfyui-menu-right flex-shrink-0" ref="menuRight"></div>
|
||||
<Actionbar />
|
||||
<BottomPanelToggleButton class="flex-shrink-0" />
|
||||
<Button
|
||||
class="flex-shrink-0"
|
||||
icon="pi pi-bars"
|
||||
severity="secondary"
|
||||
text
|
||||
v-tooltip="{ value: $t('menu.hideMenu'), showDelay: 300 }"
|
||||
:aria-label="$t('menu.hideMenu')"
|
||||
@click="workspaceState.focusMode = true"
|
||||
@contextmenu="showNativeMenu"
|
||||
/>
|
||||
<div
|
||||
v-show="menuSetting !== 'Bottom'"
|
||||
class="window-actions-spacer flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</teleport>
|
||||
|
||||
<!-- Virtual top menu for native window (drag handle) -->
|
||||
<div
|
||||
@@ -62,6 +64,11 @@ const workflowTabsPosition = computed(() =>
|
||||
)
|
||||
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
|
||||
const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled')
|
||||
const teleportTarget = computed(() =>
|
||||
settingStore.get('Comfy.UseNewMenu') === 'Top'
|
||||
? '.comfyui-body-top'
|
||||
: '.comfyui-body-bottom'
|
||||
)
|
||||
const showTopMenu = computed(
|
||||
() => betaMenuEnabled.value && !workspaceState.focusMode
|
||||
)
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
usePragmaticDraggable,
|
||||
@@ -52,8 +51,6 @@ const props = defineProps<{
|
||||
workflowOption: WorkflowOption
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowTabRef = ref<HTMLElement | null>(null)
|
||||
@@ -62,8 +59,7 @@ const closeWorkflows = async (options: WorkflowOption[]) => {
|
||||
for (const opt of options) {
|
||||
if (
|
||||
!(await useWorkflowService().closeWorkflow(opt.workflow, {
|
||||
warnIfUnsaved: !workspaceStore.shiftDown,
|
||||
hint: t('sideToolbar.workflowTab.dirtyCloseHint')
|
||||
warnIfUnsaved: !workspaceStore.shiftDown
|
||||
}))
|
||||
) {
|
||||
// User clicked cancel
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import type { InputSpec } from '@/types/apiTypes'
|
||||
|
||||
export const useBooleanWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
inputData: InputSpec
|
||||
) => {
|
||||
const inputOptions = inputData[1]
|
||||
const defaultVal = inputOptions?.default ?? false
|
||||
const options = {
|
||||
on: inputOptions?.label_on,
|
||||
off: inputOptions?.label_off
|
||||
}
|
||||
|
||||
return {
|
||||
widget: node.addWidget('toggle', inputName, defaultVal, () => {}, options)
|
||||
}
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import { addValueControlWidgets } from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import type { InputSpec } from '@/types/apiTypes'
|
||||
|
||||
import { useRemoteWidget } from './useRemoteWidget'
|
||||
|
||||
export const useComboWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
inputData: InputSpec
|
||||
) => {
|
||||
const widgetStore = useWidgetStore()
|
||||
const { remote, options } = inputData[1]
|
||||
const defaultValue = widgetStore.getDefaultValue(inputData)
|
||||
|
||||
const res = {
|
||||
widget: node.addWidget('combo', inputName, defaultValue, () => {}, {
|
||||
values: options ?? inputData[0]
|
||||
}) as IComboWidget
|
||||
}
|
||||
|
||||
if (remote) {
|
||||
const remoteWidget = useRemoteWidget({
|
||||
inputData,
|
||||
defaultValue,
|
||||
node,
|
||||
widget: res.widget
|
||||
})
|
||||
if (remote.refresh_button) remoteWidget.addRefreshButton()
|
||||
|
||||
const origOptions = res.widget.options
|
||||
res.widget.options = new Proxy(
|
||||
origOptions as Record<string | symbol, any>,
|
||||
{
|
||||
get(target, prop: string | symbol) {
|
||||
if (prop !== 'values') return target[prop]
|
||||
return remoteWidget.getValue()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (inputData[1]?.control_after_generate) {
|
||||
res.widget.linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
res.widget,
|
||||
undefined,
|
||||
undefined,
|
||||
inputData
|
||||
)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { INumericWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import type { InputSpec } from '@/types/apiTypes'
|
||||
import { getNumberDefaults } from '@/utils/mathUtil'
|
||||
|
||||
export const useFloatWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
inputData: InputSpec
|
||||
) => {
|
||||
// TODO: Move to outer scope to avoid re-initializing on every call
|
||||
// Blocked on ComfyWidgets lazy initialization.
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const sliderEnabled = !settingStore.get('Comfy.DisableSliders')
|
||||
const inputOptions = inputData[1]
|
||||
|
||||
const widgetType = sliderEnabled
|
||||
? inputOptions.display === 'slider'
|
||||
? 'slider'
|
||||
: 'number'
|
||||
: 'number'
|
||||
|
||||
const precision =
|
||||
settingStore.get('Comfy.FloatRoundingPrecision') || undefined
|
||||
const enableRounding = !settingStore.get('Comfy.DisableFloatRounding')
|
||||
|
||||
const { val, config } = getNumberDefaults(inputOptions, {
|
||||
defaultStep: 0.5,
|
||||
precision,
|
||||
enableRounding
|
||||
})
|
||||
|
||||
return {
|
||||
widget: node.addWidget(
|
||||
widgetType,
|
||||
inputName,
|
||||
val,
|
||||
function (this: INumericWidget, v: number) {
|
||||
if (config.round) {
|
||||
this.value =
|
||||
Math.round((v + Number.EPSILON) / config.round) * config.round
|
||||
if (this.value > config.max) this.value = config.max
|
||||
if (this.value < config.min) this.value = config.min
|
||||
} else {
|
||||
this.value = v
|
||||
}
|
||||
},
|
||||
config
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IStringWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import type { ComfyApp } from '@/types'
|
||||
import type { InputSpec } from '@/types/apiTypes'
|
||||
|
||||
export const useImageUploadWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
inputData: InputSpec,
|
||||
app: ComfyApp
|
||||
) => {
|
||||
const imageWidget = node.widgets?.find(
|
||||
(w) => w.name === (inputData[1]?.widget ?? 'image')
|
||||
) as IStringWidget
|
||||
const { image_folder = 'input' } = inputData[1] ?? {}
|
||||
|
||||
function showImage(name: string) {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
node.imgs = [img]
|
||||
app.graph.setDirtyCanvas(true)
|
||||
}
|
||||
const folder_separator = name.lastIndexOf('/')
|
||||
let subfolder = ''
|
||||
if (folder_separator > -1) {
|
||||
subfolder = name.substring(0, folder_separator)
|
||||
name = name.substring(folder_separator + 1)
|
||||
}
|
||||
img.src = api.apiURL(
|
||||
`/view?filename=${encodeURIComponent(name)}&type=${image_folder}&subfolder=${subfolder}${app.getPreviewFormatParam()}${app.getRandParam()}`
|
||||
)
|
||||
node.setSizeForImage?.()
|
||||
}
|
||||
|
||||
const default_value = imageWidget.value
|
||||
Object.defineProperty(imageWidget, 'value', {
|
||||
set: function (value) {
|
||||
this._real_value = value
|
||||
},
|
||||
|
||||
get: function () {
|
||||
if (!this._real_value) {
|
||||
return default_value
|
||||
}
|
||||
|
||||
let value = this._real_value
|
||||
if (value.filename) {
|
||||
const real_value = value
|
||||
value = ''
|
||||
if (real_value.subfolder) {
|
||||
value = real_value.subfolder + '/'
|
||||
}
|
||||
|
||||
value += real_value.filename
|
||||
|
||||
if (real_value.type && real_value.type !== 'input')
|
||||
value += ` [${real_value.type}]`
|
||||
}
|
||||
return value
|
||||
}
|
||||
})
|
||||
|
||||
// Add our own callback to the combo widget to render an image when it changes
|
||||
// TODO: Explain this?
|
||||
// @ts-expect-error LGraphNode.callback is not typed
|
||||
const cb = node.callback
|
||||
imageWidget.callback = function (...args) {
|
||||
showImage(imageWidget.value)
|
||||
if (cb) {
|
||||
return cb.apply(this, args)
|
||||
}
|
||||
}
|
||||
|
||||
// On load if we have a value then render the image
|
||||
// The value isnt set immediately so we need to wait a moment
|
||||
// No change callbacks seem to be fired on initial setting of the value
|
||||
requestAnimationFrame(() => {
|
||||
if (imageWidget.value) {
|
||||
showImage(imageWidget.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Add types for upload parameters
|
||||
async function uploadFile(file: File, updateNode: boolean, pasted = false) {
|
||||
try {
|
||||
// Wrap file in formdata so it includes filename
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (pasted) body.append('subfolder', 'pasted')
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status === 200) {
|
||||
const data = await resp.json()
|
||||
// Add the file to the dropdown list and update the widget value
|
||||
let path = data.name
|
||||
if (data.subfolder) path = data.subfolder + '/' + path
|
||||
|
||||
if (!imageWidget.options) {
|
||||
imageWidget.options = { values: [] }
|
||||
}
|
||||
if (!imageWidget.options.values) {
|
||||
imageWidget.options.values = []
|
||||
}
|
||||
if (!imageWidget.options.values.includes(path)) {
|
||||
imageWidget.options.values.push(path)
|
||||
}
|
||||
|
||||
if (updateNode) {
|
||||
showImage(path)
|
||||
imageWidget.value = path
|
||||
}
|
||||
} else {
|
||||
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
const fileInput = document.createElement('input')
|
||||
Object.assign(fileInput, {
|
||||
type: 'file',
|
||||
accept: 'image/jpeg,image/png,image/webp',
|
||||
style: 'display: none',
|
||||
onchange: async () => {
|
||||
// Add null check for files
|
||||
if (fileInput.files && fileInput.files.length) {
|
||||
await uploadFile(fileInput.files[0], true)
|
||||
}
|
||||
}
|
||||
})
|
||||
document.body.append(fileInput)
|
||||
|
||||
// Create the button widget for selecting the files
|
||||
const uploadWidget = node.addWidget('button', inputName, 'image', () => {
|
||||
fileInput.click()
|
||||
})
|
||||
uploadWidget.label = 'choose file to upload'
|
||||
// @ts-expect-error IWidget.serialize is not typed
|
||||
uploadWidget.serialize = false
|
||||
|
||||
// Add handler to check if an image is being dragged over our node
|
||||
node.onDragOver = function (e: DragEvent) {
|
||||
if (e.dataTransfer && e.dataTransfer.items) {
|
||||
const image = [...e.dataTransfer.items].find((f) => f.kind === 'file')
|
||||
return !!image
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// On drop upload files
|
||||
node.onDragDrop = function (e: DragEvent) {
|
||||
console.log('onDragDrop called')
|
||||
let handled = false
|
||||
if (e.dataTransfer?.files) {
|
||||
for (const file of e.dataTransfer.files) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
uploadFile(file, !handled)
|
||||
handled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return handled
|
||||
}
|
||||
|
||||
node.pasteFile = function (file: File) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const is_pasted =
|
||||
file.name === 'image.png' && file.lastModified - Date.now() < 2000
|
||||
uploadFile(file, true, is_pasted)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return { widget: uploadWidget }
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { INumericWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import {
|
||||
type ComfyWidgetConstructor,
|
||||
addValueControlWidget
|
||||
} from '@/scripts/widgets'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { InputSpec } from '@/types/apiTypes'
|
||||
import { getNumberDefaults } from '@/utils/mathUtil'
|
||||
|
||||
export const useIntWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
inputData: InputSpec,
|
||||
app: ComfyApp,
|
||||
widgetName?: string
|
||||
) => {
|
||||
const settingStore = useSettingStore()
|
||||
const sliderEnabled = !settingStore.get('Comfy.DisableSliders')
|
||||
const inputOptions = inputData[1]
|
||||
const widgetType = sliderEnabled
|
||||
? inputOptions.display === 'slider'
|
||||
? 'slider'
|
||||
: 'number'
|
||||
: 'number'
|
||||
|
||||
const { val, config } = getNumberDefaults(inputOptions, {
|
||||
defaultStep: 1,
|
||||
precision: 0,
|
||||
enableRounding: true
|
||||
})
|
||||
config.precision = 0
|
||||
|
||||
const result = {
|
||||
widget: node.addWidget(
|
||||
widgetType,
|
||||
inputName,
|
||||
val,
|
||||
function (this: INumericWidget, v: number) {
|
||||
const s = (this.options.step ?? 1) / 10
|
||||
let sh = (this.options.min ?? 0) % s
|
||||
if (isNaN(sh)) {
|
||||
sh = 0
|
||||
}
|
||||
this.value = Math.round((v - sh) / s) * s + sh
|
||||
},
|
||||
config
|
||||
)
|
||||
}
|
||||
|
||||
if (inputData[1]?.control_after_generate) {
|
||||
const seedControl = addValueControlWidget(
|
||||
node,
|
||||
result.widget,
|
||||
'randomize',
|
||||
undefined,
|
||||
widgetName,
|
||||
inputData
|
||||
)
|
||||
result.widget.linkedWidgets = [seedControl]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { Editor as TiptapEditor } from '@tiptap/core'
|
||||
import TiptapLink from '@tiptap/extension-link'
|
||||
import TiptapTable from '@tiptap/extension-table'
|
||||
import TiptapTableCell from '@tiptap/extension-table-cell'
|
||||
import TiptapTableHeader from '@tiptap/extension-table-header'
|
||||
import TiptapTableRow from '@tiptap/extension-table-row'
|
||||
import TiptapStarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
|
||||
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import type { ComfyApp } from '@/types'
|
||||
import type { InputSpec } from '@/types/apiTypes'
|
||||
|
||||
function addMarkdownWidget(
|
||||
node: LGraphNode,
|
||||
name: string,
|
||||
opts: { defaultVal: string },
|
||||
app: ComfyApp
|
||||
) {
|
||||
TiptapMarkdown.configure({
|
||||
html: false,
|
||||
breaks: true,
|
||||
transformPastedText: true
|
||||
})
|
||||
const editor = new TiptapEditor({
|
||||
extensions: [
|
||||
TiptapStarterKit,
|
||||
TiptapMarkdown,
|
||||
TiptapLink,
|
||||
TiptapTable,
|
||||
TiptapTableCell,
|
||||
TiptapTableHeader,
|
||||
TiptapTableRow
|
||||
],
|
||||
content: opts.defaultVal,
|
||||
editable: false
|
||||
})
|
||||
|
||||
const inputEl = editor.options.element as HTMLElement
|
||||
inputEl.classList.add('comfy-markdown')
|
||||
const textarea = document.createElement('textarea')
|
||||
inputEl.append(textarea)
|
||||
|
||||
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
|
||||
getValue(): string {
|
||||
return textarea.value
|
||||
},
|
||||
setValue(v: string) {
|
||||
textarea.value = v
|
||||
editor.commands.setContent(v)
|
||||
}
|
||||
})
|
||||
widget.inputEl = inputEl
|
||||
|
||||
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
|
||||
if (event.button !== 0) {
|
||||
app.canvas.processMouseDown(event)
|
||||
return
|
||||
}
|
||||
if (event.target instanceof HTMLAnchorElement) {
|
||||
return
|
||||
}
|
||||
inputEl.classList.add('editing')
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
})
|
||||
|
||||
textarea.addEventListener('blur', () => {
|
||||
inputEl.classList.remove('editing')
|
||||
})
|
||||
|
||||
textarea.addEventListener('change', () => {
|
||||
editor.commands.setContent(textarea.value)
|
||||
widget.callback?.(widget.value)
|
||||
})
|
||||
|
||||
inputEl.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
event.stopPropagation()
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseDown(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
|
||||
if ((event.buttons & 4) === 4) {
|
||||
app.canvas.processMouseMove(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseUp(event)
|
||||
}
|
||||
})
|
||||
|
||||
return { minWidth: 400, minHeight: 200, widget }
|
||||
}
|
||||
|
||||
export const useMarkdownWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
inputData: InputSpec,
|
||||
app: ComfyApp
|
||||
) => {
|
||||
const defaultVal = inputData[1]?.default || ''
|
||||
return addMarkdownWidget(
|
||||
node,
|
||||
inputName,
|
||||
{ defaultVal, ...inputData[1] },
|
||||
app
|
||||
)
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { IWidget } from '@comfyorg/litegraph'
|
||||
import axios from 'axios'
|
||||
|
||||
import type { InputSpec, RemoteWidgetConfig } from '@/types/apiTypes'
|
||||
|
||||
const MAX_RETRIES = 5
|
||||
const TIMEOUT = 4096
|
||||
|
||||
export interface CacheEntry<T> {
|
||||
data: T
|
||||
timestamp?: number
|
||||
error?: Error | null
|
||||
fetchPromise?: Promise<T>
|
||||
controller?: AbortController
|
||||
lastErrorTime?: number
|
||||
retryCount?: number
|
||||
failed?: boolean
|
||||
}
|
||||
|
||||
const dataCache = new Map<string, CacheEntry<any>>()
|
||||
|
||||
const createCacheKey = (config: RemoteWidgetConfig): string => {
|
||||
const { route, query_params = {}, refresh = 0 } = config
|
||||
|
||||
const paramsKey = Object.entries(query_params)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('&')
|
||||
|
||||
return [route, `r=${refresh}`, paramsKey].join(';')
|
||||
}
|
||||
|
||||
const getBackoff = (retryCount: number) =>
|
||||
Math.min(1000 * Math.pow(2, retryCount), 512)
|
||||
|
||||
const isInitialized = (entry: CacheEntry<unknown> | undefined) =>
|
||||
entry?.data && entry?.timestamp && entry.timestamp > 0
|
||||
|
||||
const isStale = (entry: CacheEntry<unknown> | undefined, ttl: number) =>
|
||||
entry?.timestamp && Date.now() - entry.timestamp >= ttl
|
||||
|
||||
const isFetching = (entry: CacheEntry<unknown> | undefined) =>
|
||||
entry?.fetchPromise !== undefined
|
||||
|
||||
const isFailed = (entry: CacheEntry<unknown> | undefined) =>
|
||||
entry?.failed === true
|
||||
|
||||
const isBackingOff = (entry: CacheEntry<unknown> | undefined) =>
|
||||
entry?.error &&
|
||||
entry?.lastErrorTime &&
|
||||
Date.now() - entry.lastErrorTime < getBackoff(entry.retryCount || 0)
|
||||
|
||||
const fetchData = async (
|
||||
config: RemoteWidgetConfig,
|
||||
controller: AbortController
|
||||
) => {
|
||||
const { route, response_key, query_params, timeout = TIMEOUT } = config
|
||||
const res = await axios.get(route, {
|
||||
params: query_params,
|
||||
signal: controller.signal,
|
||||
timeout
|
||||
})
|
||||
return response_key ? res.data[response_key] : res.data
|
||||
}
|
||||
|
||||
export function useRemoteWidget<
|
||||
T extends string | number | boolean | object
|
||||
>(options: {
|
||||
inputData: InputSpec
|
||||
defaultValue: T
|
||||
node: LGraphNode
|
||||
widget: IWidget
|
||||
}) {
|
||||
const { inputData, defaultValue, node, widget } = options
|
||||
const config: RemoteWidgetConfig = inputData[1].remote
|
||||
const { refresh = 0, max_retries = MAX_RETRIES } = config
|
||||
const isPermanent = refresh <= 0
|
||||
const cacheKey = createCacheKey(config)
|
||||
let isLoaded = false
|
||||
|
||||
const setSuccess = (entry: CacheEntry<T>, data: T) => {
|
||||
entry.retryCount = 0
|
||||
entry.lastErrorTime = 0
|
||||
entry.error = null
|
||||
entry.timestamp = Date.now()
|
||||
entry.data = data ?? defaultValue
|
||||
}
|
||||
|
||||
const setError = (entry: CacheEntry<T>, error: Error | unknown) => {
|
||||
entry.retryCount = (entry.retryCount || 0) + 1
|
||||
entry.lastErrorTime = Date.now()
|
||||
entry.error = error instanceof Error ? error : new Error(String(error))
|
||||
entry.data ??= defaultValue
|
||||
entry.fetchPromise = undefined
|
||||
if (entry.retryCount >= max_retries) {
|
||||
setFailed(entry)
|
||||
}
|
||||
}
|
||||
|
||||
const setFailed = (entry: CacheEntry<T>) => {
|
||||
dataCache.set(cacheKey, {
|
||||
data: entry.data ?? defaultValue,
|
||||
failed: true
|
||||
})
|
||||
}
|
||||
|
||||
const isFirstLoad = () => {
|
||||
return !isLoaded && isInitialized(dataCache.get(cacheKey))
|
||||
}
|
||||
|
||||
const onFirstLoad = (data: T[]) => {
|
||||
isLoaded = true
|
||||
widget.value = data[0]
|
||||
widget.callback?.(widget.value)
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
const fetchValue = async () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
|
||||
if (isFailed(entry)) return entry!.data
|
||||
|
||||
const isValid =
|
||||
isInitialized(entry) && (isPermanent || !isStale(entry, refresh))
|
||||
if (isValid || isBackingOff(entry) || isFetching(entry)) return entry!.data
|
||||
|
||||
const currentEntry: CacheEntry<T> = entry || { data: defaultValue }
|
||||
dataCache.set(cacheKey, currentEntry)
|
||||
|
||||
try {
|
||||
currentEntry.controller = new AbortController()
|
||||
currentEntry.fetchPromise = fetchData(config, currentEntry.controller)
|
||||
const data = await currentEntry.fetchPromise
|
||||
|
||||
setSuccess(currentEntry, data)
|
||||
return currentEntry.data
|
||||
} catch (err) {
|
||||
setError(currentEntry, err)
|
||||
return currentEntry.data
|
||||
} finally {
|
||||
currentEntry.fetchPromise = undefined
|
||||
currentEntry.controller = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
if (config.control_after_refresh) {
|
||||
const data = getCachedValue()
|
||||
if (!Array.isArray(data)) return // control_after_refresh is only supported for array values
|
||||
|
||||
switch (config.control_after_refresh) {
|
||||
case 'first':
|
||||
widget.value = data[0] ?? defaultValue
|
||||
break
|
||||
case 'last':
|
||||
widget.value = data.at(-1) ?? defaultValue
|
||||
break
|
||||
}
|
||||
widget.callback?.(widget.value)
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the widget's cached value, forcing a refresh on next access (e.g., a new render)
|
||||
*/
|
||||
const clearCachedValue = () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
if (!entry) return
|
||||
if (entry.fetchPromise) entry.controller?.abort() // Abort in-flight request
|
||||
dataCache.delete(cacheKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached value of the widget without starting a new fetch.
|
||||
* @returns the most recently computed value of the widget.
|
||||
*/
|
||||
function getCachedValue() {
|
||||
return dataCache.get(cacheKey)?.data as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter of the remote property of the widget (e.g., options.values, value, etc.).
|
||||
* Starts the fetch process then returns the cached value immediately.
|
||||
* @param onFulfilled - Optional callback to be called when the fetch is resolved.
|
||||
* @returns the most recent value of the widget.
|
||||
*/
|
||||
function getValue(onFulfilled?: () => void) {
|
||||
fetchValue().then((data) => {
|
||||
if (isFirstLoad()) onFirstLoad(data)
|
||||
onFulfilled?.()
|
||||
})
|
||||
return getCachedValue() ?? defaultValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the widget to refresh its value
|
||||
*/
|
||||
function refreshValue() {
|
||||
clearCachedValue()
|
||||
getValue(onRefresh)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a refresh button to the node that, when clicked, will force the widget to refresh
|
||||
*/
|
||||
function addRefreshButton() {
|
||||
node.addWidget('button', 'refresh', 'refresh', refreshValue)
|
||||
}
|
||||
|
||||
return {
|
||||
getCachedValue,
|
||||
getValue,
|
||||
refreshValue,
|
||||
addRefreshButton,
|
||||
getCacheEntry: () => dataCache.get(cacheKey),
|
||||
|
||||
cacheKey
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import type { ComfyApp } from '@/types'
|
||||
import type { InputSpec } from '@/types/apiTypes'
|
||||
|
||||
import { useIntWidget } from './useIntWidget'
|
||||
|
||||
export const useSeedWidget = () => {
|
||||
const IntWidget = useIntWidget()
|
||||
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
inputData: InputSpec,
|
||||
app: ComfyApp,
|
||||
widgetName?: string
|
||||
) => {
|
||||
inputData[1] = {
|
||||
...inputData[1],
|
||||
control_after_generate: true
|
||||
}
|
||||
return IntWidget(node, inputName, inputData, app, widgetName)
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { IWidget, LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import type { ComfyApp } from '@/types'
|
||||
import type { InputSpec } from '@/types/apiTypes'
|
||||
|
||||
function addMultilineWidget(
|
||||
node: LGraphNode,
|
||||
name: string,
|
||||
opts: { defaultVal: string; placeholder?: string },
|
||||
app: ComfyApp
|
||||
) {
|
||||
const inputEl = document.createElement('textarea')
|
||||
inputEl.className = 'comfy-multiline-input'
|
||||
inputEl.value = opts.defaultVal
|
||||
inputEl.placeholder = opts.placeholder || name
|
||||
if (app.vueAppReady) {
|
||||
inputEl.spellcheck = useSettingStore().get(
|
||||
'Comfy.TextareaWidget.Spellcheck'
|
||||
)
|
||||
}
|
||||
|
||||
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
|
||||
getValue(): string {
|
||||
return inputEl.value
|
||||
},
|
||||
setValue(v: string) {
|
||||
inputEl.value = v
|
||||
}
|
||||
})
|
||||
|
||||
widget.inputEl = inputEl
|
||||
|
||||
inputEl.addEventListener('input', () => {
|
||||
widget.callback?.(widget.value)
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseDown(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
|
||||
if ((event.buttons & 4) === 4) {
|
||||
app.canvas.processMouseMove(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseUp(event)
|
||||
}
|
||||
})
|
||||
|
||||
return { minWidth: 400, minHeight: 200, widget }
|
||||
}
|
||||
|
||||
export const useStringWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
inputData: InputSpec,
|
||||
app: ComfyApp
|
||||
) => {
|
||||
const defaultVal = inputData[1]?.default || ''
|
||||
const multiline = !!inputData[1]?.multiline
|
||||
|
||||
let res: { widget: IWidget }
|
||||
if (multiline) {
|
||||
res = addMultilineWidget(
|
||||
node,
|
||||
inputName,
|
||||
{ defaultVal, ...inputData[1] },
|
||||
app
|
||||
)
|
||||
} else {
|
||||
res = {
|
||||
widget: node.addWidget('text', inputName, defaultVal, () => {}, {})
|
||||
}
|
||||
}
|
||||
|
||||
if (inputData[1].dynamicPrompts != undefined)
|
||||
res.widget.dynamicPrompts = inputData[1].dynamicPrompts
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
export const RESERVED_BY_TEXT_INPUT = new Set([
|
||||
'Ctrl + a',
|
||||
'Ctrl + c',
|
||||
'Ctrl + v',
|
||||
'Ctrl + x',
|
||||
'Ctrl + z',
|
||||
'Ctrl + y',
|
||||
'Ctrl + p',
|
||||
'Enter',
|
||||
'Shift + Enter',
|
||||
'Ctrl + Backspace',
|
||||
'Ctrl + Delete',
|
||||
'Home',
|
||||
'Ctrl + Home',
|
||||
'Ctrl + Shift + Home',
|
||||
'End',
|
||||
'Ctrl + End',
|
||||
'Ctrl + Shift + End',
|
||||
'PageUp',
|
||||
'PageDown',
|
||||
'Shift + PageUp',
|
||||
'Shift + PageDown',
|
||||
'ArrowLeft',
|
||||
'Ctrl + ArrowLeft',
|
||||
'Shift + ArrowLeft',
|
||||
'Ctrl + Shift + ArrowLeft',
|
||||
'ArrowRight',
|
||||
'Ctrl + ArrowRight',
|
||||
'Shift + ArrowRight',
|
||||
'Ctrl + Shift + ArrowRight',
|
||||
'ArrowUp',
|
||||
'Shift + ArrowUp',
|
||||
'ArrowDown',
|
||||
'Shift + ArrowDown'
|
||||
])
|
||||
@@ -10,7 +10,8 @@ useExtensionService().registerExtension({
|
||||
if (node.widgets) {
|
||||
// Locate dynamic prompt text widgets
|
||||
// Include any widgets with dynamicPrompts set to true, and customtext
|
||||
const widgets = node.widgets.filter((w) => w.dynamicPrompts)
|
||||
// @ts-expect-error dynamicPrompts is not typed
|
||||
const widgets = node.widgets.filter((n) => n.dynamicPrompts)
|
||||
for (const widget of widgets) {
|
||||
// Override the serialization of the value to resolve dynamic prompts for all widgets supporting it in this node
|
||||
// @ts-expect-error hacky override
|
||||
|
||||
@@ -856,7 +856,6 @@ export class GroupNodeHandler {
|
||||
for (let i = 0; i < c.nodes.length; i++) {
|
||||
let id = innerNodes?.[i]?.id
|
||||
// Use existing IDs if they are set on the inner nodes
|
||||
// @ts-expect-error id can be string or number
|
||||
if (id == null || isNaN(id)) {
|
||||
id = undefined
|
||||
} else {
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
// @ts-strict-ignore
|
||||
import { IWidget } from '@comfyorg/litegraph'
|
||||
import type { IStringWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
const containerToLoad3D = new Map()
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.Load3D',
|
||||
|
||||
getCustomWidgets(app) {
|
||||
return {
|
||||
LOAD_3D(node, inputName) {
|
||||
let load3dNode = app.graph._nodes.filter((wi) => wi.type == 'Load3D')
|
||||
|
||||
node.addProperty('Camera Info', '')
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.id = `comfy-load-3d-${load3dNode.length}`
|
||||
container.classList.add('comfy-load-3d')
|
||||
|
||||
const load3d = useLoad3dService().registerLoad3d(
|
||||
node,
|
||||
container,
|
||||
'Load3D'
|
||||
)
|
||||
const load3d = new Load3d(container, true)
|
||||
|
||||
containerToLoad3D.set(container.id, load3d)
|
||||
|
||||
node.onMouseEnter = function () {
|
||||
if (load3d) {
|
||||
@@ -46,7 +48,7 @@ app.registerExtension({
|
||||
load3d.remove()
|
||||
}
|
||||
|
||||
useLoad3dService().removeLoad3d(node)
|
||||
containerToLoad3D.delete(container.id)
|
||||
|
||||
origOnRemoved?.apply(this, [])
|
||||
}
|
||||
@@ -63,7 +65,7 @@ app.registerExtension({
|
||||
if (fileInput.files?.length) {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w: IWidget) => w.name === 'model_file'
|
||||
) as IStringWidget
|
||||
)
|
||||
const uploadPath = await Load3dUtils.uploadFile(
|
||||
load3d,
|
||||
fileInput.files[0],
|
||||
@@ -115,7 +117,11 @@ app.registerExtension({
|
||||
|
||||
const sceneWidget = node.widgets.find((w: IWidget) => w.name === 'image')
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
const container = sceneWidget.element
|
||||
|
||||
const load3d = containerToLoad3D.get(container.id)
|
||||
|
||||
load3d.setNode(node)
|
||||
|
||||
const modelWidget = node.widgets.find(
|
||||
(w: IWidget) => w.name === 'model_file'
|
||||
@@ -129,11 +135,11 @@ app.registerExtension({
|
||||
|
||||
let cameraState = node.properties['Camera Info']
|
||||
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
|
||||
const width = node.widgets.find((w: IWidget) => w.name === 'width')
|
||||
const height = node.widgets.find((w: IWidget) => w.name === 'height')
|
||||
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
|
||||
config.configure(
|
||||
'input',
|
||||
modelWidget,
|
||||
@@ -144,12 +150,13 @@ app.registerExtension({
|
||||
height
|
||||
)
|
||||
|
||||
// @ts-expect-error hacky override
|
||||
sceneWidget.serializeValue = async () => {
|
||||
node.properties['Camera Info'] = load3d.getCameraState()
|
||||
|
||||
const { scene: imageData, mask: maskData } = await load3d.captureScene(
|
||||
width.value as number,
|
||||
height.value as number
|
||||
width.value,
|
||||
height.value
|
||||
)
|
||||
|
||||
const [data, dataMask] = await Promise.all([
|
||||
@@ -171,17 +178,19 @@ app.registerExtension({
|
||||
getCustomWidgets(app) {
|
||||
return {
|
||||
LOAD_3D_ANIMATION(node, inputName) {
|
||||
let load3dNode = app.graph._nodes.filter(
|
||||
(wi) => wi.type == 'Load3DAnimation'
|
||||
)
|
||||
|
||||
node.addProperty('Camera Info', '')
|
||||
|
||||
const container = document.createElement('div')
|
||||
|
||||
container.id = `comfy-load-3d-animation-${load3dNode.length}`
|
||||
container.classList.add('comfy-load-3d-animation')
|
||||
|
||||
const load3d = useLoad3dService().registerLoad3d(
|
||||
node,
|
||||
container,
|
||||
'Load3DAnimation'
|
||||
)
|
||||
const load3d = new Load3dAnimation(container, true)
|
||||
|
||||
containerToLoad3D.set(container.id, load3d)
|
||||
|
||||
node.onMouseEnter = function () {
|
||||
if (load3d) {
|
||||
@@ -202,7 +211,7 @@ app.registerExtension({
|
||||
load3d.remove()
|
||||
}
|
||||
|
||||
useLoad3dService().removeLoad3d(node)
|
||||
containerToLoad3D.delete(container.id)
|
||||
|
||||
origOnRemoved?.apply(this, [])
|
||||
}
|
||||
@@ -219,7 +228,7 @@ app.registerExtension({
|
||||
if (fileInput.files?.length) {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w: IWidget) => w.name === 'model_file'
|
||||
) as IStringWidget
|
||||
)
|
||||
const uploadPath = await Load3dUtils.uploadFile(
|
||||
load3d,
|
||||
fileInput.files[0],
|
||||
@@ -265,13 +274,17 @@ app.registerExtension({
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 700)])
|
||||
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 700)])
|
||||
|
||||
await nextTick()
|
||||
|
||||
const sceneWidget = node.widgets.find((w: IWidget) => w.name === 'image')
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node) as Load3dAnimation
|
||||
const container = sceneWidget.element
|
||||
|
||||
const load3d = containerToLoad3D.get(container.id)
|
||||
|
||||
load3d.setNode(node)
|
||||
|
||||
const modelWidget = node.widgets.find(
|
||||
(w: IWidget) => w.name === 'model_file'
|
||||
@@ -285,11 +298,11 @@ app.registerExtension({
|
||||
|
||||
let cameraState = node.properties['Camera Info']
|
||||
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
|
||||
const width = node.widgets.find((w: IWidget) => w.name === 'width')
|
||||
const height = node.widgets.find((w: IWidget) => w.name === 'height')
|
||||
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
|
||||
config.configure(
|
||||
'input',
|
||||
modelWidget,
|
||||
@@ -300,14 +313,15 @@ app.registerExtension({
|
||||
height
|
||||
)
|
||||
|
||||
// @ts-expect-error hacky override
|
||||
sceneWidget.serializeValue = async () => {
|
||||
node.properties['Camera Info'] = load3d.getCameraState()
|
||||
|
||||
load3d.toggleAnimation(false)
|
||||
|
||||
const { scene: imageData, mask: maskData } = await load3d.captureScene(
|
||||
width.value as number,
|
||||
height.value as number
|
||||
width.value,
|
||||
height.value
|
||||
)
|
||||
|
||||
const [data, dataMask] = await Promise.all([
|
||||
@@ -338,15 +352,15 @@ app.registerExtension({
|
||||
getCustomWidgets(app) {
|
||||
return {
|
||||
PREVIEW_3D(node, inputName) {
|
||||
const container = document.createElement('div')
|
||||
let load3dNode = app.graph._nodes.filter((wi) => wi.type == 'Preview3D')
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.id = `comfy-preview-3d-${load3dNode.length}`
|
||||
container.classList.add('comfy-preview-3d')
|
||||
|
||||
const load3d = useLoad3dService().registerLoad3d(
|
||||
node,
|
||||
container,
|
||||
'Preview3D'
|
||||
)
|
||||
const load3d = new Load3d(container)
|
||||
|
||||
containerToLoad3D.set(container.id, load3d)
|
||||
|
||||
node.onMouseEnter = function () {
|
||||
if (load3d) {
|
||||
@@ -367,7 +381,7 @@ app.registerExtension({
|
||||
load3d.remove()
|
||||
}
|
||||
|
||||
useLoad3dService().removeLoad3d(node)
|
||||
containerToLoad3D.delete(container.id)
|
||||
|
||||
origOnRemoved?.apply(this, [])
|
||||
}
|
||||
@@ -388,11 +402,17 @@ app.registerExtension({
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 550)])
|
||||
|
||||
await nextTick()
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
const sceneWidget = node.widgets.find((w: IWidget) => w.name === 'image')
|
||||
|
||||
const container = sceneWidget.element
|
||||
|
||||
const load3d = containerToLoad3D.get(container.id)
|
||||
|
||||
load3d.setNode(node)
|
||||
|
||||
const modelWidget = node.widgets.find(
|
||||
(w: IWidget) => w.name === 'model_file'
|
||||
@@ -443,15 +463,17 @@ app.registerExtension({
|
||||
getCustomWidgets(app) {
|
||||
return {
|
||||
PREVIEW_3D_ANIMATION(node, inputName) {
|
||||
const container = document.createElement('div')
|
||||
let load3dNode = app.graph._nodes.filter(
|
||||
(wi) => wi.type == 'Preview3DAnimation'
|
||||
)
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.id = `comfy-preview-3d-animation-${load3dNode.length}`
|
||||
container.classList.add('comfy-preview-3d-animation')
|
||||
|
||||
const load3d = useLoad3dService().registerLoad3d(
|
||||
node,
|
||||
container,
|
||||
'Preview3DAnimation'
|
||||
)
|
||||
const load3d = new Load3dAnimation(container)
|
||||
|
||||
containerToLoad3D.set(container.id, load3d)
|
||||
|
||||
node.onMouseEnter = function () {
|
||||
if (load3d) {
|
||||
@@ -472,7 +494,7 @@ app.registerExtension({
|
||||
load3d.remove()
|
||||
}
|
||||
|
||||
useLoad3dService().removeLoad3d(node)
|
||||
containerToLoad3D.delete(container.id)
|
||||
|
||||
origOnRemoved?.apply(this, [])
|
||||
}
|
||||
@@ -501,7 +523,13 @@ app.registerExtension({
|
||||
|
||||
await nextTick()
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
const sceneWidget = node.widgets.find((w: IWidget) => w.name === 'image')
|
||||
|
||||
const container = sceneWidget.element
|
||||
|
||||
const load3d = containerToLoad3D.get(container.id)
|
||||
|
||||
load3d.setNode(node)
|
||||
|
||||
const modelWidget = node.widgets.find(
|
||||
(w: IWidget) => w.name === 'model_file'
|
||||
|
||||
@@ -29,20 +29,6 @@ class Load3DConfiguration {
|
||||
this.setupDefaultProperties()
|
||||
}
|
||||
|
||||
private setupTargetSize(width: IWidget | null, height: IWidget | null) {
|
||||
if (width && height) {
|
||||
this.load3d.setTargetSize(width.value as number, height.value as number)
|
||||
|
||||
width.callback = (value: number) => {
|
||||
this.load3d.setTargetSize(value, height.value as number)
|
||||
}
|
||||
|
||||
height.callback = (value: number) => {
|
||||
this.load3d.setTargetSize(width.value as number, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setupModelHandling(
|
||||
modelWidget: IWidget,
|
||||
loadFolder: 'input' | 'output',
|
||||
@@ -80,6 +66,20 @@ class Load3DConfiguration {
|
||||
)
|
||||
}
|
||||
|
||||
private setupTargetSize(width: IWidget | null, height: IWidget | null) {
|
||||
if (width && height) {
|
||||
this.load3d.setTargetSize(width.value as number, height.value as number)
|
||||
|
||||
width.callback = (value: number) => {
|
||||
this.load3d.setTargetSize(value, height.value as number)
|
||||
}
|
||||
|
||||
height.callback = (value: number) => {
|
||||
this.load3d.setTargetSize(width.value as number, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setupDefaultProperties() {
|
||||
const cameraType = this.load3d.loadNodeProperty(
|
||||
'Camera Type',
|
||||
@@ -94,13 +94,20 @@ class Load3DConfiguration {
|
||||
|
||||
this.load3d.setBackgroundColor(bgColor)
|
||||
|
||||
const fov = this.load3d.loadNodeProperty('FOV', '75')
|
||||
|
||||
this.load3d.setFOV(fov)
|
||||
|
||||
const lightIntensity = this.load3d.loadNodeProperty('Light Intensity', '5')
|
||||
|
||||
this.load3d.setLightIntensity(lightIntensity)
|
||||
|
||||
const fov = this.load3d.loadNodeProperty('FOV', '75')
|
||||
const previewVisible = this.load3d.loadNodeProperty(
|
||||
'Preview Visible',
|
||||
false
|
||||
)
|
||||
|
||||
this.load3d.setFOV(fov)
|
||||
this.load3d.setPreviewVisible(previewVisible)
|
||||
}
|
||||
|
||||
private createModelUpdateHandler(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
|
||||
@@ -8,9 +7,7 @@ import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import { App, createApp } from 'vue'
|
||||
|
||||
import Load3DControls from '@/components/load3d/Load3DControls.vue'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
class Load3d {
|
||||
@@ -44,20 +41,23 @@ class Load3d {
|
||||
originalRotation: THREE.Euler | null = null
|
||||
viewHelper: ViewHelper = {} as ViewHelper
|
||||
viewHelperContainer: HTMLDivElement = {} as HTMLDivElement
|
||||
cameraSwitcherContainer: HTMLDivElement = {} as HTMLDivElement
|
||||
gridSwitcherContainer: HTMLDivElement = {} as HTMLDivElement
|
||||
node: LGraphNode = {} as LGraphNode
|
||||
bgColorInput: HTMLInputElement = {} as HTMLInputElement
|
||||
fovSliderContainer: HTMLDivElement = {} as HTMLDivElement
|
||||
lightSliderContainer: HTMLDivElement = {} as HTMLDivElement
|
||||
previewRenderer: THREE.WebGLRenderer | null = null
|
||||
previewCamera: THREE.Camera | null = null
|
||||
previewContainer: HTMLDivElement = {} as HTMLDivElement
|
||||
targetWidth: number = 1024
|
||||
targetHeight: number = 1024
|
||||
showPreview: boolean = true
|
||||
node: LGraphNode = {} as LGraphNode
|
||||
|
||||
protected controlsApp: App | null = null
|
||||
protected controlsContainer: HTMLDivElement
|
||||
previewToggleContainer: HTMLDivElement = {} as HTMLDivElement
|
||||
isPreviewVisible: boolean = true
|
||||
|
||||
constructor(
|
||||
container: Element | HTMLElement,
|
||||
options: { createPreview?: boolean } = {}
|
||||
createPreview: boolean = false
|
||||
) {
|
||||
this.scene = new THREE.Scene()
|
||||
|
||||
@@ -135,56 +135,22 @@ class Load3d {
|
||||
this.standardMaterial = this.createSTLMaterial()
|
||||
|
||||
this.createViewHelper(container)
|
||||
this.createGridSwitcher(container)
|
||||
this.createCameraSwitcher(container)
|
||||
this.createColorPicker(container)
|
||||
this.createFOVSlider(container)
|
||||
this.createLightIntensitySlider(container)
|
||||
|
||||
if (options && options.createPreview) {
|
||||
if (createPreview) {
|
||||
this.createPreviewToggle(container)
|
||||
this.createCapturePreview(container)
|
||||
}
|
||||
|
||||
this.controlsContainer = document.createElement('div')
|
||||
this.controlsContainer.style.position = 'absolute'
|
||||
this.controlsContainer.style.top = '0'
|
||||
this.controlsContainer.style.left = '0'
|
||||
this.controlsContainer.style.width = '100%'
|
||||
this.controlsContainer.style.height = '100%'
|
||||
this.controlsContainer.style.pointerEvents = 'none'
|
||||
this.controlsContainer.style.zIndex = '1'
|
||||
container.appendChild(this.controlsContainer)
|
||||
|
||||
this.mountControls(options)
|
||||
|
||||
this.handleResize()
|
||||
|
||||
this.startAnimation()
|
||||
}
|
||||
|
||||
protected mountControls(options: { createPreview?: boolean } = {}) {
|
||||
const controlsMount = document.createElement('div')
|
||||
controlsMount.style.pointerEvents = 'auto'
|
||||
this.controlsContainer.appendChild(controlsMount)
|
||||
|
||||
this.controlsApp = createApp(Load3DControls, {
|
||||
backgroundColor: '#282828',
|
||||
showGrid: true,
|
||||
showPreview: options.createPreview,
|
||||
lightIntensity: 5,
|
||||
showLightIntensityButton: true,
|
||||
fov: 75,
|
||||
showFOVButton: true,
|
||||
showPreviewButton: options.createPreview,
|
||||
onToggleCamera: () => this.toggleCamera(),
|
||||
onToggleGrid: (show: boolean) => this.toggleGrid(show),
|
||||
onTogglePreview: (show: boolean) => this.togglePreview(show),
|
||||
onUpdateBackgroundColor: (color: string) =>
|
||||
this.setBackgroundColor(color),
|
||||
onUpdateLightIntensity: (lightIntensity: number) =>
|
||||
this.setLightIntensity(lightIntensity),
|
||||
onUpdateFOV: (fov: number) => this.setFOV(fov)
|
||||
})
|
||||
|
||||
this.controlsApp.directive('tooltip', Tooltip)
|
||||
this.controlsApp.mount(controlsMount)
|
||||
}
|
||||
|
||||
setNode(node: LGraphNode) {
|
||||
this.node = node
|
||||
}
|
||||
@@ -224,14 +190,15 @@ class Load3d {
|
||||
`
|
||||
this.previewContainer.appendChild(this.previewRenderer.domElement)
|
||||
|
||||
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
|
||||
this.previewContainer.style.display = this.isPreviewVisible
|
||||
? 'block'
|
||||
: 'none'
|
||||
|
||||
container.appendChild(this.previewContainer)
|
||||
}
|
||||
|
||||
updatePreviewRender() {
|
||||
if (!this.previewRenderer || !this.previewContainer || !this.showPreview)
|
||||
return
|
||||
if (!this.previewRenderer || !this.previewContainer) return
|
||||
|
||||
if (
|
||||
!this.previewCamera ||
|
||||
@@ -279,6 +246,78 @@ class Load3d {
|
||||
this.previewRenderer.render(this.scene, this.previewCamera)
|
||||
}
|
||||
|
||||
createPreviewToggle(container: Element | HTMLElement) {
|
||||
this.previewToggleContainer = document.createElement('div')
|
||||
this.previewToggleContainer.style.position = 'absolute'
|
||||
this.previewToggleContainer.style.top = '128px'
|
||||
this.previewToggleContainer.style.left = '3px'
|
||||
this.previewToggleContainer.style.width = '20px'
|
||||
this.previewToggleContainer.style.height = '20px'
|
||||
this.previewToggleContainer.style.cursor = 'pointer'
|
||||
this.previewToggleContainer.style.display = 'flex'
|
||||
this.previewToggleContainer.style.alignItems = 'center'
|
||||
this.previewToggleContainer.style.justifyContent = 'center'
|
||||
this.previewToggleContainer.style.borderRadius = '2px'
|
||||
this.previewToggleContainer.title = 'Toggle Preview'
|
||||
|
||||
const eyeIcon = document.createElement('div')
|
||||
eyeIcon.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
`
|
||||
|
||||
const updateButtonState = () => {
|
||||
if (this.isPreviewVisible) {
|
||||
this.previewToggleContainer.style.backgroundColor =
|
||||
'rgba(255, 255, 255, 0.2)'
|
||||
} else {
|
||||
this.previewToggleContainer.style.backgroundColor = 'transparent'
|
||||
}
|
||||
}
|
||||
|
||||
this.previewToggleContainer.addEventListener('mouseenter', () => {
|
||||
if (!this.isPreviewVisible) {
|
||||
this.previewToggleContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
})
|
||||
|
||||
this.previewToggleContainer.addEventListener('mouseleave', () => {
|
||||
if (!this.isPreviewVisible) {
|
||||
this.previewToggleContainer.style.backgroundColor = 'transparent'
|
||||
}
|
||||
})
|
||||
|
||||
this.previewToggleContainer.addEventListener('click', (event) => {
|
||||
event.stopPropagation()
|
||||
|
||||
this.setPreviewVisible(!this.isPreviewVisible)
|
||||
|
||||
updateButtonState()
|
||||
})
|
||||
|
||||
this.previewToggleContainer.appendChild(eyeIcon)
|
||||
|
||||
container.appendChild(this.previewToggleContainer)
|
||||
|
||||
this.isPreviewVisible = this.loadNodeProperty('Preview Visible', true)
|
||||
|
||||
updateButtonState()
|
||||
}
|
||||
|
||||
setPreviewVisible(visible: boolean) {
|
||||
if (!this.previewContainer) return
|
||||
|
||||
this.isPreviewVisible = visible
|
||||
|
||||
this.previewContainer.style.display = this.isPreviewVisible
|
||||
? 'block'
|
||||
: 'none'
|
||||
|
||||
this.storeNodeProperty('Preview Visible', this.isPreviewVisible)
|
||||
}
|
||||
|
||||
updatePreviewSize() {
|
||||
if (!this.previewContainer) return
|
||||
|
||||
@@ -288,24 +327,6 @@ class Load3d {
|
||||
this.previewRenderer?.setSize(previewWidth, previewHeight, false)
|
||||
}
|
||||
|
||||
setTargetSize(width: number, height: number) {
|
||||
this.targetWidth = width
|
||||
this.targetHeight = height
|
||||
this.updatePreviewSize()
|
||||
if (this.previewRenderer && this.previewCamera) {
|
||||
if (this.previewCamera instanceof THREE.PerspectiveCamera) {
|
||||
this.previewCamera.aspect = width / height
|
||||
this.previewCamera.updateProjectionMatrix()
|
||||
} else if (this.previewCamera instanceof THREE.OrthographicCamera) {
|
||||
const frustumSize = 10
|
||||
const aspect = width / height
|
||||
this.previewCamera.left = (-frustumSize * aspect) / 2
|
||||
this.previewCamera.right = (frustumSize * aspect) / 2
|
||||
this.previewCamera.updateProjectionMatrix()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createViewHelper(container: Element | HTMLElement) {
|
||||
this.viewHelperContainer = document.createElement('div')
|
||||
|
||||
@@ -334,13 +355,274 @@ class Load3d {
|
||||
this.viewHelper.center = this.controls.target
|
||||
}
|
||||
|
||||
createGridSwitcher(container: Element | HTMLElement) {
|
||||
this.gridSwitcherContainer = document.createElement('div')
|
||||
this.gridSwitcherContainer.style.position = 'absolute'
|
||||
this.gridSwitcherContainer.style.top = '28px'
|
||||
this.gridSwitcherContainer.style.left = '3px'
|
||||
this.gridSwitcherContainer.style.width = '20px'
|
||||
this.gridSwitcherContainer.style.height = '20px'
|
||||
this.gridSwitcherContainer.style.cursor = 'pointer'
|
||||
this.gridSwitcherContainer.style.alignItems = 'center'
|
||||
this.gridSwitcherContainer.style.justifyContent = 'center'
|
||||
this.gridSwitcherContainer.style.transition = 'background-color 0.2s'
|
||||
|
||||
const gridIcon = document.createElement('div')
|
||||
gridIcon.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
||||
<path d="M3 3h18v18H3z"/>
|
||||
<path d="M3 9h18"/>
|
||||
<path d="M3 15h18"/>
|
||||
<path d="M9 3v18"/>
|
||||
<path d="M15 3v18"/>
|
||||
</svg>
|
||||
`
|
||||
|
||||
const updateButtonState = () => {
|
||||
if (this.gridHelper.visible) {
|
||||
this.gridSwitcherContainer.style.backgroundColor =
|
||||
'rgba(255, 255, 255, 0.2)'
|
||||
} else {
|
||||
this.gridSwitcherContainer.style.backgroundColor = 'transparent'
|
||||
}
|
||||
}
|
||||
|
||||
updateButtonState()
|
||||
|
||||
this.gridSwitcherContainer.addEventListener('mouseenter', () => {
|
||||
if (!this.gridHelper.visible) {
|
||||
this.gridSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
})
|
||||
|
||||
this.gridSwitcherContainer.addEventListener('mouseleave', () => {
|
||||
if (!this.gridHelper.visible) {
|
||||
this.gridSwitcherContainer.style.backgroundColor = 'transparent'
|
||||
}
|
||||
})
|
||||
|
||||
this.gridSwitcherContainer.title = 'Toggle Grid'
|
||||
|
||||
this.gridSwitcherContainer.addEventListener('click', (event) => {
|
||||
event.stopPropagation()
|
||||
this.toggleGrid(!this.gridHelper.visible)
|
||||
updateButtonState()
|
||||
})
|
||||
|
||||
this.gridSwitcherContainer.appendChild(gridIcon)
|
||||
container.appendChild(this.gridSwitcherContainer)
|
||||
}
|
||||
|
||||
createCameraSwitcher(container: Element | HTMLElement) {
|
||||
this.cameraSwitcherContainer = document.createElement('div')
|
||||
this.cameraSwitcherContainer.style.position = 'absolute'
|
||||
this.cameraSwitcherContainer.style.top = '3px'
|
||||
this.cameraSwitcherContainer.style.left = '3px'
|
||||
this.cameraSwitcherContainer.style.width = '20px'
|
||||
this.cameraSwitcherContainer.style.height = '20px'
|
||||
this.cameraSwitcherContainer.style.cursor = 'pointer'
|
||||
this.cameraSwitcherContainer.style.alignItems = 'center'
|
||||
this.cameraSwitcherContainer.style.justifyContent = 'center'
|
||||
|
||||
const cameraIcon = document.createElement('div')
|
||||
cameraIcon.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
||||
<path d="M18 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2Z"/>
|
||||
<path d="m12 12 4-2.4"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
`
|
||||
this.cameraSwitcherContainer.addEventListener('mouseenter', () => {
|
||||
this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
|
||||
})
|
||||
|
||||
this.cameraSwitcherContainer.addEventListener('mouseleave', () => {
|
||||
this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
|
||||
})
|
||||
|
||||
this.cameraSwitcherContainer.title =
|
||||
'Switch Camera (Perspective/Orthographic)'
|
||||
|
||||
this.cameraSwitcherContainer.addEventListener('click', (event) => {
|
||||
event.stopPropagation()
|
||||
this.toggleCamera()
|
||||
})
|
||||
|
||||
this.cameraSwitcherContainer.appendChild(cameraIcon)
|
||||
|
||||
container.appendChild(this.cameraSwitcherContainer)
|
||||
}
|
||||
|
||||
createColorPicker(container: Element | HTMLElement) {
|
||||
const colorPickerContainer = document.createElement('div')
|
||||
colorPickerContainer.style.position = 'absolute'
|
||||
colorPickerContainer.style.top = '53px'
|
||||
colorPickerContainer.style.left = '3px'
|
||||
colorPickerContainer.style.width = '20px'
|
||||
colorPickerContainer.style.height = '20px'
|
||||
colorPickerContainer.style.cursor = 'pointer'
|
||||
colorPickerContainer.style.alignItems = 'center'
|
||||
colorPickerContainer.style.justifyContent = 'center'
|
||||
colorPickerContainer.title = 'Background Color'
|
||||
|
||||
const colorInput = document.createElement('input')
|
||||
colorInput.type = 'color'
|
||||
colorInput.style.opacity = '0'
|
||||
colorInput.style.position = 'absolute'
|
||||
colorInput.style.width = '100%'
|
||||
colorInput.style.height = '100%'
|
||||
colorInput.style.cursor = 'pointer'
|
||||
|
||||
const colorIcon = document.createElement('div')
|
||||
colorIcon.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M12 3v18"/>
|
||||
<path d="M3 12h18"/>
|
||||
</svg>
|
||||
`
|
||||
|
||||
colorInput.addEventListener('input', (event) => {
|
||||
const color = (event.target as HTMLInputElement).value
|
||||
this.setBackgroundColor(color)
|
||||
this.storeNodeProperty('Background Color', color)
|
||||
})
|
||||
|
||||
this.bgColorInput = colorInput
|
||||
colorPickerContainer.appendChild(colorInput)
|
||||
colorPickerContainer.appendChild(colorIcon)
|
||||
container.appendChild(colorPickerContainer)
|
||||
}
|
||||
|
||||
createFOVSlider(container: Element | HTMLElement) {
|
||||
this.fovSliderContainer = document.createElement('div')
|
||||
this.fovSliderContainer.style.position = 'absolute'
|
||||
this.fovSliderContainer.style.top = '78px'
|
||||
this.fovSliderContainer.style.left = '3px'
|
||||
this.fovSliderContainer.style.display = 'flex'
|
||||
this.fovSliderContainer.style.alignItems = 'center'
|
||||
this.fovSliderContainer.title = 'FOV (Perspective Camera Only)'
|
||||
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.style.position = 'relative'
|
||||
wrapper.style.display = 'flex'
|
||||
wrapper.style.alignItems = 'center'
|
||||
|
||||
const iconContainer = document.createElement('div')
|
||||
iconContainer.style.width = '20px'
|
||||
iconContainer.style.height = '20px'
|
||||
iconContainer.style.cursor = 'pointer'
|
||||
iconContainer.style.display = 'flex'
|
||||
iconContainer.style.alignItems = 'center'
|
||||
iconContainer.style.justifyContent = 'center'
|
||||
iconContainer.style.borderRadius = '2px'
|
||||
|
||||
const fovIcon = document.createElement('div')
|
||||
fovIcon.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
||||
<path d="M3 12h4"/>
|
||||
<path d="M17 12h4"/>
|
||||
<path d="M12 3v4"/>
|
||||
<path d="M12 17v4"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
`
|
||||
fovIcon.style.display = 'flex'
|
||||
fovIcon.style.alignItems = 'center'
|
||||
fovIcon.style.justifyContent = 'center'
|
||||
iconContainer.appendChild(fovIcon)
|
||||
|
||||
const sliderContainer = document.createElement('div')
|
||||
sliderContainer.style.position = 'absolute'
|
||||
sliderContainer.style.left = '25px'
|
||||
sliderContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'
|
||||
sliderContainer.style.padding = '5px'
|
||||
sliderContainer.style.borderRadius = '4px'
|
||||
sliderContainer.style.display = 'none'
|
||||
sliderContainer.style.width = '150px'
|
||||
sliderContainer.style.zIndex = '1000'
|
||||
|
||||
const slider = document.createElement('input')
|
||||
slider.type = 'range'
|
||||
slider.min = '10'
|
||||
slider.max = '150'
|
||||
slider.value = '75'
|
||||
slider.style.width = '100%'
|
||||
slider.style.height = '10px'
|
||||
|
||||
slider.addEventListener('input', (event) => {
|
||||
const value = parseInt((event.target as HTMLInputElement).value)
|
||||
this.setFOV(value)
|
||||
this.storeNodeProperty('FOV', value)
|
||||
})
|
||||
|
||||
let isHovered = false
|
||||
|
||||
const showSlider = () => {
|
||||
if (this.activeCamera === this.perspectiveCamera) {
|
||||
sliderContainer.style.display = 'block'
|
||||
iconContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
|
||||
isHovered = true
|
||||
}
|
||||
}
|
||||
|
||||
const hideSlider = () => {
|
||||
isHovered = false
|
||||
setTimeout(() => {
|
||||
if (!isHovered) {
|
||||
sliderContainer.style.display = 'none'
|
||||
iconContainer.style.backgroundColor = 'transparent'
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
iconContainer.addEventListener('mouseenter', showSlider)
|
||||
iconContainer.addEventListener('mouseleave', hideSlider)
|
||||
sliderContainer.addEventListener('mouseenter', () => {
|
||||
isHovered = true
|
||||
})
|
||||
sliderContainer.addEventListener('mouseleave', hideSlider)
|
||||
|
||||
sliderContainer.appendChild(slider)
|
||||
wrapper.appendChild(iconContainer)
|
||||
wrapper.appendChild(sliderContainer)
|
||||
this.fovSliderContainer.appendChild(wrapper)
|
||||
container.appendChild(this.fovSliderContainer)
|
||||
|
||||
this.updateFOVSliderVisibility()
|
||||
}
|
||||
|
||||
updateFOVSliderVisibility() {
|
||||
if (this.activeCamera === this.perspectiveCamera) {
|
||||
this.fovSliderContainer.style.display = 'block'
|
||||
} else {
|
||||
this.fovSliderContainer.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
setTargetSize(width: number, height: number) {
|
||||
this.targetWidth = width
|
||||
this.targetHeight = height
|
||||
this.updatePreviewSize()
|
||||
if (this.previewRenderer && this.previewCamera) {
|
||||
if (this.previewCamera instanceof THREE.PerspectiveCamera) {
|
||||
this.previewCamera.aspect = width / height
|
||||
this.previewCamera.updateProjectionMatrix()
|
||||
} else if (this.previewCamera instanceof THREE.OrthographicCamera) {
|
||||
const frustumSize = 10
|
||||
const aspect = width / height
|
||||
this.previewCamera.left = (-frustumSize * aspect) / 2
|
||||
this.previewCamera.right = (frustumSize * aspect) / 2
|
||||
this.previewCamera.updateProjectionMatrix()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setFOV(fov: number) {
|
||||
if (this.activeCamera === this.perspectiveCamera) {
|
||||
this.perspectiveCamera.fov = fov
|
||||
this.perspectiveCamera.updateProjectionMatrix()
|
||||
this.renderer.render(this.scene, this.activeCamera)
|
||||
|
||||
this.storeNodeProperty('FOV', fov)
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -353,6 +635,118 @@ class Load3d {
|
||||
}
|
||||
}
|
||||
|
||||
createLightIntensitySlider(container: Element | HTMLElement) {
|
||||
this.lightSliderContainer = document.createElement('div')
|
||||
this.lightSliderContainer.style.position = 'absolute'
|
||||
this.lightSliderContainer.style.top = '103px'
|
||||
this.lightSliderContainer.style.left = '3px'
|
||||
this.lightSliderContainer.style.display = 'flex'
|
||||
this.lightSliderContainer.style.alignItems = 'center'
|
||||
this.lightSliderContainer.title = 'Light Intensity'
|
||||
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.style.position = 'relative'
|
||||
wrapper.style.display = 'flex'
|
||||
wrapper.style.alignItems = 'center'
|
||||
|
||||
const iconContainer = document.createElement('div')
|
||||
iconContainer.style.width = '20px'
|
||||
iconContainer.style.height = '20px'
|
||||
iconContainer.style.cursor = 'pointer'
|
||||
iconContainer.style.display = 'flex'
|
||||
iconContainer.style.alignItems = 'center'
|
||||
iconContainer.style.justifyContent = 'center'
|
||||
iconContainer.style.borderRadius = '2px'
|
||||
|
||||
const lightIcon = document.createElement('div')
|
||||
lightIcon.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
`
|
||||
lightIcon.style.display = 'flex'
|
||||
lightIcon.style.alignItems = 'center'
|
||||
lightIcon.style.justifyContent = 'center'
|
||||
iconContainer.appendChild(lightIcon)
|
||||
|
||||
const sliderContainer = document.createElement('div')
|
||||
sliderContainer.style.position = 'absolute'
|
||||
sliderContainer.style.left = '25px'
|
||||
sliderContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'
|
||||
sliderContainer.style.padding = '5px'
|
||||
sliderContainer.style.borderRadius = '4px'
|
||||
sliderContainer.style.display = 'none'
|
||||
sliderContainer.style.width = '150px'
|
||||
sliderContainer.style.zIndex = '1000'
|
||||
|
||||
const slider = document.createElement('input')
|
||||
slider.type = 'range'
|
||||
slider.min = '1'
|
||||
slider.max = '20'
|
||||
slider.step = '1'
|
||||
slider.value = '5'
|
||||
slider.style.width = '100%'
|
||||
slider.style.height = '10px'
|
||||
|
||||
slider.addEventListener('input', (event) => {
|
||||
const value = parseFloat((event.target as HTMLInputElement).value)
|
||||
this.setLightIntensity(value)
|
||||
this.storeNodeProperty('Light Intensity', value)
|
||||
})
|
||||
|
||||
let isHovered = false
|
||||
|
||||
const showSlider = () => {
|
||||
sliderContainer.style.display = 'block'
|
||||
iconContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
|
||||
isHovered = true
|
||||
}
|
||||
|
||||
const hideSlider = () => {
|
||||
isHovered = false
|
||||
setTimeout(() => {
|
||||
if (!isHovered) {
|
||||
sliderContainer.style.display = 'none'
|
||||
iconContainer.style.backgroundColor = 'transparent'
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
iconContainer.addEventListener('mouseenter', showSlider)
|
||||
iconContainer.addEventListener('mouseleave', hideSlider)
|
||||
sliderContainer.addEventListener('mouseenter', () => {
|
||||
isHovered = true
|
||||
})
|
||||
sliderContainer.addEventListener('mouseleave', hideSlider)
|
||||
|
||||
sliderContainer.appendChild(slider)
|
||||
wrapper.appendChild(iconContainer)
|
||||
wrapper.appendChild(sliderContainer)
|
||||
this.lightSliderContainer.appendChild(wrapper)
|
||||
container.appendChild(this.lightSliderContainer)
|
||||
|
||||
const savedIntensity = this.loadNodeProperty('Light Intensity', 5)
|
||||
slider.value = savedIntensity.toString()
|
||||
this.setLightIntensity(savedIntensity)
|
||||
this.updateLightIntensitySliderVisibility()
|
||||
}
|
||||
|
||||
updateLightIntensitySliderVisibility() {
|
||||
if (this.materialMode === 'original') {
|
||||
this.lightSliderContainer.style.display = 'block'
|
||||
} else {
|
||||
this.lightSliderContainer.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
getCameraState() {
|
||||
const currentType = this.getCurrentCameraType()
|
||||
return {
|
||||
@@ -372,6 +766,15 @@ class Load3d {
|
||||
zoom: number
|
||||
cameraType: 'perspective' | 'orthographic'
|
||||
}) {
|
||||
if (
|
||||
this.activeCamera !==
|
||||
(state.cameraType === 'perspective'
|
||||
? this.perspectiveCamera
|
||||
: this.orthographicCamera)
|
||||
) {
|
||||
//this.toggleCamera(state.cameraType)
|
||||
}
|
||||
|
||||
this.activeCamera.position.copy(state.position)
|
||||
|
||||
this.controls.target.copy(state.target)
|
||||
@@ -430,10 +833,7 @@ class Load3d {
|
||||
setMaterialMode(mode: 'original' | 'normal' | 'wireframe' | 'depth') {
|
||||
this.materialMode = mode
|
||||
|
||||
if (this.controlsApp?._instance?.exposed) {
|
||||
this.controlsApp._instance.exposed.showLightIntensityButton.value =
|
||||
mode == 'original'
|
||||
}
|
||||
this.updateLightIntensitySliderVisibility()
|
||||
|
||||
if (this.currentModel) {
|
||||
if (mode === 'depth') {
|
||||
@@ -608,10 +1008,7 @@ class Load3d {
|
||||
)
|
||||
this.viewHelper.center = this.controls.target
|
||||
|
||||
if (this.controlsApp?._instance?.exposed) {
|
||||
this.controlsApp._instance.exposed.showFOVButton.value =
|
||||
this.getCurrentCameraType() == 'perspective'
|
||||
}
|
||||
this.updateFOVSliderVisibility()
|
||||
|
||||
this.storeNodeProperty('Camera Type', this.getCurrentCameraType())
|
||||
this.handleResize()
|
||||
@@ -632,16 +1029,6 @@ class Load3d {
|
||||
}
|
||||
}
|
||||
|
||||
togglePreview(showPreview: boolean) {
|
||||
if (this.previewRenderer) {
|
||||
this.showPreview = showPreview
|
||||
|
||||
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
|
||||
|
||||
this.storeNodeProperty('Show Preview', showPreview)
|
||||
}
|
||||
}
|
||||
|
||||
setLightIntensity(intensity: number) {
|
||||
this.lights.forEach((light) => {
|
||||
if (light instanceof THREE.DirectionalLight) {
|
||||
@@ -658,15 +1045,13 @@ class Load3d {
|
||||
light.intensity = intensity * 0.5
|
||||
}
|
||||
})
|
||||
|
||||
this.storeNodeProperty('Light Intensity', intensity)
|
||||
}
|
||||
|
||||
startAnimation() {
|
||||
const animate = () => {
|
||||
this.animationFrameId = requestAnimationFrame(animate)
|
||||
|
||||
if (this.showPreview) {
|
||||
if (this.isPreviewVisible) {
|
||||
this.updatePreviewRender()
|
||||
}
|
||||
|
||||
@@ -771,10 +1156,7 @@ class Load3d {
|
||||
this.controls.dispose()
|
||||
this.viewHelper.dispose()
|
||||
this.renderer.dispose()
|
||||
if (this.controlsApp) {
|
||||
this.controlsApp.unmount()
|
||||
this.controlsApp = null
|
||||
}
|
||||
this.fovSliderContainer.remove()
|
||||
this.renderer.domElement.remove()
|
||||
this.scene.clear()
|
||||
}
|
||||
@@ -1044,11 +1426,9 @@ class Load3d {
|
||||
this.renderer.setClearColor(new THREE.Color(color))
|
||||
this.renderer.render(this.scene, this.activeCamera)
|
||||
|
||||
if (this.controlsApp?._instance?.exposed) {
|
||||
this.controlsApp._instance.exposed.backgroundColor.value = color
|
||||
if (this.bgColorInput) {
|
||||
this.bgColorInput.value = color
|
||||
}
|
||||
|
||||
this.storeNodeProperty('Background Color', color)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import * as THREE from 'three'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import Load3DAnimationControls from '@/components/load3d/Load3DAnimationControls.vue'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
|
||||
class Load3dAnimation extends Load3d {
|
||||
@@ -14,61 +10,167 @@ class Load3dAnimation extends Load3d {
|
||||
isAnimationPlaying: boolean = false
|
||||
|
||||
animationSpeed: number = 1.0
|
||||
playPauseContainer: HTMLDivElement = {} as HTMLDivElement
|
||||
animationSelect: HTMLSelectElement = {} as HTMLSelectElement
|
||||
speedSelect: HTMLSelectElement = {} as HTMLSelectElement
|
||||
|
||||
constructor(
|
||||
container: Element | HTMLElement,
|
||||
options: { createPreview?: boolean } = {}
|
||||
createPreview: boolean = false
|
||||
) {
|
||||
super(container, options)
|
||||
super(container, createPreview)
|
||||
this.createPlayPauseButton(container)
|
||||
this.createAnimationList(container)
|
||||
this.createSpeedSelect(container)
|
||||
}
|
||||
|
||||
protected mountControls(options: { createPreview?: boolean } = {}) {
|
||||
const controlsMount = document.createElement('div')
|
||||
controlsMount.style.pointerEvents = 'auto'
|
||||
this.controlsContainer.appendChild(controlsMount)
|
||||
|
||||
this.controlsApp = createApp(Load3DAnimationControls, {
|
||||
backgroundColor: '#282828',
|
||||
showGrid: true,
|
||||
showPreview: options.createPreview,
|
||||
animations: [],
|
||||
playing: false,
|
||||
lightIntensity: 5,
|
||||
showLightIntensityButton: true,
|
||||
fov: 75,
|
||||
showFOVButton: true,
|
||||
showPreviewButton: options.createPreview,
|
||||
onToggleCamera: () => this.toggleCamera(),
|
||||
onToggleGrid: (show: boolean) => this.toggleGrid(show),
|
||||
onTogglePreview: (show: boolean) => this.togglePreview(show),
|
||||
onUpdateBackgroundColor: (color: string) =>
|
||||
this.setBackgroundColor(color),
|
||||
onTogglePlay: (play: boolean) => this.toggleAnimation(play),
|
||||
onSpeedChange: (speed: number) => this.setAnimationSpeed(speed),
|
||||
onAnimationChange: (selectedAnimation: number) =>
|
||||
this.updateSelectedAnimation(selectedAnimation),
|
||||
onUpdateLightIntensity: (lightIntensity: number) =>
|
||||
this.setLightIntensity(lightIntensity),
|
||||
onUpdateFOV: (fov: number) => this.setFOV(fov)
|
||||
createAnimationList(container: Element | HTMLElement) {
|
||||
this.animationSelect = document.createElement('select')
|
||||
Object.assign(this.animationSelect.style, {
|
||||
position: 'absolute',
|
||||
top: '3px',
|
||||
left: '50%',
|
||||
transform: 'translateX(15px)',
|
||||
width: '90px',
|
||||
height: '20px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
padding: '0 8px',
|
||||
cursor: 'pointer',
|
||||
display: 'none',
|
||||
outline: 'none'
|
||||
})
|
||||
|
||||
this.controlsApp.use(PrimeVue)
|
||||
this.controlsApp.directive('tooltip', Tooltip)
|
||||
this.controlsApp.mount(controlsMount)
|
||||
this.animationSelect.addEventListener('mouseenter', () => {
|
||||
this.animationSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
|
||||
})
|
||||
|
||||
this.animationSelect.addEventListener('mouseleave', () => {
|
||||
this.animationSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
|
||||
})
|
||||
|
||||
this.animationSelect.addEventListener('change', (event) => {
|
||||
const select = event.target as HTMLSelectElement
|
||||
this.updateSelectedAnimation(select.selectedIndex)
|
||||
})
|
||||
|
||||
container.appendChild(this.animationSelect)
|
||||
}
|
||||
|
||||
updateAnimationList() {
|
||||
if (this.controlsApp?._instance?.exposed) {
|
||||
if (this.animationClips.length > 0) {
|
||||
this.controlsApp._instance.exposed.animations.value =
|
||||
this.animationClips.map((clip, index) => ({
|
||||
name: clip.name || `Animation ${index + 1}`,
|
||||
index
|
||||
}))
|
||||
} else {
|
||||
this.controlsApp._instance.exposed.animations.value = []
|
||||
this.animationSelect.innerHTML = ''
|
||||
this.animationClips.forEach((clip, index) => {
|
||||
const option = document.createElement('option')
|
||||
option.value = index.toString()
|
||||
option.text = clip.name || `Animation ${index + 1}`
|
||||
option.selected = index === this.selectedAnimationIndex
|
||||
this.animationSelect.appendChild(option)
|
||||
})
|
||||
}
|
||||
|
||||
createPlayPauseButton(container: Element | HTMLElement) {
|
||||
this.playPauseContainer = document.createElement('div')
|
||||
this.playPauseContainer.style.position = 'absolute'
|
||||
this.playPauseContainer.style.top = '3px'
|
||||
this.playPauseContainer.style.left = '50%'
|
||||
this.playPauseContainer.style.transform = 'translateX(-50%)'
|
||||
this.playPauseContainer.style.width = '20px'
|
||||
this.playPauseContainer.style.height = '20px'
|
||||
this.playPauseContainer.style.cursor = 'pointer'
|
||||
this.playPauseContainer.style.alignItems = 'center'
|
||||
this.playPauseContainer.style.justifyContent = 'center'
|
||||
|
||||
const updateButtonState = () => {
|
||||
const icon = this.playPauseContainer.querySelector('svg')
|
||||
if (icon) {
|
||||
if (this.isAnimationPlaying) {
|
||||
icon.innerHTML = `
|
||||
<path d="M6 4h4v16H6zM14 4h4v16h-4z"/>
|
||||
`
|
||||
this.playPauseContainer.title = 'Pause Animation'
|
||||
} else {
|
||||
icon.innerHTML = `
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
`
|
||||
this.playPauseContainer.title = 'Play Animation'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const playIcon = document.createElement('div')
|
||||
playIcon.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
`
|
||||
|
||||
this.playPauseContainer.addEventListener('mouseenter', () => {
|
||||
this.playPauseContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
|
||||
})
|
||||
|
||||
this.playPauseContainer.addEventListener('mouseleave', () => {
|
||||
this.playPauseContainer.style.backgroundColor = 'transparent'
|
||||
})
|
||||
|
||||
this.playPauseContainer.addEventListener('click', (event) => {
|
||||
event.stopPropagation()
|
||||
this.toggleAnimation()
|
||||
updateButtonState()
|
||||
})
|
||||
|
||||
this.playPauseContainer.appendChild(playIcon)
|
||||
container.appendChild(this.playPauseContainer)
|
||||
|
||||
this.playPauseContainer.style.display = 'none'
|
||||
}
|
||||
|
||||
createSpeedSelect(container: Element | HTMLElement) {
|
||||
this.speedSelect = document.createElement('select')
|
||||
Object.assign(this.speedSelect.style, {
|
||||
position: 'absolute',
|
||||
top: '3px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-75px)',
|
||||
width: '60px',
|
||||
height: '20px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
padding: '0 8px',
|
||||
cursor: 'pointer',
|
||||
display: 'none',
|
||||
outline: 'none'
|
||||
})
|
||||
|
||||
const speeds = [0.1, 0.5, 1, 1.5, 2]
|
||||
speeds.forEach((speed) => {
|
||||
const option = document.createElement('option')
|
||||
option.value = speed.toString()
|
||||
option.text = `${speed}x`
|
||||
option.selected = speed === 1
|
||||
this.speedSelect.appendChild(option)
|
||||
})
|
||||
|
||||
this.speedSelect.addEventListener('mouseenter', () => {
|
||||
this.speedSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
|
||||
})
|
||||
|
||||
this.speedSelect.addEventListener('mouseleave', () => {
|
||||
this.speedSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
|
||||
})
|
||||
|
||||
this.speedSelect.addEventListener('change', (event) => {
|
||||
const select = event.target as HTMLSelectElement
|
||||
const newSpeed = parseFloat(select.value)
|
||||
this.setAnimationSpeed(newSpeed)
|
||||
})
|
||||
|
||||
container.appendChild(this.speedSelect)
|
||||
}
|
||||
|
||||
protected async setupModel(model: THREE.Object3D) {
|
||||
@@ -101,7 +203,16 @@ class Load3dAnimation extends Load3d {
|
||||
}
|
||||
}
|
||||
|
||||
this.updateAnimationList()
|
||||
if (this.animationClips.length > 0) {
|
||||
this.playPauseContainer.style.display = 'block'
|
||||
this.animationSelect.style.display = 'block'
|
||||
this.speedSelect.style.display = 'block'
|
||||
this.updateAnimationList()
|
||||
} else {
|
||||
this.playPauseContainer.style.display = 'none'
|
||||
this.animationSelect.style.display = 'none'
|
||||
this.speedSelect.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
setAnimationSpeed(speed: number) {
|
||||
@@ -147,9 +258,7 @@ class Load3dAnimation extends Load3d {
|
||||
|
||||
this.animationActions = [action]
|
||||
|
||||
if (this.controlsApp?._instance?.exposed) {
|
||||
this.controlsApp._instance.exposed.selectedAnimation.value = index
|
||||
}
|
||||
this.updateAnimationList()
|
||||
}
|
||||
|
||||
clearModel() {
|
||||
@@ -165,12 +274,23 @@ class Load3dAnimation extends Load3d {
|
||||
this.isAnimationPlaying = false
|
||||
this.animationSpeed = 1.0
|
||||
|
||||
if (this.controlsApp?._instance?.exposed) {
|
||||
this.controlsApp._instance.exposed.animations.value = []
|
||||
this.controlsApp._instance.exposed.selectedAnimation.value = 0
|
||||
super.clearModel()
|
||||
|
||||
if (this.animationSelect) {
|
||||
this.animationSelect.style.display = 'none'
|
||||
this.animationSelect.innerHTML = ''
|
||||
}
|
||||
|
||||
super.clearModel()
|
||||
if (this.speedSelect) {
|
||||
this.speedSelect.style.display = 'none'
|
||||
this.speedSelect.value = '1'
|
||||
}
|
||||
}
|
||||
|
||||
getAnimationNames(): string[] {
|
||||
return this.animationClips.map((clip, index) => {
|
||||
return clip.name || `Animation ${index + 1}`
|
||||
})
|
||||
}
|
||||
|
||||
toggleAnimation(play?: boolean) {
|
||||
@@ -181,8 +301,15 @@ class Load3dAnimation extends Load3d {
|
||||
|
||||
this.isAnimationPlaying = play ?? !this.isAnimationPlaying
|
||||
|
||||
if (this.controlsApp?._instance?.exposed) {
|
||||
this.controlsApp._instance.exposed.playing.value = this.isAnimationPlaying
|
||||
const icon = this.playPauseContainer.querySelector('svg')
|
||||
if (icon) {
|
||||
if (this.isAnimationPlaying) {
|
||||
icon.innerHTML = '<path d="M6 4h4v16H6zM14 4h4v16h-4z"/>'
|
||||
this.playPauseContainer.title = 'Pause Animation'
|
||||
} else {
|
||||
icon.innerHTML = '<path d="M8 5v14l11-7z"/>'
|
||||
this.playPauseContainer.title = 'Play Animation'
|
||||
}
|
||||
}
|
||||
|
||||
this.animationActions.forEach((action) => {
|
||||
@@ -201,7 +328,7 @@ class Load3dAnimation extends Load3d {
|
||||
const animate = () => {
|
||||
this.animationFrameId = requestAnimationFrame(animate)
|
||||
|
||||
if (this.showPreview) {
|
||||
if (this.isPreviewVisible) {
|
||||
this.updatePreviewRender()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @ts-strict-ignore
|
||||
import type { IWidget } from '@comfyorg/litegraph'
|
||||
import type { IStringWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import type { DOMWidget } from '@/scripts/domWidget'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
@@ -38,7 +37,7 @@ function getResourceURL(
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
audioWidget: IStringWidget,
|
||||
audioWidget: IWidget,
|
||||
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
|
||||
file: File,
|
||||
updateNode: boolean,
|
||||
@@ -152,9 +151,9 @@ app.registerExtension({
|
||||
return {
|
||||
AUDIOUPLOAD(node, inputName: string) {
|
||||
// The widget that allows user to select file.
|
||||
const audioWidget = node.widgets.find(
|
||||
const audioWidget: IWidget = node.widgets.find(
|
||||
(w: IWidget) => w.name === 'audio'
|
||||
) as IStringWidget
|
||||
)
|
||||
const audioUIWidget = node.widgets.find(
|
||||
(w: IWidget) => w.name === 'audioUI'
|
||||
) as unknown as DOMWidget<HTMLAudioElement, string>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ComfyNodeDef, InputSpec } from '@/types/apiTypes'
|
||||
import { ComfyNodeDef } from '@/types/apiTypes'
|
||||
|
||||
import { app } from '../../scripts/app'
|
||||
|
||||
@@ -7,16 +7,8 @@ import { app } from '../../scripts/app'
|
||||
app.registerExtension({
|
||||
name: 'Comfy.UploadImage',
|
||||
beforeRegisterNodeDef(nodeType, nodeData: ComfyNodeDef) {
|
||||
// Check if there is a required input named 'image' in the nodeData
|
||||
const imageInputSpec: InputSpec | undefined =
|
||||
nodeData?.input?.required?.image
|
||||
|
||||
// Get the config from the image input spec if it exists
|
||||
const config = imageInputSpec?.[1] ?? {}
|
||||
const { image_upload = false, image_folder = 'input' } = config
|
||||
|
||||
if (image_upload && nodeData?.input?.required) {
|
||||
nodeData.input.required.upload = ['IMAGEUPLOAD', { image_folder }]
|
||||
if (nodeData?.input?.required?.image?.[1]?.image_upload === true) {
|
||||
nodeData.input.required.upload = ['IMAGEUPLOAD']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -101,8 +101,10 @@ app.registerExtension({
|
||||
capture
|
||||
)
|
||||
btn.disabled = true
|
||||
// @ts-expect-error hacky override
|
||||
btn.serializeValue = () => undefined
|
||||
|
||||
// @ts-expect-error hacky override
|
||||
camera.serializeValue = async () => {
|
||||
if (captureOnQueue.value) {
|
||||
capture()
|
||||
|
||||
145
src/hooks/remoteWidgetHook.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import type { InputSpec } from '@/types/apiTypes'
|
||||
|
||||
export interface CacheEntry<T> {
|
||||
data: T[]
|
||||
timestamp: number
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
fetchPromise?: Promise<T[]>
|
||||
controller?: AbortController
|
||||
lastErrorTime: number
|
||||
retryCount: number
|
||||
}
|
||||
|
||||
const dataCache = new Map<string, CacheEntry<any>>()
|
||||
|
||||
const createCacheKey = (inputData: InputSpec): string => {
|
||||
const { route, query_params = {}, refresh = 0 } = inputData[1]
|
||||
|
||||
const paramsKey = Object.entries(query_params)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('&')
|
||||
|
||||
return [route, `r=${refresh}`, paramsKey].join(';')
|
||||
}
|
||||
|
||||
const getBackoff = (retryCount: number) => {
|
||||
return Math.min(1000 * Math.pow(2, retryCount), 512)
|
||||
}
|
||||
|
||||
async function fetchData<T>(
|
||||
inputData: InputSpec,
|
||||
controller: AbortController
|
||||
): Promise<T[]> {
|
||||
const { route, response_key, query_params } = inputData[1]
|
||||
const res = await axios.get(route, {
|
||||
params: query_params,
|
||||
signal: controller.signal,
|
||||
validateStatus: (status) => status === 200
|
||||
})
|
||||
return response_key ? res.data[response_key] : res.data
|
||||
}
|
||||
|
||||
export function useRemoteWidget<T>(inputData: InputSpec) {
|
||||
const { refresh = 0 } = inputData[1]
|
||||
const isPermanent = refresh <= 0
|
||||
const cacheKey = createCacheKey(inputData)
|
||||
const defaultValue = useWidgetStore().getDefaultValue(inputData)
|
||||
|
||||
const setSuccess = (entry: CacheEntry<T>, data: T[]) => {
|
||||
entry.retryCount = 0
|
||||
entry.lastErrorTime = 0
|
||||
entry.error = null
|
||||
entry.timestamp = Date.now()
|
||||
entry.data = data ?? defaultValue
|
||||
}
|
||||
|
||||
const setError = (entry: CacheEntry<T>, error: Error | unknown) => {
|
||||
entry.retryCount = (entry.retryCount || 0) + 1
|
||||
entry.lastErrorTime = Date.now()
|
||||
entry.error = error instanceof Error ? error : new Error(String(error))
|
||||
entry.data ??= defaultValue
|
||||
}
|
||||
|
||||
const isInitialized = () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
return entry?.data && entry.timestamp > 0
|
||||
}
|
||||
|
||||
const isStale = () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
return entry?.timestamp && Date.now() - entry.timestamp >= refresh
|
||||
}
|
||||
|
||||
const isFetching = () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
return entry?.fetchPromise
|
||||
}
|
||||
|
||||
const isBackingOff = () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
return (
|
||||
entry?.error &&
|
||||
entry.lastErrorTime &&
|
||||
Date.now() - entry.lastErrorTime < getBackoff(entry.retryCount)
|
||||
)
|
||||
}
|
||||
|
||||
const fetchOptions = async () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
|
||||
const isValid = isInitialized() && (isPermanent || !isStale())
|
||||
if (isValid || isBackingOff()) return entry!.data
|
||||
if (isFetching()) return entry!.fetchPromise
|
||||
|
||||
const currentEntry: CacheEntry<T> = entry || {
|
||||
data: defaultValue,
|
||||
timestamp: 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
fetchPromise: undefined,
|
||||
controller: undefined,
|
||||
retryCount: 0,
|
||||
lastErrorTime: 0
|
||||
}
|
||||
dataCache.set(cacheKey, currentEntry)
|
||||
|
||||
try {
|
||||
currentEntry.loading = true
|
||||
currentEntry.error = null
|
||||
currentEntry.controller = new AbortController()
|
||||
|
||||
currentEntry.fetchPromise = fetchData<T>(
|
||||
inputData,
|
||||
currentEntry.controller
|
||||
)
|
||||
const data = await currentEntry.fetchPromise
|
||||
|
||||
setSuccess(currentEntry, data)
|
||||
return currentEntry.data
|
||||
} catch (err) {
|
||||
setError(currentEntry, err)
|
||||
return currentEntry.data
|
||||
} finally {
|
||||
currentEntry.loading = false
|
||||
currentEntry.fetchPromise = undefined
|
||||
currentEntry.controller = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getCacheKey: () => cacheKey,
|
||||
getCacheEntry: () => dataCache.get(cacheKey),
|
||||
forceUpdate: () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
if (entry?.fetchPromise) entry.controller?.abort() // Abort in-flight request
|
||||
dataCache.delete(cacheKey)
|
||||
},
|
||||
fetchOptions,
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
@@ -315,7 +315,6 @@
|
||||
"deleteFailed": "Attempt to delete the workflow failed.",
|
||||
"dirtyCloseTitle": "Save Changes?",
|
||||
"dirtyClose": "The files below have been changed. Would you like to save them before closing?",
|
||||
"dirtyCloseHint": "Hold Shift to close without prompt",
|
||||
"confirmOverwriteTitle": "Overwrite existing file?",
|
||||
"confirmOverwrite": "The file below already exists. Would you like to overwrite it?",
|
||||
"workflowTreeType": {
|
||||
@@ -770,13 +769,5 @@
|
||||
"successMessage": "Copied to clipboard",
|
||||
"errorMessage": "Failed to copy to clipboard",
|
||||
"errorNotSupported": "Clipboard API not supported in your browser"
|
||||
},
|
||||
"load3d": {
|
||||
"switchCamera": "Switch Camera",
|
||||
"showGrid": "Show Grid",
|
||||
"backgroundColor": "Background Color",
|
||||
"lightIntensity": "Light Intensity",
|
||||
"fov": "FOV",
|
||||
"previewOutput": "Preview Output"
|
||||
}
|
||||
}
|
||||
@@ -311,14 +311,6 @@
|
||||
"maxLength": "Message trop long"
|
||||
}
|
||||
},
|
||||
"load3d": {
|
||||
"backgroundColor": "Couleur de fond",
|
||||
"fov": "FOV",
|
||||
"lightIntensity": "Intensité de la lumière",
|
||||
"previewOutput": "Aperçu de la sortie",
|
||||
"showGrid": "Afficher la grille",
|
||||
"switchCamera": "Changer de caméra"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Aucun",
|
||||
"OK": "OK",
|
||||
@@ -735,7 +727,6 @@
|
||||
"deleteFailedTitle": "Échec de la suppression",
|
||||
"deleted": "Flux de travail supprimé",
|
||||
"dirtyClose": "Les fichiers ci-dessous ont été modifiés. Souhaitez-vous les enregistrer avant de fermer ?",
|
||||
"dirtyCloseHint": "Maintenez Shift pour fermer sans invite",
|
||||
"dirtyCloseTitle": "Enregistrer les modifications ?",
|
||||
"workflowTreeType": {
|
||||
"bookmarks": "Favoris",
|
||||
|
||||
@@ -311,14 +311,6 @@
|
||||
"maxLength": "メッセージが長すぎます"
|
||||
}
|
||||
},
|
||||
"load3d": {
|
||||
"backgroundColor": "背景色",
|
||||
"fov": "FOV",
|
||||
"lightIntensity": "光の強度",
|
||||
"previewOutput": "出力のプレビュー",
|
||||
"showGrid": "グリッドを表示",
|
||||
"switchCamera": "カメラを切り替える"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "なし",
|
||||
"OK": "OK",
|
||||
@@ -735,7 +727,6 @@
|
||||
"deleteFailedTitle": "削除に失敗しました",
|
||||
"deleted": "ワークフローが削除されました",
|
||||
"dirtyClose": "以下のファイルが変更されました。閉じる前に保存しますか?",
|
||||
"dirtyCloseHint": "Shiftキーを押しながら閉じると、プロンプトなしで閉じます",
|
||||
"dirtyCloseTitle": "変更を保存しますか?",
|
||||
"workflowTreeType": {
|
||||
"bookmarks": "ブックマーク",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"clipboard": {
|
||||
"errorMessage": "클립보드에 복사하지 못했습니다",
|
||||
"errorNotSupported": "브라우저가 클립보드 API를 지원하지 않습니다.",
|
||||
"errorNotSupported": "당신의 브라우저에서 클립보드 API가 지원되지 않습니다",
|
||||
"successMessage": "클립보드에 복사됨"
|
||||
},
|
||||
"color": {
|
||||
@@ -14,16 +14,16 @@
|
||||
"yellow": "노란색"
|
||||
},
|
||||
"contextMenu": {
|
||||
" to input": " 위젯을 입력으로",
|
||||
" to widget": " 입력을 위젯으로",
|
||||
" to input": " 입력으로",
|
||||
" to widget": " 위젯으로",
|
||||
"Add Group": "그룹 추가",
|
||||
"Add Group For Selected Nodes": "선택한 노드 그룹 추가",
|
||||
"Add Group For Selected Nodes": "선택된 노드에 대한 그룹 추가",
|
||||
"Add Node": "노드 추가",
|
||||
"Bypass": "실행 건너뛰기",
|
||||
"Bypass": "우회",
|
||||
"Clone": "복제",
|
||||
"Collapse": "접기",
|
||||
"Collapse": "축소",
|
||||
"Colors": "색상",
|
||||
"Convert ": "[변환] ",
|
||||
"Convert ": "변환 ",
|
||||
"Convert Input to Widget": "입력을 위젯으로 변환",
|
||||
"Convert Widget to Input": "위젯을 입력으로 변환",
|
||||
"Convert to Group Node": "그룹 노드로 변환",
|
||||
@@ -40,7 +40,7 @@
|
||||
"Properties Panel": "속성 패널",
|
||||
"Remove": "제거",
|
||||
"Resize": "크기 조정",
|
||||
"Save Selected as Template": "선택된 부분을 템플릿으로 저장",
|
||||
"Save Selected as Template": "선택 항목을 템플릿으로 저장",
|
||||
"Search": "검색",
|
||||
"Shapes": "형태",
|
||||
"Title": "제목",
|
||||
@@ -87,8 +87,8 @@
|
||||
"reinstall": "재설치"
|
||||
},
|
||||
"desktopUpdate": {
|
||||
"description": "ComfyUI 데스크톱이 새로운 종속성을 설치하고 있습니다. 이 작업은 몇 분 정도 걸릴 수 있습니다.",
|
||||
"terminalDefaultMessage": "업데이트 콘솔 출력은 여기에 표시됩니다.",
|
||||
"description": "ComfyUI 데스크톱은 새로운 종속성을 설치하고 있습니다. 이 작업은 몇 분 정도 걸릴 수 있습니다.",
|
||||
"terminalDefaultMessage": "업데이트로부터의 콘솔 출력은 여기에 표시됩니다.",
|
||||
"title": "ComfyUI 데스크톱 업데이트 중"
|
||||
},
|
||||
"downloadGit": {
|
||||
@@ -311,14 +311,6 @@
|
||||
"maxLength": "메시지가 너무 깁니다"
|
||||
}
|
||||
},
|
||||
"load3d": {
|
||||
"backgroundColor": "배경색",
|
||||
"fov": "FOV",
|
||||
"lightIntensity": "조명 강도",
|
||||
"previewOutput": "출력 미리보기",
|
||||
"showGrid": "그리드 표시",
|
||||
"switchCamera": "카메라 전환"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "없음",
|
||||
"OK": "확인",
|
||||
@@ -735,7 +727,6 @@
|
||||
"deleteFailedTitle": "삭제 실패",
|
||||
"deleted": "워크플로가 삭제되었습니다.",
|
||||
"dirtyClose": "아래 파일들이 변경되었습니다. 닫기 전에 저장하시겠습니까?",
|
||||
"dirtyCloseHint": "프롬프트 없이 닫으려면 Shift를 누르세요",
|
||||
"dirtyCloseTitle": "변경 사항 저장",
|
||||
"workflowTreeType": {
|
||||
"bookmarks": "북마크",
|
||||
|
||||
@@ -311,14 +311,6 @@
|
||||
"maxLength": "Сообщение слишком длинное"
|
||||
}
|
||||
},
|
||||
"load3d": {
|
||||
"backgroundColor": "Цвет фона",
|
||||
"fov": "Угол обзора",
|
||||
"lightIntensity": "Интенсивность света",
|
||||
"previewOutput": "Предварительный просмотр",
|
||||
"showGrid": "Показать сетку",
|
||||
"switchCamera": "Переключить камеру"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Нет",
|
||||
"OK": "OK",
|
||||
@@ -735,7 +727,6 @@
|
||||
"deleteFailedTitle": "Не удалось удалить",
|
||||
"deleted": "Рабочий процесс удалён",
|
||||
"dirtyClose": "Файлы ниже были изменены. Вы хотите сохранить их перед закрытием?",
|
||||
"dirtyCloseHint": "Удерживайте Shift, чтобы закрыть без подсказки",
|
||||
"dirtyCloseTitle": "Сохранить изменения?",
|
||||
"workflowTreeType": {
|
||||
"bookmarks": "Закладки",
|
||||
|
||||
@@ -311,14 +311,6 @@
|
||||
"maxLength": "消息过长"
|
||||
}
|
||||
},
|
||||
"load3d": {
|
||||
"backgroundColor": "背景颜色",
|
||||
"fov": "视场",
|
||||
"lightIntensity": "光照强度",
|
||||
"previewOutput": "预览输出",
|
||||
"showGrid": "显示网格",
|
||||
"switchCamera": "切换摄像头"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "无",
|
||||
"OK": "确定",
|
||||
@@ -735,7 +727,6 @@
|
||||
"deleteFailedTitle": "删除失败",
|
||||
"deleted": "工作流已删除",
|
||||
"dirtyClose": "以下文件已被更改。您想在关闭之前保存它们吗?",
|
||||
"dirtyCloseHint": "按住 Shift 关闭而不提示",
|
||||
"dirtyCloseTitle": "保存更改?",
|
||||
"workflowTreeType": {
|
||||
"bookmarks": "书签",
|
||||
|
||||
@@ -203,13 +203,13 @@ export class ComfyApp {
|
||||
this.vueAppReady = false
|
||||
this.ui = new ComfyUI(this)
|
||||
this.api = api
|
||||
// Dummy placeholder elements before GraphCanvas is mounted.
|
||||
this.bodyTop = $el('div.comfyui-body-top')
|
||||
this.bodyLeft = $el('div.comfyui-body-left')
|
||||
this.bodyRight = $el('div.comfyui-body-right')
|
||||
this.bodyBottom = $el('div.comfyui-body-bottom')
|
||||
this.canvasContainer = $el('div.graph-canvas-container')
|
||||
|
||||
this.bodyTop = $el('div.comfyui-body-top', { parent: document.body })
|
||||
this.bodyLeft = $el('div.comfyui-body-left', { parent: document.body })
|
||||
this.bodyRight = $el('div.comfyui-body-right', { parent: document.body })
|
||||
this.bodyBottom = $el('div.comfyui-body-bottom', { parent: document.body })
|
||||
this.canvasContainer = $el('div.graph-canvas-container', {
|
||||
parent: document.body
|
||||
})
|
||||
this.menu = new ComfyAppMenu(this)
|
||||
this.bypassBgColor = '#FF00FF'
|
||||
|
||||
@@ -774,12 +774,6 @@ export class ComfyApp {
|
||||
* Set up the app on the page
|
||||
*/
|
||||
async setup(canvasEl: HTMLCanvasElement) {
|
||||
this.bodyTop = document.getElementById('comfyui-body-top')
|
||||
this.bodyLeft = document.getElementById('comfyui-body-left')
|
||||
this.bodyRight = document.getElementById('comfyui-body-right')
|
||||
this.bodyBottom = document.getElementById('comfyui-body-bottom')
|
||||
this.canvasContainer = document.getElementById('graph-canvas-container')
|
||||
|
||||
this.canvasEl = canvasEl
|
||||
this.resizeCanvas()
|
||||
|
||||
@@ -1293,7 +1287,7 @@ export class ComfyApp {
|
||||
|
||||
// Store all widget values
|
||||
if (widgets) {
|
||||
for (let i = 0; i < widgets.length; i++) {
|
||||
for (const i in widgets) {
|
||||
const widget = widgets[i]
|
||||
if (!widget.options || widget.options.serialize !== false) {
|
||||
inputs[widget.name] = widget.serializeValue
|
||||
@@ -1304,7 +1298,7 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
// Store all node links
|
||||
for (let i = 0; i < node.inputs.length; i++) {
|
||||
for (let i in node.inputs) {
|
||||
let parent = node.getInputNode(i)
|
||||
if (parent) {
|
||||
let link = node.getInputLink(i)
|
||||
@@ -1324,18 +1318,14 @@ export class ComfyApp {
|
||||
} else if (link && parent.mode === LGraphEventMode.BYPASS) {
|
||||
let all_inputs = [link.origin_slot]
|
||||
if (parent.inputs) {
|
||||
// @ts-expect-error convert list of strings to list of numbers
|
||||
all_inputs = all_inputs.concat(Object.keys(parent.inputs))
|
||||
for (let parent_input in all_inputs) {
|
||||
// @ts-expect-error assign string to number
|
||||
parent_input = all_inputs[parent_input]
|
||||
if (
|
||||
parent.inputs[parent_input]?.type === node.inputs[i].type
|
||||
) {
|
||||
// @ts-expect-error convert string to number
|
||||
link = parent.getInputLink(parent_input)
|
||||
if (link) {
|
||||
// @ts-expect-error convert string to number
|
||||
parent = parent.getInputNode(parent_input)
|
||||
}
|
||||
found = true
|
||||
@@ -1357,7 +1347,6 @@ export class ComfyApp {
|
||||
if (link) {
|
||||
inputs[node.inputs[i].name] = [
|
||||
String(link.origin_id),
|
||||
// @ts-expect-error link.origin_slot is already number.
|
||||
parseInt(link.origin_slot)
|
||||
]
|
||||
}
|
||||
@@ -1474,7 +1463,9 @@ export class ComfyApp {
|
||||
for (const widget of node.widgets) {
|
||||
// Allow widgets to run callbacks after a prompt has been queued
|
||||
// e.g. random seed after every gen
|
||||
// @ts-expect-error
|
||||
if (widget.afterQueued) {
|
||||
// @ts-expect-error
|
||||
widget.afterQueued()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @ts-strict-ignore
|
||||
import { LGraphCanvas, LGraphNode, LiteGraph } from '@comfyorg/litegraph'
|
||||
import type { Size, Vector4 } from '@comfyorg/litegraph'
|
||||
import type {
|
||||
import type { Vector4 } from '@comfyorg/litegraph'
|
||||
import {
|
||||
ICustomWidget,
|
||||
IWidget,
|
||||
IWidgetOptions
|
||||
} from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
@@ -19,20 +19,13 @@ interface Rect {
|
||||
y: number
|
||||
}
|
||||
|
||||
interface DOMSizeInfo {
|
||||
minHeight: number
|
||||
prefHeight: number
|
||||
w: DOMWidget<HTMLElement, object>
|
||||
diff?: number
|
||||
}
|
||||
|
||||
export interface DOMWidget<T extends HTMLElement, V extends object | string>
|
||||
extends ICustomWidget<T> {
|
||||
// All unrecognized types will be treated the same way as 'custom' in litegraph internally.
|
||||
type: 'custom'
|
||||
name: string
|
||||
computedHeight?: number
|
||||
element: T
|
||||
element?: T
|
||||
options: DOMWidgetOptions<T, V>
|
||||
value: V
|
||||
y?: number
|
||||
@@ -87,7 +80,7 @@ function getClipPath(
|
||||
canvasRect: DOMRect
|
||||
): string {
|
||||
const selectedNode: LGraphNode = Object.values(
|
||||
app.canvas.selected_nodes ?? {}
|
||||
app.canvas.selected_nodes
|
||||
)[0] as LGraphNode
|
||||
if (selectedNode && selectedNode !== node) {
|
||||
const elRect = element.getBoundingClientRect()
|
||||
@@ -127,29 +120,21 @@ function getClipPath(
|
||||
return ''
|
||||
}
|
||||
|
||||
function isDomWidget(
|
||||
widget: IWidget
|
||||
): widget is DOMWidget<HTMLElement, object> {
|
||||
return !!widget.element
|
||||
}
|
||||
|
||||
function computeSize(this: LGraphNode, size: Size): void {
|
||||
if (!this.widgets?.[0]?.last_y) return
|
||||
function computeSize(size: [number, number]): void {
|
||||
if (this.widgets?.[0]?.last_y == null) return
|
||||
|
||||
let y = this.widgets[0].last_y
|
||||
let freeSpace = size[1] - y
|
||||
|
||||
let widgetHeight = 0
|
||||
let dom: DOMSizeInfo[] = []
|
||||
let dom = []
|
||||
for (const w of this.widgets) {
|
||||
// @ts-expect-error custom widget type
|
||||
if (w.type === 'converted-widget') {
|
||||
// Ignore
|
||||
// @ts-expect-error custom widget type
|
||||
delete w.computedHeight
|
||||
} else if (w.computeSize) {
|
||||
widgetHeight += w.computeSize()[1] + 4
|
||||
} else if (isDomWidget(w)) {
|
||||
} else if (w.element) {
|
||||
// Extract DOM widget size info
|
||||
const styles = getComputedStyle(w.element)
|
||||
let minHeight =
|
||||
@@ -159,17 +144,14 @@ function computeSize(this: LGraphNode, size: Size): void {
|
||||
w.options.getMaxHeight?.() ??
|
||||
parseInt(styles.getPropertyValue('--comfy-widget-max-height'))
|
||||
|
||||
let prefHeight: string | number =
|
||||
let prefHeight =
|
||||
w.options.getHeight?.() ??
|
||||
styles.getPropertyValue('--comfy-widget-height')
|
||||
// @ts-expect-error number has no endsWith
|
||||
if (prefHeight.endsWith?.('%')) {
|
||||
prefHeight =
|
||||
size[1] *
|
||||
// @ts-expect-error number has no substring
|
||||
(parseFloat(prefHeight.substring(0, prefHeight.length - 1)) / 100)
|
||||
} else {
|
||||
// @ts-expect-error number is not assignable to param of type string
|
||||
prefHeight = parseInt(prefHeight)
|
||||
if (isNaN(minHeight)) {
|
||||
minHeight = prefHeight
|
||||
@@ -218,7 +200,8 @@ function computeSize(this: LGraphNode, size: Size): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.imgs && !this.widgets?.find((w) => w.name === ANIM_PREVIEW_WIDGET)) {
|
||||
if (this.imgs && !this.widgets.find((w) => w.name === ANIM_PREVIEW_WIDGET)) {
|
||||
// Allocate space for image
|
||||
freeSpace -= 220
|
||||
}
|
||||
|
||||
@@ -227,7 +210,7 @@ function computeSize(this: LGraphNode, size: Size): void {
|
||||
if (freeSpace < 0) {
|
||||
// Not enough space for all widgets so we need to grow
|
||||
size[1] -= freeSpace
|
||||
this.graph?.setDirtyCanvas(true)
|
||||
this.graph.setDirtyCanvas(true)
|
||||
} else {
|
||||
// Share the space between each
|
||||
const growDiff = freeSpace - growBy
|
||||
@@ -250,9 +233,7 @@ function computeSize(this: LGraphNode, size: Size): void {
|
||||
// Grow any that are auto height
|
||||
const shared = freeSpace / canGrow.length
|
||||
for (const d of canGrow) {
|
||||
if (d.w.computedHeight) {
|
||||
d.w.computedHeight += shared
|
||||
}
|
||||
d.w.computedHeight += shared
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,9 +241,7 @@ function computeSize(this: LGraphNode, size: Size): void {
|
||||
// Position each of the widgets
|
||||
for (const w of this.widgets) {
|
||||
w.y = y
|
||||
// @ts-expect-error custom widget type
|
||||
if (w.computedHeight) {
|
||||
// @ts-expect-error custom widget type
|
||||
y += w.computedHeight
|
||||
} else if (w.computeSize) {
|
||||
y += w.computeSize()[1] + 4
|
||||
@@ -274,17 +253,16 @@ function computeSize(this: LGraphNode, size: Size): void {
|
||||
|
||||
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
|
||||
const elementWidgets = new Set()
|
||||
//@ts-ignore
|
||||
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes
|
||||
LGraphCanvas.prototype.computeVisibleNodes = function (
|
||||
nodes?: LGraphNode[],
|
||||
out?: LGraphNode[]
|
||||
): LGraphNode[] {
|
||||
const visibleNodes = computeVisibleNodes.call(this, nodes, out)
|
||||
//@ts-ignore
|
||||
LGraphCanvas.prototype.computeVisibleNodes = function (): LGraphNode[] {
|
||||
const visibleNodes = computeVisibleNodes.apply(this, arguments)
|
||||
|
||||
for (const node of app.graph.nodes) {
|
||||
if (elementWidgets.has(node)) {
|
||||
const hidden = visibleNodes.indexOf(node) === -1
|
||||
for (const w of node.widgets ?? []) {
|
||||
for (const w of node.widgets) {
|
||||
if (w.element) {
|
||||
w.element.dataset.isInVisibleNodes = hidden ? 'false' : 'true'
|
||||
const shouldOtherwiseHide = w.element.dataset.shouldHide === 'true'
|
||||
@@ -292,7 +270,7 @@ LGraphCanvas.prototype.computeVisibleNodes = function (
|
||||
const wasHidden = w.element.hidden
|
||||
const actualHidden = hidden || shouldOtherwiseHide || isCollapsed
|
||||
w.element.hidden = actualHidden
|
||||
w.element.style.display = actualHidden ? 'none' : ''
|
||||
w.element.style.display = actualHidden ? 'none' : null
|
||||
if (actualHidden && !wasHidden) {
|
||||
w.options.onHide?.(w as DOMWidget<HTMLElement, object>)
|
||||
}
|
||||
@@ -304,116 +282,6 @@ LGraphCanvas.prototype.computeVisibleNodes = function (
|
||||
return visibleNodes
|
||||
}
|
||||
|
||||
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
|
||||
implements DOMWidget<T, V>
|
||||
{
|
||||
type: 'custom'
|
||||
name: string
|
||||
element: T
|
||||
options: DOMWidgetOptions<T, V>
|
||||
computedHeight?: number
|
||||
callback?: (value: V) => void
|
||||
private mouseDownHandler?: (event: MouseEvent) => void
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
type: string,
|
||||
element: T,
|
||||
options: DOMWidgetOptions<T, V> = {}
|
||||
) {
|
||||
// @ts-expect-error custom widget type
|
||||
this.type = type
|
||||
this.name = name
|
||||
this.element = element
|
||||
this.options = options
|
||||
|
||||
if (element.blur) {
|
||||
this.mouseDownHandler = (event) => {
|
||||
if (!element.contains(event.target as HTMLElement)) {
|
||||
element.blur()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', this.mouseDownHandler)
|
||||
}
|
||||
}
|
||||
|
||||
get value(): V {
|
||||
return this.options.getValue?.() ?? ('' as V)
|
||||
}
|
||||
|
||||
set value(v: V) {
|
||||
this.options.setValue?.(v)
|
||||
this.callback?.(this.value)
|
||||
}
|
||||
|
||||
draw(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: LGraphNode,
|
||||
widgetWidth: number,
|
||||
y: number
|
||||
): void {
|
||||
if (this.computedHeight == null) {
|
||||
computeSize.call(node, node.size)
|
||||
}
|
||||
|
||||
const { offset, scale } = app.canvas.ds
|
||||
const hidden =
|
||||
(!!this.options.hideOnZoom && app.canvas.low_quality) ||
|
||||
(this.computedHeight ?? 0) <= 0 ||
|
||||
// @ts-expect-error custom widget type
|
||||
this.type === 'converted-widget' ||
|
||||
// @ts-expect-error custom widget type
|
||||
this.type === 'hidden'
|
||||
|
||||
this.element.dataset.shouldHide = hidden ? 'true' : 'false'
|
||||
const isInVisibleNodes = this.element.dataset.isInVisibleNodes === 'true'
|
||||
const isCollapsed = this.element.dataset.collapsed === 'true'
|
||||
const actualHidden = hidden || !isInVisibleNodes || isCollapsed
|
||||
const wasHidden = this.element.hidden
|
||||
this.element.hidden = actualHidden
|
||||
this.element.style.display = actualHidden ? 'none' : ''
|
||||
|
||||
if (actualHidden && !wasHidden) {
|
||||
this.options.onHide?.(this)
|
||||
}
|
||||
if (actualHidden) {
|
||||
return
|
||||
}
|
||||
|
||||
const elRect = ctx.canvas.getBoundingClientRect()
|
||||
const margin = 10
|
||||
const top = node.pos[0] + offset[0] + margin
|
||||
const left = node.pos[1] + offset[1] + margin + y
|
||||
|
||||
Object.assign(this.element.style, {
|
||||
transformOrigin: '0 0',
|
||||
transform: `scale(${scale})`,
|
||||
left: `${top * scale}px`,
|
||||
top: `${left * scale}px`,
|
||||
width: `${widgetWidth - margin * 2}px`,
|
||||
height: `${(this.computedHeight ?? 50) - margin * 2}px`,
|
||||
position: 'absolute',
|
||||
zIndex: app.graph.nodes.indexOf(node),
|
||||
pointerEvents: app.canvas.read_only ? 'none' : 'auto'
|
||||
})
|
||||
|
||||
if (useSettingStore().get('Comfy.DOMClippingEnabled')) {
|
||||
const clipPath = getClipPath(node, this.element, elRect)
|
||||
this.element.style.clipPath = clipPath ?? 'none'
|
||||
this.element.style.willChange = 'clip-path'
|
||||
}
|
||||
|
||||
this.options.onDraw?.(this)
|
||||
}
|
||||
|
||||
onRemove(): void {
|
||||
if (this.mouseDownHandler) {
|
||||
document.removeEventListener('mousedown', this.mouseDownHandler)
|
||||
}
|
||||
this.element.remove()
|
||||
}
|
||||
}
|
||||
|
||||
LGraphNode.prototype.addDOMWidget = function <
|
||||
T extends HTMLElement,
|
||||
V extends object | string
|
||||
@@ -431,6 +299,16 @@ LGraphNode.prototype.addDOMWidget = function <
|
||||
element.hidden = true
|
||||
element.style.display = 'none'
|
||||
|
||||
let mouseDownHandler
|
||||
if (element.blur) {
|
||||
mouseDownHandler = (event) => {
|
||||
if (!element.contains(event.target)) {
|
||||
element.blur()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', mouseDownHandler)
|
||||
}
|
||||
|
||||
const { nodeData } = this.constructor
|
||||
const tooltip = (nodeData?.input.required?.[name] ??
|
||||
nodeData?.input.optional?.[name])?.[1]?.tooltip
|
||||
@@ -438,23 +316,88 @@ LGraphNode.prototype.addDOMWidget = function <
|
||||
element.title = tooltip
|
||||
}
|
||||
|
||||
const widget = new DOMWidgetImpl(name, type, element, options)
|
||||
// Workaround for https://github.com/Comfy-Org/ComfyUI_frontend/issues/2493
|
||||
// Some custom nodes are explicitly expecting getter and setter of `value`
|
||||
// property to be on instance instead of prototype.
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get(this: DOMWidgetImpl<T, V>): V {
|
||||
return this.options.getValue?.() ?? ('' as V)
|
||||
const widget: DOMWidget<T, V> = {
|
||||
// @ts-expect-error All unrecognized types will be treated the same way as 'custom'
|
||||
// in litegraph internally.
|
||||
type,
|
||||
name,
|
||||
get value(): V {
|
||||
return options.getValue?.() ?? undefined
|
||||
},
|
||||
set(this: DOMWidgetImpl<T, V>, v: V) {
|
||||
this.options.setValue?.(v)
|
||||
this.callback?.(this.value)
|
||||
}
|
||||
})
|
||||
set value(v: V) {
|
||||
options.setValue?.(v)
|
||||
widget.callback?.(widget.value)
|
||||
},
|
||||
draw: function (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: LGraphNode,
|
||||
widgetWidth: number,
|
||||
y: number,
|
||||
widgetHeight: number
|
||||
) {
|
||||
if (widget.computedHeight == null) {
|
||||
computeSize.call(node, node.size)
|
||||
}
|
||||
|
||||
// Ensure selectOn exists before iteration
|
||||
const selectEvents = options.selectOn ?? ['focus', 'click']
|
||||
for (const evt of selectEvents) {
|
||||
const { offset, scale } = app.canvas.ds
|
||||
|
||||
const hidden =
|
||||
(!!options.hideOnZoom && app.canvas.low_quality) ||
|
||||
widget.computedHeight <= 0 ||
|
||||
// @ts-expect-error Used by widgetInputs.ts
|
||||
widget.type === 'converted-widget' ||
|
||||
// @ts-expect-error Used by groupNode.ts
|
||||
widget.type === 'hidden'
|
||||
|
||||
element.dataset.shouldHide = hidden ? 'true' : 'false'
|
||||
const isInVisibleNodes = element.dataset.isInVisibleNodes === 'true'
|
||||
const isCollapsed = element.dataset.collapsed === 'true'
|
||||
const actualHidden = hidden || !isInVisibleNodes || isCollapsed
|
||||
const wasHidden = element.hidden
|
||||
element.hidden = actualHidden
|
||||
element.style.display = actualHidden ? 'none' : null
|
||||
if (actualHidden && !wasHidden) {
|
||||
widget.options.onHide?.(widget)
|
||||
}
|
||||
if (actualHidden) {
|
||||
return
|
||||
}
|
||||
|
||||
const elRect = ctx.canvas.getBoundingClientRect()
|
||||
const margin = 10
|
||||
const top = node.pos[0] + offset[0] + margin
|
||||
const left = node.pos[1] + offset[1] + margin + y
|
||||
|
||||
Object.assign(element.style, {
|
||||
transformOrigin: '0 0',
|
||||
transform: `scale(${scale})`,
|
||||
left: `${top * scale}px`,
|
||||
top: `${left * scale}px`,
|
||||
width: `${widgetWidth - margin * 2}px`,
|
||||
height: `${(widget.computedHeight ?? 50) - margin * 2}px`,
|
||||
position: 'absolute',
|
||||
zIndex: app.graph.nodes.indexOf(node),
|
||||
pointerEvents: app.canvas.read_only ? 'none' : 'auto'
|
||||
})
|
||||
|
||||
if (useSettingStore().get('Comfy.DOMClippingEnabled')) {
|
||||
element.style.clipPath = getClipPath(node, element, elRect)
|
||||
element.style.willChange = 'clip-path'
|
||||
}
|
||||
|
||||
this.options.onDraw?.(widget)
|
||||
},
|
||||
element,
|
||||
options,
|
||||
onRemove() {
|
||||
if (mouseDownHandler) {
|
||||
document.removeEventListener('mousedown', mouseDownHandler)
|
||||
}
|
||||
element.remove()
|
||||
}
|
||||
}
|
||||
|
||||
for (const evt of options.selectOn) {
|
||||
element.addEventListener(evt, () => {
|
||||
app.canvas.selectNode(this)
|
||||
app.canvas.bringToFront(this)
|
||||
@@ -465,8 +408,8 @@ LGraphNode.prototype.addDOMWidget = function <
|
||||
elementWidgets.add(this)
|
||||
|
||||
const collapse = this.collapse
|
||||
this.collapse = function (force?: boolean) {
|
||||
collapse.call(this, force)
|
||||
this.collapse = function () {
|
||||
collapse.apply(this, arguments)
|
||||
if (this.flags?.collapsed) {
|
||||
element.hidden = true
|
||||
element.style.display = 'none'
|
||||
@@ -475,8 +418,8 @@ LGraphNode.prototype.addDOMWidget = function <
|
||||
}
|
||||
|
||||
const { onConfigure } = this
|
||||
this.onConfigure = function (serializedNode: any) {
|
||||
onConfigure?.call(this, serializedNode)
|
||||
this.onConfigure = function () {
|
||||
onConfigure?.apply(this, arguments)
|
||||
element.dataset.collapsed = this.flags?.collapsed ? 'true' : 'false'
|
||||
}
|
||||
|
||||
@@ -484,18 +427,16 @@ LGraphNode.prototype.addDOMWidget = function <
|
||||
this.onRemoved = function () {
|
||||
element.remove()
|
||||
elementWidgets.delete(this)
|
||||
onRemoved?.call(this)
|
||||
onRemoved?.apply(this, arguments)
|
||||
}
|
||||
|
||||
// @ts-ignore index with symbol
|
||||
if (!this[SIZE]) {
|
||||
// @ts-ignore index with symbol
|
||||
this[SIZE] = true
|
||||
const onResize = this.onResize
|
||||
this.onResize = function (size: Size) {
|
||||
this.onResize = function (size) {
|
||||
options.beforeResize?.call(widget, this)
|
||||
computeSize.call(this, size)
|
||||
onResize?.call(this, size)
|
||||
onResize?.apply(this, arguments)
|
||||
options.afterResize?.call(widget, this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
// @ts-strict-ignore
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IWidget } from '@comfyorg/litegraph'
|
||||
import type {
|
||||
IComboWidget,
|
||||
IStringWidget
|
||||
} from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { Editor as TiptapEditor } from '@tiptap/core'
|
||||
import TiptapLink from '@tiptap/extension-link'
|
||||
import TiptapTable from '@tiptap/extension-table'
|
||||
import TiptapTableCell from '@tiptap/extension-table-cell'
|
||||
import TiptapTableHeader from '@tiptap/extension-table-header'
|
||||
import TiptapTableRow from '@tiptap/extension-table-row'
|
||||
import TiptapStarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
|
||||
|
||||
import { useBooleanWidget } from '@/composables/widgets/useBooleanWidget'
|
||||
import { useComboWidget } from '@/composables/widgets/useComboWidget'
|
||||
import { useFloatWidget } from '@/composables/widgets/useFloatWidget'
|
||||
import { useImageUploadWidget } from '@/composables/widgets/useImageUploadWidget'
|
||||
import { useIntWidget } from '@/composables/widgets/useIntWidget'
|
||||
import { useMarkdownWidget } from '@/composables/widgets/useMarkdownWidget'
|
||||
import { useSeedWidget } from '@/composables/widgets/useSeedWidget'
|
||||
import { useStringWidget } from '@/composables/widgets/useStringWidget'
|
||||
import { useRemoteWidget } from '@/hooks/remoteWidgetHook'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import type { InputSpec } from '@/types/apiTypes'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { InputSpec } from '@/types/apiTypes'
|
||||
|
||||
import { api } from './api'
|
||||
import type { ComfyApp } from './app'
|
||||
import './domWidget'
|
||||
|
||||
@@ -23,7 +24,7 @@ export type ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
inputData: InputSpec,
|
||||
app: ComfyApp,
|
||||
app?: ComfyApp,
|
||||
widgetName?: string
|
||||
) => { widget: IWidget; minWidth?: number; minHeight?: number }
|
||||
|
||||
@@ -31,34 +32,64 @@ function controlValueRunBefore() {
|
||||
return useSettingStore().get('Comfy.WidgetControlMode') === 'before'
|
||||
}
|
||||
|
||||
export function updateControlWidgetLabel(widget: IWidget) {
|
||||
export function updateControlWidgetLabel(widget) {
|
||||
let replacement = 'after'
|
||||
let find = 'before'
|
||||
if (controlValueRunBefore()) {
|
||||
;[find, replacement] = [replacement, find]
|
||||
}
|
||||
widget.label = (widget.label ?? widget.name ?? '').replace(find, replacement)
|
||||
widget.label = (widget.label ?? widget.name).replace(find, replacement)
|
||||
}
|
||||
|
||||
export const IS_CONTROL_WIDGET = Symbol()
|
||||
const HAS_EXECUTED = Symbol()
|
||||
|
||||
function getNumberDefaults(
|
||||
inputData: InputSpec,
|
||||
defaultStep,
|
||||
precision,
|
||||
enable_rounding
|
||||
) {
|
||||
let defaultVal = inputData[1]['default']
|
||||
let { min, max, step, round } = inputData[1]
|
||||
|
||||
if (defaultVal == undefined) defaultVal = 0
|
||||
if (min == undefined) min = 0
|
||||
if (max == undefined) max = 2048
|
||||
if (step == undefined) step = defaultStep
|
||||
// precision is the number of decimal places to show.
|
||||
// by default, display the the smallest number of decimal places such that changes of size step are visible.
|
||||
if (precision == undefined) {
|
||||
precision = Math.max(-Math.floor(Math.log10(step)), 0)
|
||||
}
|
||||
|
||||
if (enable_rounding && (round == undefined || round === true)) {
|
||||
// by default, round the value to those decimal places shown.
|
||||
round = Math.round(1000000 * Math.pow(0.1, precision)) / 1000000
|
||||
}
|
||||
|
||||
return {
|
||||
val: defaultVal,
|
||||
config: { min, max, step: 10.0 * step, round, precision }
|
||||
}
|
||||
}
|
||||
|
||||
export function addValueControlWidget(
|
||||
node: LGraphNode,
|
||||
targetWidget: IWidget,
|
||||
defaultValue?: string,
|
||||
values?: unknown,
|
||||
widgetName?: string,
|
||||
inputData?: InputSpec
|
||||
): IWidget {
|
||||
let name = inputData?.[1]?.control_after_generate
|
||||
node,
|
||||
targetWidget,
|
||||
defaultValue = 'randomize',
|
||||
values,
|
||||
widgetName,
|
||||
inputData: InputSpec
|
||||
) {
|
||||
let name = inputData[1]?.control_after_generate
|
||||
if (typeof name !== 'string') {
|
||||
name = widgetName
|
||||
}
|
||||
const widgets = addValueControlWidgets(
|
||||
node,
|
||||
targetWidget,
|
||||
defaultValue ?? 'randomize',
|
||||
defaultValue,
|
||||
{
|
||||
addFilterList: false,
|
||||
controlAfterGenerateName: name
|
||||
@@ -69,16 +100,16 @@ export function addValueControlWidget(
|
||||
}
|
||||
|
||||
export function addValueControlWidgets(
|
||||
node: LGraphNode,
|
||||
targetWidget: IWidget,
|
||||
defaultValue?: string,
|
||||
options?: Record<string, any>,
|
||||
inputData?: InputSpec
|
||||
): IWidget[] {
|
||||
node,
|
||||
targetWidget,
|
||||
defaultValue = 'randomize',
|
||||
options,
|
||||
inputData: InputSpec
|
||||
) {
|
||||
if (!defaultValue) defaultValue = 'randomize'
|
||||
if (!options) options = {}
|
||||
|
||||
const getName = (defaultName: string, optionName: string) => {
|
||||
const getName = (defaultName, optionName) => {
|
||||
let name = defaultName
|
||||
if (options[optionName]) {
|
||||
name = options[optionName]
|
||||
@@ -90,7 +121,7 @@ export function addValueControlWidgets(
|
||||
return name
|
||||
}
|
||||
|
||||
const widgets: IWidget[] = []
|
||||
const widgets = []
|
||||
const valueControl = node.addWidget(
|
||||
'combo',
|
||||
getName('control_after_generate', 'controlAfterGenerateName'),
|
||||
@@ -100,18 +131,16 @@ export function addValueControlWidgets(
|
||||
values: ['fixed', 'increment', 'decrement', 'randomize'],
|
||||
serialize: false // Don't include this in prompt.
|
||||
}
|
||||
) as IComboWidget
|
||||
|
||||
)
|
||||
valueControl.tooltip =
|
||||
'Allows the linked widget to be changed automatically, for example randomizing the noise seed.'
|
||||
// @ts-ignore index with symbol
|
||||
valueControl[IS_CONTROL_WIDGET] = true
|
||||
updateControlWidgetLabel(valueControl)
|
||||
widgets.push(valueControl)
|
||||
|
||||
const isCombo = targetWidget.type === 'combo'
|
||||
let comboFilter: IStringWidget
|
||||
if (isCombo && valueControl.options.values) {
|
||||
let comboFilter
|
||||
if (isCombo) {
|
||||
valueControl.options.values.push('increment-wrap')
|
||||
}
|
||||
if (isCombo && options.addFilterList !== false) {
|
||||
@@ -123,7 +152,7 @@ export function addValueControlWidgets(
|
||||
{
|
||||
serialize: false // Don't include this in prompt.
|
||||
}
|
||||
) as IStringWidget
|
||||
)
|
||||
updateControlWidgetLabel(comboFilter)
|
||||
comboFilter.tooltip =
|
||||
"Allows for filtering the list of values when changing the value via the control generate mode. Allows for RegEx matches in the format /abc/ to only filter to values containing 'abc'."
|
||||
@@ -135,14 +164,14 @@ export function addValueControlWidgets(
|
||||
var v = valueControl.value
|
||||
|
||||
if (isCombo && v !== 'fixed') {
|
||||
let values = targetWidget.options.values ?? []
|
||||
let values = targetWidget.options.values
|
||||
const filter = comboFilter?.value
|
||||
if (filter) {
|
||||
let check
|
||||
if (filter.startsWith('/') && filter.endsWith('/')) {
|
||||
try {
|
||||
const regex = new RegExp(filter.substring(1, filter.length - 1))
|
||||
check = (item: string) => regex.test(item)
|
||||
check = (item) => regex.test(item)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error constructing RegExp filter for node ' + node.id,
|
||||
@@ -153,17 +182,16 @@ export function addValueControlWidgets(
|
||||
}
|
||||
if (!check) {
|
||||
const lower = filter.toLocaleLowerCase()
|
||||
check = (item: string) => item.toLocaleLowerCase().includes(lower)
|
||||
check = (item) => item.toLocaleLowerCase().includes(lower)
|
||||
}
|
||||
values = values.filter((item: string) => check(item))
|
||||
if (!values.length && targetWidget.options.values?.length) {
|
||||
values = values.filter((item) => check(item))
|
||||
if (!values.length && targetWidget.options.values.length) {
|
||||
console.warn(
|
||||
'Filter for node ' + node.id + ' has filtered out all items',
|
||||
filter
|
||||
)
|
||||
}
|
||||
}
|
||||
// @ts-expect-error targetWidget.value can be number or string
|
||||
let current_index = values.indexOf(targetWidget.value)
|
||||
let current_length = values.length
|
||||
|
||||
@@ -191,54 +219,52 @@ export function addValueControlWidgets(
|
||||
if (current_index >= 0) {
|
||||
let value = values[current_index]
|
||||
targetWidget.value = value
|
||||
targetWidget.callback?.(value)
|
||||
targetWidget.callback(value)
|
||||
}
|
||||
} else {
|
||||
//number
|
||||
let { min = 0, max = 1, step = 1 } = targetWidget.options
|
||||
let min = targetWidget.options.min
|
||||
let max = targetWidget.options.max
|
||||
// limit to something that javascript can handle
|
||||
max = Math.min(1125899906842624, max)
|
||||
min = Math.max(-1125899906842624, min)
|
||||
let range = (max - min) / (step / 10)
|
||||
let range = (max - min) / (targetWidget.options.step / 10)
|
||||
|
||||
//adjust values based on valueControl Behaviour
|
||||
switch (v) {
|
||||
case 'fixed':
|
||||
break
|
||||
case 'increment':
|
||||
// @ts-expect-error targetWidget.value can be number or string
|
||||
targetWidget.value += step / 10
|
||||
targetWidget.value += targetWidget.options.step / 10
|
||||
break
|
||||
case 'decrement':
|
||||
// @ts-expect-error targetWidget.value can be number or string
|
||||
targetWidget.value -= step / 10
|
||||
targetWidget.value -= targetWidget.options.step / 10
|
||||
break
|
||||
case 'randomize':
|
||||
targetWidget.value =
|
||||
Math.floor(Math.random() * range) * (step / 10) + min
|
||||
Math.floor(Math.random() * range) *
|
||||
(targetWidget.options.step / 10) +
|
||||
min
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
/*check if values are over or under their respective
|
||||
* ranges and set them to min or max.*/
|
||||
// @ts-expect-error targetWidget.value can be number or string
|
||||
if (targetWidget.value < min) targetWidget.value = min
|
||||
// @ts-expect-error targetWidget.value can be number or string
|
||||
|
||||
if (targetWidget.value > max) targetWidget.value = max
|
||||
targetWidget.callback?.(targetWidget.value)
|
||||
targetWidget.callback(targetWidget.value)
|
||||
}
|
||||
}
|
||||
|
||||
valueControl.beforeQueued = () => {
|
||||
if (controlValueRunBefore()) {
|
||||
// Don't run on first execution
|
||||
// @ts-ignore index with symbol
|
||||
if (valueControl[HAS_EXECUTED]) {
|
||||
applyWidgetControl()
|
||||
}
|
||||
}
|
||||
// @ts-ignore index with symbol
|
||||
valueControl[HAS_EXECUTED] = true
|
||||
}
|
||||
|
||||
@@ -251,16 +277,502 @@ export function addValueControlWidgets(
|
||||
return widgets
|
||||
}
|
||||
|
||||
const SeedWidget = useSeedWidget()
|
||||
function seedWidget(node, inputName, inputData: InputSpec, app, widgetName) {
|
||||
const seed = createIntWidget(node, inputName, inputData, app, true)
|
||||
const seedControl = addValueControlWidget(
|
||||
node,
|
||||
seed.widget,
|
||||
'randomize',
|
||||
undefined,
|
||||
widgetName,
|
||||
inputData
|
||||
)
|
||||
|
||||
seed.widget.linkedWidgets = [seedControl]
|
||||
return seed
|
||||
}
|
||||
|
||||
function createIntWidget(
|
||||
node,
|
||||
inputName,
|
||||
inputData: InputSpec,
|
||||
app,
|
||||
isSeedInput: boolean = false
|
||||
) {
|
||||
const control = inputData[1]?.control_after_generate
|
||||
if (!isSeedInput && control) {
|
||||
return seedWidget(
|
||||
node,
|
||||
inputName,
|
||||
inputData,
|
||||
app,
|
||||
typeof control === 'string' ? control : undefined
|
||||
)
|
||||
}
|
||||
|
||||
let widgetType = isSlider(inputData[1]['display'], app)
|
||||
const { val, config } = getNumberDefaults(inputData, 1, 0, true)
|
||||
Object.assign(config, { precision: 0 })
|
||||
return {
|
||||
widget: node.addWidget(
|
||||
widgetType,
|
||||
inputName,
|
||||
val,
|
||||
function (v) {
|
||||
const s = this.options.step / 10
|
||||
let sh = this.options.min % s
|
||||
if (isNaN(sh)) {
|
||||
sh = 0
|
||||
}
|
||||
this.value = Math.round((v - sh) / s) * s + sh
|
||||
},
|
||||
config
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function addMultilineWidget(node, name: string, opts, app: ComfyApp) {
|
||||
const inputEl = document.createElement('textarea')
|
||||
inputEl.className = 'comfy-multiline-input'
|
||||
inputEl.value = opts.defaultVal
|
||||
inputEl.placeholder = opts.placeholder || name
|
||||
if (app.vueAppReady) {
|
||||
inputEl.spellcheck = useSettingStore().get(
|
||||
'Comfy.TextareaWidget.Spellcheck'
|
||||
)
|
||||
}
|
||||
|
||||
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
|
||||
getValue() {
|
||||
return inputEl.value
|
||||
},
|
||||
setValue(v) {
|
||||
inputEl.value = v
|
||||
}
|
||||
})
|
||||
widget.inputEl = inputEl
|
||||
|
||||
inputEl.addEventListener('input', () => {
|
||||
widget.callback?.(widget.value)
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseDown(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
|
||||
if ((event.buttons & 4) === 4) {
|
||||
app.canvas.processMouseMove(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseUp(event)
|
||||
}
|
||||
})
|
||||
|
||||
return { minWidth: 400, minHeight: 200, widget }
|
||||
}
|
||||
|
||||
function addMarkdownWidget(node, name: string, opts, app: ComfyApp) {
|
||||
TiptapMarkdown.configure({
|
||||
html: false,
|
||||
breaks: true,
|
||||
transformPastedText: true
|
||||
})
|
||||
const editor = new TiptapEditor({
|
||||
extensions: [
|
||||
TiptapStarterKit,
|
||||
TiptapMarkdown,
|
||||
TiptapLink,
|
||||
TiptapTable,
|
||||
TiptapTableCell,
|
||||
TiptapTableHeader,
|
||||
TiptapTableRow
|
||||
],
|
||||
content: opts.defaultVal,
|
||||
editable: false
|
||||
})
|
||||
|
||||
const inputEl = editor.options.element
|
||||
inputEl.classList.add('comfy-markdown')
|
||||
const textarea = document.createElement('textarea')
|
||||
inputEl.append(textarea)
|
||||
|
||||
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
|
||||
getValue() {
|
||||
return textarea.value
|
||||
},
|
||||
setValue(v) {
|
||||
textarea.value = v
|
||||
editor.commands.setContent(v)
|
||||
}
|
||||
})
|
||||
widget.inputEl = inputEl
|
||||
|
||||
editor.options.element.addEventListener(
|
||||
'pointerdown',
|
||||
(event: PointerEvent) => {
|
||||
if (event.button !== 0) {
|
||||
app.canvas.processMouseDown(event)
|
||||
return
|
||||
}
|
||||
if (event.target instanceof HTMLAnchorElement) {
|
||||
return
|
||||
}
|
||||
inputEl.classList.add('editing')
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
}
|
||||
)
|
||||
|
||||
textarea.addEventListener('blur', () => {
|
||||
inputEl.classList.remove('editing')
|
||||
})
|
||||
|
||||
textarea.addEventListener('change', () => {
|
||||
editor.commands.setContent(textarea.value)
|
||||
widget.callback?.(widget.value)
|
||||
})
|
||||
|
||||
inputEl.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
event.stopPropagation()
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseDown(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
|
||||
if ((event.buttons & 4) === 4) {
|
||||
app.canvas.processMouseMove(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseUp(event)
|
||||
}
|
||||
})
|
||||
|
||||
return { minWidth: 400, minHeight: 200, widget }
|
||||
}
|
||||
|
||||
function isSlider(display, app) {
|
||||
if (app.ui.settings.getSettingValue('Comfy.DisableSliders')) {
|
||||
return 'number'
|
||||
}
|
||||
|
||||
return display === 'slider' ? 'slider' : 'number'
|
||||
}
|
||||
|
||||
export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
|
||||
'INT:seed': SeedWidget,
|
||||
'INT:noise_seed': SeedWidget,
|
||||
INT: useIntWidget(),
|
||||
FLOAT: useFloatWidget(),
|
||||
BOOLEAN: useBooleanWidget(),
|
||||
STRING: useStringWidget(),
|
||||
MARKDOWN: useMarkdownWidget(),
|
||||
COMBO: useComboWidget(),
|
||||
IMAGEUPLOAD: useImageUploadWidget()
|
||||
'INT:seed': seedWidget,
|
||||
'INT:noise_seed': seedWidget,
|
||||
FLOAT(node, inputName, inputData: InputSpec, app) {
|
||||
let widgetType: 'number' | 'slider' = isSlider(inputData[1]['display'], app)
|
||||
let precision = app.ui.settings.getSettingValue(
|
||||
'Comfy.FloatRoundingPrecision'
|
||||
)
|
||||
let disable_rounding = app.ui.settings.getSettingValue(
|
||||
'Comfy.DisableFloatRounding'
|
||||
)
|
||||
if (precision == 0) precision = undefined
|
||||
const { val, config } = getNumberDefaults(
|
||||
inputData,
|
||||
0.5,
|
||||
precision,
|
||||
!disable_rounding
|
||||
)
|
||||
return {
|
||||
widget: node.addWidget(
|
||||
widgetType,
|
||||
inputName,
|
||||
val,
|
||||
function (v) {
|
||||
if (config.round) {
|
||||
this.value =
|
||||
Math.round((v + Number.EPSILON) / config.round) * config.round
|
||||
if (this.value > config.max) this.value = config.max
|
||||
if (this.value < config.min) this.value = config.min
|
||||
} else {
|
||||
this.value = v
|
||||
}
|
||||
},
|
||||
config
|
||||
)
|
||||
}
|
||||
},
|
||||
INT(node, inputName, inputData: InputSpec, app) {
|
||||
return createIntWidget(node, inputName, inputData, app)
|
||||
},
|
||||
BOOLEAN(node, inputName, inputData) {
|
||||
let defaultVal = false
|
||||
let options = {}
|
||||
if (inputData[1]) {
|
||||
if (inputData[1].default) defaultVal = inputData[1].default
|
||||
if (inputData[1].label_on) options['on'] = inputData[1].label_on
|
||||
if (inputData[1].label_off) options['off'] = inputData[1].label_off
|
||||
}
|
||||
return {
|
||||
widget: node.addWidget('toggle', inputName, defaultVal, () => {}, options)
|
||||
}
|
||||
},
|
||||
STRING(node, inputName, inputData: InputSpec, app) {
|
||||
const defaultVal = inputData[1].default || ''
|
||||
const multiline = !!inputData[1].multiline
|
||||
|
||||
let res
|
||||
if (multiline) {
|
||||
res = addMultilineWidget(
|
||||
node,
|
||||
inputName,
|
||||
{ defaultVal, ...inputData[1] },
|
||||
app
|
||||
)
|
||||
} else {
|
||||
res = {
|
||||
widget: node.addWidget('text', inputName, defaultVal, () => {}, {})
|
||||
}
|
||||
}
|
||||
|
||||
if (inputData[1].dynamicPrompts != undefined)
|
||||
res.widget.dynamicPrompts = inputData[1].dynamicPrompts
|
||||
|
||||
return res
|
||||
},
|
||||
MARKDOWN(node, inputName, inputData: InputSpec, app) {
|
||||
const defaultVal = inputData[1].default || ''
|
||||
|
||||
let res
|
||||
res = addMarkdownWidget(
|
||||
node,
|
||||
inputName,
|
||||
{ defaultVal, ...inputData[1] },
|
||||
app
|
||||
)
|
||||
return res
|
||||
},
|
||||
COMBO(node, inputName, inputData: InputSpec) {
|
||||
const widgetStore = useWidgetStore()
|
||||
|
||||
const { type, options } = inputData[1]
|
||||
const defaultValue = widgetStore.getDefaultValue(inputData)
|
||||
|
||||
const res = {
|
||||
widget: node.addWidget('combo', inputName, defaultValue, () => {}, {
|
||||
values: options ?? inputData[0]
|
||||
})
|
||||
}
|
||||
|
||||
if (type === 'remote') {
|
||||
const remoteWidget = useRemoteWidget(inputData)
|
||||
|
||||
const origOptions = res.widget.options
|
||||
res.widget.options = new Proxy(
|
||||
origOptions as Record<string | symbol, any>,
|
||||
{
|
||||
get(target, prop: string | symbol) {
|
||||
if (prop !== 'values') return target[prop]
|
||||
|
||||
remoteWidget.fetchOptions().then((options) => {
|
||||
if (!options || !options.length) return
|
||||
|
||||
const isUninitialized =
|
||||
res.widget.value === remoteWidget.defaultValue &&
|
||||
!res.widget.options.values?.includes(remoteWidget.defaultValue)
|
||||
if (isUninitialized) {
|
||||
res.widget.value = options[0]
|
||||
res.widget.callback?.(options[0])
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
})
|
||||
|
||||
const current = remoteWidget.getCacheEntry()
|
||||
return current?.data || widgetStore.getDefaultValue(inputData)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (inputData[1]?.control_after_generate) {
|
||||
// TODO make combo handle a widget node type?
|
||||
res.widget.linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
res.widget,
|
||||
undefined,
|
||||
undefined,
|
||||
inputData
|
||||
)
|
||||
}
|
||||
return res
|
||||
},
|
||||
IMAGEUPLOAD(node: LGraphNode, inputName: string, inputData: InputSpec, app) {
|
||||
// TODO make image upload handle a custom node type?
|
||||
const imageWidget = node.widgets.find(
|
||||
(w) => w.name === (inputData[1]?.widget ?? 'image')
|
||||
)
|
||||
let uploadWidget
|
||||
|
||||
function showImage(name) {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
node.imgs = [img]
|
||||
app.graph.setDirtyCanvas(true)
|
||||
}
|
||||
let folder_separator = name.lastIndexOf('/')
|
||||
let subfolder = ''
|
||||
if (folder_separator > -1) {
|
||||
subfolder = name.substring(0, folder_separator)
|
||||
name = name.substring(folder_separator + 1)
|
||||
}
|
||||
img.src = api.apiURL(
|
||||
`/view?filename=${encodeURIComponent(name)}&type=input&subfolder=${subfolder}${app.getPreviewFormatParam()}${app.getRandParam()}`
|
||||
)
|
||||
node.setSizeForImage?.()
|
||||
}
|
||||
|
||||
var default_value = imageWidget.value
|
||||
Object.defineProperty(imageWidget, 'value', {
|
||||
set: function (value) {
|
||||
this._real_value = value
|
||||
},
|
||||
|
||||
get: function () {
|
||||
if (!this._real_value) {
|
||||
return default_value
|
||||
}
|
||||
|
||||
let value = this._real_value
|
||||
if (value.filename) {
|
||||
let real_value = value
|
||||
value = ''
|
||||
if (real_value.subfolder) {
|
||||
value = real_value.subfolder + '/'
|
||||
}
|
||||
|
||||
value += real_value.filename
|
||||
|
||||
if (real_value.type && real_value.type !== 'input')
|
||||
value += ` [${real_value.type}]`
|
||||
}
|
||||
return value
|
||||
}
|
||||
})
|
||||
|
||||
// Add our own callback to the combo widget to render an image when it changes
|
||||
// TODO: Explain this?
|
||||
// @ts-expect-error
|
||||
const cb = node.callback
|
||||
imageWidget.callback = function () {
|
||||
showImage(imageWidget.value)
|
||||
if (cb) {
|
||||
return cb.apply(this, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
// On load if we have a value then render the image
|
||||
// The value isnt set immediately so we need to wait a moment
|
||||
// No change callbacks seem to be fired on initial setting of the value
|
||||
requestAnimationFrame(() => {
|
||||
if (imageWidget.value) {
|
||||
showImage(imageWidget.value)
|
||||
}
|
||||
})
|
||||
|
||||
async function uploadFile(file, updateNode, pasted = false) {
|
||||
try {
|
||||
// Wrap file in formdata so it includes filename
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (pasted) body.append('subfolder', 'pasted')
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status === 200) {
|
||||
const data = await resp.json()
|
||||
// Add the file to the dropdown list and update the widget value
|
||||
let path = data.name
|
||||
if (data.subfolder) path = data.subfolder + '/' + path
|
||||
|
||||
if (!imageWidget.options.values.includes(path)) {
|
||||
imageWidget.options.values.push(path)
|
||||
}
|
||||
|
||||
if (updateNode) {
|
||||
showImage(path)
|
||||
imageWidget.value = path
|
||||
}
|
||||
} else {
|
||||
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(error)
|
||||
}
|
||||
}
|
||||
|
||||
const fileInput = document.createElement('input')
|
||||
Object.assign(fileInput, {
|
||||
type: 'file',
|
||||
accept: 'image/jpeg,image/png,image/webp',
|
||||
style: 'display: none',
|
||||
onchange: async () => {
|
||||
if (fileInput.files.length) {
|
||||
await uploadFile(fileInput.files[0], true)
|
||||
}
|
||||
}
|
||||
})
|
||||
document.body.append(fileInput)
|
||||
|
||||
// Create the button widget for selecting the files
|
||||
uploadWidget = node.addWidget('button', inputName, 'image', () => {
|
||||
fileInput.click()
|
||||
})
|
||||
uploadWidget.label = 'choose file to upload'
|
||||
uploadWidget.serialize = false
|
||||
|
||||
// Add handler to check if an image is being dragged over our node
|
||||
node.onDragOver = function (e: DragEvent) {
|
||||
if (e.dataTransfer && e.dataTransfer.items) {
|
||||
const image = [...e.dataTransfer.items].find((f) => f.kind === 'file')
|
||||
return !!image
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// On drop upload files
|
||||
node.onDragDrop = function (e: DragEvent) {
|
||||
console.log('onDragDrop called')
|
||||
let handled = false
|
||||
for (const file of e.dataTransfer.files) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
uploadFile(file, !handled) // Dont await these, any order is fine, only update on first one
|
||||
handled = true
|
||||
}
|
||||
}
|
||||
|
||||
return handled
|
||||
}
|
||||
|
||||
node.pasteFile = function (file: File) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const is_pasted =
|
||||
file.name === 'image.png' && file.lastModified - Date.now() < 2000
|
||||
uploadFile(file, true, is_pasted)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return { widget: uploadWidget }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,8 +133,7 @@ export const useDialogService = () => {
|
||||
title,
|
||||
message,
|
||||
type = 'default',
|
||||
itemList = [],
|
||||
hint
|
||||
itemList = []
|
||||
}: {
|
||||
/** Dialog heading */
|
||||
title: string
|
||||
@@ -142,9 +141,8 @@ export const useDialogService = () => {
|
||||
message: string
|
||||
/** Pre-configured dialog type */
|
||||
type?: ConfirmationDialogType
|
||||
/** Displayed as an unordered list immediately below the message body */
|
||||
/** Displayed as an unorderd list immediately below the message body */
|
||||
itemList?: string[]
|
||||
hint?: string
|
||||
}): Promise<boolean | null> {
|
||||
return new Promise((resolve) => {
|
||||
const options: ShowDialogOptions = {
|
||||
@@ -155,8 +153,7 @@ export const useDialogService = () => {
|
||||
message,
|
||||
type,
|
||||
itemList,
|
||||
onConfirm: resolve,
|
||||
hint
|
||||
onConfirm: resolve
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: () => resolve(null)
|
||||
|
||||
@@ -18,10 +18,10 @@ export const useKeybindingService = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore reserved or non-modifier keybindings if typing in input fields
|
||||
// Ignore non-modifier keybindings if typing in input fields
|
||||
const target = event.composedPath()[0] as HTMLElement
|
||||
if (
|
||||
keyCombo.isReservedByTextInput &&
|
||||
!keyCombo.hasModifier &&
|
||||
(target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'INPUT' ||
|
||||
(target.tagName === 'SPAN' &&
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
|
||||
|
||||
export class Load3dService {
|
||||
private static instance: Load3dService
|
||||
private nodeToLoad3dMap = new Map<LGraphNode, Load3d | Load3dAnimation>()
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): Load3dService {
|
||||
if (!Load3dService.instance) {
|
||||
Load3dService.instance = new Load3dService()
|
||||
}
|
||||
return Load3dService.instance
|
||||
}
|
||||
|
||||
registerLoad3d(
|
||||
node: LGraphNode,
|
||||
container: HTMLElement,
|
||||
type: 'Load3D' | 'Load3DAnimation' | 'Preview3D' | 'Preview3DAnimation'
|
||||
) {
|
||||
if (this.nodeToLoad3dMap.has(node)) {
|
||||
this.removeLoad3d(node)
|
||||
}
|
||||
|
||||
const isAnimation = type.includes('Animation')
|
||||
|
||||
const Load3dClass = isAnimation ? Load3dAnimation : Load3d
|
||||
|
||||
const isPreview = type.includes('Preview')
|
||||
|
||||
const instance = new Load3dClass(container, { createPreview: !isPreview })
|
||||
|
||||
instance.setNode(node)
|
||||
|
||||
this.nodeToLoad3dMap.set(node, instance)
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
getLoad3d(node: LGraphNode): Load3d | Load3dAnimation | null {
|
||||
return this.nodeToLoad3dMap.get(node) || null
|
||||
}
|
||||
|
||||
getNodeByLoad3d(load3d: Load3d | Load3dAnimation): LGraphNode | null {
|
||||
for (const [node, instance] of this.nodeToLoad3dMap) {
|
||||
if (instance === load3d) {
|
||||
return node
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
removeLoad3d(node: LGraphNode) {
|
||||
const instance = this.nodeToLoad3dMap.get(node)
|
||||
if (instance) {
|
||||
instance.remove()
|
||||
this.nodeToLoad3dMap.delete(node)
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
for (const [node] of this.nodeToLoad3dMap) {
|
||||
this.removeLoad3d(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useLoad3dService = () => {
|
||||
return Load3dService.getInstance()
|
||||
}
|
||||
@@ -188,17 +188,14 @@ export const useWorkflowService = () => {
|
||||
*/
|
||||
const closeWorkflow = async (
|
||||
workflow: ComfyWorkflow,
|
||||
options: { warnIfUnsaved: boolean; hint?: string } = {
|
||||
warnIfUnsaved: true
|
||||
}
|
||||
options: { warnIfUnsaved: boolean } = { warnIfUnsaved: true }
|
||||
): Promise<boolean> => {
|
||||
if (workflow.isModified && options.warnIfUnsaved) {
|
||||
const confirmed = await dialogService.confirm({
|
||||
title: t('sideToolbar.workflowTab.dirtyCloseTitle'),
|
||||
type: 'dirtyClose',
|
||||
message: t('sideToolbar.workflowTab.dirtyClose'),
|
||||
itemList: [workflow.path],
|
||||
hint: options.hint
|
||||
itemList: [workflow.path]
|
||||
})
|
||||
// Cancel
|
||||
if (confirmed === null) return false
|
||||
|
||||
@@ -2,7 +2,6 @@ import _ from 'lodash'
|
||||
import { defineStore } from 'pinia'
|
||||
import { Ref, computed, ref, toRaw } from 'vue'
|
||||
|
||||
import { RESERVED_BY_TEXT_INPUT } from '@/constants/reservedKeyCombos'
|
||||
import { KeyCombo, Keybinding } from '@/types/keyBindingTypes'
|
||||
|
||||
export class KeybindingImpl implements Keybinding {
|
||||
@@ -77,23 +76,6 @@ export class KeyComboImpl implements KeyCombo {
|
||||
return ['Control', 'Meta', 'Alt', 'Shift'].includes(this.key)
|
||||
}
|
||||
|
||||
get modifierCount(): number {
|
||||
const modifiers = [this.ctrl, this.alt, this.shift]
|
||||
return modifiers.reduce((acc, cur) => acc + Number(cur), 0)
|
||||
}
|
||||
|
||||
get isShiftOnly(): boolean {
|
||||
return this.shift && this.modifierCount === 1
|
||||
}
|
||||
|
||||
get isReservedByTextInput(): boolean {
|
||||
return (
|
||||
!this.hasModifier ||
|
||||
this.isShiftOnly ||
|
||||
RESERVED_BY_TEXT_INPUT.has(this.toString())
|
||||
)
|
||||
}
|
||||
|
||||
getKeySequences(): string[] {
|
||||
const sequences: string[] = []
|
||||
if (this.ctrl) {
|
||||
|
||||
@@ -53,7 +53,7 @@ export const useWidgetStore = defineStore('widget', () => {
|
||||
if (props.default) return props.default
|
||||
|
||||
if (widgetType === 'COMBO' && props.options?.length) return props.options[0]
|
||||
if (props.remote) return 'Loading...'
|
||||
if (props.type === 'remote') return 'Loading...'
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -269,17 +269,6 @@ function inputSpec<TType extends ZodType, TSpec extends ZodType>(
|
||||
])
|
||||
}
|
||||
|
||||
const zRemoteWidgetConfig = z.object({
|
||||
route: z.string().url().or(z.string().startsWith('/')),
|
||||
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
|
||||
response_key: z.string().optional(),
|
||||
query_params: z.record(z.string(), z.string()).optional(),
|
||||
refresh_button: z.boolean().optional(),
|
||||
control_after_refresh: z.enum(['first', 'last']).optional(),
|
||||
timeout: z.number().gte(0).optional(),
|
||||
max_retries: z.number().gte(0).optional()
|
||||
})
|
||||
|
||||
const zBaseInputSpecValue = z
|
||||
.object({
|
||||
default: z.any().optional(),
|
||||
@@ -301,12 +290,7 @@ const zIntInputSpec = inputSpec([
|
||||
step: z.number().optional(),
|
||||
// Note: Many node authors are using INT to pass list of INT.
|
||||
// TODO: Add list of ints type.
|
||||
default: z.union([z.number(), z.array(z.number())]).optional(),
|
||||
/**
|
||||
* If true, a linked widget will be added to the node to select the mode
|
||||
* of `control_after_generate`.
|
||||
*/
|
||||
control_after_generate: z.boolean().optional()
|
||||
default: z.union([z.number(), z.array(z.number())]).optional()
|
||||
})
|
||||
])
|
||||
|
||||
@@ -348,8 +332,11 @@ const zStringInputSpec = inputSpec([
|
||||
const zComboInputProps = zBaseInputSpecValue.extend({
|
||||
control_after_generate: z.boolean().optional(),
|
||||
image_upload: z.boolean().optional(),
|
||||
image_folder: z.enum(['input', 'output', 'temp']).optional(),
|
||||
remote: zRemoteWidgetConfig.optional()
|
||||
type: z.enum(['remote']).optional(),
|
||||
route: z.string().url().or(z.string().startsWith('/')).optional(),
|
||||
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
|
||||
response_key: z.string().optional(),
|
||||
query_params: z.record(z.string(), z.string()).optional()
|
||||
})
|
||||
|
||||
// Dropdown Selection.
|
||||
@@ -607,4 +594,3 @@ export type UserDataFullInfo = z.infer<typeof zUserDataFullInfo>
|
||||
export type TerminalSize = z.infer<typeof zTerminalSize>
|
||||
export type LogEntry = z.infer<typeof zLogEntry>
|
||||
export type LogsRawResponse = z.infer<typeof zLogRawResponse>
|
||||
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
|
||||
|
||||
11
src/types/litegraph-augmentation.d.ts
vendored
@@ -16,14 +16,6 @@ declare module '@comfyorg/litegraph/dist/types/widgets' {
|
||||
interface IBaseWidget {
|
||||
onRemove?: () => void
|
||||
beforeQueued?: () => unknown
|
||||
afterQueued?: () => unknown
|
||||
serializeValue?: (node: LGraphNode, index: number) => Promise<unknown>
|
||||
|
||||
/**
|
||||
* If the widget supports dynamic prompts, this will be set to true.
|
||||
* See extensions/core/dynamicPrompts.ts
|
||||
*/
|
||||
dynamicPrompts?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +43,8 @@ declare module '@comfyorg/litegraph' {
|
||||
onExecuted?(output: any): void
|
||||
onNodeCreated?(this: LGraphNode): void
|
||||
setInnerNodes?(nodes: LGraphNode[]): void
|
||||
getInnerNodes?(): LGraphNode[]
|
||||
// TODO: Requires several coercion changes to runtime code.
|
||||
getInnerNodes?() // : LGraphNode[]
|
||||
convertToNodes?(): LGraphNode[]
|
||||
recreate?(): Promise<LGraphNode>
|
||||
refreshComboInNode?(defs: Record<string, ComfyNodeDef>)
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { InputSpec } from '@/types/apiTypes'
|
||||
|
||||
export function getNumberDefaults(
|
||||
inputOptions: InputSpec[1],
|
||||
options: {
|
||||
defaultStep: number
|
||||
precision?: number
|
||||
enableRounding: boolean
|
||||
}
|
||||
) {
|
||||
const { defaultStep } = options
|
||||
const {
|
||||
default: defaultVal = 0,
|
||||
min = 0,
|
||||
max = 2048,
|
||||
step = defaultStep
|
||||
} = inputOptions
|
||||
// precision is the number of decimal places to show.
|
||||
// by default, display the the smallest number of decimal places such that changes of size step are visible.
|
||||
const { precision = Math.max(-Math.floor(Math.log10(step)), 0) } = options
|
||||
|
||||
let round = inputOptions.round
|
||||
if (options.enableRounding && (round == undefined || round === true)) {
|
||||
// by default, round the value to those decimal places shown.
|
||||
round = Math.round(1000000 * Math.pow(0.1, precision)) / 1000000
|
||||
}
|
||||
|
||||
return {
|
||||
val: defaultVal,
|
||||
config: { min, max, step: 10.0 * step, round, precision }
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,8 @@
|
||||
<template>
|
||||
<div class="comfyui-body grid h-screen w-screen overflow-hidden">
|
||||
<div class="comfyui-body-top" id="comfyui-body-top">
|
||||
<TopMenubar v-if="useNewMenu === 'Top'" />
|
||||
</div>
|
||||
<div class="comfyui-body-bottom" id="comfyui-body-bottom">
|
||||
<TopMenubar v-if="useNewMenu === 'Bottom'" />
|
||||
</div>
|
||||
<div class="comfyui-body-left" id="comfyui-body-left" />
|
||||
<div class="comfyui-body-right" id="comfyui-body-right" />
|
||||
<div class="graph-canvas-container" id="graph-canvas-container">
|
||||
<GraphCanvas @ready="onGraphReady" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top menu bar needs to load before the GraphCanvas as it needs to host
|
||||
the menu buttons added by legacy extension scripts.-->
|
||||
<TopMenubar />
|
||||
<GraphCanvas @ready="onGraphReady" />
|
||||
<GlobalToast />
|
||||
<UnloadWindowConfirmDialog v-if="!isElectron()" />
|
||||
<BrowserTabTitle />
|
||||
@@ -23,7 +13,7 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onBeforeUnmount, onMounted, watch, watchEffect } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, watch, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import BrowserTabTitle from '@/components/BrowserTabTitle.vue'
|
||||
@@ -136,11 +126,9 @@ watchEffect(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const useNewMenu = computed(() => {
|
||||
return settingStore.get('Comfy.UseNewMenu')
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (useNewMenu.value === 'Disabled') {
|
||||
const useNewMenu = settingStore.get('Comfy.UseNewMenu')
|
||||
if (useNewMenu === 'Disabled') {
|
||||
app.ui.menuContainer.style.setProperty('display', 'block')
|
||||
app.ui.restoreMenuPosition()
|
||||
} else {
|
||||
@@ -238,85 +226,3 @@ const onGraphReady = () => {
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comfyui-body {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
/**
|
||||
+------------------+------------------+------------------+
|
||||
| |
|
||||
| .comfyui-body- |
|
||||
| top |
|
||||
| (spans all cols) |
|
||||
| |
|
||||
+------------------+------------------+------------------+
|
||||
| | | |
|
||||
| .comfyui-body- | #graph-canvas | .comfyui-body- |
|
||||
| left | | right |
|
||||
| | | |
|
||||
| | | |
|
||||
+------------------+------------------+------------------+
|
||||
| |
|
||||
| .comfyui-body- |
|
||||
| bottom |
|
||||
| (spans all cols) |
|
||||
| |
|
||||
+------------------+------------------+------------------+
|
||||
*/
|
||||
|
||||
.comfyui-body-top {
|
||||
order: -5;
|
||||
/* Span across all columns */
|
||||
grid-column: 1/-1;
|
||||
/* Position at the first row */
|
||||
grid-row: 1;
|
||||
/* Top menu bar dropdown needs to be above of graph canvas splitter overlay which is z-index: 999 */
|
||||
/* Top menu bar z-index needs to be higher than bottom menu bar z-index as by default
|
||||
pysssss's image feed is located at body-bottom, and it can overlap with the queue button, which
|
||||
is located in body-top. */
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.comfyui-body-left {
|
||||
order: -4;
|
||||
/* Position in the first column */
|
||||
grid-column: 1;
|
||||
/* Position below the top element */
|
||||
grid-row: 2;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.graph-canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
order: -3;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comfyui-body-right {
|
||||
order: -2;
|
||||
z-index: 10;
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.comfyui-body-bottom {
|
||||
order: 4;
|
||||
/* Span across all columns */
|
||||
grid-column: 1/-1;
|
||||
grid-row: 3;
|
||||
/* Bottom menu bar dropdown needs to be above of graph canvas splitter overlay which is z-index: 999 */
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<div
|
||||
class="min-w-full min-h-full font-sans w-screen h-screen grid justify-around text-neutral-300 bg-neutral-900 dark-theme overflow-y-auto"
|
||||
class="min-w-full min-h-full font-sans w-screen h-screen grid justify-around text-neutral-300 bg-neutral-900 dark-theme pointer-events-auto overflow-y-auto"
|
||||
>
|
||||
<div class="max-w-screen-sm w-screen m-8 relative">
|
||||
<!-- Header -->
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="font-sans w-screen h-screen flex flex-col"
|
||||
class="font-sans w-screen h-screen flex flex-col pointer-events-auto"
|
||||
:class="[
|
||||
props.dark
|
||||
? 'text-neutral-300 bg-neutral-900 dark-theme'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { useRemoteWidget } from '@/composables/widgets/useRemoteWidget'
|
||||
import { useRemoteWidget } from '@/hooks/remoteWidgetHook'
|
||||
import type { ComboInputSpecV2 } from '@/types/apiTypes'
|
||||
|
||||
jest.mock('axios', () => ({
|
||||
@@ -21,30 +21,28 @@ jest.mock('@/stores/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
jest.mock('@/stores/widgetStore', () => ({
|
||||
useWidgetStore: () => ({
|
||||
widgets: {},
|
||||
getDefaultValue: jest.fn().mockReturnValue('Loading...')
|
||||
})
|
||||
}))
|
||||
|
||||
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
|
||||
const DEFAULT_VALUE = 'Loading...'
|
||||
|
||||
function createMockInputData(overrides = {}): ComboInputSpecV2 {
|
||||
return [
|
||||
'COMBO',
|
||||
{
|
||||
name: 'test_widget',
|
||||
remote: {
|
||||
route: `/api/test/${Date.now()}${Math.random().toString(36).substring(2, 15)}`,
|
||||
refresh: 0,
|
||||
...overrides
|
||||
}
|
||||
type: 'remote',
|
||||
route: `/api/test/${Date.now()}${Math.random().toString(36).substring(2, 15)}`,
|
||||
refresh: 0,
|
||||
...overrides
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const createMockOptions = (inputOverrides = {}) => ({
|
||||
inputData: createMockInputData(inputOverrides),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: {} as any,
|
||||
widget: {} as any
|
||||
})
|
||||
|
||||
function mockAxiosResponse(data: unknown, status = 200) {
|
||||
jest.mocked(axios.get).mockResolvedValueOnce({ data, status })
|
||||
}
|
||||
@@ -56,25 +54,16 @@ function mockAxiosError(error: Error | string) {
|
||||
|
||||
function createHookWithData(data: unknown, inputOverrides = {}) {
|
||||
mockAxiosResponse(data)
|
||||
const hook = useRemoteWidget(createMockOptions(inputOverrides))
|
||||
const hook = useRemoteWidget(createMockInputData(inputOverrides))
|
||||
return hook
|
||||
}
|
||||
|
||||
async function setupHookWithResponse(data: unknown, inputOverrides = {}) {
|
||||
const hook = createHookWithData(data, inputOverrides)
|
||||
const result = await getResolvedValue(hook)
|
||||
const result = await hook.fetchOptions()
|
||||
return { hook, result }
|
||||
}
|
||||
|
||||
async function getResolvedValue(hook: ReturnType<typeof useRemoteWidget>) {
|
||||
// Create a promise that resolves when the fetch is complete
|
||||
const responsePromise = new Promise<void>((resolve) => {
|
||||
hook.getValue(() => resolve())
|
||||
})
|
||||
await responsePromise
|
||||
return hook.getCachedValue()
|
||||
}
|
||||
|
||||
describe('useRemoteWidget', () => {
|
||||
let mockInputData: ComboInputSpecV2
|
||||
|
||||
@@ -96,26 +85,25 @@ describe('useRemoteWidget', () => {
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should create hook with default values', () => {
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
expect(hook.getCachedValue()).toBeUndefined()
|
||||
expect(hook.getValue()).toBe('Loading...')
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
expect(hook.getCacheEntry()).toBeUndefined()
|
||||
expect(hook.defaultValue).toBe('Loading...')
|
||||
})
|
||||
|
||||
it('should generate consistent cache keys', () => {
|
||||
const options = createMockOptions()
|
||||
const hook1 = useRemoteWidget(options)
|
||||
const hook2 = useRemoteWidget(options)
|
||||
expect(hook1.cacheKey).toBe(hook2.cacheKey)
|
||||
const hook1 = useRemoteWidget(mockInputData)
|
||||
const hook2 = useRemoteWidget(mockInputData)
|
||||
expect(hook1.getCacheKey()).toBe(hook2.getCacheKey())
|
||||
})
|
||||
|
||||
it('should handle query params in cache key', () => {
|
||||
const hook1 = useRemoteWidget(
|
||||
createMockOptions({ query_params: { a: 1 } })
|
||||
createMockInputData({ query_params: { a: 1 } })
|
||||
)
|
||||
const hook2 = useRemoteWidget(
|
||||
createMockOptions({ query_params: { a: 2 } })
|
||||
createMockInputData({ query_params: { a: 2 } })
|
||||
)
|
||||
expect(hook1.cacheKey).not.toBe(hook2.cacheKey)
|
||||
expect(hook1.getCacheKey()).not.toBe(hook2.getCacheKey())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -125,7 +113,7 @@ describe('useRemoteWidget', () => {
|
||||
const { hook, result } = await setupHookWithResponse(mockData)
|
||||
expect(result).toEqual(mockData)
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledWith(
|
||||
hook.cacheKey.split(';')[0], // Get the route part from cache key
|
||||
hook.getCacheKey().split(';')[0], // Get the route part from cache key
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
@@ -151,7 +139,9 @@ describe('useRemoteWidget', () => {
|
||||
const error = new Error('Network error')
|
||||
mockAxiosError(error)
|
||||
|
||||
const { hook } = await setupHookWithResponse([])
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
const data = await hook.fetchOptions()
|
||||
expect(data).toBe('Loading...')
|
||||
|
||||
const entry = hook.getCacheEntry()
|
||||
expect(entry?.error).toBeTruthy()
|
||||
@@ -164,22 +154,26 @@ describe('useRemoteWidget', () => {
|
||||
})
|
||||
|
||||
it('should handle malformed response data', async () => {
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
const { defaultValue } = hook
|
||||
|
||||
mockAxiosResponse(null)
|
||||
const data1 = hook.getValue()
|
||||
const data1 = await hook.fetchOptions()
|
||||
|
||||
mockAxiosResponse(undefined)
|
||||
const data2 = hook.getValue()
|
||||
const data2 = await hook.fetchOptions()
|
||||
|
||||
expect(data1).toBe(DEFAULT_VALUE)
|
||||
expect(data2).toBe(DEFAULT_VALUE)
|
||||
expect(data1).toBe(defaultValue)
|
||||
expect(data2).toBe(defaultValue)
|
||||
})
|
||||
|
||||
it('should handle non-200 status codes', async () => {
|
||||
mockAxiosError('Request failed with status code 404')
|
||||
|
||||
const { hook } = await setupHookWithResponse([])
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
const data = await hook.fetchOptions()
|
||||
|
||||
expect(data).toBe('Loading...')
|
||||
const entry = hook.getCacheEntry()
|
||||
expect(entry?.error?.message).toBe('Request failed with status code 404')
|
||||
})
|
||||
@@ -200,34 +194,34 @@ describe('useRemoteWidget', () => {
|
||||
const mockData = ['data that is permanent after initialization']
|
||||
const { hook } = await setupHookWithResponse(mockData)
|
||||
|
||||
await getResolvedValue(hook)
|
||||
await getResolvedValue(hook)
|
||||
await hook.fetchOptions()
|
||||
await hook.fetchOptions()
|
||||
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('permanent widgets should re-fetch if refreshValue is called', async () => {
|
||||
it('permanent widgets should re-fetch if forceUpdate is called', async () => {
|
||||
const mockData = ['data that is permanent after initialization']
|
||||
const { hook } = await setupHookWithResponse(mockData)
|
||||
|
||||
await getResolvedValue(hook)
|
||||
await hook.fetchOptions()
|
||||
const refreshedData = ['data that user forced to be fetched']
|
||||
mockAxiosResponse(refreshedData)
|
||||
|
||||
hook.refreshValue()
|
||||
const data = await getResolvedValue(hook)
|
||||
await hook.forceUpdate()
|
||||
const data = await hook.fetchOptions()
|
||||
expect(data).toEqual(refreshedData)
|
||||
})
|
||||
|
||||
it('permanent widgets should still retry if request fails', async () => {
|
||||
mockAxiosError('Network error')
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
await hook.fetchOptions()
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
|
||||
jest.setSystemTime(Date.now() + FIRST_BACKOFF)
|
||||
const secondData = await getResolvedValue(hook)
|
||||
const secondData = await hook.fetchOptions()
|
||||
expect(secondData).toBe('Loading...')
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -235,8 +229,8 @@ describe('useRemoteWidget', () => {
|
||||
it('should treat empty refresh field as permanent', async () => {
|
||||
const { hook } = await setupHookWithResponse(['data that is permanent'])
|
||||
|
||||
await getResolvedValue(hook)
|
||||
await getResolvedValue(hook)
|
||||
await hook.fetchOptions()
|
||||
await hook.fetchOptions()
|
||||
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -251,7 +245,7 @@ describe('useRemoteWidget', () => {
|
||||
mockAxiosResponse(mockData2)
|
||||
|
||||
jest.setSystemTime(Date.now() + refresh)
|
||||
const newData = await getResolvedValue(hook)
|
||||
const newData = await hook.fetchOptions()
|
||||
|
||||
expect(newData).toEqual(mockData2)
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
@@ -263,7 +257,7 @@ describe('useRemoteWidget', () => {
|
||||
})
|
||||
|
||||
jest.setSystemTime(Date.now() + 128)
|
||||
await getResolvedValue(hook)
|
||||
await hook.fetchOptions()
|
||||
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -276,12 +270,12 @@ describe('useRemoteWidget', () => {
|
||||
|
||||
mockAxiosError('Network error')
|
||||
jest.setSystemTime(Date.now() + refresh)
|
||||
await getResolvedValue(hook)
|
||||
await hook.fetchOptions()
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
|
||||
mockAxiosResponse(['second success'])
|
||||
jest.setSystemTime(Date.now() + FIRST_BACKOFF)
|
||||
const thirdData = await getResolvedValue(hook)
|
||||
const thirdData = await hook.fetchOptions()
|
||||
expect(thirdData).toEqual(['second success'])
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
@@ -294,7 +288,7 @@ describe('useRemoteWidget', () => {
|
||||
|
||||
mockAxiosError('Network error')
|
||||
jest.setSystemTime(Date.now() + refresh)
|
||||
const secondData = await getResolvedValue(hook)
|
||||
const secondData = await hook.fetchOptions()
|
||||
|
||||
expect(secondData).toEqual(['a valid value'])
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
@@ -313,33 +307,33 @@ describe('useRemoteWidget', () => {
|
||||
it('should implement exponential backoff on errors', async () => {
|
||||
mockAxiosError('Network error')
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
await hook.fetchOptions()
|
||||
const entry1 = hook.getCacheEntry()
|
||||
expect(entry1?.error).toBeTruthy()
|
||||
|
||||
await getResolvedValue(hook)
|
||||
await hook.fetchOptions()
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
|
||||
jest.setSystemTime(Date.now() + 500)
|
||||
await getResolvedValue(hook)
|
||||
await hook.fetchOptions()
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1) // Still backing off
|
||||
|
||||
jest.setSystemTime(Date.now() + 3000)
|
||||
await getResolvedValue(hook)
|
||||
await hook.fetchOptions()
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
expect(entry1?.data).toBeDefined()
|
||||
})
|
||||
|
||||
it('should reset error state on successful fetch', async () => {
|
||||
mockAxiosError('Network error')
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
const firstData = await getResolvedValue(hook)
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
const firstData = await hook.fetchOptions()
|
||||
expect(firstData).toBe('Loading...')
|
||||
|
||||
jest.setSystemTime(Date.now() + 3000)
|
||||
mockAxiosResponse(['option1'])
|
||||
const secondData = await getResolvedValue(hook)
|
||||
const secondData = await hook.fetchOptions()
|
||||
expect(secondData).toEqual(['option1'])
|
||||
|
||||
const entry = hook.getCacheEntry()
|
||||
@@ -349,14 +343,14 @@ describe('useRemoteWidget', () => {
|
||||
|
||||
it('should save successful data after backoff', async () => {
|
||||
mockAxiosError('Network error')
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
await hook.fetchOptions()
|
||||
const entry1 = hook.getCacheEntry()
|
||||
expect(entry1?.error).toBeTruthy()
|
||||
|
||||
jest.setSystemTime(Date.now() + 3000)
|
||||
mockAxiosResponse(['success after backoff'])
|
||||
const secondData = await getResolvedValue(hook)
|
||||
const secondData = await hook.fetchOptions()
|
||||
expect(secondData).toEqual(['success after backoff'])
|
||||
|
||||
const entry2 = hook.getCacheEntry()
|
||||
@@ -368,24 +362,24 @@ describe('useRemoteWidget', () => {
|
||||
mockAxiosError('Network error')
|
||||
mockAxiosError('Network error')
|
||||
mockAxiosError('Network error')
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
await hook.fetchOptions()
|
||||
const entry1 = hook.getCacheEntry()
|
||||
expect(entry1?.error).toBeTruthy()
|
||||
|
||||
jest.setSystemTime(Date.now() + 3000)
|
||||
const secondData = await getResolvedValue(hook)
|
||||
const secondData = await hook.fetchOptions()
|
||||
expect(secondData).toBe('Loading...')
|
||||
expect(entry1?.error).toBeDefined()
|
||||
|
||||
jest.setSystemTime(Date.now() + 9000)
|
||||
const thirdData = await getResolvedValue(hook)
|
||||
const thirdData = await hook.fetchOptions()
|
||||
expect(thirdData).toBe('Loading...')
|
||||
expect(entry1?.error).toBeDefined()
|
||||
|
||||
jest.setSystemTime(Date.now() + 120_000)
|
||||
mockAxiosResponse(['success after multiple backoffs'])
|
||||
const fourthData = await getResolvedValue(hook)
|
||||
const fourthData = await hook.fetchOptions()
|
||||
expect(fourthData).toEqual(['success after multiple backoffs'])
|
||||
|
||||
const entry2 = hook.getCacheEntry()
|
||||
@@ -397,20 +391,20 @@ describe('useRemoteWidget', () => {
|
||||
describe('cache management', () => {
|
||||
it('should clear cache entries', async () => {
|
||||
const { hook } = await setupHookWithResponse(['to be cleared'])
|
||||
expect(hook.getCachedValue()).toBeDefined()
|
||||
expect(hook.getCacheEntry()).toBeDefined()
|
||||
|
||||
hook.refreshValue()
|
||||
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
|
||||
hook.forceUpdate()
|
||||
expect(hook.getCacheEntry()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should prevent duplicate in-flight requests', async () => {
|
||||
const promise = Promise.resolve({ data: ['non-duplicate'] })
|
||||
jest.mocked(axios.get).mockImplementationOnce(() => promise)
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
const [result1, result2] = await Promise.all([
|
||||
getResolvedValue(hook),
|
||||
getResolvedValue(hook)
|
||||
hook.fetchOptions(),
|
||||
hook.fetchOptions()
|
||||
])
|
||||
|
||||
expect(result1).toBe(result2)
|
||||
@@ -421,43 +415,40 @@ describe('useRemoteWidget', () => {
|
||||
describe('concurrent access and multiple instances', () => {
|
||||
it('should handle concurrent hook instances with same route', async () => {
|
||||
mockAxiosResponse(['shared data'])
|
||||
const options = createMockOptions()
|
||||
const hook1 = useRemoteWidget(options)
|
||||
const hook2 = useRemoteWidget(options)
|
||||
const hook1 = useRemoteWidget(mockInputData)
|
||||
const hook2 = useRemoteWidget(mockInputData)
|
||||
|
||||
// Since they have the same route, only one request will be made
|
||||
await Promise.race([getResolvedValue(hook1), getResolvedValue(hook2)])
|
||||
|
||||
const data1 = hook1.getValue()
|
||||
const data2 = hook2.getValue()
|
||||
const [data1, data2] = await Promise.all([
|
||||
hook1.fetchOptions(),
|
||||
hook2.fetchOptions()
|
||||
])
|
||||
|
||||
expect(data1).toEqual(['shared data'])
|
||||
expect(data2).toEqual(['shared data'])
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
|
||||
expect(hook1.getCacheEntry()).toBe(hook2.getCacheEntry())
|
||||
})
|
||||
|
||||
it('should use shared cache across multiple hooks', async () => {
|
||||
mockAxiosResponse(['shared data'])
|
||||
const options = createMockOptions()
|
||||
const hook1 = useRemoteWidget(options)
|
||||
const hook2 = useRemoteWidget(options)
|
||||
const hook3 = useRemoteWidget(options)
|
||||
const hook4 = useRemoteWidget(options)
|
||||
const hook1 = useRemoteWidget(mockInputData)
|
||||
const hook2 = useRemoteWidget(mockInputData)
|
||||
const hook3 = useRemoteWidget(mockInputData)
|
||||
const hook4 = useRemoteWidget(mockInputData)
|
||||
|
||||
const data1 = await getResolvedValue(hook1)
|
||||
const data2 = await getResolvedValue(hook2)
|
||||
const data3 = await getResolvedValue(hook3)
|
||||
const data4 = await getResolvedValue(hook4)
|
||||
const data1 = await hook1.fetchOptions()
|
||||
const data2 = await hook2.fetchOptions()
|
||||
const data3 = await hook3.fetchOptions()
|
||||
const data4 = await hook4.fetchOptions()
|
||||
|
||||
expect(data1).toEqual(['shared data'])
|
||||
expect(data2).toBe(data1)
|
||||
expect(data3).toBe(data1)
|
||||
expect(data4).toBe(data1)
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
|
||||
expect(hook2.getCachedValue()).toBe(hook3.getCachedValue())
|
||||
expect(hook3.getCachedValue()).toBe(hook4.getCachedValue())
|
||||
expect(hook1.getCacheEntry()).toBe(hook2.getCacheEntry())
|
||||
expect(hook2.getCacheEntry()).toBe(hook3.getCacheEntry())
|
||||
expect(hook3.getCacheEntry()).toBe(hook4.getCacheEntry())
|
||||
})
|
||||
|
||||
it('should handle rapid cache clearing during fetch', async () => {
|
||||
@@ -468,17 +459,15 @@ describe('useRemoteWidget', () => {
|
||||
|
||||
jest.mocked(axios.get).mockImplementationOnce(() => delayedPromise)
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
hook.getValue()
|
||||
hook.refreshValue()
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
const fetchPromise = hook.fetchOptions()
|
||||
hook.forceUpdate()
|
||||
|
||||
resolvePromise!({ data: ['delayed data'] })
|
||||
const data = await getResolvedValue(hook)
|
||||
const data = await fetchPromise
|
||||
|
||||
// The value should be the default value because the refreshValue
|
||||
// clears the cache and the fetch is aborted
|
||||
expect(data).toEqual(DEFAULT_VALUE)
|
||||
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
|
||||
expect(data).toEqual(['delayed data'])
|
||||
expect(hook.getCacheEntry()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle widget destroyed during fetch', async () => {
|
||||
@@ -489,8 +478,8 @@ describe('useRemoteWidget', () => {
|
||||
|
||||
jest.mocked(axios.get).mockImplementationOnce(() => delayedPromise)
|
||||
|
||||
let hook = useRemoteWidget(createMockOptions())
|
||||
const fetchPromise = hook.getValue()
|
||||
let hook = useRemoteWidget(mockInputData)
|
||||
const fetchPromise = hook.fetchOptions()
|
||||
|
||||
hook = null as any
|
||||
|
||||
@@ -498,10 +487,10 @@ describe('useRemoteWidget', () => {
|
||||
await fetchPromise
|
||||
|
||||
expect(hook).toBeNull()
|
||||
hook = useRemoteWidget(createMockOptions())
|
||||
hook = useRemoteWidget(mockInputData)
|
||||
|
||||
const data2 = await getResolvedValue(hook)
|
||||
expect(data2).toEqual(DEFAULT_VALUE)
|
||||
const data2 = await hook.fetchOptions()
|
||||
expect(data2).toEqual(['delayed data'])
|
||||
})
|
||||
})
|
||||
})
|
||||