mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
Merge branch 'main' into fix/workflow-loss-draft-restore
This commit is contained in:
25
.github/workflows/ci-tests-storybook.yaml
vendored
25
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -4,6 +4,8 @@ name: 'CI: Tests Storybook'
|
||||
on:
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
# Post starting comment for non-forked PRs
|
||||
@@ -138,6 +140,29 @@ jobs:
|
||||
"${{ github.head_ref }}" \
|
||||
"completed"
|
||||
|
||||
# Deploy Storybook to production URL on main branch push
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build Storybook
|
||||
run: pnpm build-storybook
|
||||
|
||||
- name: Deploy to Cloudflare Pages (production)
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
npx wrangler@^4.0.0 pages deploy storybook-static \
|
||||
--project-name=comfy-storybook \
|
||||
--branch=main
|
||||
|
||||
# Update comment with Chromatic URLs for version-bump branches
|
||||
update-comment-with-chromatic:
|
||||
needs: [chromatic-deployment, deploy-and-comment]
|
||||
|
||||
1
global.d.ts
vendored
1
global.d.ts
vendored
@@ -35,6 +35,7 @@ interface Window {
|
||||
mixpanel_token?: string
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_debug?: boolean
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
max_upload_size?: number
|
||||
|
||||
@@ -43,6 +43,9 @@ export function useAppSetDefaultView() {
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
closeDialog()
|
||||
showAppliedDialog(openAsApp)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<!-- Node -->
|
||||
<div
|
||||
v-if="item.value.type === 'node'"
|
||||
v-bind="$attrs"
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
draggable="true"
|
||||
@@ -48,6 +49,7 @@
|
||||
<!-- Folder -->
|
||||
<div
|
||||
v-else
|
||||
v-bind="$attrs"
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
@@ -98,6 +100,10 @@ import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const ROW_CLASS =
|
||||
'group/tree-node flex cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input mx-2 rounded'
|
||||
|
||||
|
||||
153
src/components/graph/CanvasModeSelector.test.ts
Normal file
153
src/components/graph/CanvasModeSelector.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CanvasModeSelector from '@/components/graph/CanvasModeSelector.vue'
|
||||
|
||||
const mockExecute = vi.fn()
|
||||
const mockGetCommand = vi.fn().mockReturnValue({
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['V']
|
||||
}
|
||||
}
|
||||
})
|
||||
const mockFormatKeySequence = vi.fn().mockReturnValue('V')
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: mockExecute,
|
||||
getCommand: mockGetCommand,
|
||||
formatKeySequence: mockFormatKeySequence
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: { read_only: false }
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
graphCanvasMenu: {
|
||||
select: 'Select',
|
||||
hand: 'Hand',
|
||||
canvasMode: 'Canvas Mode'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mockPopoverHide = vi.fn()
|
||||
|
||||
function createWrapper() {
|
||||
return mount(CanvasModeSelector, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Popover: {
|
||||
template: '<div><slot /></div>',
|
||||
methods: {
|
||||
toggle: vi.fn(),
|
||||
hide: mockPopoverHide
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('CanvasModeSelector', () => {
|
||||
it('should render menu with menuitemradio roles and aria-checked', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menu = wrapper.find('[role="menu"]')
|
||||
expect(menu.exists()).toBe(true)
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
expect(menuItems).toHaveLength(2)
|
||||
|
||||
// Select mode is active (read_only: false), so select is checked
|
||||
expect(menuItems[0].attributes('aria-checked')).toBe('true')
|
||||
expect(menuItems[1].attributes('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('should render menu items as buttons with aria-labels', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
menuItems.forEach((btn) => {
|
||||
expect(btn.element.tagName).toBe('BUTTON')
|
||||
expect(btn.attributes('type')).toBe('button')
|
||||
})
|
||||
expect(menuItems[0].attributes('aria-label')).toBe('Select')
|
||||
expect(menuItems[1].attributes('aria-label')).toBe('Hand')
|
||||
})
|
||||
|
||||
it('should use roving tabindex based on active mode', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
// Select is active (read_only: false) → tabindex 0
|
||||
expect(menuItems[0].attributes('tabindex')).toBe('0')
|
||||
// Hand is inactive → tabindex -1
|
||||
expect(menuItems[1].attributes('tabindex')).toBe('-1')
|
||||
})
|
||||
|
||||
it('should mark icons as aria-hidden', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const icons = wrapper.findAll('[role="menuitemradio"] i')
|
||||
icons.forEach((icon) => {
|
||||
expect(icon.attributes('aria-hidden')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should expose trigger button with aria-haspopup and aria-expanded', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const trigger = wrapper.find('[aria-haspopup="menu"]')
|
||||
expect(trigger.exists()).toBe(true)
|
||||
expect(trigger.attributes('aria-label')).toBe('Canvas Mode')
|
||||
expect(trigger.attributes('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('should call focus on next item when ArrowDown is pressed', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
const secondItemEl = menuItems[1].element as HTMLElement
|
||||
const focusSpy = vi.spyOn(secondItemEl, 'focus')
|
||||
|
||||
await menuItems[0].trigger('keydown', { key: 'ArrowDown' })
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call focus on previous item when ArrowUp is pressed', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
const firstItemEl = menuItems[0].element as HTMLElement
|
||||
const focusSpy = vi.spyOn(firstItemEl, 'focus')
|
||||
|
||||
await menuItems[1].trigger('keydown', { key: 'ArrowUp' })
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close popover on Escape and restore focus to trigger', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
const trigger = wrapper.find('[aria-haspopup="menu"]')
|
||||
const triggerEl = trigger.element as HTMLElement
|
||||
const focusSpy = vi.spyOn(triggerEl, 'focus')
|
||||
|
||||
await menuItems[0].trigger('keydown', { key: 'Escape' })
|
||||
expect(mockPopoverHide).toHaveBeenCalled()
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -4,15 +4,21 @@
|
||||
variant="secondary"
|
||||
class="group h-8 rounded-none! bg-comfy-menu-bg p-0 transition-none! hover:rounded-lg! hover:bg-interface-button-hover-surface!"
|
||||
:style="buttonStyles"
|
||||
:aria-label="$t('graphCanvasMenu.canvasMode')"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="isOpen"
|
||||
@click="toggle"
|
||||
>
|
||||
<div class="flex items-center gap-1 pr-0.5">
|
||||
<div
|
||||
class="rounded-lg bg-interface-panel-selected-surface p-2 group-hover:bg-interface-button-hover-surface"
|
||||
>
|
||||
<i :class="currentModeIcon" class="block size-4" />
|
||||
<i :class="currentModeIcon" class="block size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<i class="icon-[lucide--chevron-down] block size-4 pr-1.5" />
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] block size-4 pr-1.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -24,31 +30,54 @@
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="popoverPt"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="flex flex-col gap-1"
|
||||
role="menu"
|
||||
:aria-label="$t('graphCanvasMenu.canvasMode')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
:aria-checked="!isCanvasReadOnly"
|
||||
:tabindex="!isCanvasReadOnly ? 0 : -1"
|
||||
class="flex w-full cursor-pointer items-center justify-between rounded-sm border-none bg-transparent px-3 py-2 text-sm text-text-primary outline-none hover:bg-node-component-surface-hovered focus-visible:bg-node-component-surface-hovered"
|
||||
:aria-label="$t('graphCanvasMenu.select')"
|
||||
@click="setMode('select')"
|
||||
@keydown.arrow-down.prevent="focusNextItem"
|
||||
@keydown.arrow-up.prevent="focusPrevItem"
|
||||
@keydown.escape.prevent="closeAndRestoreFocus"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--mouse-pointer-2] size-4" />
|
||||
<i class="icon-[lucide--mouse-pointer-2] size-4" aria-hidden="true" />
|
||||
<span>{{ $t('graphCanvasMenu.select') }}</span>
|
||||
</div>
|
||||
<span class="text-[9px] text-text-primary">{{
|
||||
unlockCommandText
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
:aria-checked="isCanvasReadOnly"
|
||||
:tabindex="isCanvasReadOnly ? 0 : -1"
|
||||
class="flex w-full cursor-pointer items-center justify-between rounded-sm border-none bg-transparent px-3 py-2 text-sm text-text-primary outline-none hover:bg-node-component-surface-hovered focus-visible:bg-node-component-surface-hovered"
|
||||
:aria-label="$t('graphCanvasMenu.hand')"
|
||||
@click="setMode('hand')"
|
||||
@keydown.arrow-down.prevent="focusNextItem"
|
||||
@keydown.arrow-up.prevent="focusPrevItem"
|
||||
@keydown.escape.prevent="closeAndRestoreFocus"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--hand] size-4" />
|
||||
<i class="icon-[lucide--hand] size-4" aria-hidden="true" />
|
||||
<span>{{ $t('graphCanvasMenu.hand') }}</span>
|
||||
</div>
|
||||
<span class="text-[9px] text-text-primary">{{ lockCommandText }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -56,7 +85,7 @@
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -69,6 +98,8 @@ interface Props {
|
||||
defineProps<Props>()
|
||||
const buttonRef = ref<ComponentPublicInstance | null>(null)
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -106,6 +137,43 @@ const setMode = (mode: 'select' | 'hand') => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
async function onPopoverShow() {
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
const checkedItem = menuRef.value?.querySelector<HTMLElement>(
|
||||
'[aria-checked="true"]'
|
||||
)
|
||||
checkedItem?.focus()
|
||||
}
|
||||
|
||||
function onPopoverHide() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function closeAndRestoreFocus() {
|
||||
popover.value?.hide()
|
||||
const el = buttonRef.value?.$el || buttonRef.value
|
||||
;(el as HTMLElement)?.focus()
|
||||
}
|
||||
|
||||
function focusNextItem(event: KeyboardEvent) {
|
||||
const items = getMenuItems(event)
|
||||
const index = items.indexOf(event.target as HTMLElement)
|
||||
items[(index + 1) % items.length]?.focus()
|
||||
}
|
||||
|
||||
function focusPrevItem(event: KeyboardEvent) {
|
||||
const items = getMenuItems(event)
|
||||
const index = items.indexOf(event.target as HTMLElement)
|
||||
items[(index - 1 + items.length) % items.length]?.focus()
|
||||
}
|
||||
|
||||
function getMenuItems(event: KeyboardEvent): HTMLElement[] {
|
||||
const menu = (event.target as HTMLElement).closest('[role="menu"]')
|
||||
if (!menu) return []
|
||||
return Array.from(menu.querySelectorAll('[role="menuitemradio"]'))
|
||||
}
|
||||
|
||||
const popoverPt = computed(() => ({
|
||||
root: {
|
||||
class: 'absolute z-50 -translate-y-2'
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
></div>
|
||||
|
||||
<ButtonGroup
|
||||
role="toolbar"
|
||||
:aria-label="t('graphCanvasMenu.canvasToolbar')"
|
||||
class="absolute right-0 bottom-0 z-1200 flex-row gap-1 border border-interface-stroke bg-comfy-menu-bg p-2"
|
||||
:style="{
|
||||
...stringifiedMinimapStyles.buttonGroupStyles
|
||||
@@ -30,7 +32,7 @@
|
||||
class="size-8 bg-comfy-menu-bg p-0 hover:bg-interface-button-hover-surface!"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
|
||||
>
|
||||
<i class="icon-[lucide--focus] size-4" />
|
||||
<i class="icon-[lucide--focus] size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -44,7 +46,7 @@
|
||||
>
|
||||
<span class="inline-flex items-center gap-1 px-2 text-xs">
|
||||
<span>{{ canvasStore.appScalePercentage }}%</span>
|
||||
<i class="icon-[lucide--chevron-down] size-4" />
|
||||
<i class="icon-[lucide--chevron-down] size-4" aria-hidden="true" />
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -59,7 +61,7 @@
|
||||
:class="minimapButtonClass"
|
||||
@click="onMinimapToggleClick"
|
||||
>
|
||||
<i class="icon-[lucide--map] size-4" />
|
||||
<i class="icon-[lucide--map] size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -78,7 +80,7 @@
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
@click="onLinkVisibilityToggleClick"
|
||||
>
|
||||
<i class="icon-[lucide--route-off] size-4" />
|
||||
<i class="icon-[lucide--route-off] size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
@@ -324,7 +324,8 @@ function safeWidgetMapper(
|
||||
}
|
||||
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
slotMetadata: slotInfo,
|
||||
slotName: name !== widget.name ? widget.name : undefined
|
||||
slotName: name !== widget.name ? widget.name : undefined,
|
||||
tooltip: widget.tooltip
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -357,7 +357,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize viewer in standalone mode (for asset preview)
|
||||
* Initialize viewer in standalone mode (for asset preview).
|
||||
* Creates the Load3d instance once; subsequent calls reuse it.
|
||||
*/
|
||||
const initializeStandaloneViewer = async (
|
||||
containerRef: HTMLElement,
|
||||
@@ -366,6 +367,11 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
if (!containerRef) return
|
||||
|
||||
try {
|
||||
if (load3d) {
|
||||
await loadStandaloneModel(modelUrl)
|
||||
return
|
||||
}
|
||||
|
||||
isStandaloneMode.value = true
|
||||
|
||||
load3d = new Load3d(containerRef, {
|
||||
@@ -392,6 +398,23 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
setupAnimationEvents()
|
||||
} catch (error) {
|
||||
console.error('Error initializing standalone 3D viewer:', error)
|
||||
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a new model into an existing standalone viewer,
|
||||
* reusing the same WebGLRenderer.
|
||||
*/
|
||||
const loadStandaloneModel = async (modelUrl: string) => {
|
||||
if (!load3d) return
|
||||
|
||||
try {
|
||||
await load3d.loadModel(modelUrl)
|
||||
isSplatModel.value = load3d.isSplatModel()
|
||||
isPlyModel.value = load3d.isPlyModel()
|
||||
} catch (error) {
|
||||
console.error('Error loading model in standalone viewer:', error)
|
||||
useToastStore().addAlert('Failed to load 3D model')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
|
||||
`${namePrefix}.${depth}.${inputIndex}`,
|
||||
Array.isArray(input)
|
||||
? ['COMFY_DYNAMICCOMBO_V3', { options: getSpec(input, depth + 1) }]
|
||||
: [input, {}]
|
||||
: [input, { tooltip: `${groupIndex}` }]
|
||||
])
|
||||
return {
|
||||
key: `${groupIndex}`,
|
||||
@@ -106,6 +106,13 @@ describe('Dynamic Combos', () => {
|
||||
expect(node.inputs[1].name).toBe('0.0.0.0')
|
||||
expect(node.inputs[3].name).toBe('2.2.0.0')
|
||||
})
|
||||
test('Dynamically added widgets have tooltips', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
expect.soft(node.widgets[1].tooltip).toBe('0')
|
||||
node.widgets[0].value = '1'
|
||||
expect.soft(node.widgets[1].tooltip).toBe('1')
|
||||
})
|
||||
})
|
||||
describe('Autogrow', () => {
|
||||
const inputsSpec = { required: { image: ['IMAGE', {}] } }
|
||||
|
||||
@@ -1052,6 +1052,8 @@
|
||||
"logoProviderSeparator": " & "
|
||||
},
|
||||
"graphCanvasMenu": {
|
||||
"canvasMode": "Canvas Mode",
|
||||
"canvasToolbar": "Canvas Toolbar",
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"resetView": "Reset View",
|
||||
|
||||
@@ -348,8 +348,8 @@
|
||||
"tooltip": "The maximum number of tasks that show in the queue history."
|
||||
},
|
||||
"Comfy_Queue_QPOV2": {
|
||||
"name": "Use the unified job queue in the Assets side panel",
|
||||
"tooltip": "Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout."
|
||||
"name": "Docked job history/queue panel",
|
||||
"tooltip": "Replaces the floating job queue panel with an equivalent job queue embedded in the job history side panel. You can disable this to return to the floating panel layout."
|
||||
},
|
||||
"Comfy_QueueButton_BatchCountLimit": {
|
||||
"name": "Batch count limit",
|
||||
|
||||
@@ -31,6 +31,7 @@ export type RemoteConfig = {
|
||||
mixpanel_token?: string
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_debug?: boolean
|
||||
subscription_required?: boolean
|
||||
server_health_alert?: ServerHealthAlert
|
||||
max_upload_size?: number
|
||||
|
||||
@@ -1201,10 +1201,10 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
{
|
||||
id: 'Comfy.Queue.QPOV2',
|
||||
category: ['Comfy', 'Queue', 'Layout'],
|
||||
name: 'Use the unified job queue in the Assets side panel',
|
||||
name: 'Docked job history/queue panel',
|
||||
type: 'boolean',
|
||||
tooltip:
|
||||
'Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout.',
|
||||
'Replaces the floating job queue panel with an equivalent job queue embedded in the job history side panel. You can disable this to return to the floating panel layout.',
|
||||
defaultValue: false,
|
||||
experimental: true
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AuditLog } from '@/services/customerEventsService'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
@@ -27,7 +28,8 @@ import type {
|
||||
TemplateMetadata,
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
@@ -157,6 +159,14 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackWorkflowOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackWorkflowSaved?.(metadata))
|
||||
}
|
||||
|
||||
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
|
||||
this.dispatch((provider) => provider.trackDefaultViewSet?.(metadata))
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.dispatch((provider) => provider.trackEnterLinear?.(metadata))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getExecutionContext } from '../../utils/getExecutionContext'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
CreditTopupMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
@@ -40,7 +41,8 @@ import type {
|
||||
TemplateMetadata,
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
} from '../../types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
@@ -359,6 +361,14 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
|
||||
}
|
||||
|
||||
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
|
||||
}
|
||||
|
||||
@@ -80,27 +80,30 @@ describe('PostHogTelemetryProvider', () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith('phc_test_token', {
|
||||
api_host: 'https://ph.comfy.org',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: false
|
||||
})
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith(
|
||||
'phc_test_token',
|
||||
expect.objectContaining({
|
||||
api_host: 'https://t.comfy.org',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses custom api_host from config when provided', async () => {
|
||||
it('enables debug mode when posthog_debug is true in config', async () => {
|
||||
window.__CONFIG__ = {
|
||||
posthog_project_token: 'phc_test_token',
|
||||
posthog_api_host: 'https://custom.host.com'
|
||||
posthog_debug: true
|
||||
} as typeof window.__CONFIG__
|
||||
new PostHogTelemetryProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith(
|
||||
'phc_test_token',
|
||||
expect.objectContaining({ api_host: 'https://custom.host.com' })
|
||||
expect.objectContaining({ debug: true })
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import type {
|
||||
AuthMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
@@ -34,7 +35,8 @@ import type {
|
||||
TemplateMetadata,
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
} from '../../types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import { getExecutionContext } from '../../utils/getExecutionContext'
|
||||
@@ -101,12 +103,15 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.posthog = posthogModule.default
|
||||
this.posthog!.init(apiKey, {
|
||||
api_host:
|
||||
window.__CONFIG__?.posthog_api_host || 'https://ph.comfy.org',
|
||||
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true'
|
||||
debug:
|
||||
window.__CONFIG__?.posthog_debug ??
|
||||
import.meta.env.VITE_POSTHOG_DEBUG === 'true'
|
||||
})
|
||||
this.isInitialized = true
|
||||
this.flushEventQueue()
|
||||
@@ -344,6 +349,14 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
|
||||
}
|
||||
|
||||
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
|
||||
}
|
||||
|
||||
@@ -149,6 +149,15 @@ export interface EnterLinearMetadata {
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface WorkflowSavedMetadata {
|
||||
is_app: boolean
|
||||
is_new: boolean
|
||||
}
|
||||
|
||||
export interface DefaultViewSetMetadata {
|
||||
default_view: 'app' | 'graph'
|
||||
}
|
||||
|
||||
type ShareFlowStep =
|
||||
| 'dialog_opened'
|
||||
| 'save_prompted'
|
||||
@@ -377,6 +386,8 @@ export interface TelemetryProvider {
|
||||
// Workflow management events
|
||||
trackWorkflowImported?(metadata: WorkflowImportMetadata): void
|
||||
trackWorkflowOpened?(metadata: WorkflowImportMetadata): void
|
||||
trackWorkflowSaved?(metadata: WorkflowSavedMetadata): void
|
||||
trackDefaultViewSet?(metadata: DefaultViewSetMetadata): void
|
||||
trackEnterLinear?(metadata: EnterLinearMetadata): void
|
||||
trackShareFlow?(metadata: ShareFlowMetadata): void
|
||||
|
||||
@@ -490,6 +501,8 @@ export const TelemetryEvents = {
|
||||
|
||||
// Workflow Creation
|
||||
WORKFLOW_CREATED: 'app:workflow_created',
|
||||
WORKFLOW_SAVED: 'app:workflow_saved',
|
||||
DEFAULT_VIEW_SET: 'app:default_view_set',
|
||||
|
||||
// Execution Lifecycle
|
||||
EXECUTION_START: 'execution_start',
|
||||
@@ -540,4 +553,6 @@ export type TelemetryEventProperties =
|
||||
| WorkflowCreatedMetadata
|
||||
| EnterLinearMetadata
|
||||
| ShareFlowMetadata
|
||||
| WorkflowSavedMetadata
|
||||
| DefaultViewSetMetadata
|
||||
| SubscriptionMetadata
|
||||
|
||||
@@ -149,6 +149,8 @@ export const useWorkflowService = () => {
|
||||
await openWorkflow(tempWorkflow)
|
||||
await workflowStore.saveWorkflow(tempWorkflow)
|
||||
}
|
||||
|
||||
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: true })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -189,6 +191,7 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
131
src/renderer/extensions/linearMode/DropZone.stories.ts
Normal file
131
src/renderer/extensions/linearMode/DropZone.stories.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import DropZone from './DropZone.vue'
|
||||
|
||||
type StoryArgs = ComponentPropsAndSlots<typeof DropZone>
|
||||
|
||||
const defaultLabel = 'Click to browse or drag an image'
|
||||
const defaultIconClass = 'icon-[lucide--image]'
|
||||
|
||||
function createFileInput(onFile: (file: File) => void) {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/*'
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files?.[0]
|
||||
if (file) onFile(file)
|
||||
})
|
||||
return input
|
||||
}
|
||||
|
||||
function fileToObjectUrl(file: File): string {
|
||||
return URL.createObjectURL(file)
|
||||
}
|
||||
|
||||
function extractDroppedImageFile(e: DragEvent): File | undefined {
|
||||
return Array.from(e.dataTransfer?.files ?? []).find((f) =>
|
||||
f.type.startsWith('image/')
|
||||
)
|
||||
}
|
||||
|
||||
const renderStory = (args: StoryArgs) => ({
|
||||
components: { DropZone },
|
||||
setup() {
|
||||
const imageUrl = ref<string | undefined>(undefined)
|
||||
const hovered = ref(false)
|
||||
|
||||
function handleFile(file: File) {
|
||||
imageUrl.value = fileToObjectUrl(file)
|
||||
}
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!e.dataTransfer?.items) return false
|
||||
return Array.from(e.dataTransfer.items).some(
|
||||
(item) => item.kind === 'file' && item.type.startsWith('image/')
|
||||
)
|
||||
}
|
||||
|
||||
const onDragDrop = (e: DragEvent) => {
|
||||
const file = extractDroppedImageFile(e)
|
||||
if (file) handleFile(file)
|
||||
return !!file
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
createFileInput(handleFile).click()
|
||||
}
|
||||
|
||||
const dropIndicator = ref({
|
||||
...args.dropIndicator,
|
||||
onClick
|
||||
})
|
||||
|
||||
return { args, onDragOver, onDragDrop, dropIndicator, imageUrl, hovered }
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
@mouseenter="hovered = true"
|
||||
@mouseleave="hovered = false"
|
||||
>
|
||||
<DropZone
|
||||
v-bind="args"
|
||||
:on-drag-over="onDragOver"
|
||||
:on-drag-drop="onDragDrop"
|
||||
:force-hovered="hovered"
|
||||
:drop-indicator="{
|
||||
...dropIndicator,
|
||||
imageUrl: imageUrl ?? dropIndicator.imageUrl
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/FileUpload',
|
||||
component: DropZone,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Linear mode drag-and-drop target with a file upload indicator. Click to browse or drag an image file to upload.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
onDragOver: { table: { disable: true } },
|
||||
onDragDrop: { table: { disable: true } },
|
||||
dropIndicator: { control: false },
|
||||
forceHovered: { table: { disable: true } }
|
||||
},
|
||||
args: {
|
||||
dropIndicator: {
|
||||
label: defaultLabel,
|
||||
iconClass: defaultIconClass
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: `
|
||||
<div class="w-[440px] rounded-xl bg-component-node-background p-4">
|
||||
<story />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: renderStory
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { useDropZone } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
const {
|
||||
onDragOver,
|
||||
onDragDrop,
|
||||
dropIndicator,
|
||||
forceHovered = false
|
||||
} = defineProps<{
|
||||
onDragOver?: (e: DragEvent) => boolean
|
||||
onDragDrop?: (e: DragEvent) => Promise<boolean> | boolean
|
||||
dropIndicator?: {
|
||||
@@ -13,6 +18,7 @@ const props = defineProps<{
|
||||
label?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
}
|
||||
forceHovered?: boolean
|
||||
}>()
|
||||
|
||||
const dropZoneRef = ref<HTMLElement | null>(null)
|
||||
@@ -23,53 +29,83 @@ const { isOverDropZone } = useDropZone(dropZoneRef, {
|
||||
// Stop propagation to prevent global handlers from creating a new node
|
||||
event?.stopPropagation()
|
||||
|
||||
if (props.onDragDrop && event) {
|
||||
props.onDragDrop(event)
|
||||
if (onDragDrop && event) {
|
||||
onDragDrop(event)
|
||||
}
|
||||
canAcceptDrop.value = false
|
||||
},
|
||||
onOver: (_, event) => {
|
||||
if (props.onDragOver && event) {
|
||||
canAcceptDrop.value = props.onDragOver(event)
|
||||
if (onDragOver && event) {
|
||||
canAcceptDrop.value = onDragOver(event)
|
||||
}
|
||||
},
|
||||
onLeave: () => {
|
||||
canAcceptDrop.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const isHovered = computed(
|
||||
() => forceHovered || (canAcceptDrop.value && isOverDropZone.value)
|
||||
)
|
||||
const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="onDragOver && onDragDrop"
|
||||
ref="dropZoneRef"
|
||||
data-slot="drop-zone"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg ring-primary-500 ring-inset',
|
||||
canAcceptDrop && isOverDropZone && 'bg-primary-500/10 ring-4'
|
||||
'rounded-lg transition-colors',
|
||||
isHovered && 'bg-component-node-widget-background-hovered'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
<component
|
||||
:is="indicatorTag"
|
||||
v-if="dropIndicator"
|
||||
:type="dropIndicator?.onClick ? 'button' : undefined"
|
||||
:aria-label="dropIndicator?.onClick ? dropIndicator.label : undefined"
|
||||
data-slot="drop-zone-indicator"
|
||||
:class="
|
||||
cn(
|
||||
'm-3 flex h-25 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-subtle py-2',
|
||||
'm-3 block w-[calc(100%-1.5rem)] appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
|
||||
dropIndicator?.onClick && 'cursor-pointer'
|
||||
)
|
||||
"
|
||||
@click.prevent="dropIndicator?.onClick?.($event)"
|
||||
>
|
||||
<img
|
||||
v-if="dropIndicator?.imageUrl"
|
||||
class="h-23"
|
||||
:src="dropIndicator?.imageUrl"
|
||||
/>
|
||||
<template v-else>
|
||||
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
|
||||
<i v-if="dropIndicator.iconClass" :class="dropIndicator.iconClass" />
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-23 w-full flex-col items-center justify-center gap-2 rounded-[7px] p-6 text-center text-sm/tight transition-colors',
|
||||
isHovered &&
|
||||
!dropIndicator?.imageUrl &&
|
||||
'border border-dashed border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
|
||||
)
|
||||
"
|
||||
>
|
||||
<img
|
||||
v-if="dropIndicator?.imageUrl"
|
||||
class="max-h-23 rounded-md object-contain"
|
||||
:alt="dropIndicator?.label ?? ''"
|
||||
:src="dropIndicator?.imageUrl"
|
||||
/>
|
||||
<template v-else>
|
||||
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
|
||||
<i
|
||||
v-if="dropIndicator.iconClass"
|
||||
:class="
|
||||
cn(
|
||||
'size-4 text-component-node-foreground-secondary',
|
||||
dropIndicator.iconClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</component>
|
||||
</div>
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, watch } from 'vue'
|
||||
import { onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import Load3DControls from '@/components/load3d/Load3DControls.vue'
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
@@ -19,6 +19,10 @@ watch([containerRef, () => modelUrl], async () => {
|
||||
await viewer.value.initializeStandaloneViewer(containerRef.value, modelUrl)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
viewer.value.cleanup()
|
||||
})
|
||||
|
||||
//TODO: refactor to add control buttons
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -187,6 +187,46 @@ describe('useMinimapViewport', () => {
|
||||
expect(transform.height).toBeCloseTo(viewportHeight * 0.5) // 300 * 0.5 = 150
|
||||
})
|
||||
|
||||
it('should maintain strict reference equality for viewportTransform when canvas state is unchanged', () => {
|
||||
vi.mocked(calculateNodeBounds).mockReturnValue({
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 500,
|
||||
maxY: 400,
|
||||
width: 500,
|
||||
height: 400
|
||||
})
|
||||
|
||||
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
|
||||
vi.mocked(calculateMinimapScale).mockReturnValue(0.5)
|
||||
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
mockCanvas.ds.scale = 2
|
||||
mockCanvas.ds.offset = [-100, -50]
|
||||
|
||||
viewport.updateBounds()
|
||||
viewport.updateCanvasDimensions()
|
||||
viewport.updateViewport()
|
||||
|
||||
const initialTransform = viewport.viewportTransform.value
|
||||
|
||||
viewport.updateViewport()
|
||||
const transformAfterIdle = viewport.viewportTransform.value
|
||||
|
||||
expect(transformAfterIdle).toBe(initialTransform)
|
||||
|
||||
mockCanvas.ds.offset = [-150, -50]
|
||||
viewport.updateViewport()
|
||||
const transformAfterPan = viewport.viewportTransform.value
|
||||
|
||||
expect(transformAfterPan).not.toBe(initialTransform)
|
||||
expect(transformAfterPan.x).not.toBe(initialTransform.x)
|
||||
})
|
||||
|
||||
it('should center view on world coordinates', () => {
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
|
||||
@@ -90,12 +90,16 @@ export function useMinimapViewport(
|
||||
const centerOffsetX = (width - bounds.value.width * scale.value) / 2
|
||||
const centerOffsetY = (height - bounds.value.height * scale.value) / 2
|
||||
|
||||
viewportTransform.value = {
|
||||
x: (worldX - bounds.value.minX) * scale.value + centerOffsetX,
|
||||
y: (worldY - bounds.value.minY) * scale.value + centerOffsetY,
|
||||
width: viewportWidth * scale.value,
|
||||
height: viewportHeight * scale.value
|
||||
}
|
||||
const x = (worldX - bounds.value.minX) * scale.value + centerOffsetX
|
||||
const y = (worldY - bounds.value.minY) * scale.value + centerOffsetY
|
||||
const w = viewportWidth * scale.value
|
||||
const h = viewportHeight * scale.value
|
||||
|
||||
const curr = viewportTransform.value
|
||||
if (curr.x === x && curr.y === y && curr.width === w && curr.height === h)
|
||||
return
|
||||
|
||||
viewportTransform.value = { x, y, width: w, height: h }
|
||||
}
|
||||
|
||||
const updateBounds = () => {
|
||||
|
||||
@@ -15,7 +15,34 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
import { createMockWidget } from './widgetTestUtils'
|
||||
|
||||
const mockCheckState = vi.hoisted(() => vi.fn())
|
||||
const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] }))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
const actual = await vi.importActual(
|
||||
'@/platform/workflow/management/stores/workflowStore'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: {
|
||||
changeTracker: {
|
||||
checkState: mockCheckState
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn(),
|
||||
apiURL: vi.fn((url: string) => url),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData',
|
||||
() => ({
|
||||
@@ -456,3 +483,69 @@ describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
|
||||
expect(selectedSet.has('missing-missing_model.safetensors')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown undo tracking', () => {
|
||||
interface UndoTrackingInstance extends ComponentPublicInstance {
|
||||
updateSelectedItems: (selectedSet: Set<string>) => void
|
||||
handleFilesUpdate: (files: File[]) => Promise<void>
|
||||
}
|
||||
|
||||
const mountForUndo = (
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined
|
||||
): VueWrapper<UndoTrackingInstance> => {
|
||||
return mount(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind: 'image',
|
||||
allowUpload: true,
|
||||
uploadFolder: 'input'
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
}) as unknown as VueWrapper<UndoTrackingInstance>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCheckState.mockClear()
|
||||
})
|
||||
|
||||
it('calls checkState after dropdown selection changes modelValue', () => {
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'img_001.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: ['img_001.png', 'photo_abc.jpg'] }
|
||||
})
|
||||
const wrapper = mountForUndo(widget, 'img_001.png')
|
||||
|
||||
wrapper.vm.updateSelectedItems(new Set(['input-1']))
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['photo_abc.jpg'])
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls checkState after file upload completes', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
|
||||
} as Response)
|
||||
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'img_001.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: ['img_001.png'] }
|
||||
})
|
||||
const wrapper = mountForUndo(widget, 'img_001.png')
|
||||
|
||||
const file = new File(['test'], 'uploaded.png', { type: 'image/png' })
|
||||
await wrapper.vm.handleFilesUpdate([file])
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['uploaded.png'])
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getAssetFilename
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
|
||||
import type {
|
||||
FilterOption,
|
||||
@@ -376,6 +377,7 @@ function updateSelectedItems(selectedItems: Set<string>) {
|
||||
return
|
||||
}
|
||||
modelValue.value = name
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const uploadFile = async (
|
||||
@@ -450,6 +452,9 @@ async function handleFilesUpdate(files: File[]) {
|
||||
if (props.widget.callback) {
|
||||
props.widget.callback(uploadedPaths[0])
|
||||
}
|
||||
|
||||
// 5. Snapshot undo state so the image change gets its own undo entry
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toastStore.addAlert(`Upload failed: ${error}`)
|
||||
|
||||
@@ -33,7 +33,7 @@ const hideLayoutField = useHideLayoutField()
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'min-w-0 cursor-default rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted',
|
||||
'cursor-default min-w-0 rounded-lg has-[:focus-visible]:ring has-[:focus-visible]:ring-component-node-widget-background-highlighted transition-all',
|
||||
widget.borderStyle
|
||||
)
|
||||
"
|
||||
|
||||
@@ -40,6 +40,7 @@ function addMultilineWidget(
|
||||
})
|
||||
|
||||
widget.element = inputEl
|
||||
widget.inputEl = inputEl
|
||||
widget.options.minNodeSize = [400, 200]
|
||||
|
||||
inputEl.addEventListener('input', (event) => {
|
||||
|
||||
@@ -216,14 +216,18 @@ export const useLitegraphService = () => {
|
||||
*/
|
||||
function addNodeInput(node: LGraphNode, inputSpec: InputSpec) {
|
||||
addInputSocket(node, inputSpec)
|
||||
addInputWidget(node, inputSpec)
|
||||
addInputWidget(node, inputSpec, { dynamic: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add a widget to the node. For both primitive types and custom widgets
|
||||
* (unless `socketless`), an input socket is also added.
|
||||
*/
|
||||
function addInputWidget(node: LGraphNode, inputSpec: InputSpec) {
|
||||
function addInputWidget(
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec,
|
||||
{ dynamic }: { dynamic?: boolean } = {}
|
||||
) {
|
||||
const widgetInputSpec = { ...inputSpec }
|
||||
if (inputSpec.widgetType) {
|
||||
widgetInputSpec.type = inputSpec.widgetType
|
||||
@@ -254,6 +258,7 @@ export const useLitegraphService = () => {
|
||||
advanced: inputSpec.advanced,
|
||||
hidden: inputSpec.hidden
|
||||
})
|
||||
if (dynamic) widget.tooltip = inputSpec.tooltip
|
||||
}
|
||||
|
||||
if (!widget?.options?.socketless) {
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface SimplifiedWidget<
|
||||
/** Optional input specification backing this widget */
|
||||
spec?: InputSpecV2
|
||||
|
||||
tooltip?: string
|
||||
|
||||
controlWidget?: SafeControlWidget
|
||||
}
|
||||
|
||||
|
||||
@@ -402,7 +402,7 @@ const isUnresolvedTab = computed(
|
||||
)
|
||||
|
||||
// Map of tab IDs to their empty state i18n key suffixes
|
||||
const tabEmptyStateKeys: Partial<Record<ManagerTab, string>> = {
|
||||
const tabEmptyStateKeys: Record<string, string> = {
|
||||
[ManagerTab.AllInstalled]: 'allInstalled',
|
||||
[ManagerTab.UpdateAvailable]: 'updateAvailable',
|
||||
[ManagerTab.Conflicting]: 'conflicting',
|
||||
@@ -410,12 +410,27 @@ const tabEmptyStateKeys: Partial<Record<ManagerTab, string>> = {
|
||||
[ManagerTab.Missing]: 'missing'
|
||||
}
|
||||
|
||||
const managerApiDependentTabs = new Set<string>([
|
||||
ManagerTab.AllInstalled,
|
||||
ManagerTab.UpdateAvailable,
|
||||
ManagerTab.Conflicting,
|
||||
ManagerTab.NotInstalled,
|
||||
ManagerTab.Missing
|
||||
])
|
||||
|
||||
const isManagerErrorRelevant = computed(() => {
|
||||
const tabId = selectedTab.value?.id
|
||||
return (
|
||||
!!comfyManagerStore.error && !!tabId && managerApiDependentTabs.has(tabId)
|
||||
)
|
||||
})
|
||||
|
||||
// Empty state messages based on current tab and search state
|
||||
const emptyStateTitle = computed(() => {
|
||||
if (comfyManagerStore.error) return t('manager.errorConnecting')
|
||||
if (isManagerErrorRelevant.value) return t('manager.errorConnecting')
|
||||
if (searchQuery.value) return t('manager.noResultsFound')
|
||||
|
||||
const tabId = selectedTab.value?.id as ManagerTab | undefined
|
||||
const tabId = selectedTab.value?.id
|
||||
const emptyStateKey = tabId ? tabEmptyStateKeys[tabId] : undefined
|
||||
|
||||
return emptyStateKey
|
||||
@@ -424,7 +439,7 @@ const emptyStateTitle = computed(() => {
|
||||
})
|
||||
|
||||
const emptyStateMessage = computed(() => {
|
||||
if (comfyManagerStore.error) return t('manager.tryAgainLater')
|
||||
if (isManagerErrorRelevant.value) return t('manager.tryAgainLater')
|
||||
if (searchQuery.value) {
|
||||
const baseMessage = t('manager.tryDifferentSearch')
|
||||
if (isLegacyManagerSearch.value) {
|
||||
@@ -433,7 +448,7 @@ const emptyStateMessage = computed(() => {
|
||||
return baseMessage
|
||||
}
|
||||
|
||||
const tabId = selectedTab.value?.id as ManagerTab | undefined
|
||||
const tabId = selectedTab.value?.id
|
||||
const emptyStateKey = tabId ? tabEmptyStateKeys[tabId] : undefined
|
||||
|
||||
return emptyStateKey
|
||||
|
||||
Reference in New Issue
Block a user