Compare commits
46 Commits
fix/codera
...
backport/2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdedc6b684 | ||
|
|
516819cc5a | ||
|
|
77051cabf5 | ||
|
|
88d92f53f0 | ||
|
|
9ad36303f6 | ||
|
|
815e2d843d | ||
|
|
baf8fb918a | ||
|
|
cb6d33007c | ||
|
|
386e10516e | ||
|
|
03a99e2eed | ||
|
|
8b2620ee0f | ||
|
|
5537877f49 | ||
|
|
2ab1a0cf2b | ||
|
|
8192bbcae8 | ||
|
|
b17ab15e90 | ||
|
|
195d779035 | ||
|
|
c814db1861 | ||
|
|
87f8de7759 | ||
|
|
623954e582 | ||
|
|
6e541b7c46 | ||
|
|
2561dd9ac0 | ||
|
|
4ed7c29f9a | ||
|
|
4231514baf | ||
|
|
79cdd0c06a | ||
|
|
f7278ddbee | ||
|
|
5b3401b8dd | ||
|
|
0e757b88fa | ||
|
|
4ae12b8006 | ||
|
|
0c265922dc | ||
|
|
7fb3d2b122 | ||
|
|
1df90b0b5f | ||
|
|
b24d155bcd | ||
|
|
cb50a45a80 | ||
|
|
3d7df5762b | ||
|
|
792554d6dd | ||
|
|
8df8149bd6 | ||
|
|
5e23ae318c | ||
|
|
5606c977c8 | ||
|
|
5103c8df3f | ||
|
|
0da2d80708 | ||
|
|
93b06525cc | ||
|
|
6867d84ec1 | ||
|
|
d0bd4c26ca | ||
|
|
9f19c8e10e | ||
|
|
36b8972442 | ||
|
|
6f77d274a4 |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
@@ -170,9 +170,7 @@ test.describe('Templates', () => {
|
||||
|
||||
// Verify English titles are shown as fallback
|
||||
await expect(
|
||||
comfyPage.templates.content.getByRole('heading', {
|
||||
name: 'Image Generation'
|
||||
})
|
||||
comfyPage.page.getByRole('main').getByText('All Templates')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Node Resizing', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should resize node without position drift after selecting', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get a Vue node fixture
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
const initialBox = await node.boundingBox()
|
||||
if (!initialBox) throw new Error('Node bounding box not found')
|
||||
|
||||
// Select the node first (this was causing the bug)
|
||||
await node.header.click()
|
||||
await comfyPage.page.waitForTimeout(100) // Brief pause after selection
|
||||
|
||||
// Get position after selection
|
||||
const selectedBox = await node.boundingBox()
|
||||
if (!selectedBox)
|
||||
throw new Error('Node bounding box not found after select')
|
||||
|
||||
// Verify position unchanged after selection
|
||||
expect(selectedBox.x).toBeCloseTo(initialBox.x, 1)
|
||||
expect(selectedBox.y).toBeCloseTo(initialBox.y, 1)
|
||||
|
||||
// Now resize from bottom-right corner
|
||||
const resizeStartX = selectedBox.x + selectedBox.width - 5
|
||||
const resizeStartY = selectedBox.y + selectedBox.height - 5
|
||||
|
||||
await comfyPage.page.mouse.move(resizeStartX, resizeStartY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
// Get final position and size
|
||||
const finalBox = await node.boundingBox()
|
||||
if (!finalBox) throw new Error('Node bounding box not found after resize')
|
||||
|
||||
// Position should NOT have changed (the bug was position drift)
|
||||
expect(finalBox.x).toBeCloseTo(initialBox.x, 1)
|
||||
expect(finalBox.y).toBeCloseTo(initialBox.y, 1)
|
||||
|
||||
// Size should have increased
|
||||
expect(finalBox.width).toBeGreaterThan(initialBox.width)
|
||||
expect(finalBox.height).toBeGreaterThan(initialBox.height)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
@@ -1,49 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Vue Nodes - LOD', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('default')
|
||||
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
|
||||
})
|
||||
|
||||
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialNodeCount).toBeGreaterThan(0)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
|
||||
|
||||
const vueNodesContainer = comfyPage.vueNodes.nodes
|
||||
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
|
||||
const comboboxesInNodes = vueNodesContainer.getByRole('combobox')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
|
||||
await comfyPage.zoom(120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeHidden()
|
||||
await expect(comboboxesInNodes.first()).toBeHidden()
|
||||
|
||||
await comfyPage.zoom(-120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-lod-inactive.png'
|
||||
)
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 83 KiB |
@@ -88,12 +88,14 @@ export function comfyAPIPlugin(isDev: boolean): Plugin {
|
||||
|
||||
if (result.exports.length > 0) {
|
||||
const projectRoot = process.cwd()
|
||||
const relativePath = path.relative(path.join(projectRoot, 'src'), id)
|
||||
const relativePath = path
|
||||
.relative(path.join(projectRoot, 'src'), id)
|
||||
.replace(/\\/g, '/')
|
||||
const shimFileName = relativePath.replace(/\.ts$/, '.js')
|
||||
|
||||
let shimContent = `// Shim for ${relativePath}\n`
|
||||
|
||||
const fileKey = relativePath.replace(/\.ts$/, '').replace(/\\/g, '/')
|
||||
const fileKey = relativePath.replace(/\.ts$/, '')
|
||||
const warningMessage = getWarningMessage(fileKey, shimFileName)
|
||||
|
||||
if (warningMessage) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.33.9",
|
||||
"version": "1.33.13",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
--node-component-executing: var(--color-blue-500);
|
||||
--node-component-header: var(--fg-color);
|
||||
--node-component-header-icon: var(--color-ash-800);
|
||||
--node-component-header-surface: var(--color-white);
|
||||
--node-component-header-surface: var(--color-smoke-400);
|
||||
--node-component-outline: var(--color-black);
|
||||
--node-component-ring: rgb(from var(--color-smoke-500) r g b / 50%);
|
||||
--node-component-slot-dot-outline-opacity-mult: 1;
|
||||
@@ -1190,24 +1190,19 @@ dialog::backdrop {
|
||||
.litegraph.litecontextmenu,
|
||||
.litegraph.litecontextmenu.dark {
|
||||
z-index: 9999 !important;
|
||||
background-color: var(--comfy-menu-bg) !important;
|
||||
background-color: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu
|
||||
.litemenu-entry:hover:not(.disabled):not(.separator) {
|
||||
background-color: var(--comfy-menu-hover-bg, var(--border-color)) !important;
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu .litemenu-entry.submenu,
|
||||
.litegraph.litecontextmenu.dark .litemenu-entry.submenu {
|
||||
background-color: var(--comfy-menu-bg) !important;
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu input {
|
||||
background-color: var(--comfy-input-bg) !important;
|
||||
color: var(--input-text) !important;
|
||||
background-color: var(--comfy-input-bg);
|
||||
color: var(--input-text);
|
||||
}
|
||||
|
||||
.comfy-context-menu-filter {
|
||||
@@ -1248,14 +1243,14 @@ dialog::backdrop {
|
||||
|
||||
.litegraph.litesearchbox {
|
||||
z-index: 9999 !important;
|
||||
background-color: var(--comfy-menu-bg) !important;
|
||||
background-color: var(--comfy-menu-bg);
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.litegraph.litesearchbox input,
|
||||
.litegraph.litesearchbox select {
|
||||
background-color: var(--comfy-input-bg) !important;
|
||||
background-color: var(--comfy-input-bg);
|
||||
color: var(--input-text);
|
||||
}
|
||||
|
||||
@@ -1329,57 +1324,6 @@ audio.comfy-audio.empty-audio-widget {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* START LOD specific styles */
|
||||
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
|
||||
|
||||
.isLOD .lg-node {
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
backdrop-filter: none;
|
||||
text-shadow: none;
|
||||
mask-image: none;
|
||||
clip-path: none;
|
||||
background-image: none;
|
||||
text-rendering: optimizeSpeed;
|
||||
border-radius: 0;
|
||||
contain: layout style;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-header {
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-widgets {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lod-toggle {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.isLOD .lod-toggle {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.lod-fallback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.isLOD .lod-fallback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.isLOD .image-preview img {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.isLOD .slot-dot {
|
||||
border-radius: 0;
|
||||
}
|
||||
/* END LOD specific styles */
|
||||
|
||||
/* ===================== Mask Editor Styles ===================== */
|
||||
/* To be migrated to Tailwind later */
|
||||
#maskEditor_brush {
|
||||
|
||||
@@ -30,6 +30,17 @@
|
||||
/>
|
||||
|
||||
<ComfyRunButton />
|
||||
<IconButton
|
||||
v-tooltip.bottom="cancelJobTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="ml-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
|
||||
:disabled="isExecutionIdle"
|
||||
:aria-label="t('menu.interrupt')"
|
||||
@click="cancelCurrentJob"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
@@ -43,17 +54,24 @@ import {
|
||||
watchDebounced
|
||||
} from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
|
||||
const settingsStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
|
||||
|
||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||
const visible = computed(() => position.value !== 'Disabled')
|
||||
@@ -255,6 +273,16 @@ watch(isDragging, (dragging) => {
|
||||
isMouseOverDropZone.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const cancelJobTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.interrupt'))
|
||||
)
|
||||
|
||||
const cancelCurrentJob = async () => {
|
||||
if (isExecutionIdle.value) return
|
||||
await commandStore.execute('Comfy.Interrupt')
|
||||
}
|
||||
|
||||
const actionbarClass = computed(() =>
|
||||
cn(
|
||||
'w-[200px] border-dashed border-blue-500 opacity-80',
|
||||
|
||||
@@ -44,17 +44,22 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
import BatchCountEdit from '../BatchCountEdit.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const queueModeMenuItemLookup = computed(() => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import type { LogEntry, LogsWsMessage, TerminalSize } from '@/schemas/apiSchema'
|
||||
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
@@ -32,27 +32,22 @@ const terminalCreated = (
|
||||
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
root: Ref<HTMLElement | undefined>
|
||||
) => {
|
||||
// `autoCols` is false because we don't want the progress bar in the terminal
|
||||
// to render incorrectly as the progress bar is rendered based on the
|
||||
// server's terminal size.
|
||||
// Apply a min cols of 80 for colab environments
|
||||
// Auto-size terminal to fill container width.
|
||||
// minCols: 80 ensures minimum width for colab environments.
|
||||
// See https://github.com/comfyanonymous/ComfyUI/issues/6396
|
||||
useAutoSize({ root, autoRows: true, autoCols: false, minCols: 80 })
|
||||
useAutoSize({ root, autoRows: true, autoCols: true, minCols: 80 })
|
||||
|
||||
const update = (entries: Array<LogEntry>, size?: TerminalSize) => {
|
||||
if (size) {
|
||||
terminal.resize(size.cols, terminal.rows)
|
||||
}
|
||||
const update = (entries: Array<LogEntry>) => {
|
||||
terminal.write(entries.map((e) => e.m).join(''))
|
||||
}
|
||||
|
||||
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
|
||||
update(e.detail.entries, e.detail.size)
|
||||
update(e.detail.entries)
|
||||
}
|
||||
|
||||
const loadLogEntries = async () => {
|
||||
const logs = await api.getRawLogs()
|
||||
update(logs.entries, logs.size)
|
||||
update(logs.entries)
|
||||
}
|
||||
|
||||
const watchLogs = async () => {
|
||||
|
||||
@@ -64,11 +64,13 @@ import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
interface Props {
|
||||
item: MenuItem
|
||||
@@ -79,7 +81,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
|
||||
@@ -125,7 +125,7 @@ import { useCopy } from '@/composables/useCopy'
|
||||
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
||||
import { usePaste } from '@/composables/usePaste'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { i18n, t } from '@/i18n'
|
||||
import { mergeCustomNodesI18n, t } from '@/i18n'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
@@ -384,9 +384,7 @@ useEventListener(
|
||||
const loadCustomNodesI18n = async () => {
|
||||
try {
|
||||
const i18nData = await api.getCustomNodesI18n()
|
||||
Object.entries(i18nData).forEach(([locale, message]) => {
|
||||
i18n.global.mergeLocaleMessage(locale, message)
|
||||
})
|
||||
mergeCustomNodesI18n(i18nData)
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom nodes i18n', error)
|
||||
}
|
||||
|
||||
@@ -36,12 +36,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
const props = defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
@@ -89,4 +89,26 @@ const onDetailsLeave = (jobId: string) => {
|
||||
hideTimer.value = null
|
||||
}, 150)
|
||||
}
|
||||
|
||||
const resetActiveDetails = () => {
|
||||
clearHideTimer()
|
||||
clearShowTimer()
|
||||
activeDetailsId.value = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.displayedJobGroups,
|
||||
(groups) => {
|
||||
const activeId = activeDetailsId.value
|
||||
if (!activeId) return
|
||||
|
||||
const hasActiveJob = groups.some((group) =>
|
||||
group.items.some((item) => item.id === activeId)
|
||||
)
|
||||
|
||||
if (!hasActiveJob) resetActiveDetails()
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(resetActiveDetails)
|
||||
</script>
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
size="sm"
|
||||
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emit('delete')"
|
||||
@click.stop="onDeleteClick"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</IconButton>
|
||||
@@ -150,7 +150,7 @@
|
||||
size="sm"
|
||||
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emit('cancel')"
|
||||
@click.stop="onCancelClick"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
@@ -190,7 +190,7 @@
|
||||
size="sm"
|
||||
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emit('cancel')"
|
||||
@click.stop="onCancelClick"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
@@ -355,6 +355,18 @@ const computedShowClear = computed(() => {
|
||||
return props.state !== 'completed'
|
||||
})
|
||||
|
||||
const emitDetailsLeave = () => emit('details-leave', props.jobId)
|
||||
|
||||
const onCancelClick = () => {
|
||||
emitDetailsLeave()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
const onDeleteClick = () => {
|
||||
emitDetailsLeave()
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
const shouldShowMenu = props.showMenu !== undefined ? props.showMenu : true
|
||||
if (shouldShowMenu) emit('menu', event)
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
@click.stop="handleNodes2ToggleClick"
|
||||
>
|
||||
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
|
||||
<Tag severity="info" class="ml-2 text-xs">{{ $t('g.beta') }}</Tag>
|
||||
<ToggleSwitch
|
||||
v-model="nodes2Enabled"
|
||||
class="ml-4"
|
||||
@@ -101,6 +102,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Tag from 'primevue/tag'
|
||||
import TieredMenu from 'primevue/tieredmenu'
|
||||
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-if="showVueNodesBanner"
|
||||
class="pointer-events-auto relative w-full h-10 bg-gradient-to-r from-blue-600 to-blue-700 flex items-center justify-center px-4"
|
||||
>
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="flex items-center text-sm text-white">
|
||||
<i class="icon-[lucide--rocket]"></i>
|
||||
<span class="pl-2">{{ $t('vueNodesBanner.title') }}</span>
|
||||
<span class="pl-1.5 hidden md:inline">{{
|
||||
@@ -17,7 +17,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
class="cursor-pointer bg-transparent border-0 outline-0 grid place-items-center absolute right-4"
|
||||
class="cursor-pointer bg-transparent border-0 outline-0 grid place-items-center absolute right-4 text-white"
|
||||
unstyled
|
||||
@click="handleDismiss"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useElementBounding, useRafFn } from '@vueuse/core'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch, watchEffect } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
@@ -157,6 +157,14 @@ export function useSelectionToolboxPosition(
|
||||
// Sync with canvas transform
|
||||
const { resume: startSync, pause: stopSync } = useRafFn(updateTransform)
|
||||
|
||||
watchEffect(() => {
|
||||
if (visible.value) {
|
||||
startSync()
|
||||
} else {
|
||||
stopSync()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for selection changes
|
||||
watch(
|
||||
() => canvasStore.getCanvas().state.selectionChanged,
|
||||
@@ -173,11 +181,6 @@ export function useSelectionToolboxPosition(
|
||||
}
|
||||
updateSelectionBounds()
|
||||
canvasStore.getCanvas().state.selectionChanged = false
|
||||
if (visible.value) {
|
||||
startSync()
|
||||
} else {
|
||||
stopSync()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -3,7 +3,6 @@ import { shallowRef, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useRenderModeSetting } from '@/composables/settings/useRenderModeSetting'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -11,6 +10,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -26,11 +26,6 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
let hasShownMigrationToast = false
|
||||
|
||||
useRenderModeSetting(
|
||||
{ setting: 'LiteGraph.Canvas.MinFontSizeForLOD', vue: 0, litegraph: 8 },
|
||||
shouldRenderVueNodes
|
||||
)
|
||||
|
||||
const initializeNodeManager = () => {
|
||||
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
|
||||
const activeGraph = comfyApp.canvas?.graph
|
||||
@@ -44,7 +39,10 @@ function useVueNodeLifecycleIndividual() {
|
||||
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
||||
id: node.id.toString(),
|
||||
pos: [node.pos[0], node.pos[1]] as [number, number],
|
||||
size: [node.size[0], node.size[1]] as [number, number]
|
||||
size: [node.size[0], removeNodeTitleHeight(node.size[1])] as [
|
||||
number,
|
||||
number
|
||||
]
|
||||
}))
|
||||
layoutStore.initializeFromLiteGraph(nodes)
|
||||
|
||||
|
||||
@@ -49,6 +49,21 @@ const calculateRunwayDurationPrice = (node: LGraphNode): string => {
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
|
||||
const makeOmniProDurationCalculator =
|
||||
(pricePerSecond: number): PricingFunction =>
|
||||
(node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
if (!durationWidget) return `$${pricePerSecond.toFixed(3)}/second`
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
if (!Number.isFinite(seconds)) return `$${pricePerSecond.toFixed(3)}/second`
|
||||
|
||||
const cost = pricePerSecond * seconds
|
||||
return `$${cost.toFixed(2)}/Run`
|
||||
}
|
||||
|
||||
const pixversePricingCalculator = (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration_seconds'
|
||||
@@ -131,6 +146,11 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
'720p': [0.51, 0.56],
|
||||
'1080p': [1.18, 1.22]
|
||||
},
|
||||
'seedance-1-0-pro-fast': {
|
||||
'480p': [0.09, 0.1],
|
||||
'720p': [0.21, 0.23],
|
||||
'1080p': [0.47, 0.49]
|
||||
},
|
||||
'seedance-1-0-lite': {
|
||||
'480p': [0.17, 0.18],
|
||||
'720p': [0.37, 0.41],
|
||||
@@ -138,11 +158,13 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
}
|
||||
}
|
||||
|
||||
const modelKey = model.includes('seedance-1-0-pro')
|
||||
? 'seedance-1-0-pro'
|
||||
: model.includes('seedance-1-0-lite')
|
||||
? 'seedance-1-0-lite'
|
||||
: ''
|
||||
const modelKey = model.includes('seedance-1-0-pro-fast')
|
||||
? 'seedance-1-0-pro-fast'
|
||||
: model.includes('seedance-1-0-pro')
|
||||
? 'seedance-1-0-pro'
|
||||
: model.includes('seedance-1-0-lite')
|
||||
? 'seedance-1-0-lite'
|
||||
: ''
|
||||
|
||||
const resKey = resolution.includes('1080')
|
||||
? '1080p'
|
||||
@@ -623,7 +645,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
const modeValue = String(modeWidget.value)
|
||||
|
||||
// Same pricing matrix as KlingTextToVideoNode
|
||||
if (modeValue.includes('v2-1')) {
|
||||
if (modeValue.includes('v2-5-turbo')) {
|
||||
if (modeValue.includes('10')) {
|
||||
return '$0.70/Run'
|
||||
}
|
||||
return '$0.35/Run' // 5s default
|
||||
} else if (modeValue.includes('v2-1')) {
|
||||
if (modeValue.includes('10s')) {
|
||||
return '$0.98/Run' // pro, 10s
|
||||
}
|
||||
@@ -699,6 +726,21 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
KlingVirtualTryOnNode: {
|
||||
displayPrice: '$0.07/Run'
|
||||
},
|
||||
KlingOmniProTextToVideoNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.112)
|
||||
},
|
||||
KlingOmniProFirstLastFrameNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.112)
|
||||
},
|
||||
KlingOmniProImageToVideoNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.112)
|
||||
},
|
||||
KlingOmniProVideoToVideoNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.168)
|
||||
},
|
||||
KlingOmniProEditVideoNode: {
|
||||
displayPrice: '$0.168/second'
|
||||
},
|
||||
LumaImageToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
// Same pricing as LumaVideoNode per CSV
|
||||
@@ -1726,6 +1768,9 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
},
|
||||
ByteDanceSeedreamNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
const sequentialGenerationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'sequential_image_generation'
|
||||
) as IComboWidget
|
||||
@@ -1733,21 +1778,31 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
(w) => w.name === 'max_images'
|
||||
) as IComboWidget
|
||||
|
||||
if (!sequentialGenerationWidget || !maxImagesWidget)
|
||||
return '$0.03/Run ($0.03 for one output image)'
|
||||
|
||||
if (
|
||||
String(sequentialGenerationWidget.value).toLowerCase() === 'disabled'
|
||||
) {
|
||||
return '$0.03/Run'
|
||||
const model = String(modelWidget?.value ?? '').toLowerCase()
|
||||
let pricePerImage = 0.03 // default for seedream-4-0-250828 and fallback
|
||||
if (model.includes('seedream-4-5-251128')) {
|
||||
pricePerImage = 0.04
|
||||
} else if (model.includes('seedream-4-0-250828')) {
|
||||
pricePerImage = 0.03
|
||||
}
|
||||
|
||||
const maxImages = Number(maxImagesWidget.value)
|
||||
if (!sequentialGenerationWidget || !maxImagesWidget) {
|
||||
return `$${pricePerImage}/Run ($${pricePerImage} for one output image)`
|
||||
}
|
||||
|
||||
const seqMode = String(sequentialGenerationWidget.value).toLowerCase()
|
||||
if (seqMode === 'disabled') {
|
||||
return `$${pricePerImage}/Run`
|
||||
}
|
||||
|
||||
const maxImagesRaw = Number(maxImagesWidget.value)
|
||||
const maxImages =
|
||||
Number.isFinite(maxImagesRaw) && maxImagesRaw > 0 ? maxImagesRaw : 1
|
||||
if (maxImages === 1) {
|
||||
return '$0.03/Run'
|
||||
return `$${pricePerImage}/Run`
|
||||
}
|
||||
const cost = (0.03 * maxImages).toFixed(2)
|
||||
return `$${cost}/Run ($0.03 for one output image)`
|
||||
const totalCost = (pricePerImage * maxImages).toFixed(2)
|
||||
return `$${totalCost}/Run ($${pricePerImage} for one output image)`
|
||||
}
|
||||
},
|
||||
ByteDanceTextToVideoNode: {
|
||||
@@ -1873,6 +1928,10 @@ export const useNodePricing = () => {
|
||||
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
|
||||
KlingSingleImageVideoEffectNode: ['effect_scene'],
|
||||
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
|
||||
KlingOmniProTextToVideoNode: ['duration'],
|
||||
KlingOmniProFirstLastFrameNode: ['duration'],
|
||||
KlingOmniProImageToVideoNode: ['duration'],
|
||||
KlingOmniProVideoToVideoNode: ['duration'],
|
||||
MinimaxHailuoVideoNode: ['resolution', 'duration'],
|
||||
OpenAIDalle3: ['size', 'quality'],
|
||||
OpenAIDalle2: ['size', 'n'],
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
|
||||
interface RenderModeSettingConfig<TSettingKey extends keyof Settings> {
|
||||
setting: TSettingKey
|
||||
vue: Settings[TSettingKey]
|
||||
litegraph: Settings[TSettingKey]
|
||||
}
|
||||
|
||||
export function useRenderModeSetting<TSettingKey extends keyof Settings>(
|
||||
config: RenderModeSettingConfig<TSettingKey>,
|
||||
isVueMode: ComputedRef<boolean>
|
||||
) {
|
||||
const settingStore = useSettingStore()
|
||||
const vueValue = ref(config.vue)
|
||||
const litegraphValue = ref(config.litegraph)
|
||||
const lastWasVue = ref<boolean | null>(null)
|
||||
|
||||
const load = async (vue: boolean) => {
|
||||
if (lastWasVue.value === vue) return
|
||||
|
||||
if (lastWasVue.value !== null) {
|
||||
const currentValue = settingStore.get(config.setting)
|
||||
if (lastWasVue.value) {
|
||||
vueValue.value = currentValue
|
||||
} else {
|
||||
litegraphValue.value = currentValue
|
||||
}
|
||||
}
|
||||
|
||||
await settingStore.set(
|
||||
config.setting,
|
||||
vue ? vueValue.value : litegraphValue.value
|
||||
)
|
||||
lastWasVue.value = vue
|
||||
}
|
||||
|
||||
watch(isVueMode, load, { immediate: true })
|
||||
}
|
||||
@@ -23,10 +23,16 @@ export const useCopy = () => {
|
||||
const canvas = canvasStore.canvas
|
||||
if (canvas?.selectedItems) {
|
||||
const serializedData = canvas.copyToClipboard()
|
||||
// Use TextEncoder to handle Unicode characters properly
|
||||
const base64Data = btoa(
|
||||
String.fromCharCode(
|
||||
...Array.from(new TextEncoder().encode(serializedData))
|
||||
)
|
||||
)
|
||||
// clearData doesn't remove images from clipboard
|
||||
e.clipboardData?.setData(
|
||||
'text/html',
|
||||
clipboardHTMLWrapper.join(btoa(serializedData))
|
||||
clipboardHTMLWrapper.join(base64Data)
|
||||
)
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
/**
|
||||
* Composable for building docs.comfy.org URLs with automatic locale and platform detection
|
||||
@@ -23,7 +23,7 @@ import { useI18n } from 'vue-i18n'
|
||||
* ```
|
||||
*/
|
||||
export function useExternalLink() {
|
||||
const { locale } = useI18n()
|
||||
const locale = computed(() => String(i18n.global.locale.value))
|
||||
|
||||
const isChinese = computed(() => {
|
||||
return locale.value === 'zh' || locale.value === 'zh-TW'
|
||||
|
||||
@@ -14,9 +14,11 @@ function pasteClipboardItems(data: DataTransfer): boolean {
|
||||
const match = rawData.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
|
||||
if (!match) return false
|
||||
try {
|
||||
useCanvasStore()
|
||||
.getCanvas()
|
||||
._deserializeItems(JSON.parse(atob(match)), {})
|
||||
// Decode UTF-8 safe base64
|
||||
const binaryString = atob(match)
|
||||
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0))
|
||||
const decodedData = new TextDecoder().decode(bytes)
|
||||
useCanvasStore().getCanvas()._deserializeItems(JSON.parse(decodedData), {})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
|
||||
@@ -7,9 +7,9 @@ import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
ISlotType,
|
||||
LLink,
|
||||
Point
|
||||
LLink
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
@@ -37,15 +37,15 @@ export class PrimitiveNode extends LGraphNode {
|
||||
}
|
||||
|
||||
override applyToGraph(extraLinks: LLink[] = []) {
|
||||
if (!this.outputs[0].links?.length) return
|
||||
if (!this.outputs[0].links?.length || !this.graph) return
|
||||
|
||||
const links = [
|
||||
...this.outputs[0].links.map((l) => app.graph.links[l]),
|
||||
...this.outputs[0].links.map((l) => this.graph!.links[l]),
|
||||
...extraLinks
|
||||
]
|
||||
let v = this.widgets?.[0].value
|
||||
if (v && this.properties[replacePropertyName]) {
|
||||
v = applyTextReplacements(app.graph, v as string)
|
||||
v = applyTextReplacements(this.graph, v as string)
|
||||
}
|
||||
|
||||
// For each output link copy our value over the original widget value
|
||||
@@ -331,13 +331,13 @@ export class PrimitiveNode extends LGraphNode {
|
||||
const config1 = (output.widget?.[GET_CONFIG] as () => InputSpec)?.()
|
||||
if (!config1) return
|
||||
const isNumber = config1[0] === 'INT' || config1[0] === 'FLOAT'
|
||||
if (!isNumber) return
|
||||
if (!isNumber || !this.graph) return
|
||||
|
||||
for (const linkId of links) {
|
||||
const link = app.graph.links[linkId]
|
||||
const link = this.graph.links[linkId]
|
||||
if (!link) continue // Can be null when removing a node
|
||||
|
||||
const theirNode = app.graph.getNodeById(link.target_id)
|
||||
const theirNode = this.graph.getNodeById(link.target_id)
|
||||
if (!theirNode) continue
|
||||
const theirInput = theirNode.inputs[link.target_slot]
|
||||
|
||||
@@ -441,10 +441,7 @@ function getWidgetType(config: InputSpec) {
|
||||
return { type }
|
||||
}
|
||||
|
||||
export function setWidgetConfig(
|
||||
slot: INodeInputSlot | INodeOutputSlot,
|
||||
config?: InputSpec
|
||||
) {
|
||||
export function setWidgetConfig(slot: INodeInputSlot, config?: InputSpec) {
|
||||
if (!slot.widget) return
|
||||
if (config) {
|
||||
slot.widget[GET_CONFIG] = () => config
|
||||
@@ -452,19 +449,18 @@ export function setWidgetConfig(
|
||||
delete slot.widget
|
||||
}
|
||||
|
||||
if ('link' in slot) {
|
||||
const link = app.graph.links[slot.link ?? -1]
|
||||
if (link) {
|
||||
const originNode = app.graph.getNodeById(link.origin_id)
|
||||
if (originNode && isPrimitiveNode(originNode)) {
|
||||
if (config) {
|
||||
originNode.recreateWidget()
|
||||
} else if (!app.configuringGraph) {
|
||||
originNode.disconnectOutput(0)
|
||||
originNode.onLastDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!(slot instanceof NodeSlot)) return
|
||||
const graph = slot.node.graph
|
||||
if (!graph) return
|
||||
const link = graph.links[slot.link ?? -1]
|
||||
if (!link) return
|
||||
const originNode = graph.getNodeById(link.origin_id)
|
||||
if (!originNode || !isPrimitiveNode(originNode)) return
|
||||
if (config) {
|
||||
originNode.recreateWidget()
|
||||
} else if (!app.configuringGraph) {
|
||||
originNode.disconnectOutput(0)
|
||||
originNode.onLastDisconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,15 +551,6 @@ app.registerExtension({
|
||||
}
|
||||
)
|
||||
|
||||
function isNodeAtPos(pos: Point) {
|
||||
for (const n of app.graph.nodes) {
|
||||
if (n.pos[0] === pos[0] && n.pos[1] === pos[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Double click a widget input to automatically attach a primitive
|
||||
const origOnInputDblClick = nodeType.prototype.onInputDblClick
|
||||
nodeType.prototype.onInputDblClick = function (
|
||||
@@ -589,18 +576,18 @@ app.registerExtension({
|
||||
|
||||
// Create a primitive node
|
||||
const node = LiteGraph.createNode('PrimitiveNode')
|
||||
if (!node) return r
|
||||
const graph = app.canvas.graph
|
||||
if (!node || !graph) return r
|
||||
|
||||
this.graph?.add(node)
|
||||
graph?.add(node)
|
||||
|
||||
// Calculate a position that won't directly overlap another node
|
||||
const pos: [number, number] = [
|
||||
this.pos[0] - node.size[0] - 30,
|
||||
this.pos[1]
|
||||
]
|
||||
while (isNodeAtPos(pos)) {
|
||||
while (graph.getNodeOnPos(pos[0], pos[1], graph.nodes))
|
||||
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
|
||||
node.pos = pos
|
||||
node.connect(0, this, slot)
|
||||
|
||||
200
src/i18n.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')
|
||||
|
||||
// Mock the JSON imports before importing i18n module
|
||||
vi.mock('./locales/en/main.json', () => ({ default: { welcome: 'Welcome' } }))
|
||||
vi.mock('./locales/en/nodeDefs.json', () => ({
|
||||
default: { testNode: 'Test Node' }
|
||||
}))
|
||||
vi.mock('./locales/en/commands.json', () => ({
|
||||
default: { save: 'Save' }
|
||||
}))
|
||||
vi.mock('./locales/en/settings.json', () => ({
|
||||
default: { theme: 'Theme' }
|
||||
}))
|
||||
|
||||
// Mock lazy-loaded locales
|
||||
vi.mock('./locales/zh/main.json', () => ({ default: { welcome: '欢迎' } }))
|
||||
vi.mock('./locales/zh/nodeDefs.json', () => ({
|
||||
default: { testNode: '测试节点' }
|
||||
}))
|
||||
vi.mock('./locales/zh/commands.json', () => ({ default: { save: '保存' } }))
|
||||
vi.mock('./locales/zh/settings.json', () => ({ default: { theme: '主题' } }))
|
||||
|
||||
describe('i18n', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('mergeCustomNodesI18n', () => {
|
||||
it('should immediately merge data for already loaded locales (en)', async () => {
|
||||
// English is pre-loaded, so merge should work immediately
|
||||
mergeCustomNodesI18n({
|
||||
en: {
|
||||
customNode: {
|
||||
title: 'Custom Node Title'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Verify the custom node data was merged
|
||||
const messages = i18n.global.getLocaleMessage('en') as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(messages.customNode).toEqual({ title: 'Custom Node Title' })
|
||||
})
|
||||
|
||||
it('should store data for not-yet-loaded locales', async () => {
|
||||
const { i18n, mergeCustomNodesI18n } = await import('./i18n')
|
||||
|
||||
// Chinese is not pre-loaded, data should be stored but not merged yet
|
||||
mergeCustomNodesI18n({
|
||||
zh: {
|
||||
customNode: {
|
||||
title: '自定义节点标题'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// zh locale should not exist yet (not loaded)
|
||||
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
// Either empty or doesn't have our custom data merged directly
|
||||
// (since zh wasn't loaded yet, mergeLocaleMessage on non-existent locale
|
||||
// may create an empty locale or do nothing useful)
|
||||
expect(zhMessages.customNode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should merge stored data when locale is lazily loaded', async () => {
|
||||
// First, store custom nodes i18n data (before locale is loaded)
|
||||
mergeCustomNodesI18n({
|
||||
zh: {
|
||||
customNode: {
|
||||
title: '自定义节点标题'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await loadLocale('zh')
|
||||
|
||||
// Verify both the base locale data and custom node data are present
|
||||
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(zhMessages.welcome).toBe('欢迎')
|
||||
expect(zhMessages.customNode).toEqual({ title: '自定义节点标题' })
|
||||
})
|
||||
|
||||
it('should preserve custom node data when locale is loaded after merge', async () => {
|
||||
// Simulate the real scenario:
|
||||
// 1. Custom nodes i18n is loaded first
|
||||
mergeCustomNodesI18n({
|
||||
zh: {
|
||||
customNode: {
|
||||
title: '自定义节点标题'
|
||||
},
|
||||
settingsCategories: {
|
||||
Hotkeys: '快捷键'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Then locale is lazily loaded (this would previously overwrite custom data)
|
||||
await loadLocale('zh')
|
||||
|
||||
// 3. Verify custom node data is still present
|
||||
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(zhMessages.customNode).toEqual({ title: '自定义节点标题' })
|
||||
expect(zhMessages.settingsCategories).toEqual({ Hotkeys: '快捷键' })
|
||||
|
||||
// 4. Also verify base locale data is present
|
||||
expect(zhMessages.welcome).toBe('欢迎')
|
||||
expect(zhMessages.nodeDefs).toEqual({ testNode: '测试节点' })
|
||||
})
|
||||
|
||||
it('should handle multiple locales in custom nodes i18n data', async () => {
|
||||
// Merge data for multiple locales
|
||||
mergeCustomNodesI18n({
|
||||
en: {
|
||||
customPlugin: { name: 'Easy Use' }
|
||||
},
|
||||
zh: {
|
||||
customPlugin: { name: '简单使用' }
|
||||
}
|
||||
})
|
||||
|
||||
// English should be merged immediately (pre-loaded)
|
||||
const enMessages = i18n.global.getLocaleMessage('en') as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(enMessages.customPlugin).toEqual({ name: 'Easy Use' })
|
||||
|
||||
await loadLocale('zh')
|
||||
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(zhMessages.customPlugin).toEqual({ name: '简单使用' })
|
||||
})
|
||||
|
||||
it('should handle calling mergeCustomNodesI18n multiple times', async () => {
|
||||
// Use fresh module instance to ensure clean state
|
||||
vi.resetModules()
|
||||
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')
|
||||
|
||||
mergeCustomNodesI18n({
|
||||
zh: { plugin1: { name: '插件1' } }
|
||||
})
|
||||
|
||||
mergeCustomNodesI18n({
|
||||
zh: { plugin2: { name: '插件2' } }
|
||||
})
|
||||
|
||||
await loadLocale('zh')
|
||||
|
||||
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
// Only the second call's data should be present
|
||||
expect(zhMessages.plugin2).toEqual({ name: '插件2' })
|
||||
// First call's data is overwritten
|
||||
expect(zhMessages.plugin1).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadLocale', () => {
|
||||
it('should not reload already loaded locale', async () => {
|
||||
await loadLocale('zh')
|
||||
await loadLocale('zh')
|
||||
|
||||
// Should complete without error (second call returns early)
|
||||
})
|
||||
|
||||
it('should warn for unsupported locale', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
await loadLocale('unsupported-locale')
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Locale "unsupported-locale" is not supported'
|
||||
)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle concurrent load requests for same locale', async () => {
|
||||
// Start multiple loads concurrently
|
||||
const promises = [loadLocale('zh'), loadLocale('zh'), loadLocale('zh')]
|
||||
|
||||
await Promise.all(promises)
|
||||
})
|
||||
})
|
||||
})
|
||||
25
src/i18n.ts
@@ -90,6 +90,9 @@ const loadedLocales = new Set<string>(['en'])
|
||||
// Track locales currently being loaded to prevent race conditions
|
||||
const loadingLocales = new Map<string, Promise<void>>()
|
||||
|
||||
// Store custom nodes i18n data for merging when locales are lazily loaded
|
||||
const customNodesI18nData: Record<string, unknown> = {}
|
||||
|
||||
/**
|
||||
* Dynamically load a locale and its associated files (nodeDefs, commands, settings)
|
||||
*/
|
||||
@@ -133,6 +136,10 @@ export async function loadLocale(locale: string): Promise<void> {
|
||||
|
||||
i18n.global.setLocaleMessage(locale, messages as LocaleMessages)
|
||||
loadedLocales.add(locale)
|
||||
|
||||
if (customNodesI18nData[locale]) {
|
||||
i18n.global.mergeLocaleMessage(locale, customNodesI18nData[locale])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load locale "${locale}":`, error)
|
||||
throw error
|
||||
@@ -146,6 +153,24 @@ export async function loadLocale(locale: string): Promise<void> {
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the data for later use when locales are lazily loaded,
|
||||
* and immediately merges data for already-loaded locales.
|
||||
*/
|
||||
export function mergeCustomNodesI18n(i18nData: Record<string, unknown>): void {
|
||||
// Clear existing data and replace with new data
|
||||
for (const key of Object.keys(customNodesI18nData)) {
|
||||
delete customNodesI18nData[key]
|
||||
}
|
||||
Object.assign(customNodesI18nData, i18nData)
|
||||
|
||||
for (const [locale, message] of Object.entries(i18nData)) {
|
||||
if (loadedLocales.has(locale)) {
|
||||
i18n.global.mergeLocaleMessage(locale, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only include English in the initial bundle
|
||||
const messages = {
|
||||
en: buildLocale(en, enNodes, enCommands, enSettings)
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
min-width: 100px;
|
||||
color: #aaf;
|
||||
padding: 0;
|
||||
box-shadow: 0 0 10px black !important;
|
||||
background-color: #2e2e2e !important;
|
||||
box-shadow: 0 0 10px black;
|
||||
background-color: #2e2e2e;
|
||||
z-index: 10;
|
||||
max-height: -webkit-fill-available;
|
||||
overflow-y: auto;
|
||||
@@ -36,10 +36,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu.dark {
|
||||
background-color: #000 !important;
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu .litemenu-title img {
|
||||
margin-top: 2px;
|
||||
margin-left: 2px;
|
||||
@@ -51,14 +47,6 @@
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu .litemenu-entry.submenu {
|
||||
background-color: #2e2e2e !important;
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu.dark .litemenu-entry.submenu {
|
||||
background-color: #000 !important;
|
||||
}
|
||||
|
||||
.litegraph .litemenubar ul {
|
||||
font-family: Tahoma, sans-serif;
|
||||
margin: 0;
|
||||
@@ -132,14 +120,13 @@
|
||||
|
||||
.litegraph .litemenu-entry.separator {
|
||||
display: block;
|
||||
border-top: 1px solid #333;
|
||||
border-bottom: 1px solid #666;
|
||||
border-top: 1px solid var(--border-default);
|
||||
width: 100%;
|
||||
height: 0px;
|
||||
margin: 3px 0 2px 0;
|
||||
background-color: transparent;
|
||||
padding: 0 !important;
|
||||
cursor: default !important;
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.litegraph .litemenu-entry.has_submenu {
|
||||
@@ -155,8 +142,8 @@
|
||||
}
|
||||
|
||||
.litegraph .litemenu-entry:hover:not(.disabled):not(.separator) {
|
||||
background-color: #444 !important;
|
||||
color: #eee;
|
||||
background-color: var(--palette-interface-panel-hover-surface);
|
||||
color: var(--content-hover-fg);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
@@ -259,7 +246,8 @@
|
||||
margin-top: -150px;
|
||||
margin-left: -200px;
|
||||
|
||||
background-color: #2a2a2a;
|
||||
color: var(--base-foreground);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
|
||||
min-width: 400px;
|
||||
min-height: 200px;
|
||||
@@ -299,8 +287,7 @@
|
||||
}
|
||||
|
||||
.litegraph .dialog .dialog-header {
|
||||
color: #aaa;
|
||||
border-bottom: 1px solid #161616;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.litegraph .dialog .dialog-header {
|
||||
@@ -310,11 +297,12 @@
|
||||
height: 50px;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.litegraph .dialog .dialog-header .dialog-title {
|
||||
font: 20px "Arial";
|
||||
font: 1rem;
|
||||
font-family: Inter, Arial, sans-serif;
|
||||
margin: 4px;
|
||||
padding: 4px 10px;
|
||||
display: inline-block;
|
||||
@@ -326,7 +314,7 @@
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
display: inline-block;
|
||||
color: #aaa;
|
||||
/* color: #aaa; */
|
||||
/*background-color: black;*/
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -362,8 +350,7 @@
|
||||
display: block;
|
||||
width: calc(100% - 4px);
|
||||
height: 1px;
|
||||
border-top: 1px solid #000;
|
||||
border-bottom: 1px solid #333;
|
||||
border-top: 1px solid var(--border-default);
|
||||
margin: 10px 2px;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -373,12 +360,8 @@
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.litegraph .dialog .property:hover {
|
||||
background: #545454;
|
||||
}
|
||||
|
||||
.litegraph .dialog .property_name {
|
||||
color: #737373;
|
||||
color: var(--muted-foreground);
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
@@ -395,8 +378,8 @@
|
||||
.litegraph .dialog .property_value {
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
color: #aaa;
|
||||
background-color: #1a1a1a;
|
||||
color: var(--input-text);
|
||||
background-color: var(--component-node-widget-background);
|
||||
/*width: calc( 100% - 122px );*/
|
||||
max-width: calc(100% - 162px);
|
||||
min-width: 200px;
|
||||
@@ -432,18 +415,18 @@
|
||||
border-radius: 4px;
|
||||
padding: 4px 20px;
|
||||
margin-left: 0px;
|
||||
background-color: #060606;
|
||||
color: #8e8e8e;
|
||||
background-color: var(--secondary-background);
|
||||
color: var(--base-foreground);
|
||||
}
|
||||
|
||||
.litegraph .dialog .btn:hover {
|
||||
background-color: #111;
|
||||
color: #fff;
|
||||
background-color: var(--secondary-background-hover);
|
||||
color: var(--base-foreground);
|
||||
}
|
||||
|
||||
.litegraph .dialog .btn.delete:hover {
|
||||
background-color: #f33;
|
||||
color: black;
|
||||
background-color: var(--color-danger-100);
|
||||
color: var(--base-foreground);
|
||||
}
|
||||
|
||||
.litegraph .bullet_icon {
|
||||
@@ -497,11 +480,11 @@
|
||||
|
||||
.graphmenu-entry.danger,
|
||||
.litemenu-entry.danger {
|
||||
color: var(--error-text) !important;
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.litegraph .litemenu-entry.danger:hover:not(.disabled) {
|
||||
color: var(--error-text) !important;
|
||||
color: var(--error-text);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -518,8 +501,7 @@
|
||||
}
|
||||
|
||||
.graphmenu-entry.separator {
|
||||
background-color: #111;
|
||||
border-bottom: 1px solid #666;
|
||||
background-color: var(--border-default);
|
||||
height: 1px;
|
||||
width: calc(100% - 20px);
|
||||
-moz-width: calc(100% - 20px);
|
||||
@@ -551,7 +533,7 @@
|
||||
min-height: 2em;
|
||||
background-color: #333;
|
||||
font-size: 1.2em;
|
||||
box-shadow: 0 0 10px black !important;
|
||||
box-shadow: 0 0 10px black;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
import { CanvasPointer } from './CanvasPointer'
|
||||
import type { ContextMenu } from './ContextMenu'
|
||||
@@ -4043,16 +4044,25 @@ export class LGraphCanvas
|
||||
|
||||
// TODO: Report failures, i.e. `failedNodes`
|
||||
|
||||
const newPositions = created.map((node) => ({
|
||||
nodeId: String(node.id),
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size?.[0] ?? 100,
|
||||
height: node.size?.[1] ?? 200
|
||||
}
|
||||
}))
|
||||
const newPositions = created
|
||||
.filter((item): item is LGraphNode => item instanceof LGraphNode)
|
||||
.map((node) => {
|
||||
const fullHeight = node.size?.[1] ?? 200
|
||||
const layoutHeight = LiteGraph.vueNodesMode
|
||||
? removeNodeTitleHeight(fullHeight)
|
||||
: fullHeight
|
||||
return {
|
||||
nodeId: String(node.id),
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size?.[0] ?? 100,
|
||||
height: layoutHeight
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
|
||||
layoutStore.batchUpdateNodeBounds(newPositions)
|
||||
|
||||
this.selectItems(created)
|
||||
@@ -8021,7 +8031,13 @@ export class LGraphCanvas
|
||||
has_submenu: true,
|
||||
callback: LGraphCanvas.onMenuAdd
|
||||
},
|
||||
{ content: 'Add Group', callback: LGraphCanvas.onGroupAdd }
|
||||
{ content: 'Add Group', callback: LGraphCanvas.onGroupAdd },
|
||||
{
|
||||
content: 'Paste',
|
||||
callback: () => {
|
||||
this.pasteFromClipboard()
|
||||
}
|
||||
}
|
||||
// { content: "Arrange", callback: that.graph.arrange },
|
||||
// {content:"Collapse All", callback: LGraphCanvas.onMenuCollapseAll }
|
||||
]
|
||||
|
||||
@@ -2,7 +2,8 @@ import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
|
||||
import {
|
||||
calculateInputSlotPos,
|
||||
calculateInputSlotPosFromSlot,
|
||||
calculateOutputSlotPos
|
||||
calculateOutputSlotPos,
|
||||
getSlotPosition
|
||||
} from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
@@ -10,6 +11,7 @@ import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import type { ColorAdjustOptions } from '@/utils/colorUtil'
|
||||
|
||||
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
|
||||
import type { DragAndScale } from './DragAndScale'
|
||||
import type { LGraph } from './LGraph'
|
||||
import { BadgePosition, LGraphBadge } from './LGraphBadge'
|
||||
@@ -44,8 +46,8 @@ import type {
|
||||
Rect,
|
||||
Size
|
||||
} from './interfaces'
|
||||
import { LiteGraph } from './litegraph'
|
||||
import type { LGraphNodeConstructor, Subgraph, SubgraphNode } from './litegraph'
|
||||
import { LiteGraph, Subgraph } from './litegraph'
|
||||
import type { LGraphNodeConstructor, SubgraphNode } from './litegraph'
|
||||
import {
|
||||
createBounds,
|
||||
isInRect,
|
||||
@@ -3073,6 +3075,17 @@ export class LGraphNode
|
||||
for (const link_id of links) {
|
||||
const link_info = graph._links.get(link_id)
|
||||
if (!link_info) continue
|
||||
if (
|
||||
link_info.target_id === SUBGRAPH_OUTPUT_ID &&
|
||||
graph instanceof Subgraph
|
||||
) {
|
||||
const targetSlot = graph.outputNode.slots[link_info.target_slot]
|
||||
if (targetSlot) {
|
||||
targetSlot.linkIds.length = 0
|
||||
} else {
|
||||
console.error('Missing subgraphOutput slot when disconnecting link')
|
||||
}
|
||||
}
|
||||
|
||||
const target = graph.getNodeById(link_info.target_id)
|
||||
graph._version++
|
||||
@@ -3354,6 +3367,16 @@ export class LGraphNode
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slot position using layout tree if available, fallback to node's position * Unified implementation used by both LitegraphLinkAdapter and useLinkLayoutSync
|
||||
* @param slotIndex The slot index
|
||||
* @param isInput Whether this is an input slot
|
||||
* @returns Position of the slot center in graph coordinates
|
||||
*/
|
||||
getSlotPosition(slotIndex: number, isInput: boolean): Point {
|
||||
return getSlotPosition(this, slotIndex, isInput)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
snapToGrid(snapTo: number): boolean {
|
||||
return this.pinned ? false : snapPoint(this.pos, snapTo)
|
||||
|
||||
@@ -63,6 +63,9 @@ export class ToInputFromIoNodeLink implements RenderLink {
|
||||
|
||||
if (existingLink) {
|
||||
// Moving an existing link
|
||||
const { input, inputNode } = existingLink.resolve(this.network)
|
||||
if (inputNode && input)
|
||||
this.node._disconnectNodeInput(inputNode, input, existingLink)
|
||||
events.dispatch('input-moved', this)
|
||||
} else {
|
||||
// Creating a new link
|
||||
|
||||
@@ -158,6 +158,7 @@ export class SubgraphOutput extends SubgraphSlot {
|
||||
//should never have more than one connection
|
||||
for (const linkId of this.linkIds) {
|
||||
const link = subgraph.links[linkId]
|
||||
if (!link) continue
|
||||
subgraph.removeLink(linkId)
|
||||
const { output, outputNode } = link.resolve(subgraph)
|
||||
if (output)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"g": {
|
||||
"beta": "Beta",
|
||||
"user": "User",
|
||||
"currentUser": "Current user",
|
||||
"empty": "Empty",
|
||||
|
||||
@@ -59,35 +59,29 @@
|
||||
|
||||
<!-- Media actions - show on hover or when playing -->
|
||||
<IconGroup v-else-if="showActionsOverlay">
|
||||
<div v-tooltip.top="$t('mediaAsset.actions.inspect')">
|
||||
<IconButton
|
||||
size="sm"
|
||||
@click.stop="handleZoomClick"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div v-tooltip.top="$t('mediaAsset.actions.more')">
|
||||
<MoreButton
|
||||
ref="moreButtonRef"
|
||||
size="sm"
|
||||
@menu-opened="handleMenuOpened"
|
||||
@menu-closed="handleMenuClosed"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetMoreMenu
|
||||
:close="close"
|
||||
:show-delete-button="showDeleteButton"
|
||||
@inspect="handleZoomClick"
|
||||
@asset-deleted="handleAssetDelete"
|
||||
/>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</div>
|
||||
<IconButton
|
||||
v-tooltip.top="$t('mediaAsset.actions.inspect')"
|
||||
size="sm"
|
||||
@click.stop="handleZoomClick"
|
||||
>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</IconButton>
|
||||
<MoreButton
|
||||
ref="moreButtonRef"
|
||||
v-tooltip.top="$t('mediaAsset.actions.more')"
|
||||
size="sm"
|
||||
@menu-opened="handleMenuOpened"
|
||||
@menu-closed="handleMenuClosed"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetMoreMenu
|
||||
:close="close"
|
||||
:show-delete-button="showDeleteButton"
|
||||
@inspect="handleZoomClick"
|
||||
@asset-deleted="handleAssetDelete"
|
||||
/>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</IconGroup>
|
||||
</template>
|
||||
|
||||
@@ -101,8 +95,6 @@
|
||||
size="sm"
|
||||
:label="String(outputCount)"
|
||||
@click.stop="handleOutputCountClick"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--layers] size-4" />
|
||||
@@ -216,7 +208,6 @@ const moreButtonRef = ref<InstanceType<typeof MoreButton>>()
|
||||
const isVideoPlaying = ref(false)
|
||||
const isMenuOpen = ref(false)
|
||||
const showVideoControls = ref(false)
|
||||
const isOverlayHovered = ref(false)
|
||||
|
||||
// Store actual image dimensions
|
||||
const imageDimensions = ref<{ width: number; height: number } | undefined>()
|
||||
@@ -299,7 +290,7 @@ const durationChipClasses = computed(() => {
|
||||
})
|
||||
|
||||
const isCardOrOverlayHovered = computed(
|
||||
() => isHovered.value || isOverlayHovered.value || isMenuOpen.value
|
||||
() => isHovered.value || isMenuOpen.value
|
||||
)
|
||||
|
||||
// Show static chips when NOT hovered and NOT playing (normal state)
|
||||
@@ -320,14 +311,6 @@ const showActionsOverlay = computed(
|
||||
(isCardOrOverlayHovered.value || isVideoPlaying.value)
|
||||
)
|
||||
|
||||
const handleOverlayMouseEnter = () => {
|
||||
isOverlayHovered.value = true
|
||||
}
|
||||
|
||||
const handleOverlayMouseLeave = () => {
|
||||
isOverlayHovered.value = false
|
||||
}
|
||||
|
||||
const handleZoomClick = () => {
|
||||
if (asset) {
|
||||
emit('zoom', asset)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative size-full overflow-hidden rounded bg-modal-card-placeholder-background"
|
||||
@dblclick="emit('view')"
|
||||
>
|
||||
<img
|
||||
v-if="!error"
|
||||
@@ -28,6 +29,7 @@ const { asset } = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'image-loaded': [width: number, height: number]
|
||||
view: []
|
||||
}>()
|
||||
|
||||
const { state, error, isReady } = useImage({
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
} from '@/platform/settings/settingStore'
|
||||
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
|
||||
export function useSettingSearch() {
|
||||
const settingStore = useSettingStore()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const searchQuery = ref<string>('')
|
||||
const filteredSettingIds = ref<string[]>([])
|
||||
@@ -54,7 +56,11 @@ export function useSettingSearch() {
|
||||
const allSettings = Object.values(settingStore.settingsById)
|
||||
const filteredSettings = allSettings.filter((setting) => {
|
||||
// Filter out hidden and deprecated settings, just like in normal settings tree
|
||||
if (setting.type === 'hidden' || setting.deprecated) {
|
||||
if (
|
||||
setting.type === 'hidden' ||
|
||||
setting.deprecated ||
|
||||
(shouldRenderVueNodes.value && setting.hideInVueNodes)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { SettingParams } from '@/platform/settings/types'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
|
||||
interface SettingPanelItem {
|
||||
node: SettingTreeNode
|
||||
@@ -31,10 +32,14 @@ export function useSettingUI(
|
||||
const settingStore = useSettingStore()
|
||||
const activeCategory = ref<SettingTreeNode | null>(null)
|
||||
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const settingRoot = computed<SettingTreeNode>(() => {
|
||||
const root = buildTree(
|
||||
Object.values(settingStore.settingsById).filter(
|
||||
(setting: SettingParams) => setting.type !== 'hidden'
|
||||
(setting: SettingParams) =>
|
||||
setting.type !== 'hidden' &&
|
||||
!(shouldRenderVueNodes.value && setting.hideInVueNodes)
|
||||
),
|
||||
(setting: SettingParams) => setting.category || setting.id.split('.')
|
||||
)
|
||||
|
||||
@@ -919,7 +919,8 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
step: 1
|
||||
},
|
||||
defaultValue: 8,
|
||||
versionAdded: '1.26.7'
|
||||
versionAdded: '1.26.7',
|
||||
hideInVueNodes: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.SelectionToolbox',
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface SettingParams<TValue = unknown> extends FormItem {
|
||||
// sortOrder for sorting settings within a group. Higher values appear first.
|
||||
// Default is 0 if not specified.
|
||||
sortOrder?: number
|
||||
hideInVueNodes?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,12 +29,6 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
|
||||
useLOD: vi.fn(() => ({
|
||||
isLOD: false
|
||||
}))
|
||||
}))
|
||||
|
||||
function createMockCanvas(): LGraphCanvas {
|
||||
return {
|
||||
canvas: {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { computed, customRef, ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type {
|
||||
@@ -136,6 +138,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
// Vue dragging state for selection toolbox (public ref for direct mutation)
|
||||
public isDraggingVueNodes = ref(false)
|
||||
// Vue resizing state to prevent drag from activating during resize
|
||||
public isResizingVueNodes = ref(false)
|
||||
|
||||
constructor() {
|
||||
// Initialize Yjs data structures
|
||||
@@ -952,6 +956,15 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
return this.currentActor
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up refs and triggers for a node when its Vue component unmounts.
|
||||
* This should be called from the component's onUnmounted hook.
|
||||
*/
|
||||
cleanupNodeRef(nodeId: NodeId): void {
|
||||
this.nodeRefs.delete(nodeId)
|
||||
this.nodeTriggers.delete(nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize store with existing nodes
|
||||
*/
|
||||
@@ -960,8 +973,10 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
): void {
|
||||
this.ydoc.transact(() => {
|
||||
this.ynodes.clear()
|
||||
this.nodeRefs.clear()
|
||||
this.nodeTriggers.clear()
|
||||
// Note: We intentionally do NOT clear nodeRefs and nodeTriggers here.
|
||||
// Vue components may already hold references to these refs, and clearing
|
||||
// them would break the reactivity chain. The refs will be reused when
|
||||
// nodes are recreated, and stale refs will be cleaned up over time.
|
||||
this.spatialIndex.clear()
|
||||
this.linkSegmentSpatialIndex.clear()
|
||||
this.slotSpatialIndex.clear()
|
||||
@@ -991,6 +1006,9 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
// Add to spatial index
|
||||
this.spatialIndex.insert(layout.id, layout.bounds)
|
||||
})
|
||||
|
||||
// Trigger all existing refs to notify Vue of the new data
|
||||
this.nodeTriggers.forEach((trigger) => trigger())
|
||||
}, 'initialization')
|
||||
}
|
||||
|
||||
@@ -1081,8 +1099,10 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
if (!this.ynodes.has(operation.nodeId)) return
|
||||
|
||||
this.ynodes.delete(operation.nodeId)
|
||||
this.nodeRefs.delete(operation.nodeId)
|
||||
this.nodeTriggers.delete(operation.nodeId)
|
||||
// Note: We intentionally do NOT delete nodeRefs and nodeTriggers here.
|
||||
// During undo/redo, Vue components may still hold references to the old ref.
|
||||
// If we delete the trigger, Vue won't be notified when the node is re-created.
|
||||
// The trigger will be called in finalizeOperation to notify Vue of the change.
|
||||
|
||||
// Remove from spatial index
|
||||
this.spatialIndex.remove(operation.nodeId)
|
||||
@@ -1414,8 +1434,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
batchUpdateNodeBounds(updates: NodeBoundsUpdate[]): void {
|
||||
if (updates.length === 0) return
|
||||
|
||||
// Set source to Vue for these DOM-driven updates
|
||||
const originalSource = this.currentSource
|
||||
const shouldNormalizeHeights = originalSource === LayoutSource.DOM
|
||||
this.currentSource = LayoutSource.Vue
|
||||
|
||||
const nodeIds: NodeId[] = []
|
||||
@@ -1426,8 +1446,15 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
if (!ynode) continue
|
||||
const currentLayout = yNodeToLayout(ynode)
|
||||
|
||||
const normalizedBounds = shouldNormalizeHeights
|
||||
? {
|
||||
...bounds,
|
||||
height: removeNodeTitleHeight(bounds.height)
|
||||
}
|
||||
: bounds
|
||||
|
||||
boundsRecord[nodeId] = {
|
||||
bounds,
|
||||
bounds: normalizedBounds,
|
||||
previousBounds: currentLayout.bounds
|
||||
}
|
||||
nodeIds.push(nodeId)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { addNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
/**
|
||||
* Composable for syncing LiteGraph with the Layout system
|
||||
@@ -43,12 +44,13 @@ export function useLayoutSync() {
|
||||
liteNode.pos[1] = layout.position.y
|
||||
}
|
||||
|
||||
const targetHeight = addNodeTitleHeight(layout.size.height)
|
||||
if (
|
||||
liteNode.size[0] !== layout.size.width ||
|
||||
liteNode.size[1] !== layout.size.height
|
||||
liteNode.size[1] !== targetHeight
|
||||
) {
|
||||
// Use setSize() to trigger onResize callback
|
||||
liteNode.setSize([layout.size.width, layout.size.height])
|
||||
liteNode.setSize([layout.size.width, targetHeight])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 w-full h-full pointer-events-none',
|
||||
isInteracting ? 'transform-pane--interacting' : 'will-change-auto',
|
||||
isLOD && 'isLOD'
|
||||
isInteracting ? 'transform-pane--interacting' : 'will-change-auto'
|
||||
)
|
||||
"
|
||||
:style="transformStyle"
|
||||
@@ -22,7 +21,6 @@ import { computed } from 'vue'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface TransformPaneProps {
|
||||
@@ -31,9 +29,7 @@ interface TransformPaneProps {
|
||||
|
||||
const props = defineProps<TransformPaneProps>()
|
||||
|
||||
const { camera, transformStyle, syncWithCanvas } = useTransformState()
|
||||
|
||||
const { isLOD } = useLOD(camera)
|
||||
const { transformStyle, syncWithCanvas } = useTransformState()
|
||||
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ComputedRef, Ref } from 'vue'
|
||||
export enum LayoutSource {
|
||||
Canvas = 'canvas',
|
||||
Vue = 'vue',
|
||||
DOM = 'dom',
|
||||
External = 'external'
|
||||
}
|
||||
|
||||
|
||||
7
src/renderer/core/layout/utils/nodeSizeUtil.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
export const removeNodeTitleHeight = (height: number) =>
|
||||
Math.max(0, height - (LiteGraph.NODE_TITLE_HEIGHT || 0))
|
||||
|
||||
export const addNodeTitleHeight = (height: number) =>
|
||||
height + LiteGraph.NODE_TITLE_HEIGHT
|
||||
@@ -26,13 +26,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<Skeleton v-else-if="isLoading" class="size-full" border-radius="5px" />
|
||||
<Skeleton
|
||||
v-if="isLoading && !videoError"
|
||||
class="absolute inset-0 size-full"
|
||||
border-radius="5px"
|
||||
width="16rem"
|
||||
height="16rem"
|
||||
/>
|
||||
|
||||
<!-- Main Video -->
|
||||
<video
|
||||
v-else
|
||||
v-if="!videoError"
|
||||
:src="currentVideoUrl"
|
||||
class="block size-full object-contain"
|
||||
:class="cn('block size-full object-contain', isLoading && 'invisible')"
|
||||
controls
|
||||
loop
|
||||
playsinline
|
||||
@@ -83,20 +89,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Video Dimensions -->
|
||||
<div class="mt-2 text-center text-xs text-white">
|
||||
<span v-if="videoError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingVideo') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-smoke-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
<LODFallback />
|
||||
<!-- Video Dimensions -->
|
||||
<div class="mt-2 text-center text-xs text-white">
|
||||
<span v-if="videoError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingVideo') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-smoke-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -109,8 +112,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
import LODFallback from './components/LODFallback.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface VideoPreviewProps {
|
||||
/** Array of video URLs to display */
|
||||
@@ -147,7 +149,7 @@ watch(
|
||||
// Reset loading and error states when URLs change
|
||||
actualDimensions.value = null
|
||||
videoError.value = false
|
||||
isLoading.value = false
|
||||
isLoading.value = newUrls.length > 0
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
@@ -26,15 +26,26 @@
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<Skeleton v-else-if="isLoading" class="size-full" border-radius="5px" />
|
||||
<Skeleton
|
||||
v-if="isLoading && !imageError"
|
||||
class="absolute inset-0 size-full"
|
||||
border-radius="5px"
|
||||
width="16rem"
|
||||
height="16rem"
|
||||
/>
|
||||
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-else
|
||||
v-if="!imageError"
|
||||
ref="currentImageEl"
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
class="block size-full object-contain pointer-events-none"
|
||||
:class="
|
||||
cn(
|
||||
'block size-full object-contain pointer-events-none',
|
||||
isLoading && 'invisible'
|
||||
)
|
||||
"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
@@ -93,20 +104,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Image Dimensions -->
|
||||
<div class="mt-2 text-center text-xs text-white">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-smoke-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
<LODFallback />
|
||||
<!-- Image Dimensions -->
|
||||
<div class="mt-2 text-center text-xs text-white">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-smoke-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -121,8 +129,7 @@ import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
/** Array of image URLs to display */
|
||||
@@ -166,7 +173,7 @@ watch(
|
||||
// Reset loading and error states when URLs change
|
||||
actualDimensions.value = null
|
||||
imageError.value = false
|
||||
isLoading.value = false
|
||||
isLoading.value = newUrls.length > 0
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
@@ -226,7 +233,7 @@ const setCurrentIndex = (index: number) => {
|
||||
if (index >= 0 && index < props.imageUrls.length) {
|
||||
currentIndex.value = index
|
||||
actualDimensions.value = null
|
||||
isLoading.value = false
|
||||
isLoading.value = true
|
||||
imageError.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,13 @@
|
||||
/>
|
||||
|
||||
<!-- Slot Name -->
|
||||
<div class="relative h-full flex items-center min-w-0">
|
||||
<div class="h-full flex items-center min-w-0">
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
:class="cn('truncate text-xs font-normal lod-toggle', labelClasses)"
|
||||
:class="cn('truncate text-xs font-normal', labelClasses)"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
|
||||
</span>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -37,7 +36,6 @@ import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composabl
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface InputSlotProps {
|
||||
|
||||
@@ -99,18 +99,14 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Node Body - rendered based on LOD level and collapsed state -->
|
||||
<div
|
||||
class="flex flex-1 flex-col gap-1 pb-2"
|
||||
:data-testid="`node-body-${nodeData.id}`"
|
||||
>
|
||||
<!-- Slots only rendered at full detail -->
|
||||
<NodeSlots :node-data="nodeData" />
|
||||
|
||||
<!-- Widgets rendered at reduced+ detail -->
|
||||
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
|
||||
|
||||
<!-- Custom content at reduced+ detail -->
|
||||
<div v-if="hasCustomContent" class="min-h-0 flex-1 flex">
|
||||
<NodeContent :node-data="nodeData" :media="nodeMedia" />
|
||||
</div>
|
||||
@@ -121,17 +117,14 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Resize handles -->
|
||||
<template v-if="!isCollapsed">
|
||||
<div
|
||||
v-for="handle in cornerResizeHandles"
|
||||
:key="handle.id"
|
||||
role="button"
|
||||
:aria-label="handle.ariaLabel"
|
||||
:class="cn(baseResizeHandleClasses, handle.classes)"
|
||||
@pointerdown.stop="handleResizePointerDown(handle.direction)($event)"
|
||||
/>
|
||||
</template>
|
||||
<!-- Resize handle (bottom-right only) -->
|
||||
<div
|
||||
v-if="!isCollapsed"
|
||||
role="button"
|
||||
:aria-label="t('g.resizeFromBottomRight')"
|
||||
:class="cn(baseResizeHandleClasses, 'right-0 bottom-0 cursor-se-resize')"
|
||||
@pointerdown.stop="handleResizePointerDown"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -175,7 +168,6 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { ResizeHandleDirection } from '../interactions/resize/resizeMath'
|
||||
import { useNodeResize } from '../interactions/resize/useNodeResize'
|
||||
import LivePreview from './LivePreview.vue'
|
||||
import NodeContent from './NodeContent.vue'
|
||||
@@ -267,7 +259,7 @@ onErrorCaptured((error) => {
|
||||
return false // Prevent error propagation
|
||||
})
|
||||
|
||||
const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id)
|
||||
const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
|
||||
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
|
||||
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
|
||||
const { startDrag } = useNodeDrag()
|
||||
@@ -318,41 +310,6 @@ onMounted(() => {
|
||||
|
||||
const baseResizeHandleClasses =
|
||||
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
|
||||
const POSITION_EPSILON = 0.01
|
||||
|
||||
type CornerResizeHandle = {
|
||||
id: string
|
||||
direction: ResizeHandleDirection
|
||||
classes: string
|
||||
ariaLabel: string
|
||||
}
|
||||
|
||||
const cornerResizeHandles: CornerResizeHandle[] = [
|
||||
{
|
||||
id: 'se',
|
||||
direction: { horizontal: 'right', vertical: 'bottom' },
|
||||
classes: 'right-0 bottom-0 cursor-se-resize',
|
||||
ariaLabel: t('g.resizeFromBottomRight')
|
||||
},
|
||||
{
|
||||
id: 'ne',
|
||||
direction: { horizontal: 'right', vertical: 'top' },
|
||||
classes: 'right-0 top-0 cursor-ne-resize',
|
||||
ariaLabel: t('g.resizeFromTopRight')
|
||||
},
|
||||
{
|
||||
id: 'sw',
|
||||
direction: { horizontal: 'left', vertical: 'bottom' },
|
||||
classes: 'left-0 bottom-0 cursor-sw-resize',
|
||||
ariaLabel: t('g.resizeFromBottomLeft')
|
||||
},
|
||||
{
|
||||
id: 'nw',
|
||||
direction: { horizontal: 'left', vertical: 'top' },
|
||||
classes: 'left-0 top-0 cursor-nw-resize',
|
||||
ariaLabel: t('g.resizeFromTopLeft')
|
||||
}
|
||||
]
|
||||
|
||||
const MIN_NODE_WIDTH = 225
|
||||
|
||||
@@ -365,22 +322,11 @@ const { startResize } = useNodeResize((result, element) => {
|
||||
// Apply size directly to DOM element - ResizeObserver will pick this up
|
||||
element.style.setProperty('--node-width', `${clampedWidth}px`)
|
||||
element.style.setProperty('--node-height', `${result.size.height}px`)
|
||||
|
||||
const currentPosition = position.value
|
||||
const deltaX = Math.abs(result.position.x - currentPosition.x)
|
||||
const deltaY = Math.abs(result.position.y - currentPosition.y)
|
||||
|
||||
if (deltaX > POSITION_EPSILON || deltaY > POSITION_EPSILON) {
|
||||
moveNodeTo(result.position)
|
||||
}
|
||||
})
|
||||
|
||||
const handleResizePointerDown = (direction: ResizeHandleDirection) => {
|
||||
return (event: PointerEvent) => {
|
||||
if (nodeData.flags?.pinned) return
|
||||
|
||||
startResize(event, direction, { ...position.value })
|
||||
}
|
||||
const handleResizePointerDown = (event: PointerEvent) => {
|
||||
if (nodeData.flags?.pinned) return
|
||||
startResize(event)
|
||||
}
|
||||
|
||||
watch(isCollapsed, (collapsed) => {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="lod-fallback absolute inset-0 h-full w-full bg-node-component-widget-skeleton-surface"
|
||||
></div>
|
||||
</template>
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="flex items-center justify-between gap-2.5 min-w-0">
|
||||
<!-- Collapse/Expand Button -->
|
||||
<div class="relative grow-1 flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<div class="lod-toggle flex shrink-0 items-center px-0.5">
|
||||
<div class="flex shrink-0 items-center px-0.5">
|
||||
<IconButton
|
||||
size="fit-content"
|
||||
type="transparent"
|
||||
@@ -44,7 +44,7 @@
|
||||
<!-- Node Title -->
|
||||
<div
|
||||
v-tooltip.top="tooltipConfig"
|
||||
class="lod-toggle flex min-w-0 flex-1 items-center gap-2 text-sm font-bold"
|
||||
class="flex min-w-0 flex-1 items-center gap-2 text-sm font-bold"
|
||||
data-testid="node-title"
|
||||
>
|
||||
<div class="truncate min-w-0 flex-1">
|
||||
@@ -57,10 +57,9 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<LODFallback />
|
||||
</div>
|
||||
|
||||
<div class="lod-toggle flex shrink-0 items-center justify-between gap-2">
|
||||
<div class="flex shrink-0 items-center justify-between gap-2">
|
||||
<NodeBadge
|
||||
v-for="badge of nodeBadges"
|
||||
:key="badge.text"
|
||||
@@ -112,7 +111,6 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
import type { NodeBadgeProps } from './NodeBadge.vue'
|
||||
|
||||
interface NodeHeaderProps {
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="lod-toggle text-xs font-normal truncate text-node-component-slot-text"
|
||||
class="text-xs font-normal truncate text-node-component-slot-text"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Output ${index}` }}
|
||||
</span>
|
||||
<LODFallback />
|
||||
</div>
|
||||
<!-- Connection Dot -->
|
||||
<SlotConnectionDot
|
||||
@@ -35,7 +34,6 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl
|
||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface OutputSlotProps {
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# ComfyUI Widget LOD System: Architecture and Implementation
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The ComfyUI widget Level of Detail (LOD) system has evolved from a reactive, Vue-based approach to a CSS-driven, non-reactive implementation. This architectural shift was driven by performance requirements at scale (300-500+ nodes) and a deeper understanding of browser rendering pipelines. The current system prioritizes consistent performance over granular control, leveraging CSS visibility rules rather than component mounting/unmounting.
|
||||
|
||||
## The Two Approaches: Reactive vs. Static LOD
|
||||
|
||||
### Approach 1: Reactive LOD (Original Design)
|
||||
|
||||
The original design envisioned a system where each widget would reactively respond to zoom level changes, controlling its own detail level through Vue's reactivity system. Widgets would import LOD utilities, compute what to show based on zoom level, and conditionally render elements using `v-if` and `v-show` directives.
|
||||
|
||||
**The promise of this approach was compelling:** widgets could intelligently manage their complexity, progressively revealing detail as users zoomed in, much like how mapping applications work. Developers would have fine-grained control over performance optimization.
|
||||
|
||||
### Approach 2: Static LOD with CSS (Current Implementation)
|
||||
|
||||
The implemented system takes a fundamentally different approach. All widget content is loaded and remains in the DOM at all times. Visual simplification happens through CSS rules, primarily using `visibility: hidden` and simplified visual representations (gray rectangles) at distant zoom levels. No reactive updates occur when zoom changes—only CSS rules apply differently.
|
||||
|
||||
**This approach seems counterintuitive at first:** aren't we wasting resources by keeping everything loaded? The answer reveals a deeper truth about modern browser rendering.
|
||||
|
||||
## The GPU Texture Bottleneck
|
||||
|
||||
The key insight driving the current architecture comes from understanding how browsers handle CSS transforms:
|
||||
|
||||
When you apply a CSS transform to a parent element (the "transformpane" in ComfyUI's case), the browser promotes that entire subtree to a compositor layer. This creates a single GPU texture containing all the transformed content. Here's where traditional performance intuitions break down:
|
||||
|
||||
### Traditional Assumption
|
||||
|
||||
"If we render less content, we get better performance. Therefore, hiding complex widgets should improve zoom/pan performance."
|
||||
|
||||
### Actual Browser Behavior
|
||||
|
||||
When all nodes are children of a single transformed parent:
|
||||
|
||||
1. The browser creates one large GPU texture for the entire node graph
|
||||
2. The texture dimensions are determined by the bounding box of all content
|
||||
3. Whether individual pixels are simple (solid rectangles) or complex (detailed widgets) has minimal impact
|
||||
4. The performance bottleneck is the texture size itself, not the complexity of rasterization
|
||||
|
||||
This means that even if we reduce every node to a simple gray rectangle, we're still paying the cost of a massive GPU texture when viewing hundreds of nodes simultaneously. The texture dimensions remain the same whether it contains simple or complex content.
|
||||
|
||||
## Two Distinct Performance Concerns
|
||||
|
||||
The analysis reveals two often-conflated performance considerations that should be understood separately:
|
||||
|
||||
### 1. Rendering Performance
|
||||
|
||||
**Question:** How fast can the browser paint and composite the node graph during interactions?
|
||||
|
||||
**Traditional thinking:** Show less content → render faster
|
||||
**Reality with CSS transforms:** GPU texture size dominates performance, not content complexity
|
||||
|
||||
The CSS transform approach means that zoom, pan, and drag operations are already optimized—they're just transforming an existing GPU texture. The cost is in the initial rasterization and texture upload, which happens regardless of content complexity when texture dimensions are fixed.
|
||||
|
||||
### 2. Memory and Lifecycle Management
|
||||
|
||||
**Question:** How much memory do widget instances consume, and what's the cost of maintaining them?
|
||||
|
||||
This is where unmounting widgets might theoretically help:
|
||||
|
||||
- Complex widgets (3D viewers, chart renderers) might hold significant memory
|
||||
- Event listeners and reactive watchers consume resources
|
||||
- Some widgets might run background processes or animations
|
||||
|
||||
However, the cost of mounting/unmounting hundreds of widgets on zoom changes could create worse performance problems than the memory savings provide. Vue's virtual DOM diffing for hundreds of nodes is expensive, potentially causing noticeable lag during zoom transitions.
|
||||
|
||||
## Design Philosophy and Trade-offs
|
||||
|
||||
The current CSS-based approach makes several deliberate trade-offs:
|
||||
|
||||
### What We Optimize For
|
||||
|
||||
1. **Consistent, predictable performance** - No reactivity means no sudden performance cliffs
|
||||
2. **Smooth zoom/pan interactions** - CSS transforms are hardware-accelerated
|
||||
3. **Simple widget development** - Widget authors don't need to implement LOD logic
|
||||
4. **Reliable state preservation** - Widgets never lose state from unmounting
|
||||
|
||||
### What We Accept
|
||||
|
||||
1. **Higher baseline memory usage** - All widgets remain mounted
|
||||
2. **Less granular control** - Widgets can't optimize their own LOD behavior
|
||||
3. **Potential waste for exotic widgets** - A 3D renderer widget still runs when hidden
|
||||
|
||||
## Open Questions and Future Considerations
|
||||
|
||||
### Should widgets have any LOD control?
|
||||
|
||||
The current system provides a uniform gray rectangle fallback with CSS visibility hiding. This works for 99% of widgets, but raises questions:
|
||||
|
||||
**Scenario:** A widget renders a complex 3D scene or runs expensive computations
|
||||
**Current behavior:** Hidden via CSS but still mounted
|
||||
**Question:** Should such widgets be able to opt into unmounting at distance?
|
||||
|
||||
The challenge is that introducing selective unmounting would require:
|
||||
|
||||
- Maintaining widget state across mount/unmount cycles
|
||||
- Accepting the performance cost of remounting when zooming in
|
||||
- Adding complexity to the widget API
|
||||
|
||||
### Could we reduce GPU texture size?
|
||||
|
||||
Since texture dimensions are the limiting factor, could we:
|
||||
|
||||
- Use multiple compositor layers for different regions (chunk the transformpane)?
|
||||
- Render the nodes using the canvas fallback when 500+ nodes and < 30% zoom.
|
||||
|
||||
These approaches would require significant architectural changes and might introduce their own performance trade-offs.
|
||||
|
||||
### Is there a hybrid approach?
|
||||
|
||||
Could we identify specific threshold scenarios where reactive LOD makes sense?
|
||||
|
||||
- When node count is low (< 50 nodes)
|
||||
- For specifically registered "expensive" widgets
|
||||
- At extreme zoom levels only
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
Given the current architecture, here's how to work within the system:
|
||||
|
||||
### For Widget Developers
|
||||
|
||||
1. **Build widgets assuming they're always visible** - Don't rely on mount/unmount for cleanup
|
||||
2. **Use CSS classes for zoom-responsive styling** - Let CSS handle visual changes
|
||||
3. **Minimize background processing** - Assume your widget is always running
|
||||
4. **Consider requestAnimationFrame throttling** - For animations that won't be visible when zoomed out
|
||||
|
||||
### For System Architects
|
||||
|
||||
1. **Monitor GPU memory usage** - The single texture approach has memory implications
|
||||
2. **Consider viewport culling** - Not rendering off-screen nodes could reduce texture size
|
||||
3. **Profile real-world workflows** - Theoretical performance differs from actual usage patterns
|
||||
4. **Document the architecture clearly** - The non-obvious performance characteristics need explanation
|
||||
|
||||
## Conclusion
|
||||
|
||||
The ComfyUI LOD system represents a pragmatic choice: accepting higher memory usage and less granular control in exchange for predictable performance and implementation simplicity. By understanding that GPU texture dimensions—not rasterization complexity—drive performance in a CSS-transform-based architecture, the team has chosen an approach that may seem counterintuitive but actually aligns with browser rendering realities.
|
||||
|
||||
The system works well for the common case of hundreds of relatively simple widgets. Edge cases involving genuinely expensive widgets may need future consideration, but the current approach provides a solid foundation that avoids the performance pitfalls of reactive LOD at scale.
|
||||
|
||||
The key insight—that showing less doesn't necessarily mean rendering faster when everything lives in a single GPU texture—challenges conventional web performance wisdom and demonstrates the importance of understanding the full rendering pipeline when making architectural decisions.
|
||||
@@ -92,12 +92,14 @@ const mockData = vi.hoisted(() => {
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => {
|
||||
const isDraggingVueNodes = ref(false)
|
||||
const isResizingVueNodes = ref(false)
|
||||
const fakeNodeLayoutRef = ref(mockData.fakeNodeLayout)
|
||||
const getNodeLayoutRef = vi.fn(() => fakeNodeLayoutRef)
|
||||
const setSource = vi.fn()
|
||||
return {
|
||||
layoutStore: {
|
||||
isDraggingVueNodes,
|
||||
isResizingVueNodes,
|
||||
getNodeLayoutRef,
|
||||
setSource
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ export function useNodePointerInteractions(
|
||||
function onPointermove(event: PointerEvent) {
|
||||
if (forwardMiddlePointerIfNeeded(event)) return
|
||||
|
||||
// Don't activate drag while resizing
|
||||
if (layoutStore.isResizingVueNodes.value) return
|
||||
|
||||
const nodeId = toValue(nodeIdRef)
|
||||
|
||||
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
|
||||
@@ -124,6 +127,10 @@ export function useNodePointerInteractions(
|
||||
safeDragEnd(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip selection handling for right-click (button 2) - context menu handles its own selection
|
||||
if (event.button === 2) return
|
||||
|
||||
const multiSelect = isMultiSelectKey(event)
|
||||
|
||||
const nodeId = toValue(nodeIdRef)
|
||||
|
||||
@@ -107,7 +107,7 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
x: topLeftCanvas.x,
|
||||
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
|
||||
width: Math.max(0, width),
|
||||
height: Math.max(0, height - LiteGraph.NODE_TITLE_HEIGHT)
|
||||
height: Math.max(0, height)
|
||||
}
|
||||
|
||||
let updates = updatesByType.get(elementType)
|
||||
@@ -123,8 +123,7 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Set source to Vue before processing DOM-driven updates
|
||||
layoutStore.setSource(LayoutSource.Vue)
|
||||
layoutStore.setSource(LayoutSource.DOM)
|
||||
|
||||
// Flush per-type
|
||||
for (const [type, updates] of updatesByType) {
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { Point, Size } from '@/renderer/core/layout/types'
|
||||
|
||||
export type ResizeHandleDirection = {
|
||||
horizontal: 'left' | 'right'
|
||||
vertical: 'top' | 'bottom'
|
||||
}
|
||||
|
||||
function applyHandleDelta(
|
||||
startSize: Size,
|
||||
delta: Point,
|
||||
handle: ResizeHandleDirection
|
||||
): Size {
|
||||
const horizontalMultiplier = handle.horizontal === 'right' ? 1 : -1
|
||||
const verticalMultiplier = handle.vertical === 'bottom' ? 1 : -1
|
||||
|
||||
return {
|
||||
width: startSize.width + delta.x * horizontalMultiplier,
|
||||
height: startSize.height + delta.y * verticalMultiplier
|
||||
}
|
||||
}
|
||||
|
||||
function computeAdjustedPosition(
|
||||
startPosition: Point,
|
||||
startSize: Size,
|
||||
nextSize: Size,
|
||||
handle: ResizeHandleDirection
|
||||
): Point {
|
||||
const widthDelta = startSize.width - nextSize.width
|
||||
const heightDelta = startSize.height - nextSize.height
|
||||
|
||||
return {
|
||||
x:
|
||||
handle.horizontal === 'left'
|
||||
? startPosition.x + widthDelta
|
||||
: startPosition.x,
|
||||
y:
|
||||
handle.vertical === 'top'
|
||||
? startPosition.y + heightDelta
|
||||
: startPosition.y
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the resulting size and position of a node given pointer movement
|
||||
* and handle orientation.
|
||||
*/
|
||||
export function computeResizeOutcome({
|
||||
startSize,
|
||||
startPosition,
|
||||
delta,
|
||||
handle,
|
||||
snapFn
|
||||
}: {
|
||||
startSize: Size
|
||||
startPosition: Point
|
||||
delta: Point
|
||||
handle: ResizeHandleDirection
|
||||
snapFn?: (size: Size) => Size
|
||||
}): { size: Size; position: Point } {
|
||||
const resized = applyHandleDelta(startSize, delta, handle)
|
||||
const snapped = snapFn?.(resized) ?? resized
|
||||
const position = computeAdjustedPosition(
|
||||
startPosition,
|
||||
startSize,
|
||||
snapped,
|
||||
handle
|
||||
)
|
||||
|
||||
return {
|
||||
size: snapped,
|
||||
position
|
||||
}
|
||||
}
|
||||
|
||||
export function createResizeSession(config: {
|
||||
startSize: Size
|
||||
startPosition: Point
|
||||
handle: ResizeHandleDirection
|
||||
}) {
|
||||
const startSize = { ...config.startSize }
|
||||
const startPosition = { ...config.startPosition }
|
||||
const handle = config.handle
|
||||
|
||||
return (delta: Point, snapFn?: (size: Size) => Size) =>
|
||||
computeResizeOutcome({
|
||||
startSize,
|
||||
startPosition,
|
||||
handle,
|
||||
delta,
|
||||
snapFn
|
||||
})
|
||||
}
|
||||
|
||||
export function toCanvasDelta(
|
||||
startPointer: Point,
|
||||
currentPointer: Point,
|
||||
scale: number
|
||||
): Point {
|
||||
const safeScale = scale === 0 ? 1 : scale
|
||||
return {
|
||||
x: (currentPointer.x - startPointer.x) / safeScale,
|
||||
y: (currentPointer.y - startPointer.y) / safeScale
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,17 @@ import { useEventListener } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Point, Size } from '@/renderer/core/layout/types'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
|
||||
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||
|
||||
import type { ResizeHandleDirection } from './resizeMath'
|
||||
import { createResizeSession, toCanvasDelta } from './resizeMath'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
interface ResizeCallbackPayload {
|
||||
size: Size
|
||||
position: Point
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for node resizing functionality
|
||||
* Composable for node resizing functionality (bottom-right corner only)
|
||||
*
|
||||
* Provides resize handle interaction that integrates with the layout system.
|
||||
* Handles pointer capture, coordinate calculations, and size constraints.
|
||||
@@ -27,16 +24,7 @@ export function useNodeResize(
|
||||
|
||||
const isResizing = ref(false)
|
||||
const resizeStartPointer = ref<Point | null>(null)
|
||||
const resizeSession = ref<
|
||||
| ((
|
||||
delta: Point,
|
||||
snapFn?: (size: Size) => Size
|
||||
) => {
|
||||
size: Size
|
||||
position: Point
|
||||
})
|
||||
| null
|
||||
>(null)
|
||||
const resizeStartSize = ref<Size | null>(null)
|
||||
|
||||
// Snap-to-grid functionality
|
||||
const { shouldSnap, applySnapToSize } = useNodeSnap()
|
||||
@@ -44,11 +32,7 @@ export function useNodeResize(
|
||||
// Shift key sync for LiteGraph canvas preview
|
||||
const { trackShiftKey } = useShiftKeySync()
|
||||
|
||||
const startResize = (
|
||||
event: PointerEvent,
|
||||
handle: ResizeHandleDirection,
|
||||
startPosition: Point
|
||||
) => {
|
||||
const startResize = (event: PointerEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -72,47 +56,49 @@ export function useNodeResize(
|
||||
// Capture pointer to ensure we get all move/up events
|
||||
target.setPointerCapture(event.pointerId)
|
||||
|
||||
// Mark as resizing to prevent drag from activating
|
||||
layoutStore.isResizingVueNodes.value = true
|
||||
isResizing.value = true
|
||||
resizeStartPointer.value = { x: event.clientX, y: event.clientY }
|
||||
resizeSession.value = createResizeSession({
|
||||
startSize,
|
||||
startPosition: { ...startPosition },
|
||||
handle
|
||||
})
|
||||
resizeStartSize.value = startSize
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
if (
|
||||
!isResizing.value ||
|
||||
!resizeStartPointer.value ||
|
||||
!resizeSession.value
|
||||
)
|
||||
!resizeStartSize.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const startPointer = resizeStartPointer.value
|
||||
const session = resizeSession.value
|
||||
const scale = transformState.camera.z
|
||||
const deltaX =
|
||||
(moveEvent.clientX - resizeStartPointer.value.x) / (scale || 1)
|
||||
const deltaY =
|
||||
(moveEvent.clientY - resizeStartPointer.value.y) / (scale || 1)
|
||||
|
||||
const delta = toCanvasDelta(
|
||||
startPointer,
|
||||
{ x: moveEvent.clientX, y: moveEvent.clientY },
|
||||
transformState.camera.z
|
||||
)
|
||||
let newSize: Size = {
|
||||
width: resizeStartSize.value.width + deltaX,
|
||||
height: resizeStartSize.value.height + deltaY
|
||||
}
|
||||
|
||||
// Apply snap if shift is held
|
||||
if (shouldSnap(moveEvent)) {
|
||||
newSize = applySnapToSize(newSize)
|
||||
}
|
||||
|
||||
const nodeElement = target.closest('[data-node-id]')
|
||||
if (nodeElement instanceof HTMLElement) {
|
||||
const outcome = session(
|
||||
delta,
|
||||
shouldSnap(moveEvent) ? applySnapToSize : undefined
|
||||
)
|
||||
|
||||
resizeCallback(outcome, nodeElement)
|
||||
resizeCallback({ size: newSize }, nodeElement)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
if (isResizing.value) {
|
||||
isResizing.value = false
|
||||
layoutStore.isResizingVueNodes.value = false
|
||||
resizeStartPointer.value = null
|
||||
resizeSession.value = null
|
||||
resizeStartSize.value = null
|
||||
|
||||
// Stop tracking shift key state
|
||||
stopShiftSync()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { LGraph, RendererType } from '@/lib/litegraph/src/LGraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { createBounds } from '@/lib/litegraph/src/measure'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
@@ -9,6 +8,7 @@ import type { NodeBoundsUpdate } from '@/renderer/core/layout/types'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import type { SubgraphInputNode } from '@/lib/litegraph/src/subgraph/SubgraphInputNode'
|
||||
import type { SubgraphOutputNode } from '@/lib/litegraph/src/subgraph/SubgraphOutputNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const SCALE_FACTOR = 1.2
|
||||
|
||||
@@ -59,25 +59,22 @@ export function ensureCorrectLayoutScale(
|
||||
|
||||
const [oldX, oldY] = lgNode.pos
|
||||
|
||||
const adjustedY = oldY - (needsUpscale ? LiteGraph.NODE_TITLE_HEIGHT : 0)
|
||||
|
||||
const relativeX = oldX - originX
|
||||
const relativeY = adjustedY - originY
|
||||
const relativeY = oldY - originY
|
||||
|
||||
const scaledX = originX + relativeX * scaleFactor
|
||||
const scaledY = originY + relativeY * scaleFactor
|
||||
|
||||
const scaledWidth = lgNode.width * scaleFactor
|
||||
const scaledHeight =
|
||||
lgNode.height * scaleFactor -
|
||||
(needsUpscale ? 0 : LiteGraph.NODE_TITLE_HEIGHT)
|
||||
|
||||
const finalY = scaledY + (needsUpscale ? 0 : LiteGraph.NODE_TITLE_HEIGHT) // Litegraph Position further down
|
||||
const scaledHeight = needsUpscale
|
||||
? lgNode.size[1] * scaleFactor + LiteGraph.NODE_TITLE_HEIGHT
|
||||
: (lgNode.size[1] - LiteGraph.NODE_TITLE_HEIGHT) * scaleFactor
|
||||
|
||||
// Directly update LiteGraph node to ensure immediate consistency
|
||||
// Dont need to reference vue directly because the pos and dims are already in yjs
|
||||
lgNode.pos[0] = scaledX
|
||||
lgNode.pos[1] = finalY
|
||||
lgNode.pos[1] = scaledY
|
||||
lgNode.size[0] = scaledWidth
|
||||
lgNode.size[1] = scaledHeight
|
||||
|
||||
@@ -87,7 +84,7 @@ export function ensureCorrectLayoutScale(
|
||||
nodeId: String(lgNode.id),
|
||||
bounds: {
|
||||
x: scaledX,
|
||||
y: finalY,
|
||||
y: scaledY,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight
|
||||
}
|
||||
@@ -147,10 +144,8 @@ export function ensureCorrectLayoutScale(
|
||||
const [oldX, oldY] = group.pos
|
||||
const [oldWidth, oldHeight] = group.size
|
||||
|
||||
const adjustedY = oldY - (needsUpscale ? LiteGraph.NODE_TITLE_HEIGHT : 0)
|
||||
|
||||
const relativeX = oldX - originX
|
||||
const relativeY = adjustedY - originY
|
||||
const relativeY = oldY - originY
|
||||
|
||||
const scaledX = originX + relativeX * scaleFactor
|
||||
const scaledY = originY + relativeY * scaleFactor
|
||||
@@ -158,9 +153,7 @@ export function ensureCorrectLayoutScale(
|
||||
const scaledWidth = oldWidth * scaleFactor
|
||||
const scaledHeight = oldHeight * scaleFactor
|
||||
|
||||
const finalY = scaledY + (needsUpscale ? 0 : LiteGraph.NODE_TITLE_HEIGHT)
|
||||
|
||||
group.pos = [scaledX, finalY]
|
||||
group.pos = [scaledX, scaledY]
|
||||
group.size = [scaledWidth, scaledHeight]
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
import { computed, onUnmounted, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
@@ -17,6 +17,11 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
// Get the customRef for this node (shared write access)
|
||||
const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
|
||||
// Clean up refs and triggers when Vue component unmounts
|
||||
onUnmounted(() => {
|
||||
layoutStore.cleanupNodeRef(nodeId)
|
||||
})
|
||||
|
||||
// Computed properties for easy access
|
||||
const position = computed(() => {
|
||||
const layout = layoutRef.value
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Level of Detail (LOD) composable for Vue-based node rendering
|
||||
*
|
||||
* Provides dynamic quality adjustment based on zoom level to maintain
|
||||
* performance with large node graphs. Uses zoom threshold based on DPR
|
||||
* to determine how much detail to render for each node component.
|
||||
* Default minFontSize = 8px
|
||||
* Default zoomThreshold = 0.57 (On a DPR = 1 monitor)
|
||||
**/
|
||||
import { useDevicePixelRatio } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
interface Camera {
|
||||
z: number // zoom level
|
||||
}
|
||||
|
||||
export function useLOD(camera: Camera) {
|
||||
const isLOD = computed(() => {
|
||||
const { pixelRatio } = useDevicePixelRatio()
|
||||
const baseFontSize = 14
|
||||
const dprAdjustment = Math.sqrt(pixelRatio.value)
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const minFontSize = settingStore.get('LiteGraph.Canvas.MinFontSizeForLOD') //default 8
|
||||
const threshold =
|
||||
Math.round((minFontSize / (baseFontSize * dprAdjustment)) * 100) / 100 //round to 2 decimal places i.e 0.86
|
||||
|
||||
return camera.z < threshold
|
||||
})
|
||||
|
||||
return { isLOD }
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<WidgetSelect v-model="modelValue" :widget />
|
||||
<div class="my-4">
|
||||
<AudioPreviewPlayer
|
||||
:audio-url="audioUrlFromWidget"
|
||||
:readonly="readonly"
|
||||
:hide-when-empty="isOutputNodeRef"
|
||||
:show-options-button="true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="w-full col-span-2 widget-expands grid grid-cols-[minmax(80px,max-content)_minmax(125px,auto)] gap-y-3 p-3"
|
||||
>
|
||||
<WidgetSelect v-model="modelValue" :widget class="col-span-2" />
|
||||
<AudioPreviewPlayer
|
||||
class="col-span-2"
|
||||
:audio-url="audioUrlFromWidget"
|
||||
:readonly="readonly"
|
||||
:hide-when-empty="isOutputNodeRef"
|
||||
:show-options-button="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ const buttonTooltip = computed(() => {
|
||||
size="small"
|
||||
variant="outlined"
|
||||
:step="stepValue"
|
||||
:min-fraction-digits="precision"
|
||||
:max-fraction-digits="precision"
|
||||
:use-grouping="useGrouping"
|
||||
:class="cn(WidgetInputBaseClass, 'grow text-xs')"
|
||||
:aria-label="widget.name"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
>
|
||||
<!-- Display mode: Rendered markdown -->
|
||||
<div
|
||||
class="comfy-markdown-content lod-toggle size-full min-h-[60px] overflow-y-auto rounded-lg text-sm"
|
||||
class="comfy-markdown-content size-full min-h-[60px] overflow-y-auto rounded-lg text-sm"
|
||||
:class="isEditing === false ? 'visible' : 'invisible'"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
@@ -27,7 +27,6 @@
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
/>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -38,8 +37,6 @@ import { computed, nextTick, ref } from 'vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import LODFallback from '../../components/LODFallback.vue'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
}>()
|
||||
|
||||
@@ -78,7 +78,6 @@
|
||||
@ended="playback.onPlaybackEnded"
|
||||
@loadedmetadata="playback.onMetadataLoaded"
|
||||
/>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -91,7 +90,6 @@ import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<Select
|
||||
v-model="modelValue"
|
||||
:invalid
|
||||
:filter="selectOptions.length > 4"
|
||||
:options="selectOptions"
|
||||
v-bind="combinedProps"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
<Textarea
|
||||
v-model="modelValue"
|
||||
v-bind="filteredProps"
|
||||
:class="
|
||||
cn(WidgetInputBaseClass, 'size-full text-xs lod-toggle resize-none')
|
||||
"
|
||||
:class="cn(WidgetInputBaseClass, 'size-full text-xs resize-none')"
|
||||
:placeholder="placeholder || widget.name || ''"
|
||||
:aria-label="widget.name"
|
||||
:readonly="widget.options?.read_only"
|
||||
@@ -17,7 +15,6 @@
|
||||
@pointerup.capture.stop
|
||||
@contextmenu.capture.stop
|
||||
/>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -32,7 +29,6 @@ import {
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import LODFallback from '../../components/LODFallback.vue'
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
|
||||
const { widget, placeholder = '' } = defineProps<{
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
</template>
|
||||
</TieredMenu>
|
||||
</div>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -143,7 +142,6 @@ import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
@@ -3,8 +3,6 @@ import { noop } from 'es-toolkit'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import LODFallback from '../../../components/LODFallback.vue'
|
||||
|
||||
defineProps<{
|
||||
widget: Pick<SimplifiedWidget<string | number | undefined>, 'name' | 'label'>
|
||||
}>()
|
||||
@@ -17,23 +15,21 @@ defineProps<{
|
||||
<div class="relative flex h-full min-w-0 items-center">
|
||||
<p
|
||||
v-if="widget.name"
|
||||
class="lod-toggle flex-1 truncate text-xs font-normal text-node-component-slot-text"
|
||||
class="flex-1 truncate text-xs font-normal text-node-component-slot-text"
|
||||
>
|
||||
{{ widget.label || widget.name }}
|
||||
</p>
|
||||
<LODFallback />
|
||||
</div>
|
||||
<!-- basis-full grow -->
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<div
|
||||
class="lod-toggle cursor-default min-w-0"
|
||||
class="cursor-default min-w-0"
|
||||
@pointerdown.stop="noop"
|
||||
@pointermove.stop="noop"
|
||||
@pointerup.stop="noop"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -20,13 +20,17 @@ import { cloudOnboardingRoutes } from './platform/cloud/onboarding/onboardingClo
|
||||
|
||||
const isFileProtocol = window.location.protocol === 'file:'
|
||||
|
||||
// Determine base path for the router
|
||||
// - Electron: always root
|
||||
// - Web: rely on Vite's BASE_URL (configured via vite.config `base`)
|
||||
/**
|
||||
* Determine base path for the router.
|
||||
* - Electron: always root
|
||||
* - Cloud: use Vite's BASE_URL (configured at build time)
|
||||
* - Standard web (including reverse proxy subpaths): use window.location.pathname
|
||||
* to support deployments like http://mysite.com/ComfyUI/
|
||||
*/
|
||||
function getBasePath(): string {
|
||||
if (isElectron()) return '/'
|
||||
// Vite injects BASE_URL at build/dev time; default to '/'
|
||||
return import.meta.env?.BASE_URL || '/'
|
||||
if (isCloud) return import.meta.env?.BASE_URL || '/'
|
||||
return window.location.pathname
|
||||
}
|
||||
|
||||
const basePath = getBasePath()
|
||||
|
||||
@@ -1057,7 +1057,13 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
let reset_invalid_values = false
|
||||
if (!graphData) {
|
||||
// Use explicit validation instead of falsy check to avoid replacing
|
||||
// valid but falsy values (empty objects, 0, false, etc.)
|
||||
if (
|
||||
!graphData ||
|
||||
typeof graphData !== 'object' ||
|
||||
Array.isArray(graphData)
|
||||
) {
|
||||
graphData = defaultGraph
|
||||
reset_invalid_values = true
|
||||
}
|
||||
@@ -1432,6 +1438,38 @@ export class ComfyApp {
|
||||
this.loadTemplateData({ templates })
|
||||
}
|
||||
|
||||
// Check workflow first - it should take priority over parameters
|
||||
// when both are present (e.g., in ComfyUI-generated PNGs)
|
||||
if (workflow) {
|
||||
let workflowObj: ComfyWorkflowJSON | undefined = undefined
|
||||
try {
|
||||
workflowObj =
|
||||
typeof workflow === 'string' ? JSON.parse(workflow) : workflow
|
||||
|
||||
// Only load workflow if parsing succeeded AND validation passed
|
||||
if (
|
||||
workflowObj &&
|
||||
typeof workflowObj === 'object' &&
|
||||
!Array.isArray(workflowObj)
|
||||
) {
|
||||
await this.loadGraphData(workflowObj, true, true, fileName, {
|
||||
openSource
|
||||
})
|
||||
return
|
||||
} else {
|
||||
console.error(
|
||||
'Invalid workflow structure, trying parameters fallback'
|
||||
)
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse workflow:', err)
|
||||
this.showErrorOnFileLoad(file)
|
||||
// Fall through to check parameters as fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Use parameters as fallback when no workflow exists
|
||||
if (parameters) {
|
||||
// Note: Not putting this in `importA1111` as it is mostly not used
|
||||
// by external callers, and `importA1111` has no access to `app`.
|
||||
@@ -1444,15 +1482,6 @@ export class ComfyApp {
|
||||
return
|
||||
}
|
||||
|
||||
if (workflow) {
|
||||
const workflowObj =
|
||||
typeof workflow === 'string' ? JSON.parse(workflow) : workflow
|
||||
await this.loadGraphData(workflowObj, true, true, fileName, {
|
||||
openSource
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (prompt) {
|
||||
const promptObj = typeof prompt === 'string' ? JSON.parse(prompt) : prompt
|
||||
this.loadApiJson(promptObj, fileName)
|
||||
|
||||
@@ -214,7 +214,9 @@ export const useLitegraphService = () => {
|
||||
*/
|
||||
function addOutputs(node: LGraphNode, outputs: OutputSpec[]) {
|
||||
for (const output of outputs) {
|
||||
const { name, type, is_list } = output
|
||||
const { name, is_list } = output
|
||||
// TODO: Fix the typing at the node spec level
|
||||
const type = output.type === 'COMFY_MATCHTYPE_V3' ? '*' : output.type
|
||||
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
|
||||
const nameKey = `${nodeKey(node)}.outputs.${output.index}.name`
|
||||
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
|
||||
|
||||
@@ -206,19 +206,25 @@ const init = () => {
|
||||
}
|
||||
|
||||
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
|
||||
const onStatus = async (e: CustomEvent<StatusWsMessageStatus>) => {
|
||||
queuePendingTaskCountStore.update(e)
|
||||
await Promise.all([
|
||||
queueStore.update(),
|
||||
assetsStore.updateHistory() // Update history assets when status changes
|
||||
])
|
||||
await queueStore.update()
|
||||
// Only update assets if the assets sidebar is currently open
|
||||
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
|
||||
if (sidebarTabStore.activeSidebarTabId === 'assets') {
|
||||
await assetsStore.updateHistory()
|
||||
}
|
||||
}
|
||||
|
||||
const onExecutionSuccess = async () => {
|
||||
await Promise.all([
|
||||
queueStore.update(),
|
||||
assetsStore.updateHistory() // Update history assets on execution success
|
||||
])
|
||||
await queueStore.update()
|
||||
// Only update assets if the assets sidebar is currently open
|
||||
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
|
||||
if (sidebarTabStore.activeSidebarTabId === 'assets') {
|
||||
await assetsStore.updateHistory()
|
||||
}
|
||||
}
|
||||
|
||||
const reconnectingMessage: ToastMessageOptions = {
|
||||
|
||||
@@ -3,6 +3,14 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
* Utility functions for handling workbench events
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if there is selected text in the document.
|
||||
*/
|
||||
function hasTextSelection(): boolean {
|
||||
const selection = window.getSelection()
|
||||
return selection !== null && selection.toString().trim().length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by clipboard handlers to determine if copy/paste events should be
|
||||
* intercepted for graph operations vs. allowing default browser behavior
|
||||
@@ -12,7 +20,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
* @returns true if copy paste events will be handled by target
|
||||
*/
|
||||
export function shouldIgnoreCopyPaste(target: EventTarget | null): boolean {
|
||||
return (
|
||||
const isTextInput =
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
(target instanceof HTMLInputElement &&
|
||||
![
|
||||
@@ -26,7 +34,6 @@ export function shouldIgnoreCopyPaste(target: EventTarget | null): boolean {
|
||||
'reset',
|
||||
'search',
|
||||
'submit'
|
||||
].includes(target.type)) ||
|
||||
useCanvasStore().linearMode
|
||||
)
|
||||
].includes(target.type))
|
||||
return isTextInput || useCanvasStore().linearMode || hasTextSelection()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { unref } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
export type NodeDefLookup = Record<string, ComfyNodeDefImpl | undefined>
|
||||
|
||||
const isNodeMissingDefinition = (
|
||||
node: LGraphNode,
|
||||
nodeDefsByName: NodeDefLookup
|
||||
) => {
|
||||
const nodeName = node?.type
|
||||
if (!nodeName) return false
|
||||
return !nodeDefsByName[nodeName]
|
||||
}
|
||||
|
||||
export const collectMissingNodes = (
|
||||
graph: LGraph | Subgraph | null | undefined,
|
||||
nodeDefsByName: MaybeRef<NodeDefLookup>
|
||||
): LGraphNode[] => {
|
||||
if (!graph) return []
|
||||
const lookup = unref(nodeDefsByName)
|
||||
return collectAllNodes(graph, (node) => isNodeMissingDefinition(node, lookup))
|
||||
}
|
||||
|
||||
export const graphHasMissingNodes = (
|
||||
graph: LGraph | Subgraph | null | undefined,
|
||||
nodeDefsByName: MaybeRef<NodeDefLookup>
|
||||
) => {
|
||||
return collectMissingNodes(graph, nodeDefsByName).length > 0
|
||||
}
|
||||
105
tests-ui/tests/composables/clipboard.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Encodes a UTF-8 string to base64 (same logic as useCopy.ts)
|
||||
*/
|
||||
function encodeClipboardData(data: string): string {
|
||||
return btoa(
|
||||
String.fromCharCode(...Array.from(new TextEncoder().encode(data)))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes base64 to UTF-8 string (same logic as usePaste.ts)
|
||||
*/
|
||||
function decodeClipboardData(base64: string): string {
|
||||
const binaryString = atob(base64)
|
||||
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0))
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
describe('Clipboard UTF-8 base64 encoding/decoding', () => {
|
||||
it('should handle ASCII-only strings', () => {
|
||||
const original = '{"nodes":[{"id":1,"type":"LoadImage"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle Chinese characters in localized_name', () => {
|
||||
const original =
|
||||
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"图像"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle Japanese characters', () => {
|
||||
const original = '{"localized_name":"画像を読み込む"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle Korean characters', () => {
|
||||
const original = '{"localized_name":"이미지 불러오기"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle mixed ASCII and Unicode characters', () => {
|
||||
const original =
|
||||
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"加载图像","label":"Load Image 图片"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle emoji characters', () => {
|
||||
const original = '{"title":"Test Node 🎨🖼️"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const original = ''
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle complex node data with multiple Unicode fields', () => {
|
||||
const original = JSON.stringify({
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'LoadImage',
|
||||
localized_name: '图像',
|
||||
inputs: [{ localized_name: '图片', name: 'image' }],
|
||||
outputs: [{ localized_name: '输出', name: 'output' }]
|
||||
}
|
||||
],
|
||||
groups: [{ title: '预处理组 🔧' }],
|
||||
links: []
|
||||
})
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
expect(JSON.parse(decoded)).toEqual(JSON.parse(original))
|
||||
})
|
||||
|
||||
it('should produce valid base64 output', () => {
|
||||
const original = '{"localized_name":"中文测试"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
// Base64 should only contain valid characters
|
||||
expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/)
|
||||
})
|
||||
|
||||
it('should fail with plain btoa for non-Latin1 characters', () => {
|
||||
const original = '{"localized_name":"图像"}'
|
||||
// This demonstrates why we need TextEncoder - plain btoa fails
|
||||
expect(() => btoa(original)).toThrow()
|
||||
})
|
||||
})
|
||||