mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-15 19:54:32 +00:00
Compare commits
39 Commits
rh/hub
...
assets/loa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a84b06a34a | ||
|
|
6761964788 | ||
|
|
94928c4dd3 | ||
|
|
e10a127c93 | ||
|
|
0310dd6ec3 | ||
|
|
89250846f4 | ||
|
|
68b23f8fe3 | ||
|
|
b73d010f1d | ||
|
|
3b93e3103d | ||
|
|
58888d6194 | ||
|
|
42c0631bb6 | ||
|
|
2dcc6b755f | ||
|
|
ba91194bcf | ||
|
|
e9158361cf | ||
|
|
40143c6a76 | ||
|
|
3b4378391e | ||
|
|
7923ca052c | ||
|
|
86c0d87c61 | ||
|
|
0f8547725d | ||
|
|
9869ad9a92 | ||
|
|
97deddb51f | ||
|
|
836040e85b | ||
|
|
8e233d07b0 | ||
|
|
745d2536dc | ||
|
|
9ac5ff3f5f | ||
|
|
c1138c2295 | ||
|
|
a1ca0e3795 | ||
|
|
d3669cf150 | ||
|
|
c9c11cd8bf | ||
|
|
0b97bf4b41 | ||
|
|
f756fbe604 | ||
|
|
40350c3c33 | ||
|
|
e7e5457d64 | ||
|
|
98cbc0dba8 | ||
|
|
474ef3751b | ||
|
|
9a97e6c21e | ||
|
|
050b153fb3 | ||
|
|
30c6d282ef | ||
|
|
90d681ad31 |
1
.github/workflows/ci-lint-format.yaml
vendored
1
.github/workflows/ci-lint-format.yaml
vendored
@@ -21,7 +21,6 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ !github.event.pull_request.head.repo.fork && github.head_ref || github.ref }}
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
2
.github/workflows/i18n-update-core.yaml
vendored
2
.github/workflows/i18n-update-core.yaml
vendored
@@ -17,8 +17,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
|
||||
# Setup playwright environment
|
||||
- name: Setup ComfyUI Frontend
|
||||
|
||||
1
.github/workflows/release-draft-create.yaml
vendored
1
.github/workflows/release-draft-create.yaml
vendored
@@ -50,7 +50,6 @@ jobs:
|
||||
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
|
||||
ENABLE_MINIFY: 'true'
|
||||
USE_PROD_CONFIG: 'true'
|
||||
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as fs from 'fs'
|
||||
|
||||
import type { LGraphNode, LGraph } from '../../src/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '../../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { KeyCombo } from '../../src/platform/keybindings'
|
||||
import type { KeyCombo } from '../../src/schemas/keyBindingSchema'
|
||||
import type { useWorkspaceStore } from '../../src/stores/workspaceStore'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { ComfyActionbar } from '../helpers/actionbar'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Keybinding } from '../../src/platform/keybindings'
|
||||
import type { Keybinding } from '../../src/schemas/keyBindingSchema'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -42,9 +42,7 @@ const config: KnipConfig = {
|
||||
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Unused experimental publish dialog
|
||||
'src/components/actionbar/PublishToHubDialogContent.vue'
|
||||
'src/scripts/ui/components/splitButton.ts'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
|
||||
@@ -46,8 +46,6 @@ onMounted(() => {
|
||||
document.addEventListener('contextmenu', showContextMenu)
|
||||
}
|
||||
|
||||
// Handle preload errors that occur during dynamic imports (e.g., stale chunks after deployment)
|
||||
// See: https://vite.dev/guide/build#load-error-handling
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
event.preventDefault()
|
||||
// eslint-disable-next-line no-undef
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
class="w-full h-full absolute top-0 left-0 z-999 pointer-events-none flex flex-col"
|
||||
>
|
||||
<slot v-if="shouldShowWorkflowTabs" name="workflow-tabs" />
|
||||
<slot name="workflow-tabs" />
|
||||
|
||||
<div
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
@@ -25,9 +25,7 @@
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="
|
||||
!focusMode &&
|
||||
!isFullPageOverlayActive &&
|
||||
(sidebarLocation === 'left' || anyRightPanelVisible)
|
||||
!focusMode && (sidebarLocation === 'left' || rightSidePanelVisible)
|
||||
"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
@@ -57,22 +55,9 @@
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="80" class="flex flex-col">
|
||||
<slot
|
||||
v-if="!isFullPageOverlayActive"
|
||||
name="topmenu"
|
||||
:sidebar-panel-visible
|
||||
/>
|
||||
|
||||
<!-- Full page content (replaces graph canvas when active) -->
|
||||
<div
|
||||
v-if="isFullPageOverlayActive"
|
||||
class="pointer-events-auto flex-1 overflow-hidden bg-comfy-menu-bg"
|
||||
>
|
||||
<slot name="full-page-content" />
|
||||
</div>
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
v-else
|
||||
class="bg-transparent pointer-events-none border-none splitter-overlay-bottom mr-1 mb-1 ml-1 flex-1"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
@@ -100,9 +85,7 @@
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="
|
||||
!focusMode &&
|
||||
!isFullPageOverlayActive &&
|
||||
(sidebarLocation === 'right' || anyRightPanelVisible)
|
||||
!focusMode && (sidebarLocation === 'right' || rightSidePanelVisible)
|
||||
"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
@@ -142,9 +125,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useHomePanelStore } from '@/stores/workspace/homePanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSharePanelStore } from '@/stores/workspace/sharePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
@@ -163,25 +144,11 @@ const unifiedWidth = computed(() =>
|
||||
|
||||
const { focusMode } = storeToRefs(workspaceStore)
|
||||
|
||||
const { activeSidebarTabId, activeSidebarTab, isFullPageTabActive } =
|
||||
storeToRefs(sidebarTabStore)
|
||||
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
|
||||
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
|
||||
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
|
||||
const { isOpen: homePanelOpen } = storeToRefs(useHomePanelStore())
|
||||
const sharePanelStore = useSharePanelStore()
|
||||
const { isOpen: sharePanelVisible } = storeToRefs(sharePanelStore)
|
||||
|
||||
const anyRightPanelVisible = computed(
|
||||
() => rightSidePanelVisible.value || sharePanelVisible.value
|
||||
)
|
||||
|
||||
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
|
||||
const isFullPageOverlayActive = computed(
|
||||
() => isFullPageTabActive.value || homePanelOpen.value
|
||||
)
|
||||
const shouldShowWorkflowTabs = computed(
|
||||
() => !isFullPageTabActive.value || homePanelOpen.value
|
||||
)
|
||||
|
||||
const sidebarStateKey = computed(() => {
|
||||
return unifiedWidth.value
|
||||
@@ -202,7 +169,7 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
|
||||
* to recalculate the width and panel order
|
||||
*/
|
||||
const splitterRefreshKey = computed(() => {
|
||||
return `main-splitter${anyRightPanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
|
||||
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
|
||||
})
|
||||
|
||||
const firstPanelStyle = computed(() => {
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<ShareButton />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -81,7 +80,6 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
import ShareButton from './ShareButton.vue'
|
||||
|
||||
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
|
||||
topMenuContainer?: HTMLElement | null
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-lg flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('discover.share.publishToHubDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<!-- Thumbnail upload -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-base-foreground">
|
||||
{{ $t('discover.share.publishToHubDialog.thumbnail') }}
|
||||
</label>
|
||||
<div
|
||||
class="relative flex aspect-video w-full cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-dashed border-border-default bg-secondary-background transition-colors hover:border-border-hover"
|
||||
@click="triggerThumbnailUpload"
|
||||
>
|
||||
<img
|
||||
v-if="thumbnailPreview"
|
||||
:src="thumbnailPreview"
|
||||
:alt="$t('discover.share.publishToHubDialog.thumbnailPreview')"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex flex-col items-center gap-2 text-center">
|
||||
<i class="icon-[lucide--image-plus] size-8 text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $t('discover.share.publishToHubDialog.uploadThumbnail') }}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref="thumbnailInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleThumbnailChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-base-foreground">
|
||||
{{ $t('g.title') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="title"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
|
||||
:placeholder="
|
||||
$t('discover.share.publishToHubDialog.titlePlaceholder')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-base-foreground">
|
||||
{{ $t('g.description') }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="description"
|
||||
rows="3"
|
||||
class="w-full resize-none rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
|
||||
:placeholder="
|
||||
$t('discover.share.publishToHubDialog.descriptionPlaceholder')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-base-foreground">
|
||||
{{ $t('discover.filters.tags') }}
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
class="flex items-center gap-1 rounded-full bg-secondary-background px-2.5 py-1 text-xs text-base-foreground"
|
||||
>
|
||||
{{ tag }}
|
||||
<button
|
||||
class="cursor-pointer border-none bg-transparent p-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="removeTag(tag)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3" />
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
v-model="newTag"
|
||||
type="text"
|
||||
class="min-w-24 flex-1 border-none bg-transparent text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
:placeholder="$t('discover.share.publishToHubDialog.addTag')"
|
||||
@keydown.enter.prevent="addTag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Open source toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-base-foreground">
|
||||
{{ $t('discover.share.publishToHubDialog.openSource') }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ $t('discover.share.publishToHubDialog.openSourceDescription') }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors',
|
||||
isOpenSource ? 'bg-green-500' : 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
@click="isOpenSource = !isOpenSource"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'absolute left-0.5 top-0.5 size-5 rounded-full bg-white shadow-sm transition-transform',
|
||||
isOpenSource && 'translate-x-5'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="flex items-center justify-end gap-3 border-t border-border-default px-4 py-3"
|
||||
>
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading
|
||||
:disabled="!isValid"
|
||||
@click="onPublish"
|
||||
>
|
||||
{{ $t('discover.share.publishToHubDialog.publish') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { workflow } = defineProps<{
|
||||
workflow: ComfyWorkflow
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const thumbnailInput = ref<HTMLInputElement>()
|
||||
const thumbnailFile = ref<File | null>(null)
|
||||
const thumbnailPreview = ref('')
|
||||
const title = ref(workflow.filename ?? '')
|
||||
const description = ref('')
|
||||
const tags = ref<string[]>([])
|
||||
const newTag = ref('')
|
||||
const isOpenSource = ref(false)
|
||||
|
||||
const isValid = computed(
|
||||
() => title.value.trim().length > 0 && description.value.trim().length > 0
|
||||
)
|
||||
|
||||
function triggerThumbnailUpload() {
|
||||
thumbnailInput.value?.click()
|
||||
}
|
||||
|
||||
function handleThumbnailChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) {
|
||||
thumbnailFile.value = file
|
||||
thumbnailPreview.value = URL.createObjectURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
function addTag() {
|
||||
const tag = newTag.value.trim()
|
||||
if (tag && !tags.value.includes(tag)) {
|
||||
tags.value.push(tag)
|
||||
}
|
||||
newTag.value = ''
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
tags.value = tags.value.filter((t) => t !== tag)
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'publish-to-hub' })
|
||||
}
|
||||
|
||||
async function onPublish() {
|
||||
loading.value = true
|
||||
try {
|
||||
toastStore.add({
|
||||
severity: 'info',
|
||||
summary: t('g.comingSoon'),
|
||||
detail: t('discover.share.publishToHubDialog.notImplemented'),
|
||||
life: 3000
|
||||
})
|
||||
onCancel()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,37 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
:variant="isOpen ? 'primary' : 'secondary'"
|
||||
size="md"
|
||||
class="px-3 text-sm font-semibold"
|
||||
:aria-label="$t('discover.share.share')"
|
||||
:aria-pressed="isOpen"
|
||||
@click="handleClick"
|
||||
>
|
||||
{{ $t('discover.share.share') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useSharePanelStore } from '@/stores/workspace/sharePanelStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const sharePanelStore = useSharePanelStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const { isOpen } = storeToRefs(sharePanelStore)
|
||||
|
||||
function handleClick() {
|
||||
if (!workflowStore.activeWorkflow) {
|
||||
toastStore.addAlert(t('discover.share.noActiveWorkflow'))
|
||||
return
|
||||
}
|
||||
sharePanelStore.togglePanel()
|
||||
}
|
||||
</script>
|
||||
@@ -1,533 +0,0 @@
|
||||
<template>
|
||||
<div class="flex size-full flex-col bg-comfy-menu-bg">
|
||||
<!-- Panel Header -->
|
||||
<section
|
||||
class="sticky top-0 z-10 border-b border-border-default bg-comfy-menu-bg/95 pt-1 backdrop-blur"
|
||||
>
|
||||
<div class="flex items-center justify-between pl-4 pr-3">
|
||||
<h3 class="my-3.5 text-base font-semibold tracking-tight">
|
||||
{{ $t('discover.share.share') }}
|
||||
</h3>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="closePanel"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<nav class="overflow-x-auto px-4 pb-3 pt-1">
|
||||
<div
|
||||
class="inline-flex rounded-full border border-border-default bg-secondary-background/70 p-1 shadow-sm"
|
||||
>
|
||||
<TabList v-model="activeTab" class="gap-1 pb-0">
|
||||
<Tab
|
||||
value="invite"
|
||||
class="rounded-full px-3 py-1.5 text-xs font-semibold tracking-wide transition-all active:scale-95"
|
||||
>
|
||||
<i class="icon-[lucide--users] mr-1.5 size-3.5" />
|
||||
{{ $t('discover.share.inviteTab.title') }}
|
||||
</Tab>
|
||||
<Tab
|
||||
value="publish"
|
||||
class="rounded-full px-3 py-1.5 text-xs font-semibold tracking-wide transition-all active:scale-95"
|
||||
>
|
||||
<i class="icon-[lucide--upload] mr-1.5 size-3.5" />
|
||||
{{ $t('discover.share.publishTab.title') }}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<!-- Share Tab -->
|
||||
<div
|
||||
v-if="activeTab === 'invite'"
|
||||
class="mx-auto flex w-full max-w-sm flex-col gap-6 p-4"
|
||||
>
|
||||
<!-- People with access -->
|
||||
<div v-if="invitedUsers.length > 0" class="flex flex-col gap-2">
|
||||
<span
|
||||
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{{ $t('discover.share.inviteByEmail.peopleWithAccess') }}
|
||||
</span>
|
||||
<div
|
||||
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-2"
|
||||
>
|
||||
<div
|
||||
v-for="user in invitedUsers"
|
||||
:key="user.email"
|
||||
class="flex items-center gap-3 rounded-lg border border-border-default/70 bg-comfy-menu-bg px-3 py-2.5 shadow-sm transition-colors hover:bg-secondary-background"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary-500/20 text-sm font-medium text-primary-500 ring-1 ring-primary-500/30"
|
||||
>
|
||||
{{ user.email.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm text-base-foreground">
|
||||
{{ user.email }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{
|
||||
user.role === 'edit'
|
||||
? $t('discover.share.inviteByEmail.roles.edit')
|
||||
: $t('discover.share.inviteByEmail.roles.viewOnly')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('g.delete')"
|
||||
@click="removeUser(user.email)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invite section header -->
|
||||
<div v-if="invitedUsers.length > 0" class="h-px bg-border-default" />
|
||||
|
||||
<!-- Email + role -->
|
||||
<div
|
||||
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-3"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{{ $t('discover.share.inviteByEmail.emailLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
class="w-full rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground transition-colors focus:outline-none focus:ring-1 focus:ring-secondary-foreground hover:border-border-hover"
|
||||
:placeholder="$t('discover.share.inviteByEmail.emailPlaceholder')"
|
||||
/>
|
||||
<select
|
||||
v-model="selectedRole"
|
||||
class="w-full rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2 text-sm text-base-foreground transition-colors focus:outline-none focus:ring-1 focus:ring-secondary-foreground hover:border-border-hover sm:w-40"
|
||||
:aria-label="$t('discover.share.inviteByEmail.roleLabel')"
|
||||
>
|
||||
<option value="view">
|
||||
{{ $t('discover.share.inviteByEmail.roles.viewOnly') }}
|
||||
</option>
|
||||
<option value="edit">
|
||||
{{ $t('discover.share.inviteByEmail.roles.edit') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
class="w-full"
|
||||
:loading="sendingInvite"
|
||||
:disabled="!isEmailValid"
|
||||
@click="handleSendInvite"
|
||||
>
|
||||
<i class="icon-[lucide--send] size-4" />
|
||||
{{ $t('discover.share.inviteByEmail.sendInvite') }}
|
||||
</Button>
|
||||
|
||||
<!-- Public URL Section -->
|
||||
<div
|
||||
class="rounded-xl border border-border-default bg-secondary-background/40 p-3"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--globe] size-4 text-muted-foreground" />
|
||||
<span class="text-sm font-semibold text-base-foreground">
|
||||
{{ $t('discover.share.publicUrl.title') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm leading-5 text-muted-foreground">
|
||||
{{ $t('discover.share.publicUrl.description') }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="w-full justify-between rounded-lg border border-border-default bg-secondary-background/90 px-4 py-3 text-sm font-semibold shadow-sm transition-colors hover:bg-secondary-background-hover"
|
||||
:aria-label="$t('discover.share.publicUrl.copyLink')"
|
||||
@click="copyToClipboard(shareUrl)"
|
||||
>
|
||||
<span class="min-w-0 truncate">
|
||||
{{ $t('discover.share.publicUrl.copyLink') }}
|
||||
</span>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-3.5',
|
||||
copied ? 'icon-[lucide--check]' : 'icon-[lucide--copy]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="w-full justify-between rounded-lg border border-border-default bg-secondary-background/90 px-4 py-3 text-sm font-semibold shadow-sm transition-colors hover:bg-secondary-background-hover"
|
||||
:aria-label="$t('discover.share.publicUrl.copyLinkAppMode')"
|
||||
@click="copyToClipboard(appModeShareUrl)"
|
||||
>
|
||||
<span class="min-w-0 truncate">
|
||||
{{ $t('discover.share.publicUrl.copyLinkAppMode') }}
|
||||
</span>
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Publish to Comfy Hub Tab -->
|
||||
<div
|
||||
v-else-if="activeTab === 'publish'"
|
||||
class="mx-auto flex w-full max-w-sm flex-col gap-6 p-4"
|
||||
>
|
||||
<div
|
||||
v-if="publishSuccessUrl"
|
||||
class="flex flex-col gap-4 rounded-xl border border-border-default bg-secondary-background/40 p-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-base font-semibold">
|
||||
<i class="icon-[lucide--check-circle] size-5 text-success" />
|
||||
{{ $t('discover.share.publishToHubDialog.successTitle') }}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('discover.share.publishToHubDialog.successDescription') }}
|
||||
</p>
|
||||
<div
|
||||
class="rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2 text-sm text-base-foreground"
|
||||
>
|
||||
{{ publishSuccessUrl }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<Button variant="primary" size="md" @click="openPublishedWorkflow">
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
{{ $t('discover.share.publishToHubDialog.successOpen') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
@click="copyToClipboard(publishSuccessUrl)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
{{ $t('discover.share.publishToHubDialog.successCopy') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<p
|
||||
class="rounded-xl border border-border-default bg-secondary-background/40 p-3 text-sm leading-5 text-muted-foreground"
|
||||
>
|
||||
{{ $t('discover.share.publishTab.description') }}
|
||||
</p>
|
||||
|
||||
<!-- Thumbnail upload -->
|
||||
<div
|
||||
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-3"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{{ $t('discover.share.publishToHubDialog.thumbnail') }}
|
||||
</span>
|
||||
<div
|
||||
class="relative flex size-24 cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-dashed border-border-default bg-comfy-menu-bg transition-colors hover:border-border-hover"
|
||||
@click="triggerThumbnailUpload"
|
||||
>
|
||||
<img
|
||||
v-if="thumbnailPreview"
|
||||
:src="thumbnailPreview"
|
||||
:alt="$t('discover.share.publishToHubDialog.thumbnailPreview')"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex flex-col items-center gap-1 text-center">
|
||||
<i
|
||||
class="icon-[lucide--image-plus] size-6 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref="thumbnailInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleThumbnailChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div
|
||||
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-3"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{{ $t('g.title') }}
|
||||
</span>
|
||||
<input
|
||||
v-model="publishTitle"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground transition-colors focus:outline-none focus:ring-1 focus:ring-secondary-foreground hover:border-border-hover"
|
||||
:placeholder="
|
||||
$t('discover.share.publishToHubDialog.titlePlaceholder')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div
|
||||
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-3"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{{ $t('g.description') }}
|
||||
</span>
|
||||
<textarea
|
||||
v-model="publishDescription"
|
||||
rows="3"
|
||||
class="w-full resize-none rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground transition-colors focus:outline-none focus:ring-1 focus:ring-secondary-foreground hover:border-border-hover"
|
||||
:placeholder="
|
||||
$t('discover.share.publishToHubDialog.descriptionPlaceholder')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div
|
||||
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-3"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{{ $t('discover.filters.tags') }}
|
||||
</span>
|
||||
<div
|
||||
class="flex flex-wrap gap-2 rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2"
|
||||
>
|
||||
<span
|
||||
v-for="tag in publishTags"
|
||||
:key="tag"
|
||||
class="flex items-center gap-1 rounded-full bg-secondary-background px-2 py-0.5 text-xs text-base-foreground"
|
||||
>
|
||||
{{ tag }}
|
||||
<button
|
||||
class="cursor-pointer border-none bg-transparent p-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="$t('g.removeTag')"
|
||||
@click="removeTag(tag)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3" />
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
v-model="newTag"
|
||||
type="text"
|
||||
class="min-w-20 flex-1 border-none bg-transparent text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
:placeholder="$t('discover.share.publishToHubDialog.addTag')"
|
||||
@keydown.enter.prevent="addTag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
class="w-full"
|
||||
:loading="publishing"
|
||||
:disabled="!isPublishValid"
|
||||
@click="handlePublishToHub"
|
||||
>
|
||||
<i class="icon-[lucide--upload] size-4" />
|
||||
{{ $t('discover.share.publishToHubDialog.publish') }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useSharePanelStore } from '@/stores/workspace/sharePanelStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type ShareTab = 'invite' | 'publish'
|
||||
|
||||
const { t } = useI18n()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const toastStore = useToastStore()
|
||||
const sharePanelStore = useSharePanelStore()
|
||||
|
||||
interface InvitedUser {
|
||||
email: string
|
||||
role: 'view' | 'edit'
|
||||
}
|
||||
|
||||
const activeTab = ref<ShareTab>('invite')
|
||||
const email = ref('')
|
||||
const selectedRole = ref<'view' | 'edit'>('view')
|
||||
const sendingInvite = ref(false)
|
||||
const publishing = ref(false)
|
||||
const copied = ref(false)
|
||||
const invitedUsers = ref<InvitedUser[]>([])
|
||||
|
||||
// Publish to Hub state
|
||||
const thumbnailInput = ref<HTMLInputElement>()
|
||||
const thumbnailPreview = ref('')
|
||||
const publishTitle = ref('')
|
||||
const publishDescription = ref('')
|
||||
const publishTags = ref<string[]>([])
|
||||
const newTag = ref('')
|
||||
const publishSuccessUrl = ref<string | null>(null)
|
||||
|
||||
const isEmailValid = computed(() => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email.value)
|
||||
})
|
||||
|
||||
const isPublishValid = computed(
|
||||
() =>
|
||||
publishTitle.value.trim().length > 0 &&
|
||||
publishDescription.value.trim().length > 0
|
||||
)
|
||||
|
||||
const shareUrl = computed(() => {
|
||||
const baseUrl = window.location.origin
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return baseUrl
|
||||
const workflowId = workflow.key.replace(/\.json$/, '').replace(/\//g, '-')
|
||||
return new URL(`/app/${workflowId}`, baseUrl).toString()
|
||||
})
|
||||
|
||||
const appModeShareUrl = computed(() => {
|
||||
const baseUrl = window.location.origin
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return baseUrl
|
||||
const workflowId = workflow.key.replace(/\.json$/, '').replace(/\//g, '-')
|
||||
const url = new URL(`/app/${workflowId}`, baseUrl)
|
||||
url.searchParams.set('mode', 'linear')
|
||||
return url.toString()
|
||||
})
|
||||
|
||||
function closePanel() {
|
||||
sharePanelStore.closePanel()
|
||||
}
|
||||
|
||||
async function handleSendInvite() {
|
||||
sendingInvite.value = true
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
const existingUser = invitedUsers.value.find((u) => u.email === email.value)
|
||||
if (existingUser) {
|
||||
existingUser.role = selectedRole.value
|
||||
} else {
|
||||
invitedUsers.value.push({
|
||||
email: email.value,
|
||||
role: selectedRole.value
|
||||
})
|
||||
}
|
||||
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: t('discover.share.inviteByEmail.inviteSent'),
|
||||
detail: t('discover.share.inviteByEmail.inviteSentDetail', {
|
||||
email: email.value
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
email.value = ''
|
||||
} finally {
|
||||
sendingInvite.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeUser(emailToRemove: string) {
|
||||
invitedUsers.value = invitedUsers.value.filter(
|
||||
(u) => u.email !== emailToRemove
|
||||
)
|
||||
}
|
||||
|
||||
async function copyToClipboard(url: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
copied.value = true
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: t('g.copied'),
|
||||
life: 2000
|
||||
})
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
} catch {
|
||||
toastStore.addAlert(t('discover.share.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function triggerThumbnailUpload() {
|
||||
thumbnailInput.value?.click()
|
||||
}
|
||||
|
||||
function handleThumbnailChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) {
|
||||
thumbnailPreview.value = URL.createObjectURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
function addTag() {
|
||||
const tag = newTag.value.trim()
|
||||
if (tag && !publishTags.value.includes(tag)) {
|
||||
publishTags.value.push(tag)
|
||||
}
|
||||
newTag.value = ''
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
publishTags.value = publishTags.value.filter((t) => t !== tag)
|
||||
}
|
||||
|
||||
async function handlePublishToHub() {
|
||||
publishing.value = true
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
const title = publishTitle.value.trim()
|
||||
const slug = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
const fakeId = slug ? `${slug}-preview` : 'workflow-preview'
|
||||
publishSuccessUrl.value = `https://comfy-hub.vercel.app/workflows/${fakeId}`
|
||||
} finally {
|
||||
publishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openPublishedWorkflow() {
|
||||
if (!publishSuccessUrl.value) return
|
||||
window.open(publishSuccessUrl.value, '_blank')
|
||||
}
|
||||
</script>
|
||||
@@ -772,7 +772,7 @@ useIntersectionObserver(loadTrigger, () => {
|
||||
// Reset pagination when filters change
|
||||
watch(
|
||||
[
|
||||
filteredTemplates,
|
||||
searchQuery,
|
||||
selectedNavItem,
|
||||
sortBy,
|
||||
selectedModels,
|
||||
|
||||
@@ -150,11 +150,13 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useKeybindingService } from '@/services/keybindingService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import {
|
||||
KeyComboImpl,
|
||||
KeybindingImpl,
|
||||
useKeybindingStore
|
||||
} from '@/stores/keybindingStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import PanelTemplate from './PanelTemplate.vue'
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
import type { KeyComboImpl } from '@/stores/keybindingStore'
|
||||
|
||||
const { keyCombo, isModified = false } = defineProps<{
|
||||
keyCombo: KeyComboImpl
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-full flex-col overflow-y-auto bg-comfy-menu-bg text-base-foreground"
|
||||
>
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-4 border-b border-interface-stroke bg-comfy-menu-bg px-6 py-4 text-base-foreground"
|
||||
>
|
||||
<Button variant="secondary" size="md" @click="emit('back')">
|
||||
<i class="icon-[lucide--arrow-left]" />
|
||||
{{ $t('g.back') }}
|
||||
</Button>
|
||||
<div class="flex-1" />
|
||||
</div>
|
||||
|
||||
<section class="px-6 pt-6">
|
||||
<div
|
||||
class="mx-auto flex w-full max-w-5xl flex-col gap-6 rounded-2xl border border-border-default bg-comfy-menu-secondary-bg p-6 shadow-sm text-base-foreground"
|
||||
>
|
||||
<div class="flex flex-wrap items-start gap-6">
|
||||
<img
|
||||
:src="authorAvatar"
|
||||
:alt="authorName"
|
||||
class="size-20 rounded-2xl bg-secondary-background object-cover ring-2 ring-border-subtle"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class="text-xs font-semibold uppercase text-base-foreground/60"
|
||||
>
|
||||
{{ $t('discover.author.title') }}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h1 class="truncate text-3xl font-semibold text-base-foreground">
|
||||
{{ authorName }}
|
||||
</h1>
|
||||
<Button variant="secondary" size="md" @click="openHubProfile">
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
{{ $t('discover.author.openInHub') }}
|
||||
</Button>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-xs text-base-foreground/80"
|
||||
>
|
||||
<span class="text-base-foreground">
|
||||
#{{ $t('discover.author.rankValue') }}
|
||||
</span>
|
||||
<span class="text-base-foreground/60">
|
||||
{{ $t('discover.author.rankLabel') }}
|
||||
</span>
|
||||
<span class="text-base-foreground/60">•</span>
|
||||
<span class="text-base-foreground/60">
|
||||
{{ $t('discover.author.rankCaption') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 max-w-2xl text-sm text-base-foreground/80">
|
||||
{{ $t('discover.author.tagline') }}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<div
|
||||
class="rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-base-foreground/80"
|
||||
>
|
||||
{{ $t('discover.author.badgeCreator') }}
|
||||
</div>
|
||||
<div
|
||||
class="rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-base-foreground/80"
|
||||
>
|
||||
{{ $t('discover.author.badgeOpenSource') }}
|
||||
</div>
|
||||
<div
|
||||
class="rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-base-foreground/80"
|
||||
>
|
||||
{{ $t('discover.author.badgeTemplates') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-3 md:w-64">
|
||||
<div
|
||||
class="rounded-xl border border-border-subtle bg-comfy-menu-bg p-4 text-base-foreground"
|
||||
>
|
||||
<div class="text-xs uppercase text-base-foreground/60">
|
||||
{{ $t('discover.author.aboutTitle') }}
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-base-foreground/80">
|
||||
{{ $t('discover.author.aboutDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
class="rounded-xl border border-border-subtle bg-comfy-menu-bg p-4 text-base-foreground"
|
||||
>
|
||||
<div class="text-xs uppercase text-base-foreground/60">
|
||||
{{ $t('discover.author.runsLabel') }}
|
||||
</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-base-foreground">
|
||||
{{ formattedRuns }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-border-subtle bg-comfy-menu-bg p-4 text-base-foreground"
|
||||
>
|
||||
<div class="text-xs uppercase text-base-foreground/60">
|
||||
{{ $t('discover.author.copiesLabel') }}
|
||||
</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-base-foreground">
|
||||
{{ formattedCopies }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="px-6 py-4 bg-comfy-menu-bg">
|
||||
<div
|
||||
class="mx-auto mb-4 flex w-full max-w-5xl flex-wrap items-center gap-3 border-b border-interface-stroke pb-4 text-base-foreground"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
class="icon-[lucide--layout-grid] size-4 text-base-foreground/60"
|
||||
/>
|
||||
<span class="text-sm font-semibold text-base-foreground">
|
||||
{{ $t('discover.author.workflowsTitle') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-full bg-secondary-background px-3 py-1 text-xs"
|
||||
>
|
||||
<i class="icon-[lucide--layers] size-3.5" />
|
||||
{{
|
||||
$t(
|
||||
'discover.author.workflowsCount',
|
||||
{ count: totalWorkflows },
|
||||
totalWorkflows
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('discover.author.searchPlaceholder')"
|
||||
size="lg"
|
||||
class="max-w-md flex-1"
|
||||
show-border
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="mx-auto grid w-full max-w-5xl grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4"
|
||||
>
|
||||
<CardContainer
|
||||
v-for="n in 12"
|
||||
:key="`author-skeleton-${n}`"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
class="hover:bg-base-background"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<div class="size-full animate-pulse bg-dialog-surface" />
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<div class="p-3">
|
||||
<div
|
||||
class="mb-2 h-5 w-3/4 animate-pulse rounded bg-dialog-surface"
|
||||
/>
|
||||
<div class="h-4 w-full animate-pulse rounded bg-dialog-surface" />
|
||||
</div>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="results && results.templates.length === 0"
|
||||
class="mx-auto flex h-64 w-full max-w-5xl flex-col items-center justify-center text-muted-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--search] mb-4 size-12 opacity-50" />
|
||||
<p class="mb-2 text-lg">{{ $t('discover.author.noResults') }}</p>
|
||||
<p class="text-sm">{{ $t('discover.author.noResultsHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="results"
|
||||
class="mx-auto grid w-full max-w-5xl grid-cols-[repeat(auto-fill,minmax(250px,1fr))] gap-4"
|
||||
>
|
||||
<CardContainer
|
||||
v-for="template in results.templates"
|
||||
:key="template.objectID"
|
||||
size="compact"
|
||||
custom-aspect-ratio="2/3"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
class="hover:bg-base-background"
|
||||
@mouseenter="hoveredTemplate = template.objectID"
|
||||
@mouseleave="hoveredTemplate = null"
|
||||
@click="emit('selectWorkflow', template)"
|
||||
>
|
||||
<template #top>
|
||||
<div class="shrink-0">
|
||||
<CardTop ratio="square">
|
||||
<LazyImage
|
||||
:src="template.thumbnail_url"
|
||||
:alt="template.title"
|
||||
class="size-full rounded-lg object-cover transition-transform duration-300"
|
||||
:class="hoveredTemplate === template.objectID && 'scale-105'"
|
||||
/>
|
||||
<template #bottom-right>
|
||||
<SquareChip
|
||||
v-for="tag in template.tags.slice(0, 2)"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
/>
|
||||
</template>
|
||||
</CardTop>
|
||||
</div>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<div class="flex flex-col gap-1.5 p-3">
|
||||
<h3
|
||||
class="line-clamp-1 text-sm font-medium"
|
||||
:title="template.title"
|
||||
>
|
||||
{{ template.title }}
|
||||
</h3>
|
||||
<p
|
||||
class="line-clamp-2 text-xs text-muted-foreground"
|
||||
:title="template.description"
|
||||
>
|
||||
{{ template.description }}
|
||||
</p>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="model in template.models.slice(0, 2)"
|
||||
:key="model"
|
||||
class="rounded bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ model }}
|
||||
</span>
|
||||
<span
|
||||
v-if="template.models.length > 2"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
+{{ template.models.length - 2 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="results && results.totalPages > 1"
|
||||
class="flex shrink-0 items-center justify-center gap-2 border-t border-interface-stroke px-6 py-3"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:disabled="currentPage === 0"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left]" />
|
||||
</Button>
|
||||
<span class="px-4 text-sm text-muted-foreground">
|
||||
{{ currentPage + 1 }} / {{ results.totalPages }}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:disabled="currentPage >= results.totalPages - 1"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-right]" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowTemplateSearch } from '@/composables/discover/useWorkflowTemplateSearch'
|
||||
import type { AlgoliaWorkflowTemplate } from '@/types/discoverTypes'
|
||||
|
||||
const { authorName, authorAvatarUrl, stats } = defineProps<{
|
||||
authorName: string
|
||||
authorAvatarUrl?: string
|
||||
stats?: { runs: number; copies: number }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
selectWorkflow: [workflow: AlgoliaWorkflowTemplate]
|
||||
}>()
|
||||
|
||||
const { search, isLoading, results } = useWorkflowTemplateSearch()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(0)
|
||||
const hoveredTemplate = ref<string | null>(null)
|
||||
|
||||
const hubProfileUrl = 'https://pr-2289.testenvs.comfy.org/profile/Comfy%20Org'
|
||||
|
||||
const authorAvatar = computed(
|
||||
() => authorAvatarUrl ?? '/assets/images/comfy-logo-single.svg'
|
||||
)
|
||||
|
||||
const formattedRuns = computed(() =>
|
||||
stats?.runs ? stats.runs.toLocaleString() : '--'
|
||||
)
|
||||
const formattedCopies = computed(() =>
|
||||
stats?.copies ? stats.copies.toLocaleString() : '--'
|
||||
)
|
||||
|
||||
const totalWorkflows = computed(() => results.value?.totalHits ?? 0)
|
||||
|
||||
const authorFacetFilters = computed(() => [[`author_name:${authorName}`]])
|
||||
|
||||
function openHubProfile() {
|
||||
window.location.assign(hubProfileUrl)
|
||||
}
|
||||
|
||||
async function performSearch() {
|
||||
await search({
|
||||
query: searchQuery.value,
|
||||
pageSize: 24,
|
||||
pageNumber: currentPage.value,
|
||||
facetFilters: authorFacetFilters.value
|
||||
})
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 0
|
||||
performSearch()
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
currentPage.value = page
|
||||
performSearch()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authorName,
|
||||
() => {
|
||||
currentPage.value = 0
|
||||
performSearch()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => searchQuery.value,
|
||||
() => {
|
||||
currentPage.value = 0
|
||||
performSearch()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
performSearch()
|
||||
})
|
||||
</script>
|
||||
@@ -1,387 +0,0 @@
|
||||
<template>
|
||||
<WorkflowDetailView
|
||||
v-if="selectedWorkflow"
|
||||
:workflow="selectedWorkflow"
|
||||
@back="selectedWorkflow = null"
|
||||
@run-workflow="handleRunWorkflow"
|
||||
@make-copy="handleMakeCopy"
|
||||
@author-selected="handleAuthorSelected"
|
||||
/>
|
||||
<div v-else class="flex size-full flex-col">
|
||||
<!-- Header with search -->
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-4 border-b border-interface-stroke px-6 py-4"
|
||||
>
|
||||
<h1 class="text-xl font-semibold text-base-foreground">
|
||||
{{ $t('sideToolbar.discover') }}
|
||||
</h1>
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('discover.searchPlaceholder')"
|
||||
size="lg"
|
||||
class="max-w-md flex-1"
|
||||
show-border
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-3 px-6 py-3">
|
||||
<!-- Tags filter -->
|
||||
<MultiSelect
|
||||
v-model="selectedTags"
|
||||
:label="$t('discover.filters.tags')"
|
||||
:options="tagOptions"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
class="w-48"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--tag]" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<!-- Models filter -->
|
||||
<MultiSelect
|
||||
v-model="selectedModels"
|
||||
:label="$t('discover.filters.models')"
|
||||
:options="modelOptions"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
class="w-48"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--cpu]" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<!-- Open source toggle -->
|
||||
<Button
|
||||
:variant="openSourceOnly ? 'primary' : 'secondary'"
|
||||
size="md"
|
||||
@click="openSourceOnly = !openSourceOnly"
|
||||
>
|
||||
<i class="icon-[lucide--unlock]" />
|
||||
{{ $t('discover.filters.openSource') }}
|
||||
</Button>
|
||||
|
||||
<!-- Cloud only toggle -->
|
||||
<Button
|
||||
:variant="cloudOnly ? 'primary' : 'secondary'"
|
||||
size="md"
|
||||
@click="cloudOnly = !cloudOnly"
|
||||
>
|
||||
<i class="icon-[lucide--cloud]" />
|
||||
{{ $t('discover.filters.cloudOnly') }}
|
||||
</Button>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Results count -->
|
||||
<span v-if="!isLoading && results" class="text-sm text-muted-foreground">
|
||||
{{
|
||||
$t(
|
||||
'discover.resultsCount',
|
||||
{ count: results.totalHits },
|
||||
results.totalHits
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
<!-- Loading state -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4"
|
||||
>
|
||||
<CardContainer
|
||||
v-for="n in 12"
|
||||
:key="`skeleton-${n}`"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
class="hover:bg-base-background"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<div class="size-full animate-pulse bg-dialog-surface" />
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<div class="p-3">
|
||||
<div
|
||||
class="mb-2 h-5 w-3/4 animate-pulse rounded bg-dialog-surface"
|
||||
/>
|
||||
<div class="h-4 w-full animate-pulse rounded bg-dialog-surface" />
|
||||
</div>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
|
||||
<!-- No results state -->
|
||||
<div
|
||||
v-else-if="!cloudOnly || (results && results.templates.length === 0)"
|
||||
class="flex h-64 flex-col items-center justify-center text-muted-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--search] mb-4 size-12 opacity-50" />
|
||||
<p class="mb-2 text-lg">{{ $t('discover.noResults') }}</p>
|
||||
<p class="text-sm">{{ $t('discover.noResultsHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Results grid -->
|
||||
<div
|
||||
v-else-if="results"
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4"
|
||||
>
|
||||
<CardContainer
|
||||
v-for="template in results.templates"
|
||||
:key="template.objectID"
|
||||
size="compact"
|
||||
custom-aspect-ratio="2/3"
|
||||
variant="ghost"
|
||||
class="hover:bg-base-background"
|
||||
@mouseenter="hoveredTemplate = template.objectID"
|
||||
@mouseleave="hoveredTemplate = null"
|
||||
@click="handleTemplateClick(template)"
|
||||
>
|
||||
<template #top>
|
||||
<div class="shrink-0">
|
||||
<CardTop ratio="square">
|
||||
<LazyImage
|
||||
:src="template.thumbnail_url"
|
||||
:alt="template.title"
|
||||
class="size-full rounded-lg object-cover transition-transform duration-300"
|
||||
:class="hoveredTemplate === template.objectID && 'scale-105'"
|
||||
/>
|
||||
<template #bottom-right>
|
||||
<SquareChip
|
||||
v-for="tag in template.tags.slice(0, 2)"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
/>
|
||||
</template>
|
||||
</CardTop>
|
||||
</div>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<div class="flex flex-col gap-1.5 p-3">
|
||||
<h3
|
||||
class="line-clamp-1 text-sm font-medium"
|
||||
:title="template.title"
|
||||
>
|
||||
{{ template.title }}
|
||||
</h3>
|
||||
<button
|
||||
v-if="template.author_name"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded px-1 -mx-1 border border-transparent bg-transparent text-muted-foreground transition-colors hover:bg-secondary-background hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-subtle"
|
||||
@click.stop="handleAuthorClick(template)"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
template.author_avatar_url ??
|
||||
'/assets/images/comfy-logo-single.svg'
|
||||
"
|
||||
:alt="template.author_name"
|
||||
class="size-4 rounded-full bg-secondary-background object-cover"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ template.author_name }}
|
||||
</span>
|
||||
</button>
|
||||
<p
|
||||
class="line-clamp-2 text-xs text-muted-foreground"
|
||||
:title="template.description"
|
||||
>
|
||||
{{ template.description }}
|
||||
</p>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="model in template.models.slice(0, 2)"
|
||||
:key="model"
|
||||
class="rounded bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ model }}
|
||||
</span>
|
||||
<span
|
||||
v-if="template.models.length > 2"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
+{{ template.models.length - 2 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
|
||||
<!-- Initial state -->
|
||||
<div
|
||||
v-else
|
||||
class="flex h-64 flex-col items-center justify-center text-muted-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--compass] mb-4 size-16 opacity-50" />
|
||||
<p class="text-lg">{{ $t('sideToolbar.discoverPlaceholder') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination (inside v-else block) -->
|
||||
<div
|
||||
v-if="results && results.totalPages > 1"
|
||||
class="flex shrink-0 items-center justify-center gap-2 border-t border-interface-stroke px-6 py-3"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:disabled="currentPage === 0"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left]" />
|
||||
</Button>
|
||||
<span class="px-4 text-sm text-muted-foreground">
|
||||
{{ currentPage + 1 }} / {{ results.totalPages }}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:disabled="currentPage >= results.totalPages - 1"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-right]" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import WorkflowDetailView from '@/components/discover/WorkflowDetailView.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowTemplateSearch } from '@/composables/discover/useWorkflowTemplateSearch'
|
||||
import type { AlgoliaWorkflowTemplate } from '@/types/discoverTypes'
|
||||
|
||||
const { search, isLoading, results } = useWorkflowTemplateSearch()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(0)
|
||||
const hoveredTemplate = ref<string | null>(null)
|
||||
const selectedWorkflow = ref<AlgoliaWorkflowTemplate | null>(null)
|
||||
|
||||
const selectedTags = ref<Array<{ name: string; value: string }>>([])
|
||||
const selectedModels = ref<Array<{ name: string; value: string }>>([])
|
||||
const openSourceOnly = ref(false)
|
||||
const cloudOnly = ref(true)
|
||||
|
||||
// Store initial facet values to preserve filter options
|
||||
const initialFacets = ref<Record<string, Record<string, number>> | null>(null)
|
||||
|
||||
const tagOptions = computed(() => {
|
||||
const facets = initialFacets.value?.tags ?? results.value?.facets?.tags
|
||||
if (!facets) return []
|
||||
return Object.entries(facets).map(([tag, count]) => ({
|
||||
name: `${tag} (${count})`,
|
||||
value: tag
|
||||
}))
|
||||
})
|
||||
|
||||
const modelOptions = computed(() => {
|
||||
const facets = initialFacets.value?.models ?? results.value?.facets?.models
|
||||
if (!facets) return []
|
||||
return Object.entries(facets).map(([model, count]) => ({
|
||||
name: `${model} (${count})`,
|
||||
value: model
|
||||
}))
|
||||
})
|
||||
|
||||
function buildFacetFilters(): string[][] {
|
||||
const filters: string[][] = []
|
||||
|
||||
if (selectedTags.value.length > 0) {
|
||||
filters.push(selectedTags.value.map((t) => `tags:${t.value}`))
|
||||
}
|
||||
if (selectedModels.value.length > 0) {
|
||||
filters.push(selectedModels.value.map((m) => `models:${m.value}`))
|
||||
}
|
||||
if (openSourceOnly.value) {
|
||||
filters.push(['open_source:true'])
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
|
||||
async function performSearch() {
|
||||
const result = await search({
|
||||
query: searchQuery.value,
|
||||
pageSize: 24,
|
||||
pageNumber: currentPage.value,
|
||||
facetFilters: buildFacetFilters()
|
||||
})
|
||||
|
||||
// Store initial facets on first search (no filters applied)
|
||||
if (!initialFacets.value && result.facets) {
|
||||
initialFacets.value = result.facets
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 0
|
||||
performSearch()
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
currentPage.value = page
|
||||
performSearch()
|
||||
}
|
||||
|
||||
function handleTemplateClick(template: AlgoliaWorkflowTemplate) {
|
||||
selectedWorkflow.value = template
|
||||
}
|
||||
|
||||
function handleAuthorClick(template: AlgoliaWorkflowTemplate) {
|
||||
if (!template.author_name) return
|
||||
window.open(
|
||||
`https://comfy-hub.vercel.app/profile/${encodeURIComponent(
|
||||
template.author_name
|
||||
)}`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
function handleAuthorSelected(author: { name: string; avatarUrl?: string }) {
|
||||
window.open(
|
||||
`https://comfy-hub.vercel.app/profile/${encodeURIComponent(author.name)}`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
function handleRunWorkflow(_workflow: AlgoliaWorkflowTemplate) {
|
||||
// TODO: Implement workflow run
|
||||
}
|
||||
|
||||
function handleMakeCopy(_workflow: AlgoliaWorkflowTemplate) {
|
||||
// TODO: Implement make a copy
|
||||
}
|
||||
|
||||
watch(
|
||||
[selectedTags, selectedModels, openSourceOnly, cloudOnly],
|
||||
() => {
|
||||
currentPage.value = 0
|
||||
performSearch()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
performSearch()
|
||||
})
|
||||
</script>
|
||||
@@ -1,268 +0,0 @@
|
||||
<template>
|
||||
<div class="flex size-full flex-col">
|
||||
<!-- Header with back button and actions -->
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-4 border-b border-interface-stroke px-6 py-4"
|
||||
>
|
||||
<Button variant="secondary" size="md" @click="emit('back')">
|
||||
<i class="icon-[lucide--arrow-left]" />
|
||||
{{ $t('g.back') }}
|
||||
</Button>
|
||||
<h1 class="flex-1 truncate text-xl font-semibold text-base-foreground">
|
||||
{{ workflow.title }}
|
||||
</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="primary" size="md" @click="handleMakeCopy">
|
||||
<i class="icon-[lucide--copy]" />
|
||||
{{ $t('discover.detail.makeCopy') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="md" @click="openHubWorkflow">
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
{{ $t('discover.detail.openInHub') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two-column layout -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left column: Workflow info -->
|
||||
<div class="flex w-80 shrink-0 flex-col border-r border-interface-stroke">
|
||||
<div class="flex-1 space-y-5 overflow-y-auto p-4">
|
||||
<!-- Thumbnail -->
|
||||
<div
|
||||
class="aspect-video overflow-hidden rounded-lg bg-dialog-surface"
|
||||
>
|
||||
<LazyImage
|
||||
:src="workflow.thumbnail_url"
|
||||
:alt="workflow.title"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Author -->
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!hasAuthor"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full items-center gap-3 rounded-lg border border-transparent p-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-subtle',
|
||||
hasAuthor
|
||||
? 'hover:bg-secondary-background'
|
||||
: 'cursor-default opacity-80'
|
||||
)
|
||||
"
|
||||
@click="handleAuthorClick"
|
||||
>
|
||||
<img
|
||||
:src="authorAvatar"
|
||||
:alt="authorName"
|
||||
class="size-8 rounded-full bg-secondary-background object-cover"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-base-foreground">
|
||||
{{ authorName }}
|
||||
</div>
|
||||
<div class="truncate text-xs text-muted-foreground">
|
||||
{{ $t('discover.detail.officialWorkflow') }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<i class="icon-[lucide--play] size-3.5" />
|
||||
<span>{{ formatCount(runCount) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<i class="icon-[lucide--eye] size-3.5" />
|
||||
<span>{{ formatCount(viewCount) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
<span>{{ formatCount(copyCount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-xs font-medium text-muted-foreground">
|
||||
{{ $t('g.description') }}
|
||||
</h3>
|
||||
<p class="whitespace-pre-wrap text-sm text-base-foreground">
|
||||
{{ workflow.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="workflow.tags.length > 0" class="space-y-1">
|
||||
<h3 class="text-xs font-medium text-muted-foreground">
|
||||
{{ $t('discover.filters.tags') }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<SquareChip
|
||||
v-for="tag in workflow.tags"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Models -->
|
||||
<div v-if="workflow.models.length > 0" class="space-y-1">
|
||||
<h3 class="text-xs font-medium text-muted-foreground">
|
||||
{{ $t('discover.filters.models') }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="model in workflow.models"
|
||||
:key="model"
|
||||
class="rounded bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ model }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Open source badge -->
|
||||
<div v-if="workflow.open_source" class="flex items-center gap-1.5">
|
||||
<i class="icon-[lucide--unlock] size-4 text-green-500" />
|
||||
<span class="text-xs text-green-500">
|
||||
{{ $t('discover.detail.openSource') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Required custom nodes -->
|
||||
<div
|
||||
v-if="workflow.requires_custom_nodes.length > 0"
|
||||
class="space-y-1"
|
||||
>
|
||||
<h3 class="text-xs font-medium text-muted-foreground">
|
||||
{{ $t('discover.detail.requiredNodes') }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="node in workflow.requires_custom_nodes"
|
||||
:key="node"
|
||||
class="rounded bg-warning-background px-1.5 py-0.5 text-xs text-warning-foreground"
|
||||
>
|
||||
{{ node }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Workflow preview -->
|
||||
<div class="min-h-0 min-w-0 flex-1">
|
||||
<WorkflowPreviewCanvas :workflow-url="workflow.workflow_url" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import WorkflowPreviewCanvas from '@/components/discover/WorkflowPreviewCanvas.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useHomePanelStore } from '@/stores/workspace/homePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import type { AlgoliaWorkflowTemplate } from '@/types/discoverTypes'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { workflow } = defineProps<{
|
||||
workflow: AlgoliaWorkflowTemplate
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
makeCopy: [workflow: AlgoliaWorkflowTemplate]
|
||||
}>()
|
||||
|
||||
const hasAuthor = computed(() => !!workflow.author_name)
|
||||
|
||||
const authorName = computed(
|
||||
() => workflow.author_name ?? t('discover.detail.author')
|
||||
)
|
||||
|
||||
const authorAvatar = computed(
|
||||
() => workflow.author_avatar_url ?? '/assets/images/comfy-logo-single.svg'
|
||||
)
|
||||
|
||||
const hubWorkflowBaseUrl = 'https://comfy-hub.vercel.app/workflows'
|
||||
|
||||
const runCount = computed(() => workflow.run_count ?? 1_234)
|
||||
const viewCount = computed(() => workflow.view_count ?? 5_678)
|
||||
const copyCount = computed(() => workflow.copy_count ?? 890)
|
||||
|
||||
function formatCount(count: number): string {
|
||||
if (count >= 1_000_000) {
|
||||
return `${(count / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
if (count >= 1_000) {
|
||||
return `${(count / 1_000).toFixed(1)}K`
|
||||
}
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
const handleAuthorClick = () => {
|
||||
if (!workflow.author_name) return
|
||||
window.open(
|
||||
`https://comfy-hub.vercel.app/profile/${encodeURIComponent(
|
||||
workflow.author_name
|
||||
)}`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const openHubWorkflow = () => {
|
||||
window.open(`${hubWorkflowBaseUrl}/${workflow.objectID}`, '_blank')
|
||||
}
|
||||
|
||||
const loadWorkflowFromUrl = async () => {
|
||||
if (!workflow.workflow_url) return false
|
||||
|
||||
// Check that app canvas and graph are initialized
|
||||
if (!app.canvas?.graph) {
|
||||
useToastStore().addAlert(t('discover.detail.appNotReady'))
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(workflow.workflow_url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch workflow: ${response.status}`)
|
||||
}
|
||||
const workflowData = await response.json()
|
||||
await app.loadGraphData(workflowData, true, true, workflow.title, {
|
||||
openSource: 'template'
|
||||
})
|
||||
|
||||
// Close overlay panels to show the new workflow
|
||||
useSidebarTabStore().activeSidebarTabId = null
|
||||
useHomePanelStore().closePanel()
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(
|
||||
t('discover.detail.makeCopyFailed', { error: String(error) })
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMakeCopy() {
|
||||
const didLoad = await loadWorkflowFromUrl()
|
||||
if (didLoad) {
|
||||
emit('makeCopy', workflow)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,188 +0,0 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="relative size-full min-h-0 bg-graph-canvas">
|
||||
<canvas ref="canvasRef" class="absolute left-0 top-0" />
|
||||
<!-- Loading state -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-2] size-8 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<!-- Error state -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--alert-circle] size-8" />
|
||||
<span class="text-sm">{{ $t('discover.detail.previewError') }}</span>
|
||||
<span class="text-xs opacity-50">{{ error.message }}</span>
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else-if="!workflowUrl"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--workflow] size-16 opacity-30" />
|
||||
<span class="text-sm">{{ $t('discover.detail.workflowPreview') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import {
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
|
||||
import { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const { workflowUrl } = defineProps<{
|
||||
workflowUrl?: string
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const graph = shallowRef<LGraph>()
|
||||
const canvas = shallowRef<LGraphCanvas>()
|
||||
const isLoading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
const isInitialized = ref(false)
|
||||
|
||||
function updateCanvasSize() {
|
||||
if (!canvasRef.value || !containerRef.value || !canvas.value) return
|
||||
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) return
|
||||
|
||||
const dpr = Math.max(window.devicePixelRatio, 1)
|
||||
canvas.value.resize(
|
||||
Math.round(rect.width * dpr),
|
||||
Math.round(rect.height * dpr)
|
||||
)
|
||||
}
|
||||
|
||||
function initCanvas() {
|
||||
if (!canvasRef.value || !containerRef.value || isInitialized.value) return
|
||||
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) return
|
||||
|
||||
const dpr = Math.max(window.devicePixelRatio, 1)
|
||||
canvasRef.value.width = Math.round(rect.width * dpr)
|
||||
canvasRef.value.height = Math.round(rect.height * dpr)
|
||||
|
||||
graph.value = new LGraph()
|
||||
canvas.value = new LGraphCanvas(canvasRef.value, graph.value, {
|
||||
skip_render: true
|
||||
})
|
||||
canvas.value.startRendering()
|
||||
isInitialized.value = true
|
||||
}
|
||||
|
||||
function fitGraphToCanvas() {
|
||||
if (!graph.value || !canvas.value) return
|
||||
|
||||
const nodes = graph.value.nodes
|
||||
if (nodes.length === 0) return
|
||||
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
for (const node of nodes) {
|
||||
minX = Math.min(minX, node.pos[0])
|
||||
minY = Math.min(minY, node.pos[1])
|
||||
maxX = Math.max(maxX, node.pos[0] + node.size[0])
|
||||
maxY = Math.max(maxY, node.pos[1] + node.size[1])
|
||||
}
|
||||
|
||||
const graphWidth = maxX - minX
|
||||
const graphHeight = maxY - minY
|
||||
const dpr = Math.max(window.devicePixelRatio, 1)
|
||||
const canvasWidth = canvas.value.canvas.width / dpr
|
||||
const canvasHeight = canvas.value.canvas.height / dpr
|
||||
const padding = 50
|
||||
|
||||
if (graphWidth <= 0 || graphHeight <= 0) return
|
||||
if (canvasWidth <= 0 || canvasHeight <= 0) return
|
||||
|
||||
const scaleX = (canvasWidth - padding * 2) / graphWidth
|
||||
const scaleY = (canvasHeight - padding * 2) / graphHeight
|
||||
const scale = Math.min(scaleX, scaleY, 1)
|
||||
|
||||
canvas.value.ds.scale = scale
|
||||
canvas.value.ds.offset[0] = -minX + padding / scale
|
||||
canvas.value.ds.offset[1] = -minY + padding / scale
|
||||
canvas.value.setDirty(true, true)
|
||||
}
|
||||
|
||||
async function loadWorkflow() {
|
||||
if (!workflowUrl) return
|
||||
|
||||
// Wait for canvas to be initialized
|
||||
if (!isInitialized.value) {
|
||||
await nextTick()
|
||||
initCanvas()
|
||||
}
|
||||
|
||||
if (!graph.value || !canvas.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch(workflowUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
// Check if node types are registered
|
||||
const registeredTypes = Object.keys(LiteGraph.registered_node_types)
|
||||
if (registeredTypes.length === 0) {
|
||||
throw new Error('No node types registered yet')
|
||||
}
|
||||
|
||||
graph.value.configure(data)
|
||||
await nextTick()
|
||||
updateCanvasSize()
|
||||
fitGraphToCanvas()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e : new Error(String(e))
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
useResizeObserver(containerRef, () => {
|
||||
updateCanvasSize()
|
||||
fitGraphToCanvas()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => workflowUrl,
|
||||
() => {
|
||||
loadWorkflow()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
initCanvas()
|
||||
await loadWorkflow()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
canvas.value?.stopRendering()
|
||||
})
|
||||
</script>
|
||||
@@ -21,23 +21,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showUI && !isHomePanelOpen" #side-toolbar>
|
||||
<template v-if="showUI" #side-toolbar>
|
||||
<SideToolbar />
|
||||
</template>
|
||||
<template v-if="showUI && !isFullPageOverlayActive" #side-bar-panel>
|
||||
<template v-if="showUI" #side-bar-panel>
|
||||
<div
|
||||
class="sidebar-content-container h-full w-full overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
<ExtensionSlot v-if="activeSidebarTab" :extension="activeSidebarTab" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showUI && isFullPageOverlayActive" #full-page-content>
|
||||
<HomePanel v-if="isHomePanelOpen" />
|
||||
<ExtensionSlot
|
||||
v-else-if="activeSidebarTab"
|
||||
:extension="activeSidebarTab"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="showUI" #topmenu>
|
||||
<TopMenuSection />
|
||||
</template>
|
||||
@@ -45,8 +38,7 @@
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template v-if="showUI" #right-side-panel>
|
||||
<SharePanel v-if="sharePanelStore.isOpen" />
|
||||
<NodePropertiesPanel v-else />
|
||||
<NodePropertiesPanel />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
|
||||
@@ -113,8 +105,6 @@ import {
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
|
||||
import SharePanel from '@/components/actionbar/SharePanel.vue'
|
||||
import HomePanel from '@/components/home/HomePanel.vue'
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
@@ -167,9 +157,7 @@ import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useHomePanelStore } from '@/stores/workspace/homePanelStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useSharePanelStore } from '@/stores/workspace/sharePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isNativeWindow } from '@/utils/envUtil'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
@@ -194,8 +182,6 @@ const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const toastStore = useToastStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const sharePanelStore = useSharePanelStore()
|
||||
const homePanelStore = useHomePanelStore()
|
||||
const colorPaletteService = useColorPaletteService()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
const bootstrapStore = useBootstrapStore()
|
||||
@@ -219,13 +205,6 @@ const selectionToolboxEnabled = computed(() =>
|
||||
const activeSidebarTab = computed(() => {
|
||||
return workspaceStore.sidebarTab.activeSidebarTab
|
||||
})
|
||||
const isFullPageTabActive = computed(() => {
|
||||
return workspaceStore.sidebarTab.isFullPageTabActive
|
||||
})
|
||||
const isHomePanelOpen = computed(() => homePanelStore.isOpen)
|
||||
const isFullPageOverlayActive = computed(
|
||||
() => isFullPageTabActive.value || isHomePanelOpen.value
|
||||
)
|
||||
const showUI = computed(
|
||||
() => !workspaceStore.focusMode && betaMenuEnabled.value
|
||||
)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full bg-comfy-menu-bg">
|
||||
<aside
|
||||
class="flex w-56 shrink-0 flex-col gap-2 border-r border-border-default bg-comfy-menu-bg p-3"
|
||||
>
|
||||
<div
|
||||
class="px-2 pb-2 pt-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{{ $t('home.title') }}
|
||||
</div>
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm font-medium transition-colors',
|
||||
activeTab === 'recents'
|
||||
? 'bg-secondary-background text-base-foreground border-border-hover'
|
||||
: 'text-base-foreground/80 hover:bg-secondary-background'
|
||||
)
|
||||
"
|
||||
@click="activeTab = 'recents'"
|
||||
>
|
||||
<i class="icon-[lucide--clock] size-4" />
|
||||
{{ $t('home.tabs.recents') }}
|
||||
</button>
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm font-medium transition-colors',
|
||||
activeTab === 'discover'
|
||||
? 'bg-secondary-background text-base-foreground border-border-hover'
|
||||
: 'text-base-foreground/80 hover:bg-secondary-background'
|
||||
)
|
||||
"
|
||||
@click="activeTab = 'discover'"
|
||||
>
|
||||
<i class="icon-[lucide--compass] size-4" />
|
||||
{{ $t('home.tabs.discover') }}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main class="flex min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
v-if="activeTab === 'recents'"
|
||||
class="flex flex-1 items-center justify-center"
|
||||
>
|
||||
<div class="flex max-w-md flex-col items-center gap-2 text-center">
|
||||
<i class="icon-[lucide--clock] size-10 text-muted-foreground" />
|
||||
<h2 class="text-lg font-semibold text-base-foreground">
|
||||
{{ $t('home.recentsStubTitle') }}
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('home.recentsStubDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DiscoverView v-else class="flex-1" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import DiscoverView from '@/components/discover/DiscoverView.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type HomeTab = 'recents' | 'discover'
|
||||
|
||||
const activeTab = ref<HomeTab>('discover')
|
||||
</script>
|
||||
@@ -19,7 +19,6 @@
|
||||
>
|
||||
<div ref="topToolbarRef" :class="groupClasses">
|
||||
<ComfyMenuButton />
|
||||
<SidebarTemplatesButton :is-small="isSmall" />
|
||||
<SidebarIcon
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@@ -33,6 +32,7 @@
|
||||
:class="tab.id + '-tab-button'"
|
||||
@click="onTabClick(tab)"
|
||||
/>
|
||||
<SidebarTemplatesButton />
|
||||
</div>
|
||||
|
||||
<div ref="bottomToolbarRef" class="mt-auto" :class="groupClasses">
|
||||
@@ -64,14 +64,13 @@ import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import SidebarTemplatesButton from '@/components/sidebar/SidebarTemplatesButton.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -81,6 +80,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
|
||||
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
@@ -116,8 +116,6 @@
|
||||
icon="pi pi-folder"
|
||||
:title="$t('g.empty')"
|
||||
:message="$t('g.noWorkflowsFound')"
|
||||
:button-label="$t('sideToolbar.browseTemplates')"
|
||||
@action="openTemplates"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +155,6 @@ import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
@@ -306,12 +303,6 @@ const selectionKeys = computed(() => ({
|
||||
}))
|
||||
|
||||
const workflowBookmarkStore = useWorkflowBookmarkStore()
|
||||
const { show } = useWorkflowTemplateSelectorDialog()
|
||||
|
||||
function openTemplates() {
|
||||
show('sidebar')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
searchBoxRef.value?.focus()
|
||||
await workflowBookmarkStore.loadBookmarks()
|
||||
|
||||
@@ -4,25 +4,6 @@
|
||||
class="workflow-tabs-container flex h-full max-w-full flex-auto flex-row overflow-hidden"
|
||||
:class="{ 'workflow-tabs-container-desktop': isDesktop }"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.bottom="{ value: $t('home.title'), showDelay: 300 }"
|
||||
class="no-drag shrink-0 rounded-none h-full w-auto aspect-square"
|
||||
:class="
|
||||
cn(
|
||||
'transition-colors',
|
||||
homePanelStore.isOpen
|
||||
? 'bg-secondary-background text-base-foreground'
|
||||
: 'text-muted-foreground hover:bg-secondary-background'
|
||||
)
|
||||
"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('home.title')"
|
||||
:aria-pressed="homePanelStore.isOpen"
|
||||
@click="toggleHomePanel"
|
||||
>
|
||||
<i class="icon-[lucide--home] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="showOverflowArrows"
|
||||
variant="muted-textonly"
|
||||
@@ -132,14 +113,10 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useHomePanelStore } from '@/stores/workspace/homePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import WorkflowOverflowMenu from './WorkflowOverflowMenu.vue'
|
||||
|
||||
@@ -158,9 +135,6 @@ const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const commandStore = useCommandStore()
|
||||
const homePanelStore = useHomePanelStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
const isIntegratedTabBar = computed(
|
||||
@@ -200,20 +174,9 @@ const onWorkflowChange = async (option: WorkflowOption) => {
|
||||
return
|
||||
}
|
||||
|
||||
homePanelStore.closePanel()
|
||||
await workflowService.openWorkflow(option.workflow)
|
||||
}
|
||||
|
||||
const toggleHomePanel = () => {
|
||||
if (homePanelStore.isOpen) {
|
||||
homePanelStore.closePanel()
|
||||
return
|
||||
}
|
||||
sidebarTabStore.activeSidebarTabId = null
|
||||
canvasStore.linearMode = false
|
||||
homePanelStore.openPanel()
|
||||
}
|
||||
|
||||
const closeWorkflows = async (options: WorkflowOption[]) => {
|
||||
for (const opt of options) {
|
||||
if (
|
||||
|
||||
@@ -276,7 +276,7 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
expect(selectedNodes).toContainEqual(subNode2)
|
||||
})
|
||||
|
||||
it('toggleSelectedNodesMode should not apply state to subgraph children', () => {
|
||||
it('toggleSelectedNodesMode should apply unified state to subgraph children', () => {
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
|
||||
@@ -294,8 +294,9 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
// regularNode: BYPASS -> NEVER (since BYPASS != NEVER)
|
||||
expect(regularNode.mode).toBe(LGraphEventMode.NEVER)
|
||||
|
||||
// Subgraph children do not change state
|
||||
expect(subNode1.mode).toBe(LGraphEventMode.ALWAYS) // was ALWAYS, stays ALWAYS
|
||||
// Subgraph children get unified state (same as their parent):
|
||||
// Both children should now be NEVER, regardless of their previous states
|
||||
expect(subNode1.mode).toBe(LGraphEventMode.NEVER) // was ALWAYS, now NEVER
|
||||
expect(subNode2.mode).toBe(LGraphEventMode.NEVER) // was NEVER, stays NEVER
|
||||
})
|
||||
|
||||
@@ -316,9 +317,9 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
// Selected subgraph should toggle to ALWAYS (since it was already NEVER)
|
||||
expect(subgraphNode.mode).toBe(LGraphEventMode.ALWAYS)
|
||||
|
||||
// All children should be unchanged
|
||||
// All children should also get ALWAYS (unified with parent's new state)
|
||||
expect(subNode1.mode).toBe(LGraphEventMode.ALWAYS)
|
||||
expect(subNode2.mode).toBe(LGraphEventMode.BYPASS)
|
||||
expect(subNode2.mode).toBe(LGraphEventMode.ALWAYS)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { collectFromNodes } from '@/utils/graphTraversalUtil'
|
||||
import {
|
||||
collectFromNodes,
|
||||
traverseNodesDepthFirst
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
* Composable for handling selected LiteGraph items filtering and operations.
|
||||
@@ -94,10 +97,16 @@ export function useSelectedLiteGraphItems() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the execution mode of all selected nodes
|
||||
* Toggle the execution mode of all selected nodes with unified subgraph behavior.
|
||||
*
|
||||
* - If any nodes are not already the specified node mode → all are set to specified mode
|
||||
* - Otherwise → set all nodes to ALWAYS
|
||||
* Top-level behavior (selected nodes): Standard toggle logic
|
||||
* - If the selected node is already in the specified mode → set to ALWAYS
|
||||
* - Otherwise → set to the specified mode
|
||||
*
|
||||
* Subgraph behavior (children of selected subgraph nodes): Unified state application
|
||||
* - All children inherit the same mode that their parent subgraph node was set to
|
||||
* - This creates predictable behavior: if you toggle a subgraph to "mute",
|
||||
* ALL nodes inside become muted, regardless of their previous individual states
|
||||
*
|
||||
* @param mode - The LGraphEventMode to toggle to (e.g., NEVER for mute, BYPASS for bypass)
|
||||
*/
|
||||
@@ -115,8 +124,27 @@ export function useSelectedLiteGraphItems() {
|
||||
)
|
||||
const newModeForSelectedNode = allNodesMatch ? LGraphEventMode.ALWAYS : mode
|
||||
|
||||
for (const selectedNode of selectedNodeArray)
|
||||
// Process each selected node independently to determine its target state and apply to children
|
||||
selectedNodeArray.forEach((selectedNode) => {
|
||||
// Apply standard toggle logic to the selected node itself
|
||||
|
||||
selectedNode.mode = newModeForSelectedNode
|
||||
|
||||
// If this selected node is a subgraph, apply the same mode uniformly to all its children
|
||||
// This ensures predictable behavior: all children get the same state as their parent
|
||||
if (selectedNode.isSubgraphNode?.() && selectedNode.subgraph) {
|
||||
traverseNodesDepthFirst([selectedNode], {
|
||||
visitor: (node) => {
|
||||
// Skip the parent node since we already handled it above
|
||||
if (node === selectedNode) return undefined
|
||||
|
||||
// Apply the parent's new mode to all children uniformly
|
||||
node.mode = newModeForSelectedNode
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { SearchResponse } from 'algoliasearch/dist/lite/browser'
|
||||
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type {
|
||||
AlgoliaWorkflowTemplate,
|
||||
WorkflowTemplateSearchParams,
|
||||
WorkflowTemplateSearchResult
|
||||
} from '@/types/discoverTypes'
|
||||
|
||||
const ALGOLIA_APP_ID = 'CSTOFZ5FPH'
|
||||
const ALGOLIA_SEARCH_KEY = 'bbb304247bd251791b8653e9a816b322'
|
||||
const INDEX_NAME = 'workflow_templates'
|
||||
|
||||
const RETRIEVE_ATTRIBUTES = [
|
||||
'objectID',
|
||||
'name',
|
||||
'title',
|
||||
'description',
|
||||
'thumbnail_url',
|
||||
'thumbnail_urls',
|
||||
'thumbnail_count',
|
||||
'thumbnail_variant',
|
||||
'media_type',
|
||||
'media_subtype',
|
||||
'tags',
|
||||
'models',
|
||||
'open_source',
|
||||
'requires_custom_nodes',
|
||||
'author_name',
|
||||
'author_avatar_url',
|
||||
'run_count',
|
||||
'view_count',
|
||||
'copy_count',
|
||||
'workflow_url'
|
||||
] as const
|
||||
|
||||
export function useWorkflowTemplateSearch() {
|
||||
const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_KEY)
|
||||
|
||||
const isLoading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
const results = ref<WorkflowTemplateSearchResult | null>(null)
|
||||
|
||||
async function search(
|
||||
params: WorkflowTemplateSearchParams
|
||||
): Promise<WorkflowTemplateSearchResult> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await searchClient.search<AlgoliaWorkflowTemplate>({
|
||||
requests: [
|
||||
{
|
||||
indexName: INDEX_NAME,
|
||||
query: params.query,
|
||||
attributesToRetrieve: [...RETRIEVE_ATTRIBUTES],
|
||||
hitsPerPage: params.pageSize,
|
||||
page: params.pageNumber,
|
||||
filters: params.filters,
|
||||
facetFilters: params.facetFilters,
|
||||
facets: ['tags', 'models', 'media_type', 'open_source']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const searchResponse = response
|
||||
.results[0] as SearchResponse<AlgoliaWorkflowTemplate>
|
||||
|
||||
const result: WorkflowTemplateSearchResult = {
|
||||
templates: searchResponse.hits,
|
||||
totalHits: searchResponse.nbHits ?? 0,
|
||||
totalPages: searchResponse.nbPages ?? 0,
|
||||
page: searchResponse.page ?? 0,
|
||||
facets: searchResponse.facets
|
||||
}
|
||||
|
||||
results.value = result
|
||||
return result
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e : new Error(String(e))
|
||||
throw error.value
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
results.value = null
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
search,
|
||||
clearResults,
|
||||
isLoading,
|
||||
error,
|
||||
results
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { refDebounced, watchDebounced } from '@vueuse/core'
|
||||
import { refThrottled, watchDebounced } from '@vueuse/core'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
@@ -119,7 +119,7 @@ export function useTemplateFiltering(
|
||||
)
|
||||
})
|
||||
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 150)
|
||||
const debouncedSearchQuery = refThrottled(searchQuery, 50)
|
||||
|
||||
const filteredBySearch = computed(() => {
|
||||
if (!debouncedSearchQuery.value.trim()) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Keybinding } from './types'
|
||||
import type { Keybinding } from '@/schemas/keyBindingSchema'
|
||||
|
||||
export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
{
|
||||
@@ -76,6 +76,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
},
|
||||
commandId: 'Comfy.ShowSettingsDialog'
|
||||
},
|
||||
// For '=' both holding shift and not holding shift
|
||||
{
|
||||
combo: {
|
||||
key: '=',
|
||||
@@ -93,6 +94,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
commandId: 'Comfy.Canvas.ZoomIn',
|
||||
targetElementId: 'graph-canvas'
|
||||
},
|
||||
// For number pad '+'
|
||||
{
|
||||
combo: {
|
||||
key: '+',
|
||||
@@ -292,10 +292,10 @@ export class GroupNodeConfig {
|
||||
this.processNode(node, seenInputs, seenOutputs)
|
||||
}
|
||||
|
||||
for (const p of this._convertedToProcess) {
|
||||
for (const p of this.#convertedToProcess) {
|
||||
p()
|
||||
}
|
||||
this._convertedToProcess = []
|
||||
this.#convertedToProcess = []
|
||||
if (!this.nodeDef) return
|
||||
await app.registerNodeDef(`${PREFIX}${SEPARATOR}` + this.name, this.nodeDef)
|
||||
useNodeDefStore().addNodeDef(this.nodeDef)
|
||||
@@ -773,7 +773,7 @@ export class GroupNodeConfig {
|
||||
}
|
||||
}
|
||||
|
||||
private _convertedToProcess: (() => void)[] = []
|
||||
#convertedToProcess: (() => void)[] = []
|
||||
processNodeInputs(
|
||||
node: GroupNodeData,
|
||||
seenInputs: Record<string, number>,
|
||||
@@ -804,7 +804,7 @@ export class GroupNodeConfig {
|
||||
)
|
||||
|
||||
// Converted inputs have to be processed after all other nodes as they'll be at the end of the list
|
||||
this._convertedToProcess.push(() =>
|
||||
this.#convertedToProcess.push(() =>
|
||||
this.processConvertedWidgets(
|
||||
inputs,
|
||||
node,
|
||||
|
||||
@@ -103,7 +103,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
|
||||
override onAfterGraphConfigured() {
|
||||
if (this.outputs[0].links?.length && !this.widgets?.length) {
|
||||
this._onFirstConnection()
|
||||
this.#onFirstConnection()
|
||||
|
||||
// Populate widget values from config data
|
||||
if (this.widgets && this.widgets_values) {
|
||||
@@ -116,7 +116,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
}
|
||||
|
||||
// Merge values if required
|
||||
this._mergeWidgetConfig()
|
||||
this.#mergeWidgetConfig()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,11 +133,11 @@ export class PrimitiveNode extends LGraphNode {
|
||||
const links = this.outputs[0].links
|
||||
if (connected) {
|
||||
if (links?.length && !this.widgets?.length) {
|
||||
this._onFirstConnection()
|
||||
this.#onFirstConnection()
|
||||
}
|
||||
} else {
|
||||
// We may have removed a link that caused the constraints to change
|
||||
this._mergeWidgetConfig()
|
||||
this.#mergeWidgetConfig()
|
||||
|
||||
if (!links?.length) {
|
||||
this.onLastDisconnect()
|
||||
@@ -159,7 +159,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
}
|
||||
|
||||
if (this.outputs[slot].links?.length) {
|
||||
const valid = this._isValidConnection(input)
|
||||
const valid = this.#isValidConnection(input)
|
||||
if (valid) {
|
||||
// On connect of additional outputs, copy our value to their widget
|
||||
this.applyToGraph([{ target_id: target_node.id, target_slot } as LLink])
|
||||
@@ -170,7 +170,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
return true
|
||||
}
|
||||
|
||||
private _onFirstConnection(recreating?: boolean) {
|
||||
#onFirstConnection(recreating?: boolean) {
|
||||
// First connection can fire before the graph is ready on initial load so random things can be missing
|
||||
if (!this.outputs[0].links || !this.graph) {
|
||||
this.onLastDisconnect()
|
||||
@@ -204,7 +204,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
this.outputs[0].name = type
|
||||
this.outputs[0].widget = widget
|
||||
|
||||
this._createWidget(
|
||||
this.#createWidget(
|
||||
widget[CONFIG] ?? config,
|
||||
theirNode,
|
||||
widget.name,
|
||||
@@ -213,7 +213,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
)
|
||||
}
|
||||
|
||||
private _createWidget(
|
||||
#createWidget(
|
||||
inputData: InputSpec,
|
||||
node: LGraphNode,
|
||||
widgetName: string,
|
||||
@@ -307,8 +307,8 @@ export class PrimitiveNode extends LGraphNode {
|
||||
|
||||
recreateWidget() {
|
||||
const values = this.widgets?.map((w) => w.value)
|
||||
this._removeWidgets()
|
||||
this._onFirstConnection(true)
|
||||
this.#removeWidgets()
|
||||
this.#onFirstConnection(true)
|
||||
if (values?.length && this.widgets) {
|
||||
for (let i = 0; i < this.widgets.length; i++)
|
||||
this.widgets[i].value = values[i]
|
||||
@@ -316,7 +316,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
return this.widgets?.[0]
|
||||
}
|
||||
|
||||
private _mergeWidgetConfig() {
|
||||
#mergeWidgetConfig() {
|
||||
// Merge widget configs if the node has multiple outputs
|
||||
const output = this.outputs[0]
|
||||
const links = output.links ?? []
|
||||
@@ -348,11 +348,11 @@ export class PrimitiveNode extends LGraphNode {
|
||||
const theirInput = theirNode.inputs[link.target_slot]
|
||||
|
||||
// Call is valid connection so it can merge the configs when validating
|
||||
this._isValidConnection(theirInput, hasConfig)
|
||||
this.#isValidConnection(theirInput, hasConfig)
|
||||
}
|
||||
}
|
||||
|
||||
private _isValidConnection(input: INodeInputSlot, forceUpdate?: boolean) {
|
||||
#isValidConnection(input: INodeInputSlot, forceUpdate?: boolean) {
|
||||
// Only allow connections where the configs match
|
||||
const output = this.outputs?.[0]
|
||||
const config2 = (input.widget?.[GET_CONFIG] as () => InputSpec)?.()
|
||||
@@ -367,7 +367,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
)
|
||||
}
|
||||
|
||||
private _removeWidgets() {
|
||||
#removeWidgets() {
|
||||
if (this.widgets) {
|
||||
// Allow widgets to cleanup
|
||||
for (const w of this.widgets) {
|
||||
@@ -398,7 +398,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
this.outputs[0].name = 'connect to widget input'
|
||||
delete this.outputs[0].widget
|
||||
|
||||
this._removeWidgets()
|
||||
this.#removeWidgets()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,17 +31,17 @@ export class CanvasPointer {
|
||||
|
||||
/** Maximum offset from click location */
|
||||
static get maxClickDrift() {
|
||||
return this._maxClickDrift
|
||||
return this.#maxClickDrift
|
||||
}
|
||||
|
||||
static set maxClickDrift(value) {
|
||||
this._maxClickDrift = value
|
||||
this._maxClickDrift2 = value * value
|
||||
this.#maxClickDrift = value
|
||||
this.#maxClickDrift2 = value * value
|
||||
}
|
||||
|
||||
private static _maxClickDrift = 6
|
||||
static #maxClickDrift = 6
|
||||
/** {@link maxClickDrift} squared. Used to calculate click drift without `sqrt`. */
|
||||
private static _maxClickDrift2 = this._maxClickDrift ** 2
|
||||
static #maxClickDrift2 = this.#maxClickDrift ** 2
|
||||
|
||||
/** Assume that "wheel" events with both deltaX and deltaY less than this value are trackpad gestures. */
|
||||
static trackpadThreshold = 60
|
||||
@@ -153,18 +153,18 @@ export class CanvasPointer {
|
||||
* Therefore, simply setting this value twice will execute the first callback.
|
||||
*/
|
||||
get finally() {
|
||||
return this._finally
|
||||
return this.#finally
|
||||
}
|
||||
|
||||
set finally(value) {
|
||||
try {
|
||||
this._finally?.()
|
||||
this.#finally?.()
|
||||
} finally {
|
||||
this._finally = value
|
||||
this.#finally = value
|
||||
}
|
||||
}
|
||||
|
||||
private _finally?: () => unknown
|
||||
#finally?: () => unknown
|
||||
|
||||
constructor(element: Element) {
|
||||
this.element = element
|
||||
@@ -197,7 +197,7 @@ export class CanvasPointer {
|
||||
|
||||
// Primary button released - treat as pointerup.
|
||||
if (!(e.buttons & eDown.buttons)) {
|
||||
this._completeClick(e)
|
||||
this.#completeClick(e)
|
||||
this.reset()
|
||||
return
|
||||
}
|
||||
@@ -209,8 +209,8 @@ export class CanvasPointer {
|
||||
|
||||
const longerThanBufferTime =
|
||||
e.timeStamp - eDown.timeStamp > CanvasPointer.bufferTime
|
||||
if (longerThanBufferTime || !this._hasSamePosition(e, eDown)) {
|
||||
this._setDragStarted(e)
|
||||
if (longerThanBufferTime || !this.#hasSamePosition(e, eDown)) {
|
||||
this.#setDragStarted(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,13 +221,13 @@ export class CanvasPointer {
|
||||
up(e: CanvasPointerEvent): boolean {
|
||||
if (e.button !== this.eDown?.button) return false
|
||||
|
||||
this._completeClick(e)
|
||||
this.#completeClick(e)
|
||||
const { dragStarted } = this
|
||||
this.reset()
|
||||
return !dragStarted
|
||||
}
|
||||
|
||||
private _completeClick(e: CanvasPointerEvent): void {
|
||||
#completeClick(e: CanvasPointerEvent): void {
|
||||
const { eDown } = this
|
||||
if (!eDown) return
|
||||
|
||||
@@ -236,11 +236,11 @@ export class CanvasPointer {
|
||||
if (this.dragStarted) {
|
||||
// A move event already started drag
|
||||
this.onDragEnd?.(e)
|
||||
} else if (!this._hasSamePosition(e, eDown)) {
|
||||
} else if (!this.#hasSamePosition(e, eDown)) {
|
||||
// Teleport without a move event (e.g. tab out, move, tab back)
|
||||
this._setDragStarted()
|
||||
this.#setDragStarted()
|
||||
this.onDragEnd?.(e)
|
||||
} else if (this.onDoubleClick && this._isDoubleClick()) {
|
||||
} else if (this.onDoubleClick && this.#isDoubleClick()) {
|
||||
// Double-click event
|
||||
this.onDoubleClick(e)
|
||||
this.eLastDown = undefined
|
||||
@@ -258,10 +258,10 @@ export class CanvasPointer {
|
||||
* @param tolerance2 The maximum distance (squared) before the positions are considered different
|
||||
* @returns `true` if the two events were no more than {@link maxClickDrift} apart, otherwise `false`
|
||||
*/
|
||||
private _hasSamePosition(
|
||||
#hasSamePosition(
|
||||
a: PointerEvent,
|
||||
b: PointerEvent,
|
||||
tolerance2 = CanvasPointer._maxClickDrift2
|
||||
tolerance2 = CanvasPointer.#maxClickDrift2
|
||||
): boolean {
|
||||
const drift = dist2(a.clientX, a.clientY, b.clientX, b.clientY)
|
||||
return drift <= tolerance2
|
||||
@@ -271,21 +271,21 @@ export class CanvasPointer {
|
||||
* Checks whether the pointer is currently past the max click drift threshold.
|
||||
* @returns `true` if the latest pointer event is past the the click drift threshold
|
||||
*/
|
||||
private _isDoubleClick(): boolean {
|
||||
#isDoubleClick(): boolean {
|
||||
const { eDown, eLastDown } = this
|
||||
if (!eDown || !eLastDown) return false
|
||||
|
||||
// Use thrice the drift distance for double-click gap
|
||||
const tolerance2 = (3 * CanvasPointer._maxClickDrift) ** 2
|
||||
const tolerance2 = (3 * CanvasPointer.#maxClickDrift) ** 2
|
||||
const diff = eDown.timeStamp - eLastDown.timeStamp
|
||||
return (
|
||||
diff > 0 &&
|
||||
diff < CanvasPointer.doubleClickTime &&
|
||||
this._hasSamePosition(eDown, eLastDown, tolerance2)
|
||||
this.#hasSamePosition(eDown, eLastDown, tolerance2)
|
||||
)
|
||||
}
|
||||
|
||||
private _setDragStarted(eMove?: CanvasPointerEvent): void {
|
||||
#setDragStarted(eMove?: CanvasPointerEvent): void {
|
||||
this.dragStarted = true
|
||||
this.onDragStart?.(this, eMove)
|
||||
delete this.onDragStart
|
||||
@@ -303,14 +303,14 @@ export class CanvasPointer {
|
||||
const timeSinceLastEvent = Math.max(0, now - this.lastWheelEventTime)
|
||||
this.lastWheelEventTime = now
|
||||
|
||||
if (this._isHighResWheelEvent(e, now)) {
|
||||
if (this.#isHighResWheelEvent(e, now)) {
|
||||
this.detectedDevice = 'mouse'
|
||||
} else if (this._isWithinCooldown(timeSinceLastEvent)) {
|
||||
if (this._shouldBufferLinuxEvent(e)) {
|
||||
this._bufferLinuxEvent(e, now)
|
||||
} else if (this.#isWithinCooldown(timeSinceLastEvent)) {
|
||||
if (this.#shouldBufferLinuxEvent(e)) {
|
||||
this.#bufferLinuxEvent(e, now)
|
||||
}
|
||||
} else {
|
||||
this._updateDeviceMode(e, now)
|
||||
this.#updateDeviceMode(e, now)
|
||||
this.hasReceivedWheelEvent = true
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ export class CanvasPointer {
|
||||
* Validates buffered high res wheel events and switches to mouse mode if pattern matches.
|
||||
* @returns `true` if switched to mouse mode
|
||||
*/
|
||||
private _isHighResWheelEvent(event: WheelEvent, now: number): boolean {
|
||||
#isHighResWheelEvent(event: WheelEvent, now: number): boolean {
|
||||
if (!this.bufferedLinuxEvent || this.bufferedLinuxEventTime <= 0) {
|
||||
return false
|
||||
}
|
||||
@@ -329,15 +329,15 @@ export class CanvasPointer {
|
||||
const timeSinceBuffer = now - this.bufferedLinuxEventTime
|
||||
|
||||
if (timeSinceBuffer > CanvasPointer.maxHighResBufferTime) {
|
||||
this._clearLinuxBuffer()
|
||||
this.#clearLinuxBuffer()
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
event.deltaX === 0 &&
|
||||
this._isLinuxWheelPattern(this.bufferedLinuxEvent.deltaY, event.deltaY)
|
||||
this.#isLinuxWheelPattern(this.bufferedLinuxEvent.deltaY, event.deltaY)
|
||||
) {
|
||||
this._clearLinuxBuffer()
|
||||
this.#clearLinuxBuffer()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@ export class CanvasPointer {
|
||||
/**
|
||||
* Checks if we're within the cooldown period where mode switching is disabled.
|
||||
*/
|
||||
private _isWithinCooldown(timeSinceLastEvent: number): boolean {
|
||||
#isWithinCooldown(timeSinceLastEvent: number): boolean {
|
||||
const isFirstEvent = !this.hasReceivedWheelEvent
|
||||
const cooldownExpired = timeSinceLastEvent >= CanvasPointer.trackpadMaxGap
|
||||
return !isFirstEvent && !cooldownExpired
|
||||
@@ -356,23 +356,23 @@ export class CanvasPointer {
|
||||
/**
|
||||
* Updates the device mode based on event patterns.
|
||||
*/
|
||||
private _updateDeviceMode(event: WheelEvent, now: number): void {
|
||||
if (this._isTrackpadPattern(event)) {
|
||||
#updateDeviceMode(event: WheelEvent, now: number): void {
|
||||
if (this.#isTrackpadPattern(event)) {
|
||||
this.detectedDevice = 'trackpad'
|
||||
} else if (this._isMousePattern(event)) {
|
||||
} else if (this.#isMousePattern(event)) {
|
||||
this.detectedDevice = 'mouse'
|
||||
} else if (
|
||||
this.detectedDevice === 'trackpad' &&
|
||||
this._shouldBufferLinuxEvent(event)
|
||||
this.#shouldBufferLinuxEvent(event)
|
||||
) {
|
||||
this._bufferLinuxEvent(event, now)
|
||||
this.#bufferLinuxEvent(event, now)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the buffered Linux wheel event and associated timer.
|
||||
*/
|
||||
private _clearLinuxBuffer(): void {
|
||||
#clearLinuxBuffer(): void {
|
||||
this.bufferedLinuxEvent = undefined
|
||||
this.bufferedLinuxEventTime = 0
|
||||
if (this.linuxBufferTimeoutId !== undefined) {
|
||||
@@ -385,7 +385,7 @@ export class CanvasPointer {
|
||||
* Checks if the event matches trackpad input patterns.
|
||||
* @param event The wheel event to check
|
||||
*/
|
||||
private _isTrackpadPattern(event: WheelEvent): boolean {
|
||||
#isTrackpadPattern(event: WheelEvent): boolean {
|
||||
// Two-finger panning: non-zero deltaX AND deltaY
|
||||
if (event.deltaX !== 0 && event.deltaY !== 0) return true
|
||||
|
||||
@@ -399,7 +399,7 @@ export class CanvasPointer {
|
||||
* Checks if the event matches mouse wheel input patterns.
|
||||
* @param event The wheel event to check
|
||||
*/
|
||||
private _isMousePattern(event: WheelEvent): boolean {
|
||||
#isMousePattern(event: WheelEvent): boolean {
|
||||
const absoluteDeltaY = Math.abs(event.deltaY)
|
||||
|
||||
// Primary threshold for switching from trackpad to mouse
|
||||
@@ -417,7 +417,7 @@ export class CanvasPointer {
|
||||
* Checks if the event should be buffered as a potential Linux wheel event.
|
||||
* @param event The wheel event to check
|
||||
*/
|
||||
private _shouldBufferLinuxEvent(event: WheelEvent): boolean {
|
||||
#shouldBufferLinuxEvent(event: WheelEvent): boolean {
|
||||
const absoluteDeltaY = Math.abs(event.deltaY)
|
||||
const isInLinuxRange = absoluteDeltaY >= 10 && absoluteDeltaY < 60
|
||||
const isVerticalOnly = event.deltaX === 0
|
||||
@@ -436,7 +436,7 @@ export class CanvasPointer {
|
||||
* @param event The event to buffer
|
||||
* @param now The current timestamp
|
||||
*/
|
||||
private _bufferLinuxEvent(event: WheelEvent, now: number): void {
|
||||
#bufferLinuxEvent(event: WheelEvent, now: number): void {
|
||||
if (this.linuxBufferTimeoutId !== undefined) {
|
||||
clearTimeout(this.linuxBufferTimeoutId)
|
||||
}
|
||||
@@ -446,7 +446,7 @@ export class CanvasPointer {
|
||||
|
||||
// Set timeout to clear buffer after 10ms
|
||||
this.linuxBufferTimeoutId = setTimeout(() => {
|
||||
this._clearLinuxBuffer()
|
||||
this.#clearLinuxBuffer()
|
||||
}, CanvasPointer.maxHighResBufferTime)
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ export class CanvasPointer {
|
||||
* @param deltaY1 The first deltaY value
|
||||
* @param deltaY2 The second deltaY value
|
||||
*/
|
||||
private _isLinuxWheelPattern(deltaY1: number, deltaY2: number): boolean {
|
||||
#isLinuxWheelPattern(deltaY1: number, deltaY2: number): boolean {
|
||||
const absolute1 = Math.abs(deltaY1)
|
||||
const absolute2 = Math.abs(deltaY2)
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ export class DragAndScale {
|
||||
* Returns `true` if the current state has changed from the previous state.
|
||||
* @returns `true` if the current state has changed from the previous state, otherwise `false`.
|
||||
*/
|
||||
private _stateHasChanged(): boolean {
|
||||
#stateHasChanged(): boolean {
|
||||
const current = this.state
|
||||
const previous = this.lastState
|
||||
|
||||
@@ -95,7 +95,7 @@ export class DragAndScale {
|
||||
computeVisibleArea(viewport: Rect | undefined): void {
|
||||
const { scale, offset, visible_area } = this
|
||||
|
||||
if (this._stateHasChanged()) {
|
||||
if (this.#stateHasChanged()) {
|
||||
this.onChanged?.(scale, offset)
|
||||
copyState(this.state, this.lastState)
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ export class LGraph
|
||||
}
|
||||
|
||||
/** Internal only. Not required for serialisation; calculated on deserialise. */
|
||||
private _lastFloatingLinkId: number = 0
|
||||
#lastFloatingLinkId: number = 0
|
||||
|
||||
private readonly floatingLinksInternal: Map<LinkId, LLink> = new Map()
|
||||
get floatingLinks(): ReadonlyMap<LinkId, LLink> {
|
||||
@@ -365,7 +365,7 @@ export class LGraph
|
||||
this.reroutes.clear()
|
||||
this.floatingLinksInternal.clear()
|
||||
|
||||
this._lastFloatingLinkId = 0
|
||||
this.#lastFloatingLinkId = 0
|
||||
|
||||
// other scene stuff
|
||||
this._groups = []
|
||||
@@ -1304,7 +1304,7 @@ export class LGraph
|
||||
|
||||
addFloatingLink(link: LLink): LLink {
|
||||
if (link.id === -1) {
|
||||
link.id = ++this._lastFloatingLinkId
|
||||
link.id = ++this.#lastFloatingLinkId
|
||||
}
|
||||
this.floatingLinksInternal.set(link.id, link)
|
||||
|
||||
@@ -2175,16 +2175,8 @@ export class LGraph
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom JSON serialization to prevent circular reference errors.
|
||||
* Called automatically by JSON.stringify().
|
||||
*/
|
||||
toJSON(): ISerialisedGraph {
|
||||
return this.serialize()
|
||||
}
|
||||
|
||||
/** @returns The drag and scale state of the first attached canvas, otherwise `undefined`. */
|
||||
private _getDragAndScale(): DragAndScaleState | undefined {
|
||||
#getDragAndScale(): DragAndScaleState | undefined {
|
||||
const ds = this.list_of_graphcanvas?.at(0)?.ds
|
||||
if (ds) return { scale: ds.scale, offset: ds.offset }
|
||||
}
|
||||
@@ -2224,7 +2216,7 @@ export class LGraph
|
||||
|
||||
// Save scale and offset
|
||||
const extra = { ...this.extra }
|
||||
if (LiteGraph.saveViewportWithGraph) extra.ds = this._getDragAndScale()
|
||||
if (LiteGraph.saveViewportWithGraph) extra.ds = this.#getDragAndScale()
|
||||
if (!extra.ds) delete extra.ds
|
||||
|
||||
const data: ReturnType<typeof this.asSerialisable> = {
|
||||
@@ -2414,8 +2406,8 @@ export class LGraph
|
||||
const floatingLink = LLink.create(linkData)
|
||||
this.addFloatingLink(floatingLink)
|
||||
|
||||
if (floatingLink.id > this._lastFloatingLinkId)
|
||||
this._lastFloatingLinkId = floatingLink.id
|
||||
if (floatingLink.id > this.#lastFloatingLinkId)
|
||||
this.#lastFloatingLinkId = floatingLink.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2466,13 +2458,13 @@ export class LGraph
|
||||
}
|
||||
}
|
||||
|
||||
private _canvas?: LGraphCanvas
|
||||
#canvas?: LGraphCanvas
|
||||
get primaryCanvas(): LGraphCanvas | undefined {
|
||||
return this.rootGraph._canvas
|
||||
return this.rootGraph.#canvas
|
||||
}
|
||||
|
||||
set primaryCanvas(canvas: LGraphCanvas) {
|
||||
this.rootGraph._canvas = canvas
|
||||
this.rootGraph.#canvas = canvas
|
||||
}
|
||||
|
||||
load(url: string | Blob | URL | File, callback: () => void) {
|
||||
@@ -2541,9 +2533,9 @@ export class Subgraph
|
||||
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
|
||||
readonly widgets: ExposedWidget[] = []
|
||||
|
||||
private _rootGraph: LGraph
|
||||
#rootGraph: LGraph
|
||||
override get rootGraph(): LGraph {
|
||||
return this._rootGraph
|
||||
return this.#rootGraph
|
||||
}
|
||||
|
||||
constructor(rootGraph: LGraph, data: ExportedSubgraph) {
|
||||
@@ -2551,11 +2543,11 @@ export class Subgraph
|
||||
|
||||
super()
|
||||
|
||||
this._rootGraph = rootGraph
|
||||
this.#rootGraph = rootGraph
|
||||
|
||||
const cloned = structuredClone(data)
|
||||
this._configureBase(cloned)
|
||||
this._configureSubgraph(cloned)
|
||||
this.#configureSubgraph(cloned)
|
||||
}
|
||||
|
||||
getIoNodeOnPos(
|
||||
@@ -2567,7 +2559,7 @@ export class Subgraph
|
||||
if (outputNode.containsPoint([x, y])) return outputNode
|
||||
}
|
||||
|
||||
private _configureSubgraph(
|
||||
#configureSubgraph(
|
||||
data:
|
||||
| (ISerialisedGraph & ExportedSubgraph)
|
||||
| (SerialisableGraph & ExportedSubgraph)
|
||||
@@ -2611,7 +2603,7 @@ export class Subgraph
|
||||
): boolean | undefined {
|
||||
const r = super.configure(data, keep_old)
|
||||
|
||||
this._configureSubgraph(data)
|
||||
this.#configureSubgraph(data)
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
@@ -316,17 +316,17 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
selectionChanged: false
|
||||
}
|
||||
|
||||
private _subgraph?: Subgraph
|
||||
#subgraph?: Subgraph
|
||||
get subgraph(): Subgraph | undefined {
|
||||
return this._subgraph
|
||||
return this.#subgraph
|
||||
}
|
||||
|
||||
set subgraph(value: Subgraph | undefined) {
|
||||
if (value !== this._subgraph) {
|
||||
this._subgraph = value
|
||||
if (value !== this.#subgraph) {
|
||||
this.#subgraph = value
|
||||
if (value)
|
||||
this.dispatch('litegraph:set-graph', {
|
||||
oldGraph: this._subgraph,
|
||||
oldGraph: this.#subgraph,
|
||||
newGraph: value
|
||||
})
|
||||
}
|
||||
@@ -361,7 +361,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.canvas.dispatchEvent(new CustomEvent(type, { detail }))
|
||||
}
|
||||
|
||||
private _updateCursorStyle() {
|
||||
#updateCursorStyle() {
|
||||
if (!this.state.shouldSetCursor) return
|
||||
|
||||
const crosshairItems =
|
||||
@@ -398,7 +398,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
set read_only(value: boolean) {
|
||||
this.state.readOnly = value
|
||||
this._updateCursorStyle()
|
||||
this.#updateCursorStyle()
|
||||
}
|
||||
|
||||
get isDragging(): boolean {
|
||||
@@ -415,7 +415,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
set hoveringOver(value: CanvasItem) {
|
||||
this.state.hoveringOver = value
|
||||
this._updateCursorStyle()
|
||||
this.#updateCursorStyle()
|
||||
}
|
||||
|
||||
/** @deprecated Replace all references with {@link pointer}.{@link CanvasPointer.isDown isDown}. */
|
||||
@@ -435,7 +435,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
set dragging_canvas(value: boolean) {
|
||||
this.state.draggingCanvas = value
|
||||
this._updateCursorStyle()
|
||||
this.#updateCursorStyle()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -450,16 +450,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
return `normal ${LiteGraph.NODE_SUBTEXT_SIZE}px ${LiteGraph.NODE_FONT}`
|
||||
}
|
||||
|
||||
private _maximumFrameGap = 0
|
||||
#maximumFrameGap = 0
|
||||
/** Maximum frames per second to render. 0: unlimited. Default: 0 */
|
||||
public get maximumFps() {
|
||||
return this._maximumFrameGap > Number.EPSILON
|
||||
? this._maximumFrameGap / 1000
|
||||
return this.#maximumFrameGap > Number.EPSILON
|
||||
? this.#maximumFrameGap / 1000
|
||||
: 0
|
||||
}
|
||||
|
||||
public set maximumFps(value) {
|
||||
this._maximumFrameGap = value > Number.EPSILON ? 1000 / value : 0
|
||||
this.#maximumFrameGap = value > Number.EPSILON ? 1000 / value : 0
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -660,12 +660,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* The IDs of the nodes that are currently visible on the canvas. More
|
||||
* performant than {@link visible_nodes} for visibility checks.
|
||||
*/
|
||||
private _visible_node_ids: Set<NodeId> = new Set()
|
||||
#visible_node_ids: Set<NodeId> = new Set()
|
||||
node_over?: LGraphNode
|
||||
node_capturing_input?: LGraphNode | null
|
||||
highlighted_links: Dictionary<boolean> = {}
|
||||
|
||||
private _visibleReroutes: Set<Reroute> = new Set()
|
||||
#visibleReroutes: Set<Reroute> = new Set()
|
||||
|
||||
dirty_canvas: boolean = true
|
||||
dirty_bgcanvas: boolean = true
|
||||
@@ -725,9 +725,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
NODEPANEL_IS_OPEN?: boolean
|
||||
|
||||
/** Once per frame check of snap to grid value. @todo Update on change. */
|
||||
private _snapToGrid?: number
|
||||
#snapToGrid?: number
|
||||
/** Set on keydown, keyup. @todo */
|
||||
private _shiftDown: boolean = false
|
||||
#shiftDown: boolean = false
|
||||
|
||||
/** Link rendering adapter for litegraph-to-canvas integration */
|
||||
linkRenderer: LitegraphLinkAdapter | null = null
|
||||
@@ -735,11 +735,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
/** If true, enable drag zoom. Ctrl+Shift+Drag Up/Down: zoom canvas. */
|
||||
dragZoomEnabled: boolean = false
|
||||
/** The start position of the drag zoom and original read-only state. */
|
||||
private _dragZoomStart: {
|
||||
pos: Point
|
||||
scale: number
|
||||
readOnly: boolean
|
||||
} | null = null
|
||||
#dragZoomStart: { pos: Point; scale: number; readOnly: boolean } | null = null
|
||||
|
||||
/** If true, enable live selection during drag. Nodes are selected/deselected in real-time. */
|
||||
liveSelection: boolean = false
|
||||
@@ -814,7 +810,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
this.linkConnector.events.addEventListener('link-created', () =>
|
||||
this._dirty()
|
||||
this.#dirty()
|
||||
)
|
||||
|
||||
// @deprecated Workaround: Keep until connecting_links is removed.
|
||||
@@ -1812,7 +1808,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
this.dragging_canvas = false
|
||||
|
||||
this._dirty()
|
||||
this.#dirty()
|
||||
this.dirty_area = null
|
||||
|
||||
this.node_in_panel = null
|
||||
@@ -1840,7 +1836,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.linkRenderer = new LitegraphLinkAdapter(false)
|
||||
|
||||
this.dispatch('litegraph:set-graph', { newGraph, oldGraph: graph })
|
||||
this._dirty()
|
||||
this.#dirty()
|
||||
}
|
||||
|
||||
openSubgraph(subgraph: Subgraph, fromNode: SubgraphNode): void {
|
||||
@@ -1877,7 +1873,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* @returns The canvas element
|
||||
* @throws If {@link canvas} is an element ID that does not belong to a valid HTML canvas element
|
||||
*/
|
||||
private _validateCanvas(
|
||||
#validateCanvas(
|
||||
canvas: string | HTMLCanvasElement
|
||||
): HTMLCanvasElement & { data?: LGraphCanvas } {
|
||||
if (typeof canvas === 'string') {
|
||||
@@ -1896,7 +1892,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* @param skip_events If true, events on the previous canvas will not be removed. Has no effect on the first invocation.
|
||||
*/
|
||||
setCanvas(canvas: string | HTMLCanvasElement, skip_events?: boolean) {
|
||||
const element = this._validateCanvas(canvas)
|
||||
const element = this.#validateCanvas(canvas)
|
||||
if (element === this.canvas) return
|
||||
// maybe detach events from old_canvas
|
||||
if (!element && this.canvas && !skip_events) this.unbindEvents()
|
||||
@@ -1909,12 +1905,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
// TODO: classList.add
|
||||
element.className += ' lgraphcanvas'
|
||||
Object.defineProperty(element, 'data', {
|
||||
value: this,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
enumerable: false
|
||||
})
|
||||
element.data = this
|
||||
|
||||
// Background canvas: To render objects behind nodes (background, links, groups)
|
||||
this.bgcanvas = document.createElement('canvas')
|
||||
@@ -2035,12 +2026,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
/** Marks the entire canvas as dirty. */
|
||||
private _dirty(): void {
|
||||
#dirty(): void {
|
||||
this.dirty_canvas = true
|
||||
this.dirty_bgcanvas = true
|
||||
}
|
||||
|
||||
private _linkConnectorDrop(): void {
|
||||
#linkConnectorDrop(): void {
|
||||
const { graph, linkConnector, pointer } = this
|
||||
if (!graph) throw new NullGraphError()
|
||||
|
||||
@@ -2079,10 +2070,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
const window = this.getCanvasWindow()
|
||||
if (this.is_rendering) {
|
||||
if (this._maximumFrameGap > 0) {
|
||||
if (this.#maximumFrameGap > 0) {
|
||||
// Manual FPS limit
|
||||
const gap =
|
||||
this._maximumFrameGap - (LiteGraph.getTime() - this.last_draw_time)
|
||||
this.#maximumFrameGap - (LiteGraph.getTime() - this.last_draw_time)
|
||||
setTimeout(renderFrame.bind(this), Math.max(1, gap))
|
||||
} else {
|
||||
// FPS limited by refresh rate
|
||||
@@ -2170,7 +2161,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
!e.altKey &&
|
||||
e.buttons
|
||||
) {
|
||||
this._dragZoomStart = {
|
||||
this.#dragZoomStart = {
|
||||
pos: [e.x, e.y],
|
||||
scale: this.ds.scale,
|
||||
readOnly: this.read_only
|
||||
@@ -2217,9 +2208,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
// left button mouse / single finger
|
||||
if (e.button === 0 && !pointer.isDouble) {
|
||||
this._processPrimaryButton(e, node)
|
||||
this.#processPrimaryButton(e, node)
|
||||
} else if (e.button === 1) {
|
||||
this._processMiddleButton(e, node)
|
||||
this.#processMiddleButton(e, node)
|
||||
} else if (
|
||||
(e.button === 2 || pointer.isDouble) &&
|
||||
this.allow_interaction &&
|
||||
@@ -2255,7 +2246,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
reroute = graph.getRerouteOnPos(
|
||||
e.canvasX,
|
||||
e.canvasY,
|
||||
this._visibleReroutes
|
||||
this.#visibleReroutes
|
||||
)
|
||||
}
|
||||
if (reroute) {
|
||||
@@ -2311,24 +2302,18 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* @param y The y coordinate in canvas space
|
||||
* @returns The positionable item or undefined
|
||||
*/
|
||||
private _getPositionableOnPos(
|
||||
x: number,
|
||||
y: number
|
||||
): Positionable | undefined {
|
||||
#getPositionableOnPos(x: number, y: number): Positionable | undefined {
|
||||
const ioNode = this.subgraph?.getIoNodeOnPos(x, y)
|
||||
if (ioNode) return ioNode
|
||||
|
||||
for (const reroute of this._visibleReroutes) {
|
||||
for (const reroute of this.#visibleReroutes) {
|
||||
if (reroute.containsPoint([x, y])) return reroute
|
||||
}
|
||||
|
||||
return this.graph?.getGroupTitlebarOnPos(x, y)
|
||||
}
|
||||
|
||||
private _processPrimaryButton(
|
||||
e: CanvasPointerEvent,
|
||||
node: LGraphNode | undefined
|
||||
) {
|
||||
#processPrimaryButton(e: CanvasPointerEvent, node: LGraphNode | undefined) {
|
||||
const { pointer, graph, linkConnector, subgraph } = this
|
||||
if (!graph) throw new NullGraphError()
|
||||
|
||||
@@ -2344,7 +2329,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
!e.altKey &&
|
||||
LiteGraph.leftMouseClickBehavior === 'panning'
|
||||
) {
|
||||
this._setupNodeSelectionDrag(e, pointer, node)
|
||||
this.#setupNodeSelectionDrag(e, pointer, node)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -2375,16 +2360,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
if (this.allow_dragnodes) {
|
||||
pointer.onDragStart = (pointer) => {
|
||||
this._startDraggingItems(cloned, pointer)
|
||||
this.#startDraggingItems(cloned, pointer)
|
||||
}
|
||||
pointer.onDragEnd = (e) => this._processDraggedItems(e)
|
||||
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Node clicked
|
||||
if (node && (this.allow_interaction || node.flags.allow_interaction)) {
|
||||
this._processNodeClick(e, ctrlOrMeta, node)
|
||||
this.#processNodeClick(e, ctrlOrMeta, node)
|
||||
} else {
|
||||
// Subgraph IO nodes
|
||||
if (subgraph) {
|
||||
@@ -2402,8 +2387,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
ioNode.onPointerDown(e, pointer, linkConnector)
|
||||
pointer.onClick ??= () => canvas.processSelect(ioNode, e)
|
||||
pointer.onDragStart ??= () =>
|
||||
canvas._startDraggingItems(ioNode, pointer, true)
|
||||
pointer.onDragEnd ??= (eUp) => canvas._processDraggedItems(eUp)
|
||||
canvas.#startDraggingItems(ioNode, pointer, true)
|
||||
pointer.onDragEnd ??= (eUp) => canvas.#processDraggedItems(eUp)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -2419,7 +2404,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
// Fallback to checking visible reroutes directly
|
||||
for (const reroute of this._visibleReroutes) {
|
||||
for (const reroute of this.#visibleReroutes) {
|
||||
const overReroute =
|
||||
foundReroute === reroute || reroute.containsPoint([x, y])
|
||||
if (!reroute.isSlotHovered && !overReroute) continue
|
||||
@@ -2428,19 +2413,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
pointer.onClick = () => this.processSelect(reroute, e)
|
||||
if (!e.shiftKey) {
|
||||
pointer.onDragStart = (pointer) =>
|
||||
this._startDraggingItems(reroute, pointer, true)
|
||||
pointer.onDragEnd = (e) => this._processDraggedItems(e)
|
||||
this.#startDraggingItems(reroute, pointer, true)
|
||||
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
|
||||
}
|
||||
}
|
||||
|
||||
if (reroute.isOutputHovered || (overReroute && e.shiftKey)) {
|
||||
linkConnector.dragFromReroute(graph, reroute)
|
||||
this._linkConnectorDrop()
|
||||
this.#linkConnectorDrop()
|
||||
}
|
||||
|
||||
if (reroute.isInputHovered) {
|
||||
linkConnector.dragFromRerouteToOutput(graph, reroute)
|
||||
this._linkConnectorDrop()
|
||||
this.#linkConnectorDrop()
|
||||
}
|
||||
|
||||
reroute.hideSlots()
|
||||
@@ -2485,14 +2470,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
if (e.shiftKey && !e.altKey) {
|
||||
linkConnector.dragFromLinkSegment(graph, linkSegment)
|
||||
this._linkConnectorDrop()
|
||||
this.#linkConnectorDrop()
|
||||
|
||||
return
|
||||
} else if (e.altKey && !e.shiftKey) {
|
||||
const newReroute = graph.createReroute([x, y], linkSegment)
|
||||
pointer.onDragStart = (pointer) =>
|
||||
this._startDraggingItems(newReroute, pointer)
|
||||
pointer.onDragEnd = (e) => this._processDraggedItems(e)
|
||||
this.#startDraggingItems(newReroute, pointer)
|
||||
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
|
||||
return
|
||||
}
|
||||
} else if (
|
||||
@@ -2534,7 +2519,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
eMove.canvasY - group.pos[1] - offsetY
|
||||
]
|
||||
// Unless snapping.
|
||||
if (this._snapToGrid) snapPoint(pos, this._snapToGrid)
|
||||
if (this.#snapToGrid) snapPoint(pos, this.#snapToGrid)
|
||||
|
||||
const resized = group.resize(pos[0], pos[1])
|
||||
if (resized) this.dirty_bgcanvas = true
|
||||
@@ -2557,9 +2542,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
pointer.onClick = () => this.processSelect(group, e)
|
||||
pointer.onDragStart = (pointer) => {
|
||||
group.recomputeInsideNodes()
|
||||
this._startDraggingItems(group, pointer, true)
|
||||
this.#startDraggingItems(group, pointer, true)
|
||||
}
|
||||
pointer.onDragEnd = (e) => this._processDraggedItems(e)
|
||||
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2597,12 +2582,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
pointer.finally = () => (this.dragging_canvas = false)
|
||||
this.dragging_canvas = true
|
||||
} else {
|
||||
this._setupNodeSelectionDrag(e, pointer)
|
||||
this.#setupNodeSelectionDrag(e, pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _setupNodeSelectionDrag(
|
||||
#setupNodeSelectionDrag(
|
||||
e: CanvasPointerEvent,
|
||||
pointer: CanvasPointer,
|
||||
node?: LGraphNode | undefined
|
||||
@@ -2617,7 +2602,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
pointer.onClick = (eUp) => {
|
||||
// Click, not drag
|
||||
const clickedItem =
|
||||
node ?? this._getPositionableOnPos(eUp.canvasX, eUp.canvasY)
|
||||
node ?? this.#getPositionableOnPos(eUp.canvasX, eUp.canvasY)
|
||||
this.processSelect(clickedItem, eUp)
|
||||
}
|
||||
pointer.onDragStart = () => (this.dragging_rectangle = dragRect)
|
||||
@@ -2632,7 +2617,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
} else {
|
||||
// Classic mode: select only when drag ends
|
||||
pointer.onDragEnd = (upEvent) =>
|
||||
this._handleMultiSelect(upEvent, dragRect)
|
||||
this.#handleMultiSelect(upEvent, dragRect)
|
||||
}
|
||||
|
||||
pointer.finally = () => (this.dragging_rectangle = null)
|
||||
@@ -2644,7 +2629,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* @param ctrlOrMeta Ctrl or meta key is pressed
|
||||
* @param node The node to process a click event for
|
||||
*/
|
||||
private _processNodeClick(
|
||||
#processNodeClick(
|
||||
e: CanvasPointerEvent,
|
||||
ctrlOrMeta: boolean,
|
||||
node: LGraphNode
|
||||
@@ -2700,13 +2685,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// Drag multiple output links
|
||||
if (e.shiftKey && hasRelevantOutputLinks(output, graph)) {
|
||||
linkConnector.moveOutputLink(graph, output)
|
||||
this._linkConnectorDrop()
|
||||
this.#linkConnectorDrop()
|
||||
return
|
||||
}
|
||||
|
||||
// New output link
|
||||
linkConnector.dragNewFromOutput(graph, node, output)
|
||||
this._linkConnectorDrop()
|
||||
this.#linkConnectorDrop()
|
||||
|
||||
if (LiteGraph.shift_click_do_break_link_from) {
|
||||
if (e.shiftKey) {
|
||||
@@ -2759,7 +2744,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
linkConnector.dragNewFromInput(graph, node, input)
|
||||
}
|
||||
|
||||
this._linkConnectorDrop()
|
||||
this.#linkConnectorDrop()
|
||||
this.dirty_bgcanvas = true
|
||||
|
||||
return
|
||||
@@ -2889,7 +2874,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
// Apply snapping to position changes
|
||||
if (this._snapToGrid) {
|
||||
if (this.#snapToGrid) {
|
||||
if (
|
||||
resizeDirection.includes('N') ||
|
||||
resizeDirection.includes('W')
|
||||
@@ -2897,7 +2882,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const originalX = newBounds.x
|
||||
const originalY = newBounds.y
|
||||
|
||||
snapPoint(newBounds.pos, this._snapToGrid)
|
||||
snapPoint(newBounds.pos, this.#snapToGrid)
|
||||
|
||||
// Adjust size to compensate for snapped position
|
||||
if (resizeDirection.includes('N')) {
|
||||
@@ -2908,7 +2893,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
snapPoint(newBounds.size, this._snapToGrid)
|
||||
snapPoint(newBounds.size, this.#snapToGrid)
|
||||
}
|
||||
|
||||
// Apply snapping to size changes
|
||||
@@ -2933,11 +2918,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
node.pos = newBounds.pos
|
||||
node.setSize(newBounds.size)
|
||||
|
||||
this._dirty()
|
||||
this.#dirty()
|
||||
}
|
||||
|
||||
pointer.onDragEnd = () => {
|
||||
this._dirty()
|
||||
this.#dirty()
|
||||
graph.afterChange(node)
|
||||
}
|
||||
pointer.finally = () => {
|
||||
@@ -2953,8 +2938,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
// Drag node
|
||||
pointer.onDragStart = (pointer) =>
|
||||
this._startDraggingItems(node, pointer, true)
|
||||
pointer.onDragEnd = (e) => this._processDraggedItems(e)
|
||||
this.#startDraggingItems(node, pointer, true)
|
||||
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
|
||||
}
|
||||
|
||||
this.dirty_canvas = true
|
||||
@@ -3023,10 +3008,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* @param e The pointerdown event
|
||||
* @param node The node to process a click event for
|
||||
*/
|
||||
private _processMiddleButton(
|
||||
e: CanvasPointerEvent,
|
||||
node: LGraphNode | undefined
|
||||
) {
|
||||
#processMiddleButton(e: CanvasPointerEvent, node: LGraphNode | undefined) {
|
||||
const { pointer } = this
|
||||
|
||||
if (
|
||||
@@ -3123,14 +3105,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
private _processDragZoom(e: PointerEvent): void {
|
||||
#processDragZoom(e: PointerEvent): void {
|
||||
// stop canvas zoom action
|
||||
if (!e.buttons) {
|
||||
this._finishDragZoom()
|
||||
this.#finishDragZoom()
|
||||
return
|
||||
}
|
||||
|
||||
const start = this._dragZoomStart
|
||||
const start = this.#dragZoomStart
|
||||
if (!start) throw new TypeError('Drag-zoom state object was null')
|
||||
if (!this.graph) throw new NullGraphError()
|
||||
|
||||
@@ -3144,10 +3126,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.graph.change()
|
||||
}
|
||||
|
||||
private _finishDragZoom(): void {
|
||||
const start = this._dragZoomStart
|
||||
#finishDragZoom(): void {
|
||||
const start = this.#dragZoomStart
|
||||
if (!start) return
|
||||
this._dragZoomStart = null
|
||||
this.#dragZoomStart = null
|
||||
this.read_only = start.readOnly
|
||||
}
|
||||
|
||||
@@ -3159,9 +3141,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.dragZoomEnabled &&
|
||||
e.ctrlKey &&
|
||||
e.shiftKey &&
|
||||
this._dragZoomStart
|
||||
this.#dragZoomStart
|
||||
) {
|
||||
this._processDragZoom(e)
|
||||
this.#processDragZoom(e)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3228,7 +3210,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
} else if (this.dragging_canvas) {
|
||||
this.ds.offset[0] += delta[0] / this.ds.scale
|
||||
this.ds.offset[1] += delta[1] / this.ds.scale
|
||||
this._dirty()
|
||||
this.#dirty()
|
||||
} else if (
|
||||
(this.allow_interaction || node?.flags.allow_interaction) &&
|
||||
!this.read_only
|
||||
@@ -3276,7 +3258,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.node_over = node
|
||||
this.dirty_canvas = true
|
||||
|
||||
for (const reroute of this._visibleReroutes) {
|
||||
for (const reroute of this.#visibleReroutes) {
|
||||
reroute.hideSlots()
|
||||
this.dirty_bgcanvas = true
|
||||
}
|
||||
@@ -3400,10 +3382,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
} else {
|
||||
// Reroutes
|
||||
underPointer = this._updateReroutes(underPointer)
|
||||
underPointer = this.#updateReroutes(underPointer)
|
||||
|
||||
// Not over a node
|
||||
const segment = this._getLinkCentreOnPos(e)
|
||||
const segment = this.#getLinkCentreOnPos(e)
|
||||
if (this.over_link_center !== segment) {
|
||||
underPointer |= CanvasItem.Link
|
||||
this.over_link_center = segment
|
||||
@@ -3453,7 +3435,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
this._dirty()
|
||||
this.#dirty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3467,14 +3449,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* Updates the hover / snap state of all visible reroutes.
|
||||
* @returns The original value of {@link underPointer}, with any found reroute items added.
|
||||
*/
|
||||
private _updateReroutes(underPointer: CanvasItem): CanvasItem {
|
||||
#updateReroutes(underPointer: CanvasItem): CanvasItem {
|
||||
const { graph, pointer, linkConnector } = this
|
||||
if (!graph) throw new NullGraphError()
|
||||
|
||||
// Update reroute hover state
|
||||
if (!pointer.isDown) {
|
||||
let anyChanges = false
|
||||
for (const reroute of this._visibleReroutes) {
|
||||
for (const reroute of this.#visibleReroutes) {
|
||||
anyChanges ||= reroute.updateVisibility(this.graph_mouse)
|
||||
|
||||
if (reroute.isSlotHovered) underPointer |= CanvasItem.RerouteSlot
|
||||
@@ -3482,7 +3464,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (anyChanges) this.dirty_bgcanvas = true
|
||||
} else if (linkConnector.isConnecting) {
|
||||
// Highlight the reroute that the mouse is over
|
||||
for (const reroute of this._visibleReroutes) {
|
||||
for (const reroute of this.#visibleReroutes) {
|
||||
if (reroute.containsPoint(this.graph_mouse)) {
|
||||
if (linkConnector.isRerouteValidDrop(reroute)) {
|
||||
linkConnector.overReroute = reroute
|
||||
@@ -3507,7 +3489,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* @param pointer The pointer event that initiated the drag, e.g. pointerdown
|
||||
* @param sticky If `true`, the item is added to the selection - see {@link processSelect}
|
||||
*/
|
||||
private _startDraggingItems(
|
||||
#startDraggingItems(
|
||||
item: Positionable,
|
||||
pointer: CanvasPointer,
|
||||
sticky = false
|
||||
@@ -3529,7 +3511,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* Handles shared clean up and placement after items have been dragged.
|
||||
* @param e The event that completed the drag, e.g. pointerup, pointermove
|
||||
*/
|
||||
private _processDraggedItems(e: CanvasPointerEvent): void {
|
||||
#processDraggedItems(e: CanvasPointerEvent): void {
|
||||
const { graph } = this
|
||||
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
|
||||
graph?.snapToGrid(this.selectedItems)
|
||||
@@ -3551,7 +3533,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const { graph, pointer } = this
|
||||
if (!graph) return
|
||||
|
||||
this._finishDragZoom()
|
||||
this.#finishDragZoom()
|
||||
|
||||
LGraphCanvas.active_canvas = this
|
||||
|
||||
@@ -3695,7 +3677,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
return
|
||||
}
|
||||
|
||||
private _noItemsSelected(): void {
|
||||
#noItemsSelected(): void {
|
||||
const event = new CustomEvent('litegraph:no-items-selected', {
|
||||
bubbles: true
|
||||
})
|
||||
@@ -3706,7 +3688,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* process a key event
|
||||
*/
|
||||
processKey(e: KeyboardEvent): void {
|
||||
this._shiftDown = e.shiftKey
|
||||
this.#shiftDown = e.shiftKey
|
||||
|
||||
const { graph } = this
|
||||
if (!graph) return
|
||||
@@ -3753,7 +3735,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// @ts-expect-error EventTarget.localName is not in standard types
|
||||
if (e.target.localName != 'input' && e.target.localName != 'textarea') {
|
||||
if (this.selectedItems.size === 0) {
|
||||
this._noItemsSelected()
|
||||
this.#noItemsSelected()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4115,7 +4097,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* @param dragRect The drag rectangle to normalize (modified in place)
|
||||
* @returns The normalized rectangle
|
||||
*/
|
||||
private _normalizeDragRect(dragRect: Rect): Rect {
|
||||
#normalizeDragRect(dragRect: Rect): Rect {
|
||||
const w = Math.abs(dragRect[2])
|
||||
const h = Math.abs(dragRect[3])
|
||||
if (dragRect[2] < 0) dragRect[0] -= w
|
||||
@@ -4130,7 +4112,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* @param rect The rectangle to check against
|
||||
* @returns Set of positionable items that overlap with the rectangle
|
||||
*/
|
||||
private _getItemsInRect(rect: Rect): Set<Positionable> {
|
||||
#getItemsInRect(rect: Rect): Set<Positionable> {
|
||||
const { graph, subgraph } = this
|
||||
if (!graph) throw new NullGraphError()
|
||||
|
||||
@@ -4184,9 +4166,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
dragRect[2],
|
||||
dragRect[3]
|
||||
]
|
||||
this._normalizeDragRect(normalizedRect)
|
||||
this.#normalizeDragRect(normalizedRect)
|
||||
|
||||
const itemsInRect = this._getItemsInRect(normalizedRect)
|
||||
const itemsInRect = this.#getItemsInRect(normalizedRect)
|
||||
|
||||
const desired = new Set<Positionable>()
|
||||
if (e.shiftKey && !e.altKey) {
|
||||
@@ -4233,16 +4215,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* @param e The pointer up event
|
||||
* @param dragRect The drag rectangle
|
||||
*/
|
||||
private _handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect): void {
|
||||
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect): void {
|
||||
const normalizedRect: Rect = [
|
||||
dragRect[0],
|
||||
dragRect[1],
|
||||
dragRect[2],
|
||||
dragRect[3]
|
||||
]
|
||||
this._normalizeDragRect(normalizedRect)
|
||||
this.#normalizeDragRect(normalizedRect)
|
||||
|
||||
const itemsInRect = this._getItemsInRect(normalizedRect)
|
||||
const itemsInRect = this.#getItemsInRect(normalizedRect)
|
||||
const { selectedItems } = this
|
||||
|
||||
if (e.shiftKey) {
|
||||
@@ -4606,7 +4588,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
*/
|
||||
setZoom(value: number, zooming_center: Point) {
|
||||
this.ds.changeScale(value, zooming_center)
|
||||
this._dirty()
|
||||
this.#dirty()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4689,7 +4671,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* @returns `true` if the node is visible, otherwise `false`
|
||||
*/
|
||||
isNodeVisible(node: LGraphNode): boolean {
|
||||
return this._visible_node_ids.has(node.id)
|
||||
return this.#visible_node_ids.has(node.id)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4710,7 +4692,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (this.dirty_canvas || force_canvas) {
|
||||
this.computeVisibleNodes(undefined, this.visible_nodes)
|
||||
// Update visible node IDs
|
||||
this._visible_node_ids = new Set(
|
||||
this.#visible_node_ids = new Set(
|
||||
this.visible_nodes.map((node) => node.id)
|
||||
)
|
||||
|
||||
@@ -4764,8 +4746,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
// TODO: Set snapping value when changed instead of once per frame
|
||||
this._snapToGrid =
|
||||
this._shiftDown || LiteGraph.alwaysSnapToGrid
|
||||
this.#snapToGrid =
|
||||
this.#shiftDown || LiteGraph.alwaysSnapToGrid
|
||||
? this.graph?.getSnapToGridSize()
|
||||
: undefined
|
||||
|
||||
@@ -4807,7 +4789,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// draw nodes
|
||||
const { visible_nodes } = this
|
||||
const drawSnapGuides =
|
||||
this._snapToGrid &&
|
||||
this.#snapToGrid &&
|
||||
(this.isDragging || layoutStore.isDraggingVueNodes.value)
|
||||
|
||||
for (const node of visible_nodes) {
|
||||
@@ -4847,7 +4829,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (linkConnector.isConnecting) {
|
||||
// current connection (the one being dragged by the mouse)
|
||||
const { renderLinks } = linkConnector
|
||||
const highlightPos = this._getHighlightPosition()
|
||||
const highlightPos = this.#getHighlightPosition()
|
||||
ctx.lineWidth = this.connections_width
|
||||
|
||||
for (const renderLink of renderLinks) {
|
||||
@@ -4901,7 +4883,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
// Gradient half-border over target node
|
||||
this._renderSnapHighlight(ctx, highlightPos)
|
||||
this.#renderSnapHighlight(ctx, highlightPos)
|
||||
}
|
||||
|
||||
// on top of link center
|
||||
@@ -4927,7 +4909,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
/** @returns If the pointer is over a link centre marker, the link segment it belongs to. Otherwise, `undefined`. */
|
||||
private _getLinkCentreOnPos(e: CanvasPointerEvent): LinkSegment | undefined {
|
||||
#getLinkCentreOnPos(e: CanvasPointerEvent): LinkSegment | undefined {
|
||||
// Skip hit detection if center markers are disabled
|
||||
if (this.linkMarkerShape === LinkMarkerShape.None) {
|
||||
return undefined
|
||||
@@ -4946,7 +4928,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
/** Get the target snap / highlight point in graph space */
|
||||
private _getHighlightPosition(): Readonly<Point> {
|
||||
#getHighlightPosition(): Readonly<Point> {
|
||||
return LiteGraph.snaps_for_comfy
|
||||
? (this.linkConnector.state.snapLinksPos ??
|
||||
this._highlight_pos ??
|
||||
@@ -4959,7 +4941,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* Partial border over target node and a highlight over the slot itself.
|
||||
* @param ctx Canvas 2D context
|
||||
*/
|
||||
private _renderSnapHighlight(
|
||||
#renderSnapHighlight(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
highlightPos: Readonly<Point>
|
||||
): void {
|
||||
@@ -5610,7 +5592,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// Normalise boundingRect to pos to snap
|
||||
snapGuide[0] += offsetX
|
||||
snapGuide[1] += offsetY
|
||||
if (this._snapToGrid) snapPoint(snapGuide, this._snapToGrid)
|
||||
if (this.#snapToGrid) snapPoint(snapGuide, this.#snapToGrid)
|
||||
snapGuide[0] -= offsetX
|
||||
snapGuide[1] -= offsetY
|
||||
|
||||
@@ -5690,7 +5672,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const output = start_node.outputs[outputId]
|
||||
if (!output) continue
|
||||
|
||||
this._renderAllLinkSegments(
|
||||
this.#renderAllLinkSegments(
|
||||
ctx,
|
||||
link,
|
||||
startPos,
|
||||
@@ -5719,7 +5701,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
? getSlotPosition(inputNode, link.target_slot, true)
|
||||
: inputNode.getInputPos(link.target_slot)
|
||||
|
||||
this._renderAllLinkSegments(
|
||||
this.#renderAllLinkSegments(
|
||||
ctx,
|
||||
link,
|
||||
output.pos,
|
||||
@@ -5746,7 +5728,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
? getSlotPosition(outputNode, link.origin_slot, false)
|
||||
: outputNode.getOutputPos(link.origin_slot)
|
||||
|
||||
this._renderAllLinkSegments(
|
||||
this.#renderAllLinkSegments(
|
||||
ctx,
|
||||
link,
|
||||
startPos,
|
||||
@@ -5760,10 +5742,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
if (graph.floatingLinks.size > 0) {
|
||||
this._renderFloatingLinks(ctx, graph, visibleReroutes, now)
|
||||
this.#renderFloatingLinks(ctx, graph, visibleReroutes, now)
|
||||
}
|
||||
|
||||
const rerouteSet = this._visibleReroutes
|
||||
const rerouteSet = this.#visibleReroutes
|
||||
rerouteSet.clear()
|
||||
|
||||
// Render reroutes, ordered by number of non-floating links
|
||||
@@ -5772,7 +5754,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
rerouteSet.add(reroute)
|
||||
|
||||
if (
|
||||
this._snapToGrid &&
|
||||
this.#snapToGrid &&
|
||||
this.isDragging &&
|
||||
this.selectedItems.has(reroute)
|
||||
) {
|
||||
@@ -5794,7 +5776,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
: this.editor_alpha
|
||||
}
|
||||
|
||||
private _renderFloatingLinks(
|
||||
#renderFloatingLinks(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
graph: LGraph,
|
||||
visibleReroutes: Reroute[],
|
||||
@@ -5823,7 +5805,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const endDirection = node.inputs[link.target_slot]?.dir
|
||||
|
||||
firstReroute._dragging = true
|
||||
this._renderAllLinkSegments(
|
||||
this.#renderAllLinkSegments(
|
||||
ctx,
|
||||
link,
|
||||
startPos,
|
||||
@@ -5845,7 +5827,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const startDirection = node.outputs[link.origin_slot]?.dir
|
||||
|
||||
link._dragging = true
|
||||
this._renderAllLinkSegments(
|
||||
this.#renderAllLinkSegments(
|
||||
ctx,
|
||||
link,
|
||||
startPos,
|
||||
@@ -5861,7 +5843,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
ctx.globalAlpha = globalAlpha
|
||||
}
|
||||
|
||||
private _renderAllLinkSegments(
|
||||
#renderAllLinkSegments(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
link: LLink,
|
||||
startPos: Point,
|
||||
@@ -6164,7 +6146,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
ctx.save()
|
||||
ctx.globalAlpha = 0.5 * this.editor_alpha
|
||||
const drawSnapGuides =
|
||||
this._snapToGrid &&
|
||||
this.#snapToGrid &&
|
||||
(this.isDragging || layoutStore.isDraggingVueNodes.value)
|
||||
|
||||
for (const group of groups) {
|
||||
@@ -6531,7 +6513,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
},
|
||||
optPass || {}
|
||||
)
|
||||
const dirty = () => this._dirty()
|
||||
const dirty = () => this.#dirty()
|
||||
|
||||
const that = this
|
||||
const { graph } = this
|
||||
@@ -7540,7 +7522,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
function inner() {
|
||||
setValue(input?.value)
|
||||
}
|
||||
const dirty = () => this._dirty()
|
||||
const dirty = () => this.#dirty()
|
||||
|
||||
function setValue(value: string | number | undefined) {
|
||||
if (
|
||||
@@ -8374,7 +8356,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
reroute = this.graph.getRerouteOnPos(
|
||||
event.canvasX,
|
||||
event.canvasY,
|
||||
this._visibleReroutes
|
||||
this.#visibleReroutes
|
||||
)
|
||||
}
|
||||
if (reroute) {
|
||||
@@ -8664,17 +8646,4 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const mutations = this.initLayoutMutations()
|
||||
this.applyNodePositionUpdates(nodesToReposition, mutations)
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom JSON serialization to prevent circular reference errors.
|
||||
* LGraphCanvas should not be serialized directly - serialize the graph instead.
|
||||
*/
|
||||
toJSON(): { ds: { scale: number; offset: [number, number] } } {
|
||||
return {
|
||||
ds: {
|
||||
scale: this.ds.scale,
|
||||
offset: [...this.ds.offset] as [number, number]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,8 +273,8 @@ export class LGraphNode
|
||||
inputs: INodeInputSlot[] = []
|
||||
outputs: INodeOutputSlot[] = []
|
||||
|
||||
private _concreteInputs: NodeInputSlot[] = []
|
||||
private _concreteOutputs: NodeOutputSlot[] = []
|
||||
#concreteInputs: NodeInputSlot[] = []
|
||||
#concreteOutputs: NodeOutputSlot[] = []
|
||||
|
||||
properties: Dictionary<NodeProperty | undefined> = {}
|
||||
properties_info: INodePropertyInfo[] = []
|
||||
@@ -438,24 +438,24 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
/** @inheritdoc {@link renderArea} */
|
||||
private _renderArea = new Rectangle()
|
||||
#renderArea = new Rectangle()
|
||||
/**
|
||||
* Rect describing the node area, including shadows and any protrusions.
|
||||
* Determines if the node is visible. Calculated once at the start of every frame.
|
||||
*/
|
||||
get renderArea(): ReadOnlyRect {
|
||||
return this._renderArea
|
||||
return this.#renderArea
|
||||
}
|
||||
|
||||
/** @inheritdoc {@link boundingRect} */
|
||||
private _boundingRect: Rectangle = new Rectangle()
|
||||
#boundingRect: Rectangle = new Rectangle()
|
||||
/**
|
||||
* Cached node position & area as `x, y, width, height`. Includes changes made by {@link onBounding}, if present.
|
||||
*
|
||||
* Determines the node hitbox and other rendering effects. Calculated once at the start of every frame.
|
||||
*/
|
||||
get boundingRect(): ReadOnlyRectangle {
|
||||
return this._boundingRect
|
||||
return this.#boundingRect
|
||||
}
|
||||
|
||||
/** The offset from {@link pos} to the top-left of {@link boundingRect}. */
|
||||
@@ -753,9 +753,7 @@ export class LGraphNode
|
||||
onPropertyChange?(this: LGraphNode): void
|
||||
updateOutputData?(this: LGraphNode, origin_slot: number): void
|
||||
|
||||
private _getErrorStrokeStyle(
|
||||
this: LGraphNode
|
||||
): IDrawBoundingOptions | undefined {
|
||||
#getErrorStrokeStyle(this: LGraphNode): IDrawBoundingOptions | undefined {
|
||||
if (this.has_errors) {
|
||||
return {
|
||||
padding: 12,
|
||||
@@ -765,9 +763,7 @@ export class LGraphNode
|
||||
}
|
||||
}
|
||||
|
||||
private _getSelectedStrokeStyle(
|
||||
this: LGraphNode
|
||||
): IDrawBoundingOptions | undefined {
|
||||
#getSelectedStrokeStyle(this: LGraphNode): IDrawBoundingOptions | undefined {
|
||||
if (this.selected) {
|
||||
return {
|
||||
padding: this.has_errors ? 20 : undefined
|
||||
@@ -782,8 +778,8 @@ export class LGraphNode
|
||||
this.size = [LiteGraph.NODE_WIDTH, 60]
|
||||
this.pos = [10, 10]
|
||||
this.strokeStyles = {
|
||||
error: this._getErrorStrokeStyle,
|
||||
selected: this._getSelectedStrokeStyle
|
||||
error: this.#getErrorStrokeStyle,
|
||||
selected: this.#getSelectedStrokeStyle
|
||||
}
|
||||
// Initialize property manager with tracked properties
|
||||
this.changeTracker = new LGraphNodeProperties(this)
|
||||
@@ -2071,11 +2067,11 @@ export class LGraphNode
|
||||
* Called automatically at the start of every frame.
|
||||
*/
|
||||
updateArea(ctx?: CanvasRenderingContext2D): void {
|
||||
const bounds = this._boundingRect
|
||||
const bounds = this.#boundingRect
|
||||
this.measure(bounds, ctx)
|
||||
this.onBounding?.(bounds)
|
||||
|
||||
const renderArea = this._renderArea
|
||||
const renderArea = this.#renderArea
|
||||
renderArea.set(bounds)
|
||||
// 4 offset for collapsed node connection points
|
||||
renderArea[0] -= 4
|
||||
@@ -2297,7 +2293,7 @@ export class LGraphNode
|
||||
optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }
|
||||
): INodeInputSlot | -1
|
||||
findInputSlotFree(optsIn?: FindFreeSlotOptions) {
|
||||
return this._findFreeSlot(this.inputs, optsIn)
|
||||
return this.#findFreeSlot(this.inputs, optsIn)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2312,14 +2308,14 @@ export class LGraphNode
|
||||
optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }
|
||||
): INodeOutputSlot | -1
|
||||
findOutputSlotFree(optsIn?: FindFreeSlotOptions) {
|
||||
return this._findFreeSlot(this.outputs, optsIn)
|
||||
return this.#findFreeSlot(this.outputs, optsIn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the next free slot
|
||||
* @param slots The slots to search, i.e. this.inputs or this.outputs
|
||||
*/
|
||||
private _findFreeSlot<TSlot extends INodeInputSlot | INodeOutputSlot>(
|
||||
#findFreeSlot<TSlot extends INodeInputSlot | INodeOutputSlot>(
|
||||
slots: TSlot[],
|
||||
options?: FindFreeSlotOptions
|
||||
): TSlot | number {
|
||||
@@ -2361,7 +2357,7 @@ export class LGraphNode
|
||||
preferFreeSlot?: boolean,
|
||||
doNotUseOccupied?: boolean
|
||||
) {
|
||||
return this._findSlotByType(
|
||||
return this.#findSlotByType(
|
||||
this.inputs,
|
||||
type,
|
||||
returnObj,
|
||||
@@ -2391,7 +2387,7 @@ export class LGraphNode
|
||||
preferFreeSlot?: boolean,
|
||||
doNotUseOccupied?: boolean
|
||||
) {
|
||||
return this._findSlotByType(
|
||||
return this.#findSlotByType(
|
||||
this.outputs,
|
||||
type,
|
||||
returnObj,
|
||||
@@ -2437,14 +2433,14 @@ export class LGraphNode
|
||||
doNotUseOccupied?: boolean
|
||||
): number | INodeOutputSlot | INodeInputSlot {
|
||||
return input
|
||||
? this._findSlotByType(
|
||||
? this.#findSlotByType(
|
||||
this.inputs,
|
||||
type,
|
||||
returnObj,
|
||||
preferFreeSlot,
|
||||
doNotUseOccupied
|
||||
)
|
||||
: this._findSlotByType(
|
||||
: this.#findSlotByType(
|
||||
this.outputs,
|
||||
type,
|
||||
returnObj,
|
||||
@@ -2465,7 +2461,7 @@ export class LGraphNode
|
||||
* @see {findInputSlotByType}
|
||||
* @returns If a match is found, the slot if returnObj is true, otherwise the index. If no matches are found, -1
|
||||
*/
|
||||
private _findSlotByType<TSlot extends INodeInputSlot | INodeOutputSlot>(
|
||||
#findSlotByType<TSlot extends INodeInputSlot | INodeOutputSlot>(
|
||||
slots: TSlot[],
|
||||
type: ISlotType,
|
||||
returnObj?: boolean,
|
||||
@@ -3314,8 +3310,8 @@ export class LGraphNode
|
||||
// default vertical slots
|
||||
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
const slotIndex = is_input
|
||||
? this._defaultVerticalInputs.indexOf(this.inputs[slot_number])
|
||||
: this._defaultVerticalOutputs.indexOf(this.outputs[slot_number])
|
||||
? this.#defaultVerticalInputs.indexOf(this.inputs[slot_number])
|
||||
: this.#defaultVerticalOutputs.indexOf(this.outputs[slot_number])
|
||||
|
||||
out[0] = is_input ? nodeX + offset : nodeX + this.size[0] + 1 - offset
|
||||
out[1] =
|
||||
@@ -3328,7 +3324,7 @@ export class LGraphNode
|
||||
/**
|
||||
* @internal The inputs that are not positioned with absolute coordinates.
|
||||
*/
|
||||
private get _defaultVerticalInputs() {
|
||||
get #defaultVerticalInputs() {
|
||||
return this.inputs.filter(
|
||||
(slot) => !slot.pos && !(this.widgets?.length && isWidgetInputSlot(slot))
|
||||
)
|
||||
@@ -3337,7 +3333,7 @@ export class LGraphNode
|
||||
/**
|
||||
* @internal The outputs that are not positioned with absolute coordinates.
|
||||
*/
|
||||
private get _defaultVerticalOutputs() {
|
||||
get #defaultVerticalOutputs() {
|
||||
return this.outputs.filter((slot: INodeOutputSlot) => !slot.pos)
|
||||
}
|
||||
|
||||
@@ -3345,7 +3341,7 @@ export class LGraphNode
|
||||
* Get the context needed for slot position calculations
|
||||
* @internal
|
||||
*/
|
||||
private _getSlotPositionContext(): SlotPositionContext {
|
||||
#getSlotPositionContext(): SlotPositionContext {
|
||||
return {
|
||||
nodeX: this.pos[0],
|
||||
nodeY: this.pos[1],
|
||||
@@ -3377,7 +3373,7 @@ export class LGraphNode
|
||||
* @returns Position of the centre of the input slot in graph co-ordinates.
|
||||
*/
|
||||
getInputSlotPos(input: INodeInputSlot): Point {
|
||||
return calculateInputSlotPosFromSlot(this._getSlotPositionContext(), input)
|
||||
return calculateInputSlotPosFromSlot(this.#getSlotPositionContext(), input)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3920,13 +3916,13 @@ export class LGraphNode
|
||||
*/
|
||||
drawCollapsedSlots(ctx: CanvasRenderingContext2D): void {
|
||||
// Render the first connected slot only.
|
||||
for (const slot of this._concreteInputs) {
|
||||
for (const slot of this.#concreteInputs) {
|
||||
if (slot.link != null) {
|
||||
slot.drawCollapsed(ctx)
|
||||
break
|
||||
}
|
||||
}
|
||||
for (const slot of this._concreteOutputs) {
|
||||
for (const slot of this.#concreteOutputs) {
|
||||
if (slot.links?.length) {
|
||||
slot.drawCollapsed(ctx)
|
||||
break
|
||||
@@ -3938,7 +3934,7 @@ export class LGraphNode
|
||||
return [...this.inputs, ...this.outputs]
|
||||
}
|
||||
|
||||
private _measureSlot(
|
||||
#measureSlot(
|
||||
slot: NodeInputSlot | NodeOutputSlot,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
@@ -3955,27 +3951,27 @@ export class LGraphNode
|
||||
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
|
||||
}
|
||||
|
||||
private _measureSlots(): ReadOnlyRect | null {
|
||||
#measureSlots(): ReadOnlyRect | null {
|
||||
const slots: (NodeInputSlot | NodeOutputSlot)[] = []
|
||||
|
||||
for (const [slotIndex, slot] of this._concreteInputs.entries()) {
|
||||
for (const [slotIndex, slot] of this.#concreteInputs.entries()) {
|
||||
// Unrecognized nodes (Nodes with error) has inputs but no widgets. Treat
|
||||
// converted inputs as normal inputs.
|
||||
/** Widget input slots are handled in {@link layoutWidgetInputSlots} */
|
||||
if (this.widgets?.length && isWidgetInputSlot(slot)) continue
|
||||
|
||||
this._measureSlot(slot, slotIndex, true)
|
||||
this.#measureSlot(slot, slotIndex, true)
|
||||
slots.push(slot)
|
||||
}
|
||||
for (const [slotIndex, slot] of this._concreteOutputs.entries()) {
|
||||
this._measureSlot(slot, slotIndex, false)
|
||||
for (const [slotIndex, slot] of this.#concreteOutputs.entries()) {
|
||||
this.#measureSlot(slot, slotIndex, false)
|
||||
slots.push(slot)
|
||||
}
|
||||
|
||||
return slots.length ? createBounds(slots, 0) : null
|
||||
}
|
||||
|
||||
private _getMouseOverSlot(slot: INodeSlot): INodeSlot | null {
|
||||
#getMouseOverSlot(slot: INodeSlot): INodeSlot | null {
|
||||
const isInput = isINodeInputSlot(slot)
|
||||
const mouseOverId = this.mouseOver?.[isInput ? 'inputId' : 'outputId'] ?? -1
|
||||
if (mouseOverId === -1) {
|
||||
@@ -3984,11 +3980,11 @@ export class LGraphNode
|
||||
return isInput ? this.inputs[mouseOverId] : this.outputs[mouseOverId]
|
||||
}
|
||||
|
||||
private _isMouseOverSlot(slot: INodeSlot): boolean {
|
||||
return this._getMouseOverSlot(slot) === slot
|
||||
#isMouseOverSlot(slot: INodeSlot): boolean {
|
||||
return this.#getMouseOverSlot(slot) === slot
|
||||
}
|
||||
|
||||
private _isMouseOverWidget(widget: IBaseWidget | undefined): boolean {
|
||||
#isMouseOverWidget(widget: IBaseWidget | undefined): boolean {
|
||||
if (!widget) return false
|
||||
return this.mouseOver?.overWidget === widget
|
||||
}
|
||||
@@ -4020,9 +4016,9 @@ export class LGraphNode
|
||||
ctx: CanvasRenderingContext2D,
|
||||
{ fromSlot, colorContext, editorAlpha, lowQuality }: DrawSlotsOptions
|
||||
) {
|
||||
for (const slot of [...this._concreteInputs, ...this._concreteOutputs]) {
|
||||
for (const slot of [...this.#concreteInputs, ...this.#concreteOutputs]) {
|
||||
const isValidTarget = fromSlot && slot.isValidTarget(fromSlot)
|
||||
const isMouseOverSlot = this._isMouseOverSlot(slot)
|
||||
const isMouseOverSlot = this.#isMouseOverSlot(slot)
|
||||
|
||||
// change opacity of incompatible slots when dragging a connection
|
||||
const isValid = !fromSlot || isValidTarget
|
||||
@@ -4037,7 +4033,7 @@ export class LGraphNode
|
||||
isMouseOverSlot ||
|
||||
isValidTarget ||
|
||||
!slot.isWidgetInputSlot ||
|
||||
this._isMouseOverWidget(this.getWidgetFromSlot(slot)) ||
|
||||
this.#isMouseOverWidget(this.getWidgetFromSlot(slot)) ||
|
||||
slot.isConnected ||
|
||||
slot.alwaysVisible
|
||||
) {
|
||||
@@ -4058,7 +4054,7 @@ export class LGraphNode
|
||||
* - {@link IBaseWidget.y}
|
||||
* @param widgetStartY The y-coordinate of the first widget
|
||||
*/
|
||||
private _arrangeWidgets(widgetStartY: number): void {
|
||||
#arrangeWidgets(widgetStartY: number): void {
|
||||
if (!this.widgets || !this.widgets.length) return
|
||||
|
||||
const bodyHeight = this.bodyHeight
|
||||
@@ -4136,7 +4132,7 @@ export class LGraphNode
|
||||
/**
|
||||
* Arranges the layout of the node's widget input slots.
|
||||
*/
|
||||
private _arrangeWidgetInputSlots(): void {
|
||||
#arrangeWidgetInputSlots(): void {
|
||||
if (!this.widgets) return
|
||||
|
||||
const slotByWidgetName = new Map<
|
||||
@@ -4158,10 +4154,10 @@ export class LGraphNode
|
||||
const slot = slotByWidgetName.get(widget.name)
|
||||
if (!slot) continue
|
||||
|
||||
const actualSlot = this._concreteInputs[slot.index]
|
||||
const actualSlot = this.#concreteInputs[slot.index]
|
||||
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
actualSlot.pos = [offset, widget.y + offset]
|
||||
this._measureSlot(actualSlot, slot.index, true)
|
||||
this.#measureSlot(actualSlot, slot.index, true)
|
||||
}
|
||||
} else {
|
||||
// For Vue positioning, just measure the slots without setting pos
|
||||
@@ -4169,7 +4165,7 @@ export class LGraphNode
|
||||
const slot = slotByWidgetName.get(widget.name)
|
||||
if (!slot) continue
|
||||
|
||||
this._measureSlot(this._concreteInputs[slot.index], slot.index, true)
|
||||
this.#measureSlot(this.#concreteInputs[slot.index], slot.index, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4182,10 +4178,10 @@ export class LGraphNode
|
||||
* have been removed from the ecosystem.
|
||||
*/
|
||||
_setConcreteSlots(): void {
|
||||
this._concreteInputs = this.inputs.map((slot) =>
|
||||
this.#concreteInputs = this.inputs.map((slot) =>
|
||||
toClass(NodeInputSlot, slot, this)
|
||||
)
|
||||
this._concreteOutputs = this.outputs.map((slot) =>
|
||||
this.#concreteOutputs = this.outputs.map((slot) =>
|
||||
toClass(NodeOutputSlot, slot, this)
|
||||
)
|
||||
}
|
||||
@@ -4194,12 +4190,12 @@ export class LGraphNode
|
||||
* Arranges node elements in preparation for rendering (slots & widgets).
|
||||
*/
|
||||
arrange(): void {
|
||||
const slotsBounds = this._measureSlots()
|
||||
const slotsBounds = this.#measureSlots()
|
||||
const widgetStartY = slotsBounds
|
||||
? slotsBounds[1] + slotsBounds[3] - this.pos[1]
|
||||
: 0
|
||||
this._arrangeWidgets(widgetStartY)
|
||||
this._arrangeWidgetInputSlots()
|
||||
this.#arrangeWidgets(widgetStartY)
|
||||
this.#arrangeWidgetInputSlots()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,24 +25,24 @@ export class LGraphNodeProperties {
|
||||
node: LGraphNode
|
||||
|
||||
/** Set of property paths that have been instrumented */
|
||||
private _instrumentedPaths = new Set<string>()
|
||||
#instrumentedPaths = new Set<string>()
|
||||
|
||||
constructor(node: LGraphNode) {
|
||||
this.node = node
|
||||
|
||||
this._setupInstrumentation()
|
||||
this.#setupInstrumentation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up property instrumentation for all tracked properties
|
||||
*/
|
||||
private _setupInstrumentation(): void {
|
||||
#setupInstrumentation(): void {
|
||||
for (const path of DEFAULT_TRACKED_PROPERTIES) {
|
||||
this._instrumentProperty(path)
|
||||
this.#instrumentProperty(path)
|
||||
}
|
||||
}
|
||||
|
||||
private _resolveTargetObject(parts: string[]): {
|
||||
#resolveTargetObject(parts: string[]): {
|
||||
targetObject: Record<string, unknown>
|
||||
propertyName: string
|
||||
} {
|
||||
@@ -73,14 +73,14 @@ export class LGraphNodeProperties {
|
||||
/**
|
||||
* Instruments a single property to track changes
|
||||
*/
|
||||
private _instrumentProperty(path: string): void {
|
||||
#instrumentProperty(path: string): void {
|
||||
const parts = path.split('.')
|
||||
|
||||
if (parts.length > 1) {
|
||||
this._ensureNestedPath(path)
|
||||
this.#ensureNestedPath(path)
|
||||
}
|
||||
|
||||
const { targetObject, propertyName } = this._resolveTargetObject(parts)
|
||||
const { targetObject, propertyName } = this.#resolveTargetObject(parts)
|
||||
|
||||
const hasProperty = Object.prototype.hasOwnProperty.call(
|
||||
targetObject,
|
||||
@@ -96,7 +96,7 @@ export class LGraphNodeProperties {
|
||||
set: (newValue: unknown) => {
|
||||
const oldValue = value
|
||||
value = newValue
|
||||
this._emitPropertyChange(path, oldValue, newValue)
|
||||
this.#emitPropertyChange(path, oldValue, newValue)
|
||||
|
||||
// Update enumerable: true for non-undefined values, false for undefined
|
||||
const shouldBeEnumerable = newValue !== undefined
|
||||
@@ -121,24 +121,24 @@ export class LGraphNodeProperties {
|
||||
Object.defineProperty(
|
||||
targetObject,
|
||||
propertyName,
|
||||
this._createInstrumentedDescriptor(path, currentValue)
|
||||
this.#createInstrumentedDescriptor(path, currentValue)
|
||||
)
|
||||
}
|
||||
|
||||
this._instrumentedPaths.add(path)
|
||||
this.#instrumentedPaths.add(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a property descriptor that emits change events
|
||||
*/
|
||||
private _createInstrumentedDescriptor(
|
||||
#createInstrumentedDescriptor(
|
||||
propertyPath: string,
|
||||
initialValue: unknown
|
||||
): PropertyDescriptor {
|
||||
return this._createInstrumentedDescriptorTyped(propertyPath, initialValue)
|
||||
return this.#createInstrumentedDescriptorTyped(propertyPath, initialValue)
|
||||
}
|
||||
|
||||
private _createInstrumentedDescriptorTyped<TValue>(
|
||||
#createInstrumentedDescriptorTyped<TValue>(
|
||||
propertyPath: string,
|
||||
initialValue: TValue
|
||||
): PropertyDescriptor {
|
||||
@@ -149,7 +149,7 @@ export class LGraphNodeProperties {
|
||||
set: (newValue: TValue) => {
|
||||
const oldValue = value
|
||||
value = newValue
|
||||
this._emitPropertyChange(propertyPath, oldValue, newValue)
|
||||
this.#emitPropertyChange(propertyPath, oldValue, newValue)
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
@@ -159,15 +159,15 @@ export class LGraphNodeProperties {
|
||||
/**
|
||||
* Emits a property change event if the node is connected to a graph
|
||||
*/
|
||||
private _emitPropertyChange(
|
||||
#emitPropertyChange(
|
||||
propertyPath: string,
|
||||
oldValue: unknown,
|
||||
newValue: unknown
|
||||
): void {
|
||||
this._emitPropertyChangeTyped(propertyPath, oldValue, newValue)
|
||||
this.#emitPropertyChangeTyped(propertyPath, oldValue, newValue)
|
||||
}
|
||||
|
||||
private _emitPropertyChangeTyped<TValue>(
|
||||
#emitPropertyChangeTyped<TValue>(
|
||||
propertyPath: string,
|
||||
oldValue: TValue,
|
||||
newValue: TValue
|
||||
@@ -183,7 +183,7 @@ export class LGraphNodeProperties {
|
||||
/**
|
||||
* Ensures parent objects exist for nested properties
|
||||
*/
|
||||
private _ensureNestedPath(path: string): void {
|
||||
#ensureNestedPath(path: string): void {
|
||||
const parts = path.split('.')
|
||||
// LGraphNode supports dynamic property access at runtime
|
||||
let current: Record<string, unknown> = this.node as unknown as Record<
|
||||
@@ -208,7 +208,7 @@ export class LGraphNodeProperties {
|
||||
* Checks if a property is being tracked
|
||||
*/
|
||||
isTracked(path: string): boolean {
|
||||
return this._instrumentedPaths.has(path)
|
||||
return this.#instrumentedPaths.has(path)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -119,14 +119,14 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
/** @inheritdoc */
|
||||
_dragging?: boolean
|
||||
|
||||
private _color?: CanvasColour | null
|
||||
#color?: CanvasColour | null
|
||||
/** Custom colour for this link only */
|
||||
public get color(): CanvasColour | null | undefined {
|
||||
return this._color
|
||||
return this.#color
|
||||
}
|
||||
|
||||
public set color(value: CanvasColour) {
|
||||
this._color = value === '' ? null : value
|
||||
this.#color = value === '' ? null : value
|
||||
}
|
||||
|
||||
public get isFloatingOutput(): boolean {
|
||||
|
||||
@@ -1,43 +1,301 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LGraph > supports schema v0.4 graphs > oldSchemaGraph 1`] = `
|
||||
{
|
||||
"config": {},
|
||||
"definitions": undefined,
|
||||
"extra": {
|
||||
"reroutes": undefined,
|
||||
},
|
||||
"floatingLinks": undefined,
|
||||
"groups": [
|
||||
{
|
||||
"bounding": [
|
||||
LGraph {
|
||||
"_groups": [
|
||||
LGraphGroup {
|
||||
"_bounding": Rectangle [
|
||||
20,
|
||||
20,
|
||||
1,
|
||||
3,
|
||||
],
|
||||
"_children": Set {},
|
||||
"_nodes": [],
|
||||
"_pos": Float64Array [
|
||||
20,
|
||||
20,
|
||||
],
|
||||
"_size": Float64Array [
|
||||
1,
|
||||
3,
|
||||
],
|
||||
"color": "#6029aa",
|
||||
"flags": {},
|
||||
"font": undefined,
|
||||
"font_size": 14,
|
||||
"graph": [Circular],
|
||||
"id": 123,
|
||||
"selected": undefined,
|
||||
"setDirtyCanvas": [Function],
|
||||
"title": "A group to test with",
|
||||
},
|
||||
],
|
||||
"id": "b4e984f1-b421-4d24-b8b4-ff895793af13",
|
||||
"last_link_id": 0,
|
||||
"last_node_id": 1,
|
||||
"links": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"mode": 0,
|
||||
"pos": [
|
||||
"_input_nodes": undefined,
|
||||
"_last_trigger_time": undefined,
|
||||
"_links": Map {},
|
||||
"_nodes": [
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float64Array [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Rectangle [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float64Array [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"action_call": undefined,
|
||||
"action_triggered": undefined,
|
||||
"badgePosition": "top-left",
|
||||
"badges": [],
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"changeTracker": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
"console": undefined,
|
||||
"exec_version": undefined,
|
||||
"execute_triggered": undefined,
|
||||
"flags": {},
|
||||
"freeWidgetSpace": undefined,
|
||||
"gotFocusAt": undefined,
|
||||
"graph": [Circular],
|
||||
"has_errors": true,
|
||||
"id": 1,
|
||||
"ignore_remove": undefined,
|
||||
"inputs": [],
|
||||
"last_serialization": {
|
||||
"id": 1,
|
||||
},
|
||||
"locked": undefined,
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
"properties": {},
|
||||
"properties_info": [],
|
||||
"redraw_on_mouse": undefined,
|
||||
"removable": undefined,
|
||||
"resizable": undefined,
|
||||
"selected": undefined,
|
||||
"serialize_widgets": undefined,
|
||||
"showAdvanced": undefined,
|
||||
"strokeStyles": {
|
||||
"error": [Function],
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": undefined,
|
||||
"title_buttons": [],
|
||||
"type": "",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
"widgets_up": undefined,
|
||||
},
|
||||
],
|
||||
"_nodes_by_id": {
|
||||
"1": LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float64Array [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Rectangle [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float64Array [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"action_call": undefined,
|
||||
"action_triggered": undefined,
|
||||
"badgePosition": "top-left",
|
||||
"badges": [],
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"changeTracker": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
"console": undefined,
|
||||
"exec_version": undefined,
|
||||
"execute_triggered": undefined,
|
||||
"flags": {},
|
||||
"freeWidgetSpace": undefined,
|
||||
"gotFocusAt": undefined,
|
||||
"graph": [Circular],
|
||||
"has_errors": true,
|
||||
"id": 1,
|
||||
"ignore_remove": undefined,
|
||||
"inputs": [],
|
||||
"last_serialization": {
|
||||
"id": 1,
|
||||
},
|
||||
"locked": undefined,
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
"properties": {},
|
||||
"properties_info": [],
|
||||
"redraw_on_mouse": undefined,
|
||||
"removable": undefined,
|
||||
"resizable": undefined,
|
||||
"selected": undefined,
|
||||
"serialize_widgets": undefined,
|
||||
"showAdvanced": undefined,
|
||||
"strokeStyles": {
|
||||
"error": [Function],
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": undefined,
|
||||
"title_buttons": [],
|
||||
"type": "",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
"widgets_up": undefined,
|
||||
},
|
||||
},
|
||||
"_nodes_executable": [],
|
||||
"_nodes_in_order": [
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float64Array [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Rectangle [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float64Array [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"action_call": undefined,
|
||||
"action_triggered": undefined,
|
||||
"badgePosition": "top-left",
|
||||
"badges": [],
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"changeTracker": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
"console": undefined,
|
||||
"exec_version": undefined,
|
||||
"execute_triggered": undefined,
|
||||
"flags": {},
|
||||
"freeWidgetSpace": undefined,
|
||||
"gotFocusAt": undefined,
|
||||
"graph": [Circular],
|
||||
"has_errors": true,
|
||||
"id": 1,
|
||||
"ignore_remove": undefined,
|
||||
"inputs": [],
|
||||
"last_serialization": {
|
||||
"id": 1,
|
||||
},
|
||||
"locked": undefined,
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
"properties": {},
|
||||
"properties_info": [],
|
||||
"redraw_on_mouse": undefined,
|
||||
"removable": undefined,
|
||||
"resizable": undefined,
|
||||
"selected": undefined,
|
||||
"serialize_widgets": undefined,
|
||||
"showAdvanced": undefined,
|
||||
"strokeStyles": {
|
||||
"error": [Function],
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": undefined,
|
||||
"title_buttons": [],
|
||||
"type": "",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
"widgets_up": undefined,
|
||||
},
|
||||
],
|
||||
"_subgraphs": Map {},
|
||||
"_version": 3,
|
||||
"catch_errors": true,
|
||||
"config": {},
|
||||
"elapsed_time": 0.01,
|
||||
"errors_in_execution": undefined,
|
||||
"events": CustomEventTarget {
|
||||
Symbol(listeners): {
|
||||
"bubbling": Map {},
|
||||
"capturing": Map {},
|
||||
},
|
||||
Symbol(listenerOptions): {
|
||||
"bubbling": Map {},
|
||||
"capturing": Map {},
|
||||
},
|
||||
},
|
||||
"execution_time": undefined,
|
||||
"execution_timer_id": undefined,
|
||||
"extra": {},
|
||||
"filter": undefined,
|
||||
"fixedtime": 0,
|
||||
"fixedtime_lapse": 0.01,
|
||||
"floatingLinksInternal": Map {},
|
||||
"globaltime": 0,
|
||||
"id": "b4e984f1-b421-4d24-b8b4-ff895793af13",
|
||||
"iteration": 0,
|
||||
"last_update_time": 0,
|
||||
"links": Map {},
|
||||
"list_of_graphcanvas": null,
|
||||
"nodes_actioning": [],
|
||||
"nodes_executedAction": [],
|
||||
"nodes_executing": [],
|
||||
"onTrigger": undefined,
|
||||
"reroutesInternal": Map {},
|
||||
"revision": 0,
|
||||
"runningtime": 0,
|
||||
"starttime": 0,
|
||||
"state": {
|
||||
"lastGroupId": 123,
|
||||
"lastLinkId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastRerouteId": 0,
|
||||
},
|
||||
"status": 1,
|
||||
"vars": {},
|
||||
"version": 0.4,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -51,11 +51,11 @@ export class InputIndicators implements Disposable {
|
||||
const element = canvas.canvas
|
||||
const options = { capture: true, signal } satisfies AddEventListenerOptions
|
||||
|
||||
element.addEventListener('pointerdown', this._onPointerDownOrMove, options)
|
||||
element.addEventListener('pointermove', this._onPointerDownOrMove, options)
|
||||
element.addEventListener('pointerup', this._onPointerUp, options)
|
||||
element.addEventListener('keydown', this._onKeyDownOrUp, options)
|
||||
document.addEventListener('keyup', this._onKeyDownOrUp, options)
|
||||
element.addEventListener('pointerdown', this.#onPointerDownOrMove, options)
|
||||
element.addEventListener('pointermove', this.#onPointerDownOrMove, options)
|
||||
element.addEventListener('pointerup', this.#onPointerUp, options)
|
||||
element.addEventListener('keydown', this.#onKeyDownOrUp, options)
|
||||
document.addEventListener('keyup', this.#onKeyDownOrUp, options)
|
||||
|
||||
const origDrawFrontCanvas = canvas.drawFrontCanvas.bind(canvas)
|
||||
signal.addEventListener('abort', () => {
|
||||
@@ -68,7 +68,7 @@ export class InputIndicators implements Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
private _onPointerDownOrMove = this.onPointerDownOrMove.bind(this)
|
||||
#onPointerDownOrMove = this.onPointerDownOrMove.bind(this)
|
||||
onPointerDownOrMove(e: MouseEvent): void {
|
||||
this.mouse0Down = (e.buttons & 1) === 1
|
||||
this.mouse1Down = (e.buttons & 4) === 4
|
||||
@@ -80,14 +80,14 @@ export class InputIndicators implements Disposable {
|
||||
this.canvas.setDirty(true)
|
||||
}
|
||||
|
||||
private _onPointerUp = this.onPointerUp.bind(this)
|
||||
#onPointerUp = this.onPointerUp.bind(this)
|
||||
onPointerUp(): void {
|
||||
this.mouse0Down = false
|
||||
this.mouse1Down = false
|
||||
this.mouse2Down = false
|
||||
}
|
||||
|
||||
private _onKeyDownOrUp = this.onKeyDownOrUp.bind(this)
|
||||
#onKeyDownOrUp = this.onKeyDownOrUp.bind(this)
|
||||
onKeyDownOrUp(e: KeyboardEvent): void {
|
||||
this.ctrlDown = e.ctrlKey
|
||||
this.altDown = e.altKey
|
||||
|
||||
@@ -115,10 +115,10 @@ export class LinkConnector {
|
||||
/** The reroute beneath the pointer, if it is a valid connection target. */
|
||||
overReroute?: Reroute
|
||||
|
||||
private readonly _setConnectingLinks: (value: ConnectingLink[]) => void
|
||||
readonly #setConnectingLinks: (value: ConnectingLink[]) => void
|
||||
|
||||
constructor(setConnectingLinks: (value: ConnectingLink[]) => void) {
|
||||
this._setConnectingLinks = setConnectingLinks
|
||||
this.#setConnectingLinks = setConnectingLinks
|
||||
}
|
||||
|
||||
get isConnecting() {
|
||||
@@ -253,7 +253,7 @@ export class LinkConnector {
|
||||
state.connectingTo = 'input'
|
||||
state.draggingExistingLinks = true
|
||||
|
||||
this._setLegacyLinks(false)
|
||||
this.#setLegacyLinks(false)
|
||||
}
|
||||
|
||||
/** Drag all links from an output to a new output. */
|
||||
@@ -364,7 +364,7 @@ export class LinkConnector {
|
||||
state.multi = true
|
||||
state.connectingTo = 'output'
|
||||
|
||||
this._setLegacyLinks(true)
|
||||
this.#setLegacyLinks(true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,7 +387,7 @@ export class LinkConnector {
|
||||
|
||||
state.connectingTo = 'input'
|
||||
|
||||
this._setLegacyLinks(false)
|
||||
this.#setLegacyLinks(false)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,7 +410,7 @@ export class LinkConnector {
|
||||
|
||||
state.connectingTo = 'output'
|
||||
|
||||
this._setLegacyLinks(true)
|
||||
this.#setLegacyLinks(true)
|
||||
}
|
||||
|
||||
dragNewFromSubgraphInput(
|
||||
@@ -431,7 +431,7 @@ export class LinkConnector {
|
||||
|
||||
this.state.connectingTo = 'input'
|
||||
|
||||
this._setLegacyLinks(false)
|
||||
this.#setLegacyLinks(false)
|
||||
}
|
||||
|
||||
dragNewFromSubgraphOutput(
|
||||
@@ -452,7 +452,7 @@ export class LinkConnector {
|
||||
|
||||
this.state.connectingTo = 'output'
|
||||
|
||||
this._setLegacyLinks(true)
|
||||
this.#setLegacyLinks(true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,7 +489,7 @@ export class LinkConnector {
|
||||
|
||||
this.state.connectingTo = 'input'
|
||||
|
||||
this._setLegacyLinks(false)
|
||||
this.#setLegacyLinks(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -516,7 +516,7 @@ export class LinkConnector {
|
||||
|
||||
this.state.connectingTo = 'input'
|
||||
|
||||
this._setLegacyLinks(false)
|
||||
this.#setLegacyLinks(false)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -553,7 +553,7 @@ export class LinkConnector {
|
||||
|
||||
this.state.connectingTo = 'output'
|
||||
|
||||
this._setLegacyLinks(false)
|
||||
this.#setLegacyLinks(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -581,7 +581,7 @@ export class LinkConnector {
|
||||
|
||||
this.state.connectingTo = 'output'
|
||||
|
||||
this._setLegacyLinks(true)
|
||||
this.#setLegacyLinks(true)
|
||||
}
|
||||
|
||||
dragFromLinkSegment(network: LinkNetwork, linkSegment: LinkSegment): void {
|
||||
@@ -603,7 +603,7 @@ export class LinkConnector {
|
||||
|
||||
state.connectingTo = 'input'
|
||||
|
||||
this._setLegacyLinks(false)
|
||||
this.#setLegacyLinks(false)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -754,7 +754,7 @@ export class LinkConnector {
|
||||
const output = node.getOutputOnPos([canvasX, canvasY])
|
||||
|
||||
if (output) {
|
||||
this._dropOnOutput(node, output)
|
||||
this.#dropOnOutput(node, output)
|
||||
} else {
|
||||
this.connectToNode(node, event)
|
||||
}
|
||||
@@ -765,7 +765,7 @@ export class LinkConnector {
|
||||
|
||||
// Input slot
|
||||
if (inputOrSocket) {
|
||||
this._dropOnInput(node, inputOrSocket)
|
||||
this.#dropOnInput(node, inputOrSocket)
|
||||
} else {
|
||||
// Node background / title
|
||||
this.connectToNode(node, event)
|
||||
@@ -911,7 +911,7 @@ export class LinkConnector {
|
||||
return
|
||||
}
|
||||
|
||||
this._dropOnOutput(node, output)
|
||||
this.#dropOnOutput(node, output)
|
||||
} else if (connectingTo === 'input') {
|
||||
// Dropping new input link
|
||||
const input = node.findInputByType(firstLink.fromSlot.type)?.slot
|
||||
@@ -922,11 +922,11 @@ export class LinkConnector {
|
||||
return
|
||||
}
|
||||
|
||||
this._dropOnInput(node, input)
|
||||
this.#dropOnInput(node, input)
|
||||
}
|
||||
}
|
||||
|
||||
private _dropOnInput(node: LGraphNode, input: INodeInputSlot): void {
|
||||
#dropOnInput(node: LGraphNode, input: INodeInputSlot): void {
|
||||
for (const link of this.renderLinks) {
|
||||
if (!link.canConnectToInput(node, input)) continue
|
||||
|
||||
@@ -934,7 +934,7 @@ export class LinkConnector {
|
||||
}
|
||||
}
|
||||
|
||||
private _dropOnOutput(node: LGraphNode, output: INodeOutputSlot): void {
|
||||
#dropOnOutput(node: LGraphNode, output: INodeOutputSlot): void {
|
||||
for (const link of this.renderLinks) {
|
||||
if (!link.canConnectToOutput(node, output)) {
|
||||
if (
|
||||
@@ -1014,7 +1014,7 @@ export class LinkConnector {
|
||||
}
|
||||
|
||||
/** Sets connecting_links, used by some extensions still. */
|
||||
private _setLegacyLinks(fromSlotIsInput: boolean): void {
|
||||
#setLegacyLinks(fromSlotIsInput: boolean): void {
|
||||
const links = this.renderLinks.map((link) => {
|
||||
const input = fromSlotIsInput ? (link.fromSlot as INodeInputSlot) : null
|
||||
const output = fromSlotIsInput ? null : (link.fromSlot as INodeOutputSlot)
|
||||
@@ -1033,7 +1033,7 @@ export class LinkConnector {
|
||||
afterRerouteId
|
||||
} satisfies ConnectingLink
|
||||
})
|
||||
this._setConnectingLinks(links)
|
||||
this.#setConnectingLinks(links)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,10 +10,10 @@ import type { ReadOnlyRect, Size } from '@/lib/litegraph/src/interfaces'
|
||||
* - Width and height are then updated, clamped to min/max values
|
||||
*/
|
||||
export class ConstrainedSize {
|
||||
private _width: number = 0
|
||||
private _height: number = 0
|
||||
private _desiredWidth: number = 0
|
||||
private _desiredHeight: number = 0
|
||||
#width: number = 0
|
||||
#height: number = 0
|
||||
#desiredWidth: number = 0
|
||||
#desiredHeight: number = 0
|
||||
|
||||
minWidth: number = 0
|
||||
minHeight: number = 0
|
||||
@@ -21,29 +21,29 @@ export class ConstrainedSize {
|
||||
maxHeight: number = Infinity
|
||||
|
||||
get width() {
|
||||
return this._width
|
||||
return this.#width
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this._height
|
||||
return this.#height
|
||||
}
|
||||
|
||||
get desiredWidth() {
|
||||
return this._desiredWidth
|
||||
return this.#desiredWidth
|
||||
}
|
||||
|
||||
set desiredWidth(value: number) {
|
||||
this._desiredWidth = value
|
||||
this._width = clamp(value, this.minWidth, this.maxWidth)
|
||||
this.#desiredWidth = value
|
||||
this.#width = clamp(value, this.minWidth, this.maxWidth)
|
||||
}
|
||||
|
||||
get desiredHeight() {
|
||||
return this._desiredHeight
|
||||
return this.#desiredHeight
|
||||
}
|
||||
|
||||
set desiredHeight(value: number) {
|
||||
this._desiredHeight = value
|
||||
this._height = clamp(value, this.minHeight, this.maxHeight)
|
||||
this.#desiredHeight = value
|
||||
this.#height = clamp(value, this.minHeight, this.maxHeight)
|
||||
}
|
||||
|
||||
constructor(width: number, height: number) {
|
||||
@@ -70,6 +70,6 @@ export class ConstrainedSize {
|
||||
}
|
||||
|
||||
toSize(): Size {
|
||||
return [this._width, this._height]
|
||||
return [this.#width, this.#height]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ import { isInRectangle } from '@/lib/litegraph/src/measure'
|
||||
* - {@link size}: The size of the rectangle.
|
||||
*/
|
||||
export class Rectangle extends Float64Array {
|
||||
private _pos: Float64Array<ArrayBuffer> | undefined
|
||||
private _size: Float64Array<ArrayBuffer> | undefined
|
||||
#pos: Float64Array<ArrayBuffer> | undefined
|
||||
#size: Float64Array<ArrayBuffer> | undefined
|
||||
|
||||
constructor(
|
||||
x: number = 0,
|
||||
@@ -78,8 +78,8 @@ export class Rectangle extends Float64Array {
|
||||
* Updating the values of the returned object will update this rectangle.
|
||||
*/
|
||||
get pos(): Point {
|
||||
this._pos ??= this.subarray(0, 2)
|
||||
return this._pos! as unknown as Point
|
||||
this.#pos ??= this.subarray(0, 2)
|
||||
return this.#pos! as unknown as Point
|
||||
}
|
||||
|
||||
set pos(value: Readonly<Point>) {
|
||||
@@ -93,8 +93,8 @@ export class Rectangle extends Float64Array {
|
||||
* Updating the values of the returned object will update this rectangle.
|
||||
*/
|
||||
get size(): Size {
|
||||
this._size ??= this.subarray(2, 4)
|
||||
return this._size! as unknown as Size
|
||||
this.#size ??= this.subarray(2, 4)
|
||||
return this.#size! as unknown as Size
|
||||
}
|
||||
|
||||
set size(value: Readonly<Size>) {
|
||||
|
||||
@@ -23,15 +23,15 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
|
||||
return !!this.widget
|
||||
}
|
||||
|
||||
private _widgetRef: WeakRef<IBaseWidget> | undefined
|
||||
#widget: WeakRef<IBaseWidget> | undefined
|
||||
|
||||
/** Internal use only; API is not finalised and may change at any time. */
|
||||
get _widget(): IBaseWidget | undefined {
|
||||
return this._widgetRef?.deref()
|
||||
return this.#widget?.deref()
|
||||
}
|
||||
|
||||
set _widget(widget: IBaseWidget | undefined) {
|
||||
this._widgetRef = widget ? new WeakRef(widget) : undefined
|
||||
this.#widget = widget ? new WeakRef(widget) : undefined
|
||||
}
|
||||
|
||||
get collapsedPos(): Readonly<Point> {
|
||||
@@ -79,12 +79,4 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
|
||||
|
||||
ctx.textAlign = textAlign
|
||||
}
|
||||
|
||||
override toJSON(): INodeInputSlot {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
link: this.link,
|
||||
widget: this.widget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput
|
||||
import { isSubgraphOutput } from '@/lib/litegraph/src/subgraph/subgraphUtils'
|
||||
|
||||
export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
|
||||
#node: LGraphNode
|
||||
|
||||
links: LinkId[] | null
|
||||
_data?: unknown
|
||||
slot_index?: number
|
||||
@@ -25,7 +27,7 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
|
||||
|
||||
get collapsedPos(): Readonly<Point> {
|
||||
return [
|
||||
this._node._collapsed_width ?? LiteGraph.NODE_COLLAPSED_WIDTH,
|
||||
this.#node._collapsed_width ?? LiteGraph.NODE_COLLAPSED_WIDTH,
|
||||
LiteGraph.NODE_TITLE_HEIGHT * -0.5
|
||||
]
|
||||
}
|
||||
@@ -38,6 +40,7 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
|
||||
this.links = slot.links
|
||||
this._data = slot._data
|
||||
this.slot_index = slot.slot_index
|
||||
this.#node = node
|
||||
}
|
||||
|
||||
override isValidTarget(
|
||||
@@ -75,12 +78,4 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
|
||||
ctx.textAlign = textAlign
|
||||
ctx.strokeStyle = strokeStyle
|
||||
}
|
||||
|
||||
override toJSON(): INodeOutputSlot {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
links: this.links,
|
||||
slot_index: this.slot_index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
|
||||
pos?: Point
|
||||
|
||||
/** The offset from the parent node to the centre point of this slot. */
|
||||
private get _centreOffset(): Readonly<Point> {
|
||||
get #centreOffset(): Readonly<Point> {
|
||||
const nodePos = this.node.pos
|
||||
const { boundingRect } = this
|
||||
|
||||
@@ -55,9 +55,9 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
|
||||
/** The center point of this slot when the node is collapsed. */
|
||||
abstract get collapsedPos(): Readonly<Point>
|
||||
|
||||
protected _node: LGraphNode
|
||||
#node: LGraphNode
|
||||
get node(): LGraphNode {
|
||||
return this._node
|
||||
return this.#node
|
||||
}
|
||||
|
||||
get highlightColor(): CanvasColour {
|
||||
@@ -89,7 +89,7 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
|
||||
super(name, type, rectangle)
|
||||
|
||||
Object.assign(this, rest)
|
||||
this._node = node
|
||||
this.#node = node
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +126,7 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
|
||||
? this.highlightColor
|
||||
: LiteGraph.NODE_TEXT_COLOR
|
||||
|
||||
const pos = this._centreOffset
|
||||
const pos = this.#centreOffset
|
||||
const slot_type = this.type
|
||||
const slot_shape = (
|
||||
slot_type === SlotType.Array ? SlotShape.Grid : this.shape
|
||||
@@ -260,25 +260,6 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
|
||||
ctx.lineWidth = originalLineWidth
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom JSON serialization to prevent circular reference errors.
|
||||
* Returns only serializable slot properties without the node back-reference.
|
||||
*/
|
||||
toJSON(): INodeSlot {
|
||||
return {
|
||||
name: this.name,
|
||||
type: this.type,
|
||||
label: this.label,
|
||||
color_on: this.color_on,
|
||||
color_off: this.color_off,
|
||||
shape: this.shape,
|
||||
dir: this.dir,
|
||||
localized_name: this.localized_name,
|
||||
pos: this.pos,
|
||||
boundingRect: [...this.boundingRect] as [number, number, number, number]
|
||||
}
|
||||
}
|
||||
|
||||
drawCollapsed(ctx: CanvasRenderingContext2D) {
|
||||
const [x, y] = this.collapsedPos
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode, LiteGraph } from './litegraph'
|
||||
import type { NodeInputSlot } from './node/NodeInputSlot'
|
||||
import type { NodeOutputSlot } from './node/NodeOutputSlot'
|
||||
|
||||
class TestNode extends LGraphNode {
|
||||
static override title = 'TestNode'
|
||||
constructor() {
|
||||
super('TestNode')
|
||||
}
|
||||
}
|
||||
|
||||
LiteGraph.registerNodeType('test/TestNode', TestNode)
|
||||
|
||||
describe('Serialization - Circular Reference Prevention', () => {
|
||||
describe('LGraph.toJSON()', () => {
|
||||
it('should serialize without circular reference errors', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
expect(() => JSON.stringify(graph)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should return serialize() output from toJSON()', () => {
|
||||
const graph = new LGraph()
|
||||
const serialized = graph.serialize()
|
||||
const jsonOutput = graph.toJSON()
|
||||
|
||||
expect(jsonOutput).toEqual(serialized)
|
||||
})
|
||||
|
||||
it('should not include list_of_graphcanvas in JSON output', () => {
|
||||
const graph = new LGraph()
|
||||
const json = JSON.stringify(graph)
|
||||
const parsed = JSON.parse(json)
|
||||
|
||||
expect(parsed.list_of_graphcanvas).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NodeSlot.toJSON()', () => {
|
||||
it('NodeInputSlot should serialize without circular reference errors', () => {
|
||||
const graph = new LGraph()
|
||||
const node = LiteGraph.createNode('test/TestNode')!
|
||||
graph.add(node)
|
||||
node.addInput('test_input', 'TEST')
|
||||
|
||||
const inputSlot = node.inputs[0]
|
||||
|
||||
expect(() => JSON.stringify(inputSlot)).not.toThrow()
|
||||
})
|
||||
|
||||
it('NodeOutputSlot should serialize without circular reference errors', () => {
|
||||
const graph = new LGraph()
|
||||
const node = LiteGraph.createNode('test/TestNode')!
|
||||
graph.add(node)
|
||||
node.addOutput('test_output', 'TEST')
|
||||
|
||||
const outputSlot = node.outputs[0]
|
||||
|
||||
expect(() => JSON.stringify(outputSlot)).not.toThrow()
|
||||
})
|
||||
|
||||
it('NodeInputSlot.toJSON() should not include _node reference', () => {
|
||||
const graph = new LGraph()
|
||||
const node = LiteGraph.createNode('test/TestNode')!
|
||||
graph.add(node)
|
||||
node.addInput('test_input', 'TEST')
|
||||
|
||||
const inputSlot = node.inputs[0] as NodeInputSlot
|
||||
const json = inputSlot.toJSON()
|
||||
|
||||
expect('_node' in json).toBe(false)
|
||||
expect('node' in json).toBe(false)
|
||||
expect(json.name).toBe('test_input')
|
||||
expect(json.type).toBe('TEST')
|
||||
})
|
||||
|
||||
it('NodeOutputSlot.toJSON() should not include _node reference', () => {
|
||||
const graph = new LGraph()
|
||||
const node = LiteGraph.createNode('test/TestNode')!
|
||||
graph.add(node)
|
||||
node.addOutput('test_output', 'TEST')
|
||||
|
||||
const outputSlot = node.outputs[0] as NodeOutputSlot
|
||||
const json = outputSlot.toJSON()
|
||||
|
||||
expect('_node' in json).toBe(false)
|
||||
expect('node' in json).toBe(false)
|
||||
expect(json.name).toBe('test_output')
|
||||
expect(json.type).toBe('TEST')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Full graph with nodes - no circular references', () => {
|
||||
it('should serialize a graph with connected nodes', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const node1 = LiteGraph.createNode('test/TestNode')!
|
||||
const node2 = LiteGraph.createNode('test/TestNode')!
|
||||
graph.add(node1)
|
||||
graph.add(node2)
|
||||
|
||||
node1.addOutput('out', 'TEST')
|
||||
node2.addInput('in', 'TEST')
|
||||
|
||||
node1.connect(0, node2, 0)
|
||||
|
||||
expect(() => JSON.stringify(graph)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should serialize graph.serialize() output without errors', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const node1 = LiteGraph.createNode('test/TestNode')!
|
||||
const node2 = LiteGraph.createNode('test/TestNode')!
|
||||
graph.add(node1)
|
||||
graph.add(node2)
|
||||
|
||||
node1.addOutput('out', 'TEST')
|
||||
node2.addInput('in', 'TEST')
|
||||
|
||||
node1.connect(0, node2, 0)
|
||||
|
||||
const serialized = graph.serialize()
|
||||
|
||||
expect(() => JSON.stringify(serialized)).not.toThrow()
|
||||
expect(() => JSON.parse(JSON.stringify(serialized))).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -50,7 +50,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
inputs: { linkId: number | null; name: string; type: ISlotType }[]
|
||||
|
||||
/** Backing field for {@link id}. */
|
||||
private _id: ExecutionId
|
||||
#id: ExecutionId
|
||||
|
||||
/**
|
||||
* The path to the actual node through subgraph instances, represented as a list of all subgraph node IDs (instances),
|
||||
@@ -62,7 +62,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
* - `3` is the node ID of the actual node in the subgraph definition
|
||||
*/
|
||||
get id() {
|
||||
return this._id
|
||||
return this.#id
|
||||
}
|
||||
|
||||
get type() {
|
||||
@@ -106,7 +106,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
if (!node.graph) throw new NullGraphError()
|
||||
|
||||
// Set the internal ID of the DTO
|
||||
this._id = [...this.subgraphNodePath, this.node.id].join(':')
|
||||
this.#id = [...this.subgraphNodePath, this.node.id].join(':')
|
||||
this.graph = node.graph
|
||||
this.inputs = this.node.inputs.map((x) => ({
|
||||
linkId: x.link,
|
||||
@@ -265,7 +265,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
// Upstreamed: Bypass nodes are bypassed using the first input with matching type
|
||||
if (this.mode === LGraphEventMode.BYPASS) {
|
||||
// Bypass nodes by finding first input with matching type
|
||||
const matchingIndex = this._getBypassSlotIndex(slot, type)
|
||||
const matchingIndex = this.#getBypassSlotIndex(slot, type)
|
||||
|
||||
// No input types match - bypass not possible
|
||||
if (matchingIndex === -1) {
|
||||
@@ -281,7 +281,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
|
||||
const { node } = this
|
||||
if (node.isSubgraphNode())
|
||||
return this._resolveSubgraphOutput(slot, type, visited)
|
||||
return this.#resolveSubgraphOutput(slot, type, visited)
|
||||
|
||||
if (node.isVirtualNode) {
|
||||
const virtualLink = this.node.getInputLink(slot)
|
||||
@@ -321,7 +321,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
* @param type The type of the final target input (so type list matches are accurate)
|
||||
* @returns The index of the input slot on this node, otherwise `-1`.
|
||||
*/
|
||||
private _getBypassSlotIndex(slot: number, type: ISlotType) {
|
||||
#getBypassSlotIndex(slot: number, type: ISlotType) {
|
||||
const { inputs } = this
|
||||
const oppositeInput = inputs[slot]
|
||||
const outputType = this.node.outputs[slot].type
|
||||
@@ -358,7 +358,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
* @param visited A set of unique IDs to guard against infinite recursion. See {@link resolveInput}.
|
||||
* @returns A DTO for the node, and the origin ID / slot index of the output.
|
||||
*/
|
||||
private _resolveSubgraphOutput(
|
||||
#resolveSubgraphOutput(
|
||||
slot: number,
|
||||
type: ISlotType,
|
||||
visited: Set<string>
|
||||
|
||||
@@ -38,12 +38,12 @@ export abstract class SubgraphIONodeBase<
|
||||
static minWidth = 100
|
||||
static roundedRadius = 10
|
||||
|
||||
private readonly _boundingRect: Rectangle = new Rectangle()
|
||||
readonly #boundingRect: Rectangle = new Rectangle()
|
||||
|
||||
abstract readonly id: NodeId
|
||||
|
||||
get boundingRect(): Rectangle {
|
||||
return this._boundingRect
|
||||
return this.#boundingRect
|
||||
}
|
||||
|
||||
selected: boolean = false
|
||||
@@ -181,7 +181,7 @@ export abstract class SubgraphIONodeBase<
|
||||
): void {
|
||||
// Only allow renaming non-empty slots
|
||||
if (slot !== this.emptySlot) {
|
||||
this._promptForSlotRename(slot, event)
|
||||
this.#promptForSlotRename(slot, event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,14 +191,14 @@ export abstract class SubgraphIONodeBase<
|
||||
* @param event The event that triggered the context menu.
|
||||
*/
|
||||
protected showSlotContextMenu(slot: TSlot, event: CanvasPointerEvent): void {
|
||||
const options: (IContextMenuValue | null)[] = this._getSlotMenuOptions(slot)
|
||||
const options: (IContextMenuValue | null)[] = this.#getSlotMenuOptions(slot)
|
||||
if (!(options.length > 0)) return
|
||||
|
||||
new LiteGraph.ContextMenu(options, {
|
||||
event,
|
||||
title: slot.name || 'Subgraph Output',
|
||||
callback: (item: IContextMenuValue) => {
|
||||
this._onSlotMenuAction(item, slot, event)
|
||||
this.#onSlotMenuAction(item, slot, event)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -208,7 +208,7 @@ export abstract class SubgraphIONodeBase<
|
||||
* @param slot The slot to get the context menu options for.
|
||||
* @returns The context menu options.
|
||||
*/
|
||||
private _getSlotMenuOptions(slot: TSlot): (IContextMenuValue | null)[] {
|
||||
#getSlotMenuOptions(slot: TSlot): (IContextMenuValue | null)[] {
|
||||
const options: (IContextMenuValue | null)[] = []
|
||||
|
||||
// Disconnect option if slot has connections
|
||||
@@ -239,7 +239,7 @@ export abstract class SubgraphIONodeBase<
|
||||
* @param slot The slot
|
||||
* @param event The event that triggered the context menu.
|
||||
*/
|
||||
private _onSlotMenuAction(
|
||||
#onSlotMenuAction(
|
||||
selectedItem: IContextMenuValue,
|
||||
slot: TSlot,
|
||||
event: CanvasPointerEvent
|
||||
@@ -260,7 +260,7 @@ export abstract class SubgraphIONodeBase<
|
||||
// Rename the slot
|
||||
case 'rename':
|
||||
if (slot !== this.emptySlot) {
|
||||
this._promptForSlotRename(slot, event)
|
||||
this.#promptForSlotRename(slot, event)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -273,7 +273,7 @@ export abstract class SubgraphIONodeBase<
|
||||
* @param slot The slot to rename.
|
||||
* @param event The event that triggered the rename.
|
||||
*/
|
||||
private _promptForSlotRename(slot: TSlot, event: CanvasPointerEvent): void {
|
||||
#promptForSlotRename(slot: TSlot, event: CanvasPointerEvent): void {
|
||||
this.subgraph.canvasAction((c) =>
|
||||
c.prompt(
|
||||
'Slot name',
|
||||
@@ -362,7 +362,7 @@ export abstract class SubgraphIONodeBase<
|
||||
}
|
||||
|
||||
configure(data: ExportedSubgraphIONode): void {
|
||||
this._boundingRect.set(data.bounding)
|
||||
this.#boundingRect.set(data.bounding)
|
||||
this.pinned = data.pinned ?? false
|
||||
}
|
||||
|
||||
|
||||
@@ -35,14 +35,14 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
events = new CustomEventTarget<SubgraphInputEventMap>()
|
||||
|
||||
/** The linked widget that this slot is connected to. */
|
||||
private _widgetRef?: WeakRef<IBaseWidget>
|
||||
#widgetRef?: WeakRef<IBaseWidget>
|
||||
|
||||
get _widget() {
|
||||
return this._widgetRef?.deref()
|
||||
return this.#widgetRef?.deref()
|
||||
}
|
||||
|
||||
set _widget(widget) {
|
||||
this._widgetRef = widget ? new WeakRef(widget) : undefined
|
||||
this.#widgetRef = widget ? new WeakRef(widget) : undefined
|
||||
}
|
||||
|
||||
override connect(
|
||||
@@ -187,7 +187,7 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
* @returns `true` if the connection is valid, otherwise `false`.
|
||||
*/
|
||||
matchesWidget(otherWidget: IBaseWidget): boolean {
|
||||
const widget = this._widgetRef?.deref()
|
||||
const widget = this.#widgetRef?.deref()
|
||||
if (!widget) return true
|
||||
|
||||
if (
|
||||
|
||||
@@ -63,7 +63,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
override widgets: IBaseWidget[] = []
|
||||
|
||||
/** Manages lifecycle of all subgraph event listeners */
|
||||
private _eventAbortController = new AbortController()
|
||||
#eventAbortController = new AbortController()
|
||||
|
||||
constructor(
|
||||
/** The (sub)graph that contains this subgraph instance. */
|
||||
@@ -76,7 +76,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
// Update this node when the subgraph input / output slots are changed
|
||||
const subgraphEvents = this.subgraph.events
|
||||
const { signal } = this._eventAbortController
|
||||
const { signal } = this.#eventAbortController
|
||||
|
||||
subgraphEvents.addEventListener(
|
||||
'input-added',
|
||||
@@ -89,12 +89,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const { inputNode, input } = subgraph.links[linkId].resolve(subgraph)
|
||||
const widget = inputNode?.widgets?.find?.((w) => w.name == name)
|
||||
if (widget)
|
||||
this._setWidget(subgraphInput, existingInput, widget, input?.widget)
|
||||
this.#setWidget(subgraphInput, existingInput, widget, input?.widget)
|
||||
return
|
||||
}
|
||||
const input = this.addInput(name, type)
|
||||
|
||||
this._addSubgraphInputListeners(subgraphInput, input)
|
||||
this.#addSubgraphInputListeners(subgraphInput, input)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -179,7 +179,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
}
|
||||
|
||||
private _addSubgraphInputListeners(
|
||||
#addSubgraphInputListeners(
|
||||
subgraphInput: SubgraphInput,
|
||||
input: INodeInputSlot & Partial<ISubgraphInput>
|
||||
) {
|
||||
@@ -201,7 +201,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (!widget) return
|
||||
|
||||
const widgetLocator = e.detail.input.widget
|
||||
this._setWidget(subgraphInput, input, widget, widgetLocator)
|
||||
this.#setWidget(subgraphInput, input, widget, widgetLocator)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -288,7 +288,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
continue
|
||||
}
|
||||
|
||||
this._addSubgraphInputListeners(subgraphInput, input)
|
||||
this.#addSubgraphInputListeners(subgraphInput, input)
|
||||
|
||||
// Find the first widget that this slot is connected to
|
||||
for (const linkId of subgraphInput.linkIds) {
|
||||
@@ -318,13 +318,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const widget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!widget) continue
|
||||
|
||||
this._setWidget(subgraphInput, input, widget, targetInput.widget)
|
||||
this.#setWidget(subgraphInput, input, widget, targetInput.widget)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _setWidget(
|
||||
#setWidget(
|
||||
subgraphInput: Readonly<SubgraphInput>,
|
||||
input: INodeInputSlot,
|
||||
widget: Readonly<IBaseWidget>,
|
||||
@@ -553,7 +553,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
override onRemoved(): void {
|
||||
// Clean up all subgraph event listeners
|
||||
this._eventAbortController.abort()
|
||||
this.#eventAbortController.abort()
|
||||
|
||||
// Clean up all promoted widgets
|
||||
for (const widget of this.widgets) {
|
||||
|
||||
@@ -46,7 +46,7 @@ export abstract class SubgraphSlot
|
||||
return LiteGraph.NODE_SLOT_HEIGHT
|
||||
}
|
||||
|
||||
private readonly _pos: Point = [0, 0]
|
||||
readonly #pos: Point = [0, 0]
|
||||
|
||||
readonly measurement: ConstrainedSize = new ConstrainedSize(
|
||||
SubgraphSlot.defaultHeight,
|
||||
@@ -67,14 +67,14 @@ export abstract class SubgraphSlot
|
||||
)
|
||||
|
||||
override get pos() {
|
||||
return this._pos
|
||||
return this.#pos
|
||||
}
|
||||
|
||||
override set pos(value) {
|
||||
if (!value || value.length < 2) return
|
||||
|
||||
this._pos[0] = value[0]
|
||||
this._pos[1] = value[1]
|
||||
this.#pos[0] = value[0]
|
||||
this.#pos[1] = value[1]
|
||||
}
|
||||
|
||||
/** Whether this slot is connected to another slot. */
|
||||
|
||||
@@ -57,10 +57,10 @@ export abstract class BaseWidget<
|
||||
maxWidth?: number
|
||||
}
|
||||
|
||||
private _node: LGraphNode
|
||||
#node: LGraphNode
|
||||
/** The node that this widget belongs to. */
|
||||
get node() {
|
||||
return this._node
|
||||
return this.#node
|
||||
}
|
||||
|
||||
linkedWidgets?: IBaseWidget[]
|
||||
@@ -97,20 +97,20 @@ export abstract class BaseWidget<
|
||||
canvas: LGraphCanvas
|
||||
): boolean
|
||||
|
||||
private _value?: TWidget['value']
|
||||
#value?: TWidget['value']
|
||||
get value(): TWidget['value'] {
|
||||
return this._value
|
||||
return this.#value
|
||||
}
|
||||
|
||||
set value(value: TWidget['value']) {
|
||||
this._value = value
|
||||
this.#value = value
|
||||
}
|
||||
|
||||
constructor(widget: TWidget & { node: LGraphNode })
|
||||
constructor(widget: TWidget, node: LGraphNode)
|
||||
constructor(widget: TWidget & { node: LGraphNode }, node?: LGraphNode) {
|
||||
// Private fields
|
||||
this._node = node ?? widget.node
|
||||
this.#node = node ?? widget.node
|
||||
|
||||
// The set and get functions for DOM widget values are hacked on to the options object;
|
||||
// attempting to set value before options will throw.
|
||||
|
||||
@@ -1,115 +1,4 @@
|
||||
{
|
||||
"discover": {
|
||||
"searchPlaceholder": "Search workflows...",
|
||||
"filters": {
|
||||
"tags": "Tags",
|
||||
"models": "Models",
|
||||
"mediaType": "Media Type",
|
||||
"openSource": "Open Source models only",
|
||||
"cloudOnly": "Cloud only"
|
||||
},
|
||||
"resultsCount": "{count} result | {count} results",
|
||||
"noResults": "No templates found",
|
||||
"noResultsHint": "Try adjusting your search or filters",
|
||||
"detail": {
|
||||
"officialWorkflow": "Official Workflow",
|
||||
"mediaType": "Media Type",
|
||||
"openSource": "Uses open source models only",
|
||||
"requiredNodes": "Required Custom Nodes",
|
||||
"gallery": "Gallery",
|
||||
"author": "Comfy Org",
|
||||
"runWorkflow": "Run Workflow",
|
||||
"makeCopy": "Make a Copy",
|
||||
"openInHub": "Open in Hub",
|
||||
"appMode": "App Mode",
|
||||
"workflowPreview": "Workflow Preview",
|
||||
"previewError": "Failed to load preview",
|
||||
"previewHint": "Make a copy to save your changes",
|
||||
"runWorkflowNotImplemented": "Run workflow is not implemented yet",
|
||||
"makeCopyFailed": "Failed to copy workflow: {error}",
|
||||
"appNotReady": "ComfyUI is still loading. Please wait and try again."
|
||||
},
|
||||
"author": {
|
||||
"title": "Creator",
|
||||
"searchPlaceholder": "Search this creator's workflows",
|
||||
"workflowsCount": "{count} workflow | {count} workflows",
|
||||
"runsLabel": "runs",
|
||||
"copiesLabel": "copies",
|
||||
"rankLabel": "rank",
|
||||
"rankValue": "31",
|
||||
"rankCaption": "Last 30 days",
|
||||
"workflowsTitle": "Workflows",
|
||||
"tagline": "Designing workflows that feel polished, practical, and production-ready.",
|
||||
"aboutTitle": "About",
|
||||
"aboutDescription": "Focused on building clean, reliable workflows with thoughtful defaults and clear documentation.",
|
||||
"badgeCreator": "Creator profile",
|
||||
"badgeOpenSource": "Open-source friendly",
|
||||
"badgeTemplates": "Workflow templates",
|
||||
"openInHub": "Open in Hub",
|
||||
"noResults": "No workflows found for this creator",
|
||||
"noResultsHint": "Try a different search or check back later."
|
||||
},
|
||||
"share": {
|
||||
"share": "Share",
|
||||
"copyFailed": "Failed to copy link to clipboard",
|
||||
"noActiveWorkflow": "No active workflow to share",
|
||||
"inviteTab": {
|
||||
"title": "Share"
|
||||
},
|
||||
"publishTab": {
|
||||
"title": "Publish",
|
||||
"description": "Publish your workflow to Comfy Hub to share it with the community. Your workflow will be publicly visible and discoverable."
|
||||
},
|
||||
"inviteByEmail": {
|
||||
"emailLabel": "Email Address",
|
||||
"emailPlaceholder": "colleague{'@'}example.com",
|
||||
"roleLabel": "Permission Level",
|
||||
"roles": {
|
||||
"viewOnly": "View Only",
|
||||
"viewOnlyDescription": "Can view and run the workflow, but cannot make changes",
|
||||
"edit": "Can Edit",
|
||||
"editDescription": "Can view, run, and make changes to the workflow"
|
||||
},
|
||||
"sendInvite": "Send Invite",
|
||||
"inviteSent": "Invite Sent",
|
||||
"inviteSentDetail": "An invitation has been sent to {email}",
|
||||
"peopleWithAccess": "People with access",
|
||||
"inviteAnother": "Invite another person"
|
||||
},
|
||||
"publicUrl": {
|
||||
"title": "Share with public link",
|
||||
"description": "Anyone with this link can view and run your workflow, but they cannot edit it.",
|
||||
"copyLink": "Copy link",
|
||||
"copyLinkAppMode": "Copy link to App mode"
|
||||
},
|
||||
"publishToHubDialog": {
|
||||
"title": "Publish to Hub",
|
||||
"thumbnail": "Thumbnail",
|
||||
"thumbnailPreview": "Thumbnail preview",
|
||||
"uploadThumbnail": "Click to upload a thumbnail image",
|
||||
"titlePlaceholder": "Enter a title for your workflow",
|
||||
"descriptionPlaceholder": "Describe what your workflow does...",
|
||||
"addTag": "Add a tag and press Enter",
|
||||
"openSource": "Open Source",
|
||||
"openSourceDescription": "Uses only open source models",
|
||||
"publish": "Publish",
|
||||
"notImplemented": "Publishing to Hub is not implemented yet",
|
||||
"successTitle": "Published to Hub",
|
||||
"successDescription": "Your workflow is live on Comfy Hub.",
|
||||
"successOpen": "Open in Hub",
|
||||
"successCopy": "Copy link"
|
||||
}
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"title": "Home",
|
||||
"tabs": {
|
||||
"recents": "Recents",
|
||||
"discover": "Discover"
|
||||
},
|
||||
"recentsStubTitle": "No recent workflows yet",
|
||||
"recentsStubDescription": "Your recently opened workflows will appear here."
|
||||
},
|
||||
"g": {
|
||||
"user": "User",
|
||||
"you": "You",
|
||||
@@ -117,7 +6,6 @@
|
||||
"empty": "Empty",
|
||||
"noWorkflowsFound": "No workflows found.",
|
||||
"comingSoon": "Coming Soon",
|
||||
"appModePlaceholderDescription": "Will show the workflow in app mode and you will be able to run it",
|
||||
"download": "Download",
|
||||
"downloadImage": "Download image",
|
||||
"downloadVideo": "Download video",
|
||||
@@ -830,8 +718,6 @@
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates",
|
||||
"assets": "Assets",
|
||||
"discover": "Discover",
|
||||
"discoverPlaceholder": "Discover content coming soon",
|
||||
"mediaAssets": {
|
||||
"title": "Media Assets",
|
||||
"sortNewestFirst": "Newest first",
|
||||
@@ -944,8 +830,7 @@
|
||||
"browse": "Browse",
|
||||
"bookmarks": "Bookmarks",
|
||||
"open": "Open"
|
||||
},
|
||||
"discoverWorkflows": "Discover Workflows"
|
||||
}
|
||||
}
|
||||
},
|
||||
"helpCenter": {
|
||||
|
||||
@@ -35,9 +35,38 @@ vi.mock('vue-i18n', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockShowDialog = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
showDialog: vi.fn()
|
||||
showDialog: mockShowDialog
|
||||
})
|
||||
}))
|
||||
|
||||
const mockInvalidateModelsForCategory = vi.hoisted(() => vi.fn())
|
||||
const mockSetAssetDeleting = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateHistory = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateInputs = vi.hoisted(() => vi.fn())
|
||||
const mockHasCategory = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({
|
||||
setAssetDeleting: mockSetAssetDeleting,
|
||||
updateHistory: mockUpdateHistory,
|
||||
updateInputs: mockUpdateInputs,
|
||||
invalidateModelsForCategory: mockInvalidateModelsForCategory,
|
||||
hasCategory: mockHasCategory
|
||||
})
|
||||
}))
|
||||
|
||||
const mockGetCategoryForNodeType = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({
|
||||
getCategoryForNodeType: mockGetCategoryForNodeType,
|
||||
getAllNodeProviders: vi.fn((category: string) => {
|
||||
if (category === 'checkpoints' || category === 'loras') {
|
||||
return [{ nodeDef: { name: 'TestLoader' }, key: 'model_name' }]
|
||||
}
|
||||
return []
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -93,14 +122,33 @@ vi.mock('@/utils/typeGuardUtil', () => ({
|
||||
isResultItemType: vi.fn().mockReturnValue(true)
|
||||
}))
|
||||
|
||||
const mockGetAssetType = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/assets/utils/assetTypeUtil', () => ({
|
||||
getAssetType: vi.fn().mockReturnValue('input')
|
||||
getAssetType: mockGetAssetType
|
||||
}))
|
||||
|
||||
vi.mock('../schemas/assetMetadataSchema', () => ({
|
||||
getOutputAssetMetadata: vi.fn().mockReturnValue(null)
|
||||
}))
|
||||
|
||||
const mockDeleteAsset = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../services/assetService', () => ({
|
||||
assetService: {
|
||||
deleteAsset: mockDeleteAsset
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
deleteItem: vi.fn(),
|
||||
apiURL: vi.fn((path: string) => `http://localhost:8188/api${path}`),
|
||||
internalURL: vi.fn((path: string) => `http://localhost:8188${path}`),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
user: 'test-user'
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'test-asset-id',
|
||||
@@ -218,4 +266,114 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAssets - model cache invalidation', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
mockGetAssetType.mockReturnValue('input')
|
||||
mockDeleteAsset.mockResolvedValue(undefined)
|
||||
mockInvalidateModelsForCategory.mockClear()
|
||||
mockSetAssetDeleting.mockClear()
|
||||
mockUpdateHistory.mockClear()
|
||||
mockUpdateInputs.mockClear()
|
||||
mockHasCategory.mockClear()
|
||||
// By default, hasCategory returns true for model categories
|
||||
mockHasCategory.mockImplementation(
|
||||
(tag: string) => tag === 'checkpoints' || tag === 'loras'
|
||||
)
|
||||
})
|
||||
|
||||
it('should invalidate model cache when deleting a model asset', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const modelAsset = createMockAsset({
|
||||
id: 'checkpoint-1',
|
||||
name: 'model.safetensors',
|
||||
tags: ['models', 'checkpoints']
|
||||
})
|
||||
|
||||
mockShowDialog.mockImplementation(
|
||||
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
||||
void props.onConfirm()
|
||||
}
|
||||
)
|
||||
|
||||
await actions.deleteAssets(modelAsset)
|
||||
|
||||
// Only 'checkpoints' exists in cache; 'models' is excluded
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
|
||||
'checkpoints'
|
||||
)
|
||||
})
|
||||
|
||||
it('should invalidate multiple categories for multiple assets', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const assets = [
|
||||
createMockAsset({ id: '1', tags: ['models', 'checkpoints'] }),
|
||||
createMockAsset({ id: '2', tags: ['models', 'loras'] })
|
||||
]
|
||||
|
||||
mockShowDialog.mockImplementation(
|
||||
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
||||
void props.onConfirm()
|
||||
}
|
||||
)
|
||||
|
||||
await actions.deleteAssets(assets)
|
||||
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
|
||||
'checkpoints'
|
||||
)
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith('loras')
|
||||
})
|
||||
|
||||
it('should not invalidate model cache for non-model assets', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const inputAsset = createMockAsset({
|
||||
id: 'input-1',
|
||||
name: 'image.png',
|
||||
tags: ['input']
|
||||
})
|
||||
|
||||
mockShowDialog.mockImplementation(
|
||||
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
||||
void props.onConfirm()
|
||||
}
|
||||
)
|
||||
|
||||
await actions.deleteAssets(inputAsset)
|
||||
|
||||
// 'input' tag is excluded, so no cache invalidation
|
||||
expect(mockInvalidateModelsForCategory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should only invalidate categories that exist in cache', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
// hasCategory returns false for 'unknown-category'
|
||||
mockHasCategory.mockImplementation((tag: string) => tag === 'checkpoints')
|
||||
|
||||
const assets = [
|
||||
createMockAsset({ id: '1', tags: ['models', 'checkpoints'] }),
|
||||
createMockAsset({ id: '2', tags: ['models', 'unknown-category'] })
|
||||
]
|
||||
|
||||
mockShowDialog.mockImplementation(
|
||||
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
||||
void props.onConfirm()
|
||||
}
|
||||
)
|
||||
|
||||
await actions.deleteAssets(assets)
|
||||
|
||||
// Only checkpoints should be invalidated (unknown-category not in cache)
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
|
||||
'checkpoints'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -586,6 +586,25 @@ export function useMediaAssetActions() {
|
||||
await assetsStore.updateInputs()
|
||||
}
|
||||
|
||||
// Invalidate model caches for affected categories
|
||||
const modelCategories = new Set<string>()
|
||||
const excludedTags = ['models', 'input', 'output']
|
||||
|
||||
for (const asset of assetArray) {
|
||||
for (const tag of asset.tags ?? []) {
|
||||
if (excludedTags.includes(tag)) continue
|
||||
if (assetsStore.hasCategory(tag)) {
|
||||
modelCategories.add(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(
|
||||
[...modelCategories].map((category) =>
|
||||
assetsStore.invalidateModelsForCategory(category)
|
||||
)
|
||||
)
|
||||
|
||||
// Show appropriate feedback based on results
|
||||
if (failed.length === 0) {
|
||||
toast.add({
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { toRaw } from 'vue'
|
||||
|
||||
import { RESERVED_BY_TEXT_INPUT } from './reserved'
|
||||
import type { KeyCombo } from './types'
|
||||
|
||||
export class KeyComboImpl implements KeyCombo {
|
||||
key: string
|
||||
ctrl: boolean
|
||||
alt: boolean
|
||||
shift: boolean
|
||||
|
||||
constructor(obj: KeyCombo) {
|
||||
this.key = obj.key
|
||||
this.ctrl = obj.ctrl ?? false
|
||||
this.alt = obj.alt ?? false
|
||||
this.shift = obj.shift ?? false
|
||||
}
|
||||
|
||||
static fromEvent(event: KeyboardEvent) {
|
||||
return new KeyComboImpl({
|
||||
key: event.key,
|
||||
ctrl: event.ctrlKey || event.metaKey,
|
||||
alt: event.altKey,
|
||||
shift: event.shiftKey
|
||||
})
|
||||
}
|
||||
|
||||
equals(other: unknown): boolean {
|
||||
const raw = toRaw(other)
|
||||
|
||||
return raw instanceof KeyComboImpl
|
||||
? this.key.toUpperCase() === raw.key.toUpperCase() &&
|
||||
this.ctrl === raw.ctrl &&
|
||||
this.alt === raw.alt &&
|
||||
this.shift === raw.shift
|
||||
: false
|
||||
}
|
||||
|
||||
serialize(): string {
|
||||
return `${this.key.toUpperCase()}:${this.ctrl}:${this.alt}:${this.shift}`
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.getKeySequences().join(' + ')
|
||||
}
|
||||
|
||||
get hasModifier(): boolean {
|
||||
return this.ctrl || this.alt || this.shift
|
||||
}
|
||||
|
||||
get isModifier(): boolean {
|
||||
return ['Control', 'Meta', 'Alt', 'Shift'].includes(this.key)
|
||||
}
|
||||
|
||||
get modifierCount(): number {
|
||||
const modifiers = [this.ctrl, this.alt, this.shift]
|
||||
return modifiers.reduce((acc, cur) => acc + Number(cur), 0)
|
||||
}
|
||||
|
||||
get isShiftOnly(): boolean {
|
||||
return this.shift && this.modifierCount === 1
|
||||
}
|
||||
|
||||
get isReservedByTextInput(): boolean {
|
||||
return (
|
||||
!this.hasModifier ||
|
||||
this.isShiftOnly ||
|
||||
RESERVED_BY_TEXT_INPUT.has(this.toString())
|
||||
)
|
||||
}
|
||||
|
||||
getKeySequences(): string[] {
|
||||
const sequences: string[] = []
|
||||
if (this.ctrl) {
|
||||
sequences.push('Ctrl')
|
||||
}
|
||||
if (this.alt) {
|
||||
sequences.push('Alt')
|
||||
}
|
||||
if (this.shift) {
|
||||
sequences.push('Shift')
|
||||
}
|
||||
sequences.push(this.key)
|
||||
return sequences
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { toRaw } from 'vue'
|
||||
|
||||
import { KeyComboImpl } from './keyCombo'
|
||||
import type { Keybinding } from './types'
|
||||
|
||||
export class KeybindingImpl implements Keybinding {
|
||||
commandId: string
|
||||
combo: KeyComboImpl
|
||||
targetElementId?: string
|
||||
|
||||
constructor(obj: Keybinding) {
|
||||
this.commandId = obj.commandId
|
||||
this.combo = new KeyComboImpl(obj.combo)
|
||||
this.targetElementId = obj.targetElementId
|
||||
}
|
||||
|
||||
equals(other: unknown): boolean {
|
||||
const raw = toRaw(other)
|
||||
|
||||
return raw instanceof KeybindingImpl
|
||||
? this.commandId === raw.commandId &&
|
||||
this.combo.equals(raw.combo) &&
|
||||
this.targetElementId === raw.targetElementId
|
||||
: false
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CORE_KEYBINDINGS } from '@/platform/keybindings/defaults'
|
||||
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn(() => [])
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: vi.fn(() => ({
|
||||
dialogStack: []
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: null
|
||||
}
|
||||
}))
|
||||
|
||||
describe('keybindingService - Escape key handling', () => {
|
||||
let keybindingService: ReturnType<typeof useKeybindingService>
|
||||
let mockCommandExecute: ReturnType<typeof useCommandStore>['execute']
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
mockCommandExecute = vi.fn()
|
||||
commandStore.execute = mockCommandExecute
|
||||
|
||||
vi.mocked(useDialogStore).mockReturnValue({
|
||||
dialogStack: [] as DialogInstance[]
|
||||
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
|
||||
typeof useDialogStore
|
||||
>)
|
||||
|
||||
keybindingService = useKeybindingService()
|
||||
keybindingService.registerCoreKeybindings()
|
||||
})
|
||||
|
||||
function createKeyboardEvent(
|
||||
key: string,
|
||||
options: {
|
||||
ctrlKey?: boolean
|
||||
altKey?: boolean
|
||||
metaKey?: boolean
|
||||
shiftKey?: boolean
|
||||
} = {}
|
||||
): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
ctrlKey: options.ctrlKey ?? false,
|
||||
altKey: options.altKey ?? false,
|
||||
metaKey: options.metaKey ?? false,
|
||||
shiftKey: options.shiftKey ?? false,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
event.preventDefault = vi.fn()
|
||||
event.composedPath = vi.fn(() => [document.body])
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
it('should execute Escape keybinding when no dialogs are open', async () => {
|
||||
vi.mocked(useDialogStore).mockReturnValue({
|
||||
dialogStack: [] as DialogInstance[]
|
||||
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
|
||||
typeof useDialogStore
|
||||
>)
|
||||
|
||||
const event = createKeyboardEvent('Escape')
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(mockCommandExecute).toHaveBeenCalledWith('Comfy.Graph.ExitSubgraph')
|
||||
})
|
||||
|
||||
it('should NOT execute Escape keybinding when dialogs are open', async () => {
|
||||
vi.mocked(useDialogStore).mockReturnValue({
|
||||
dialogStack: [{ key: 'test-dialog' } as DialogInstance]
|
||||
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
|
||||
typeof useDialogStore
|
||||
>)
|
||||
|
||||
keybindingService = useKeybindingService()
|
||||
|
||||
const event = createKeyboardEvent('Escape')
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(mockCommandExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should execute Escape keybinding with modifiers regardless of dialog state', async () => {
|
||||
vi.mocked(useDialogStore).mockReturnValue({
|
||||
dialogStack: [{ key: 'test-dialog' } as DialogInstance]
|
||||
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
|
||||
typeof useDialogStore
|
||||
>)
|
||||
|
||||
const keybindingStore = useKeybindingStore()
|
||||
keybindingStore.addDefaultKeybinding(
|
||||
new KeybindingImpl({
|
||||
commandId: 'Test.CtrlEscape',
|
||||
combo: { key: 'Escape', ctrl: true }
|
||||
})
|
||||
)
|
||||
|
||||
keybindingService = useKeybindingService()
|
||||
|
||||
const event = createKeyboardEvent('Escape', { ctrlKey: true })
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(mockCommandExecute).toHaveBeenCalledWith('Test.CtrlEscape')
|
||||
})
|
||||
|
||||
it('should verify Escape keybinding exists in CORE_KEYBINDINGS', () => {
|
||||
const escapeBinding = CORE_KEYBINDINGS.find(
|
||||
(kb) => kb.combo.key === 'Escape' && !kb.combo.ctrl && !kb.combo.alt
|
||||
)
|
||||
|
||||
expect(escapeBinding).toBeDefined()
|
||||
expect(escapeBinding?.commandId).toBe('Comfy.Graph.ExitSubgraph')
|
||||
})
|
||||
|
||||
it('should create correct KeyComboImpl from Escape event', () => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false
|
||||
})
|
||||
|
||||
const keyCombo = KeyComboImpl.fromEvent(event)
|
||||
|
||||
expect(keyCombo.key).toBe('Escape')
|
||||
expect(keyCombo.ctrl).toBe(false)
|
||||
expect(keyCombo.alt).toBe(false)
|
||||
expect(keyCombo.shift).toBe(false)
|
||||
})
|
||||
|
||||
it('should still close legacy modals on Escape when no keybinding matched', async () => {
|
||||
setActivePinia(createPinia())
|
||||
keybindingService = useKeybindingService()
|
||||
|
||||
const mockModal = document.createElement('div')
|
||||
mockModal.className = 'comfy-modal'
|
||||
mockModal.style.display = 'block'
|
||||
document.body.appendChild(mockModal)
|
||||
|
||||
const originalGetComputedStyle = window.getComputedStyle
|
||||
window.getComputedStyle = vi.fn().mockReturnValue({
|
||||
getPropertyValue: vi.fn().mockReturnValue('block')
|
||||
})
|
||||
|
||||
try {
|
||||
const event = createKeyboardEvent('Escape')
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(mockModal.style.display).toBe('none')
|
||||
} finally {
|
||||
document.body.removeChild(mockModal)
|
||||
window.getComputedStyle = originalGetComputedStyle
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import type { ColorPalettes } from '@/schemas/colorPaletteSchema'
|
||||
import type { Keybinding } from '@/platform/keybindings/types'
|
||||
import type { Keybinding } from '@/schemas/keyBindingSchema'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
import { breakpointsTailwind } from '@vueuse/core'
|
||||
|
||||
@@ -158,15 +158,7 @@ const hasMultipleVideos = computed(() => props.imageUrls.length > 1)
|
||||
// Watch for URL changes and reset state
|
||||
watch(
|
||||
() => props.imageUrls,
|
||||
(newUrls, oldUrls) => {
|
||||
// Only reset state if URLs actually changed (not just array reference)
|
||||
const urlsChanged =
|
||||
!oldUrls ||
|
||||
newUrls.length !== oldUrls.length ||
|
||||
newUrls.some((url, i) => url !== oldUrls[i])
|
||||
|
||||
if (!urlsChanged) return
|
||||
|
||||
(newUrls) => {
|
||||
// Reset current index if it's out of bounds
|
||||
if (currentIndex.value >= newUrls.length) {
|
||||
currentIndex.value = 0
|
||||
@@ -177,7 +169,7 @@ watch(
|
||||
videoError.value = false
|
||||
showLoader.value = newUrls.length > 0
|
||||
},
|
||||
{ immediate: true }
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
|
||||
@@ -308,80 +308,4 @@ describe('ImagePreview', () => {
|
||||
expect(imgElement.exists()).toBe(true)
|
||||
expect(imgElement.attributes('alt')).toBe('Node output 2')
|
||||
})
|
||||
|
||||
describe('URL change detection', () => {
|
||||
it('should NOT reset loading state when imageUrls prop is reassigned with identical URLs', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const urls = ['/api/view?filename=test.png&type=output']
|
||||
const wrapper = mountImagePreview({ imageUrls: urls })
|
||||
|
||||
// Simulate image load completing
|
||||
const img = wrapper.find('img')
|
||||
await img.trigger('load')
|
||||
await nextTick()
|
||||
|
||||
// Verify loader is hidden after load
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||
|
||||
// Reassign with new array reference but same content
|
||||
await wrapper.setProps({ imageUrls: [...urls] })
|
||||
await nextTick()
|
||||
|
||||
// Advance past the 250ms delayed loader timeout
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await nextTick()
|
||||
|
||||
// Loading state should NOT have been reset - aria-busy should still be false
|
||||
// because the URLs are identical (just a new array reference)
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('should reset loading state when imageUrls prop changes to different URLs', async () => {
|
||||
const urls = ['/api/view?filename=test.png&type=output']
|
||||
const wrapper = mountImagePreview({ imageUrls: urls })
|
||||
|
||||
// Simulate image load completing
|
||||
const img = wrapper.find('img')
|
||||
await img.trigger('load')
|
||||
await nextTick()
|
||||
|
||||
// Verify loader is hidden
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||
|
||||
// Change to different URL
|
||||
await wrapper.setProps({
|
||||
imageUrls: ['/api/view?filename=different.png&type=output']
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
// After 250ms timeout, loading state should be reset (aria-busy="true")
|
||||
// We can check the internal state via the Skeleton appearing
|
||||
// or wait for the timeout
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle empty to non-empty URL transitions correctly', async () => {
|
||||
const wrapper = mountImagePreview({ imageUrls: [] })
|
||||
|
||||
// No preview initially
|
||||
expect(wrapper.find('.image-preview').exists()).toBe(false)
|
||||
|
||||
// Add URLs
|
||||
await wrapper.setProps({
|
||||
imageUrls: ['/api/view?filename=test.png&type=output']
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
// Preview should appear
|
||||
expect(wrapper.find('.image-preview').exists()).toBe(true)
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -176,15 +176,7 @@ const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
|
||||
// Watch for URL changes and reset state
|
||||
watch(
|
||||
() => props.imageUrls,
|
||||
(newUrls, oldUrls) => {
|
||||
// Only reset state if URLs actually changed (not just array reference)
|
||||
const urlsChanged =
|
||||
!oldUrls ||
|
||||
newUrls.length !== oldUrls.length ||
|
||||
newUrls.some((url, i) => url !== oldUrls[i])
|
||||
|
||||
if (!urlsChanged) return
|
||||
|
||||
(newUrls) => {
|
||||
// Reset current index if it's out of bounds
|
||||
if (currentIndex.value >= newUrls.length) {
|
||||
currentIndex.value = 0
|
||||
@@ -196,7 +188,7 @@ watch(
|
||||
imageError.value = false
|
||||
if (newUrls.length > 0) startDelayedLoader()
|
||||
},
|
||||
{ immediate: true }
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
|
||||
@@ -551,12 +551,6 @@ const showAdvancedState = customRef((track, trigger) => {
|
||||
}
|
||||
})
|
||||
|
||||
const hasVideoInput = computed(() => {
|
||||
return (
|
||||
lgraphNode.value?.inputs?.some((input) => input.type === 'VIDEO') ?? false
|
||||
)
|
||||
})
|
||||
|
||||
const nodeMedia = computed(() => {
|
||||
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
|
||||
const node = lgraphNode.value
|
||||
@@ -566,9 +560,13 @@ const nodeMedia = computed(() => {
|
||||
const urls = nodeOutputs.getNodeImageUrls(node)
|
||||
if (!urls?.length) return undefined
|
||||
|
||||
// Determine media type from previewMediaType or fallback to input slot types
|
||||
// Note: Despite the field name "images", videos are also included in outputs
|
||||
// TODO: fix the backend to return videos using the videos key instead of the images key
|
||||
const hasVideoInput = node.inputs?.some((input) => input.type === 'VIDEO')
|
||||
const type =
|
||||
node.previewMediaType === 'video' ||
|
||||
(!node.previewMediaType && hasVideoInput.value)
|
||||
(!node.previewMediaType && hasVideoInput)
|
||||
? 'video'
|
||||
: 'image'
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
>
|
||||
<InputSlot
|
||||
v-for="(input, index) in filteredInputs"
|
||||
:key="`input-${input.name}`"
|
||||
:key="`input-${index}`"
|
||||
:slot-data="input"
|
||||
:node-type="nodeData?.type || ''"
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
@@ -23,7 +23,7 @@
|
||||
>
|
||||
<OutputSlot
|
||||
v-for="(output, index) in nodeData.outputs"
|
||||
:key="`output-${output.name}`"
|
||||
:key="`output-${index}`"
|
||||
:slot-data="output"
|
||||
:node-type="nodeData?.type || ''"
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
|
||||
@@ -183,14 +183,6 @@ export function useSlotElementTracking(options: {
|
||||
// Register slot
|
||||
const slotKey = getSlotKey(nodeId, index, type === 'input')
|
||||
|
||||
// Defensive cleanup: remove stale entry if it exists with different element
|
||||
// This handles edge cases where Vue component reuse prevents proper unmount
|
||||
const existingEntry = node.slots.get(slotKey)
|
||||
if (existingEntry && existingEntry.el !== el) {
|
||||
delete existingEntry.el.dataset.slotKey
|
||||
layoutStore.deleteSlotLayout(slotKey)
|
||||
}
|
||||
|
||||
el.dataset.slotKey = slotKey
|
||||
node.slots.set(slotKey, { el, index, type })
|
||||
|
||||
|
||||
@@ -68,11 +68,6 @@ const router = createRouter({
|
||||
path: 'user-select',
|
||||
name: 'UserSelectView',
|
||||
component: () => import('@/views/UserSelectView.vue')
|
||||
},
|
||||
{
|
||||
path: 'profiles/:slug',
|
||||
name: 'AuthorProfileView',
|
||||
component: () => import('@/views/AuthorProfileView.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod'
|
||||
import { LinkMarkerShape } from '@/lib/litegraph/src/litegraph'
|
||||
import { zNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { colorPalettesSchema } from '@/schemas/colorPaletteSchema'
|
||||
import { zKeybinding } from '@/platform/keybindings/types'
|
||||
import { zKeybinding } from '@/schemas/keyBindingSchema'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// KeyCombo schema
|
||||
const zKeyCombo = z.object({
|
||||
key: z.string(),
|
||||
ctrl: z.boolean().optional(),
|
||||
@@ -8,11 +9,17 @@ const zKeyCombo = z.object({
|
||||
meta: z.boolean().optional()
|
||||
})
|
||||
|
||||
// Keybinding schema
|
||||
export const zKeybinding = z.object({
|
||||
commandId: z.string(),
|
||||
combo: zKeyCombo,
|
||||
// Optional target element ID to limit keybinding to.
|
||||
// Note: Currently only used to distinguish between global keybindings
|
||||
// and litegraph canvas keybindings.
|
||||
// Do NOT use this field in extensions as it has no effect.
|
||||
targetElementId: z.string().optional()
|
||||
})
|
||||
|
||||
// Infer types from schemas
|
||||
export type KeyCombo = z.infer<typeof zKeyCombo>
|
||||
export type Keybinding = z.infer<typeof zKeybinding>
|
||||
@@ -294,7 +294,7 @@ export class PromptExecutionError extends Error {
|
||||
}
|
||||
|
||||
export class ComfyApi extends EventTarget {
|
||||
private _registered = new Set()
|
||||
#registered = new Set()
|
||||
api_host: string
|
||||
api_base: string
|
||||
/**
|
||||
@@ -451,7 +451,7 @@ export class ComfyApi extends EventTarget {
|
||||
) {
|
||||
// Type assertion: strictFunctionTypes. So long as we emit events in a type-safe fashion, this is safe.
|
||||
super.addEventListener(type, callback as EventListener, options)
|
||||
this._registered.add(type)
|
||||
this.#registered.add(type)
|
||||
}
|
||||
|
||||
override removeEventListener<TEvent extends keyof ApiEvents>(
|
||||
@@ -492,7 +492,7 @@ export class ComfyApi extends EventTarget {
|
||||
/**
|
||||
* Poll status for colab and other things that don't support websockets.
|
||||
*/
|
||||
private _pollQueue() {
|
||||
#pollQueue() {
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const resp = await this.fetchApi('/prompt')
|
||||
@@ -568,7 +568,7 @@ export class ComfyApi extends EventTarget {
|
||||
this.socket.addEventListener('error', () => {
|
||||
if (this.socket) this.socket.close()
|
||||
if (!isReconnect && !opened) {
|
||||
this._pollQueue()
|
||||
this.#pollQueue()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -691,7 +691,7 @@ export class ComfyApi extends EventTarget {
|
||||
)
|
||||
break
|
||||
default:
|
||||
if (this._registered.has(msg.type)) {
|
||||
if (this.#registered.has(msg.type)) {
|
||||
// Fallback for custom types - calls super direct.
|
||||
super.dispatchEvent(
|
||||
new CustomEvent(msg.type, { detail: msg.data })
|
||||
@@ -956,7 +956,7 @@ export class ComfyApi extends EventTarget {
|
||||
* @param {*} type The endpoint to post to
|
||||
* @param {*} body Optional POST data
|
||||
*/
|
||||
private async _postItem(type: string, body?: Record<string, unknown>) {
|
||||
async #postItem(type: string, body?: Record<string, unknown>) {
|
||||
try {
|
||||
await this.fetchApi('/' + type, {
|
||||
method: 'POST',
|
||||
@@ -976,7 +976,7 @@ export class ComfyApi extends EventTarget {
|
||||
* @param {number} id The id of the item to delete
|
||||
*/
|
||||
async deleteItem(type: string, id: string) {
|
||||
await this._postItem(type, { delete: [id] })
|
||||
await this.#postItem(type, { delete: [id] })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -984,7 +984,7 @@ export class ComfyApi extends EventTarget {
|
||||
* @param {string} type The type of list to clear, queue or history
|
||||
*/
|
||||
async clearItems(type: string) {
|
||||
await this._postItem(type, { clear: true })
|
||||
await this.#postItem(type, { clear: true })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -993,7 +993,7 @@ export class ComfyApi extends EventTarget {
|
||||
* @param {string | null} [runningPromptId] Optional Running Prompt ID to interrupt
|
||||
*/
|
||||
async interrupt(runningPromptId: string | null) {
|
||||
await this._postItem(
|
||||
await this.#postItem(
|
||||
'interrupt',
|
||||
runningPromptId ? { prompt_id: runningPromptId } : undefined
|
||||
)
|
||||
|
||||
@@ -59,8 +59,7 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useModelStore } from '@/stores/modelStore'
|
||||
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
|
||||
@@ -241,17 +241,17 @@ function dragElement(dragEl): () => void {
|
||||
}
|
||||
|
||||
class ComfyList {
|
||||
private _type
|
||||
private _text
|
||||
private _reverse
|
||||
#type
|
||||
#text
|
||||
#reverse
|
||||
element: HTMLDivElement
|
||||
button?: HTMLButtonElement
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
constructor(text, type?, reverse?) {
|
||||
this._text = text
|
||||
this._type = type || text.toLowerCase()
|
||||
this._reverse = reverse || false
|
||||
this.#text = text
|
||||
this.#type = type || text.toLowerCase()
|
||||
this.#reverse = reverse || false
|
||||
this.element = $el('div.comfy-list') as HTMLDivElement
|
||||
this.element.style.display = 'none'
|
||||
}
|
||||
@@ -261,7 +261,7 @@ class ComfyList {
|
||||
}
|
||||
|
||||
async load() {
|
||||
const items = await api.getItems(this._type)
|
||||
const items = await api.getItems(this.#type)
|
||||
this.element.replaceChildren(
|
||||
...Object.keys(items).flatMap((section) => [
|
||||
$el('h4', {
|
||||
@@ -269,12 +269,12 @@ class ComfyList {
|
||||
}),
|
||||
$el('div.comfy-list-items', [
|
||||
// @ts-expect-error fixme ts strict error
|
||||
...(this._reverse ? items[section].reverse() : items[section]).map(
|
||||
...(this.#reverse ? items[section].reverse() : items[section]).map(
|
||||
(item: LegacyQueueItem) => {
|
||||
// Allow items to specify a custom remove action (e.g. for interrupt current prompt)
|
||||
const removeAction = item.remove ?? {
|
||||
name: 'Delete',
|
||||
cb: () => api.deleteItem(this._type, item.prompt[1])
|
||||
cb: () => api.deleteItem(this.#type, item.prompt[1])
|
||||
}
|
||||
return $el('div', { textContent: item.prompt[0] + ': ' }, [
|
||||
$el('button', {
|
||||
@@ -311,9 +311,9 @@ class ComfyList {
|
||||
]),
|
||||
$el('div.comfy-list-actions', [
|
||||
$el('button', {
|
||||
textContent: 'Clear ' + this._text,
|
||||
textContent: 'Clear ' + this.#text,
|
||||
onclick: async () => {
|
||||
await api.clearItems(this._type)
|
||||
await api.clearItems(this.#type)
|
||||
await this.load()
|
||||
}
|
||||
}),
|
||||
@@ -339,7 +339,7 @@ class ComfyList {
|
||||
hide() {
|
||||
this.element.style.display = 'none'
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.button.textContent = 'View ' + this._text
|
||||
this.button.textContent = 'View ' + this.#text
|
||||
}
|
||||
|
||||
toggle() {
|
||||
|
||||
@@ -6,7 +6,7 @@ type DialogAction<T> = string | { value?: T; text: string }
|
||||
export class ComfyAsyncDialog<
|
||||
T = string | null
|
||||
> extends ComfyDialog<HTMLDialogElement> {
|
||||
private _resolve: (value: T | null) => void = () => {}
|
||||
#resolve: (value: T | null) => void = () => {}
|
||||
|
||||
constructor(actions?: Array<DialogAction<T>>) {
|
||||
super(
|
||||
@@ -30,7 +30,7 @@ export class ComfyAsyncDialog<
|
||||
super.show(html)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this._resolve = resolve
|
||||
this.#resolve = resolve
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,12 +43,12 @@ export class ComfyAsyncDialog<
|
||||
this.element.showModal()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this._resolve = resolve
|
||||
this.#resolve = resolve
|
||||
})
|
||||
}
|
||||
|
||||
override close(result: T | null = null) {
|
||||
this._resolve(result)
|
||||
this.#resolve(result)
|
||||
this.element.close()
|
||||
super.close()
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ type ComfyButtonProps = {
|
||||
}
|
||||
|
||||
export class ComfyButton implements ComfyComponent<HTMLElement> {
|
||||
private _over = 0
|
||||
private _popupOpen = false
|
||||
#over = 0
|
||||
#popupOpen = false
|
||||
isOver = false
|
||||
iconElement = $el('i.mdi')
|
||||
contentElement = $el('span')
|
||||
@@ -123,7 +123,7 @@ export class ComfyButton implements ComfyComponent<HTMLElement> {
|
||||
this.element.addEventListener('click', (e) => {
|
||||
if (this.popup) {
|
||||
// we are either a touch device or triggered by click not hover
|
||||
if (!this._over) {
|
||||
if (!this.#over) {
|
||||
this.popup.toggle()
|
||||
}
|
||||
}
|
||||
@@ -157,7 +157,7 @@ export class ComfyButton implements ComfyComponent<HTMLElement> {
|
||||
internalClasses.push('disabled')
|
||||
}
|
||||
if (this.popup) {
|
||||
if (this._popupOpen) {
|
||||
if (this.#popupOpen) {
|
||||
internalClasses.push('popup-open')
|
||||
} else {
|
||||
internalClasses.push('popup-closed')
|
||||
@@ -172,16 +172,16 @@ export class ComfyButton implements ComfyComponent<HTMLElement> {
|
||||
if (mode === 'hover') {
|
||||
for (const el of [this.element, this.popup.element]) {
|
||||
el.addEventListener('mouseenter', () => {
|
||||
this.popup.open = !!++this._over
|
||||
this.popup.open = !!++this.#over
|
||||
})
|
||||
el.addEventListener('mouseleave', () => {
|
||||
this.popup.open = !!--this._over
|
||||
this.popup.open = !!--this.#over
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
popup.addEventListener('change', () => {
|
||||
this._popupOpen = popup.open
|
||||
this.#popupOpen = popup.open
|
||||
this.updateClasses()
|
||||
})
|
||||
|
||||
|
||||
@@ -54,9 +54,9 @@ export class ComfyPopup extends EventTarget {
|
||||
this.open = prop(this, 'open', false, (v, o) => {
|
||||
if (v === o) return
|
||||
if (v) {
|
||||
this._show()
|
||||
this.#show()
|
||||
} else {
|
||||
this._hide()
|
||||
this.#hide()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -65,24 +65,24 @@ export class ComfyPopup extends EventTarget {
|
||||
this.open = !this.open
|
||||
}
|
||||
|
||||
private _hide() {
|
||||
#hide() {
|
||||
this.element.classList.remove('open')
|
||||
window.removeEventListener('resize', this.update)
|
||||
window.removeEventListener('click', this._clickHandler, { capture: true })
|
||||
window.removeEventListener('keydown', this._escHandler, { capture: true })
|
||||
window.removeEventListener('click', this.#clickHandler, { capture: true })
|
||||
window.removeEventListener('keydown', this.#escHandler, { capture: true })
|
||||
|
||||
this.dispatchEvent(new CustomEvent('close'))
|
||||
this.dispatchEvent(new CustomEvent('change'))
|
||||
}
|
||||
|
||||
private _show() {
|
||||
#show() {
|
||||
this.element.classList.add('open')
|
||||
this.update()
|
||||
|
||||
window.addEventListener('resize', this.update)
|
||||
window.addEventListener('click', this._clickHandler, { capture: true })
|
||||
window.addEventListener('click', this.#clickHandler, { capture: true })
|
||||
if (this.closeOnEscape) {
|
||||
window.addEventListener('keydown', this._escHandler, { capture: true })
|
||||
window.addEventListener('keydown', this.#escHandler, { capture: true })
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent('open'))
|
||||
@@ -90,7 +90,7 @@ export class ComfyPopup extends EventTarget {
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
private _escHandler = (e) => {
|
||||
#escHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.open = false
|
||||
e.preventDefault()
|
||||
@@ -99,7 +99,7 @@ export class ComfyPopup extends EventTarget {
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
private _clickHandler = (e) => {
|
||||
#clickHandler = (e) => {
|
||||
/** @type {any} */
|
||||
const target = e.target
|
||||
if (
|
||||
|
||||
@@ -5,11 +5,11 @@ export class ComfyDialog<
|
||||
> extends EventTarget {
|
||||
element: T
|
||||
textElement!: HTMLElement
|
||||
private _buttons: HTMLButtonElement[] | null
|
||||
#buttons: HTMLButtonElement[] | null
|
||||
|
||||
constructor(type = 'div', buttons: HTMLButtonElement[] | null = null) {
|
||||
super()
|
||||
this._buttons = buttons
|
||||
this.#buttons = buttons
|
||||
this.element = $el(type + '.comfy-modal', { parent: document.body }, [
|
||||
$el('div.comfy-modal-content', [
|
||||
$el('p', { $: (p) => (this.textElement = p) }),
|
||||
@@ -20,7 +20,7 @@ export class ComfyDialog<
|
||||
|
||||
createButtons() {
|
||||
return (
|
||||
this._buttons ?? [
|
||||
this.#buttons ?? [
|
||||
$el('button', {
|
||||
type: 'button',
|
||||
textContent: 'Close',
|
||||
|
||||
@@ -71,6 +71,7 @@ The following table lists ALL services in the system as of 2025-09-01:
|
||||
| customerEventsService.ts | Handles customer event tracking and audit logs | Analytics |
|
||||
| dialogService.ts | Provides dialog and modal management | UI |
|
||||
| extensionService.ts | Manages extension registration and lifecycle | Extensions |
|
||||
| keybindingService.ts | Handles keyboard shortcuts and keybindings | Input |
|
||||
| litegraphService.ts | Provides utilities for working with the LiteGraph library | Graph |
|
||||
| load3dService.ts | Manages 3D model loading and visualization | 3D |
|
||||
| mediaCacheService.ts | Manages media file caching with blob storage and cleanup | Media |
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { KeybindingImpl, useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
209
src/services/keybindingService.escape.test.ts
Normal file
209
src/services/keybindingService.escape.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
|
||||
import { useKeybindingService } from '@/services/keybindingService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import {
|
||||
KeyComboImpl,
|
||||
KeybindingImpl,
|
||||
useKeybindingStore
|
||||
} from '@/stores/keybindingStore'
|
||||
|
||||
// Mock stores
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn(() => [])
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: vi.fn(() => ({
|
||||
dialogStack: []
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('keybindingService - Escape key handling', () => {
|
||||
let keybindingService: ReturnType<typeof useKeybindingService>
|
||||
let mockCommandExecute: ReturnType<typeof useCommandStore>['execute']
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
// Mock command store execute
|
||||
const commandStore = useCommandStore()
|
||||
mockCommandExecute = vi.fn()
|
||||
commandStore.execute = mockCommandExecute
|
||||
|
||||
// Reset dialog store mock to empty - only mock the properties we need for testing
|
||||
vi.mocked(useDialogStore).mockReturnValue({
|
||||
dialogStack: [],
|
||||
// Add other required properties as undefined/default values to satisfy the type
|
||||
// but they won't be used in these tests
|
||||
...({} as Omit<ReturnType<typeof useDialogStore>, 'dialogStack'>)
|
||||
})
|
||||
|
||||
keybindingService = useKeybindingService()
|
||||
keybindingService.registerCoreKeybindings()
|
||||
})
|
||||
|
||||
it('should register Escape key for ExitSubgraph command', () => {
|
||||
const keybindingStore = useKeybindingStore()
|
||||
|
||||
// Check that the Escape keybinding exists in core keybindings
|
||||
const escapeKeybinding = CORE_KEYBINDINGS.find(
|
||||
(kb) =>
|
||||
kb.combo.key === 'Escape' && kb.commandId === 'Comfy.Graph.ExitSubgraph'
|
||||
)
|
||||
expect(escapeKeybinding).toBeDefined()
|
||||
|
||||
// Check that it was registered in the store
|
||||
const registeredBinding = keybindingStore.getKeybinding(
|
||||
new KeyComboImpl({ key: 'Escape' })
|
||||
)
|
||||
expect(registeredBinding).toBeDefined()
|
||||
expect(registeredBinding?.commandId).toBe('Comfy.Graph.ExitSubgraph')
|
||||
})
|
||||
|
||||
it('should execute ExitSubgraph command when Escape is pressed', async () => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
// Mock event methods
|
||||
event.preventDefault = vi.fn()
|
||||
event.composedPath = vi.fn(() => [document.body])
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(mockCommandExecute).toHaveBeenCalledWith('Comfy.Graph.ExitSubgraph')
|
||||
})
|
||||
|
||||
it('should not execute command when Escape is pressed with modifiers', async () => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
event.preventDefault = vi.fn()
|
||||
event.composedPath = vi.fn(() => [document.body])
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(mockCommandExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not execute command when typing in input field', async () => {
|
||||
const inputElement = document.createElement('input')
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
event.preventDefault = vi.fn()
|
||||
event.composedPath = vi.fn(() => [inputElement])
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(mockCommandExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close dialogs when no keybinding is registered', async () => {
|
||||
// Remove the Escape keybinding
|
||||
const keybindingStore = useKeybindingStore()
|
||||
keybindingStore.unsetKeybinding(
|
||||
new KeybindingImpl({
|
||||
commandId: 'Comfy.Graph.ExitSubgraph',
|
||||
combo: { key: 'Escape' }
|
||||
})
|
||||
)
|
||||
|
||||
// Create a mock dialog
|
||||
const dialog = document.createElement('dialog')
|
||||
dialog.open = true
|
||||
dialog.close = vi.fn()
|
||||
document.body.appendChild(dialog)
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
event.composedPath = vi.fn(() => [document.body])
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(dialog.close).toHaveBeenCalled()
|
||||
expect(mockCommandExecute).not.toHaveBeenCalled()
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(dialog)
|
||||
})
|
||||
|
||||
it('should allow user to rebind Escape key to different command', async () => {
|
||||
const keybindingStore = useKeybindingStore()
|
||||
|
||||
// Add a user keybinding for Escape to a different command
|
||||
keybindingStore.addUserKeybinding(
|
||||
new KeybindingImpl({
|
||||
commandId: 'Custom.Command',
|
||||
combo: { key: 'Escape' }
|
||||
})
|
||||
)
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
event.preventDefault = vi.fn()
|
||||
event.composedPath = vi.fn(() => [document.body])
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(mockCommandExecute).toHaveBeenCalledWith('Custom.Command')
|
||||
expect(mockCommandExecute).not.toHaveBeenCalledWith(
|
||||
'Comfy.Graph.ExitSubgraph'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not execute Escape keybinding when dialogs are open', async () => {
|
||||
// Mock dialog store to have open dialogs
|
||||
vi.mocked(useDialogStore).mockReturnValue({
|
||||
dialogStack: [{ key: 'test-dialog' } as DialogInstance],
|
||||
// Add other required properties as undefined/default values to satisfy the type
|
||||
...({} as Omit<ReturnType<typeof useDialogStore>, 'dialogStack'>)
|
||||
})
|
||||
|
||||
// Re-create keybinding service to pick up new mock
|
||||
keybindingService = useKeybindingService()
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
event.preventDefault = vi.fn()
|
||||
event.composedPath = vi.fn(() => [document.body])
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
// Should not call preventDefault or execute command
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockCommandExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -2,11 +2,12 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useKeybindingService } from '@/services/keybindingService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
// Mock the app and canvas using factory functions
|
||||
vi.mock('@/scripts/app', () => {
|
||||
return {
|
||||
app: {
|
||||
@@ -17,6 +18,7 @@ vi.mock('@/scripts/app', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Mock stores
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn(() => [])
|
||||
@@ -29,6 +31,7 @@ vi.mock('@/stores/dialogStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Test utility for creating keyboard events with mocked methods
|
||||
function createTestKeyboardEvent(
|
||||
key: string,
|
||||
options: {
|
||||
@@ -54,6 +57,7 @@ function createTestKeyboardEvent(
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
// Mock event methods
|
||||
event.preventDefault = vi.fn()
|
||||
event.composedPath = vi.fn(() => [target])
|
||||
|
||||
@@ -67,9 +71,11 @@ describe('keybindingService - Event Forwarding', () => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
// Mock command store execute
|
||||
const commandStore = useCommandStore()
|
||||
commandStore.execute = vi.fn()
|
||||
|
||||
// Reset dialog store mock to empty
|
||||
vi.mocked(useDialogStore).mockReturnValue({
|
||||
dialogStack: []
|
||||
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
|
||||
@@ -85,7 +91,9 @@ describe('keybindingService - Event Forwarding', () => {
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
// Should forward to canvas processKey
|
||||
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
|
||||
// Should not execute any command
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -104,6 +112,7 @@ describe('keybindingService - Event Forwarding', () => {
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
// Should not forward to canvas when in input field
|
||||
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -135,6 +144,7 @@ describe('keybindingService - Event Forwarding', () => {
|
||||
})
|
||||
|
||||
it('should not forward Delete key when canvas is not available', async () => {
|
||||
// Temporarily set canvas to null
|
||||
const originalCanvas = vi.mocked(app).canvas
|
||||
vi.mocked(app).canvas = null!
|
||||
|
||||
@@ -154,6 +164,7 @@ describe('keybindingService - Event Forwarding', () => {
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
// Should not forward Enter key
|
||||
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -163,6 +174,7 @@ describe('keybindingService - Event Forwarding', () => {
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
// Should not forward when modifiers are pressed
|
||||
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1,35 +1,40 @@
|
||||
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import {
|
||||
KeyComboImpl,
|
||||
KeybindingImpl,
|
||||
useKeybindingStore
|
||||
} from '@/stores/keybindingStore'
|
||||
|
||||
import { CORE_KEYBINDINGS } from './defaults'
|
||||
import { KeyComboImpl } from './keyCombo'
|
||||
import { KeybindingImpl } from './keybinding'
|
||||
import { useKeybindingStore } from './keybindingStore'
|
||||
|
||||
export function useKeybindingService() {
|
||||
export const useKeybindingService = () => {
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const settingStore = useSettingStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function shouldForwardToCanvas(event: KeyboardEvent): boolean {
|
||||
// Helper function to determine if an event should be forwarded to canvas
|
||||
const shouldForwardToCanvas = (event: KeyboardEvent): boolean => {
|
||||
// Don't forward if modifier keys are pressed (except shift)
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Keys that LiteGraph handles but aren't in core keybindings
|
||||
const canvasKeys = ['Delete', 'Backspace']
|
||||
|
||||
return canvasKeys.includes(event.key)
|
||||
}
|
||||
|
||||
async function keybindHandler(event: KeyboardEvent) {
|
||||
const keybindHandler = async function (event: KeyboardEvent) {
|
||||
const keyCombo = KeyComboImpl.fromEvent(event)
|
||||
if (keyCombo.isModifier) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore reserved or non-modifier keybindings if typing in input fields
|
||||
const target = event.composedPath()[0] as HTMLElement
|
||||
if (
|
||||
keyCombo.isReservedByTextInput &&
|
||||
@@ -44,17 +49,20 @@ export function useKeybindingService() {
|
||||
|
||||
const keybinding = keybindingStore.getKeybinding(keyCombo)
|
||||
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
|
||||
// Special handling for Escape key - let dialogs handle it first
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.metaKey
|
||||
) {
|
||||
// If dialogs are open, don't execute the keybinding - let the dialog handle it
|
||||
if (dialogStore.dialogStack.length > 0) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent default browser behavior first, then execute the command
|
||||
event.preventDefault()
|
||||
const runCommandIds = new Set([
|
||||
'Comfy.QueuePrompt',
|
||||
@@ -73,6 +81,7 @@ export function useKeybindingService() {
|
||||
return
|
||||
}
|
||||
|
||||
// Forward unhandled canvas-targeted events to LiteGraph
|
||||
if (!keybinding && shouldForwardToCanvas(event)) {
|
||||
const canvas = app.canvas
|
||||
if (
|
||||
@@ -80,15 +89,18 @@ export function useKeybindingService() {
|
||||
canvas.processKey &&
|
||||
typeof canvas.processKey === 'function'
|
||||
) {
|
||||
// Let LiteGraph handle the event
|
||||
canvas.processKey(event)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Only clear dialogs if not using modifiers
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return
|
||||
}
|
||||
|
||||
// Escape key: close the first open modal found, and all dialogs
|
||||
if (event.key === 'Escape') {
|
||||
const modals = document.querySelectorAll<HTMLElement>('.comfy-modal')
|
||||
for (const modal of modals) {
|
||||
@@ -106,13 +118,14 @@ export function useKeybindingService() {
|
||||
}
|
||||
}
|
||||
|
||||
function registerCoreKeybindings() {
|
||||
const registerCoreKeybindings = () => {
|
||||
for (const keybinding of CORE_KEYBINDINGS) {
|
||||
keybindingStore.addDefaultKeybinding(new KeybindingImpl(keybinding))
|
||||
}
|
||||
}
|
||||
|
||||
function registerUserKeybindings() {
|
||||
// Unset bindings first as new bindings might conflict with default bindings.
|
||||
const unsetBindings = settingStore.get('Comfy.Keybinding.UnsetBindings')
|
||||
for (const keybinding of unsetBindings) {
|
||||
keybindingStore.unsetKeybinding(new KeybindingImpl(keybinding))
|
||||
@@ -124,6 +137,8 @@ export function useKeybindingService() {
|
||||
}
|
||||
|
||||
async function persistUserKeybindings() {
|
||||
// TODO(https://github.com/Comfy-Org/ComfyUI_frontend/issues/1079):
|
||||
// Allow setting multiple values at once in settingStore
|
||||
await settingStore.set(
|
||||
'Comfy.Keybinding.NewBindings',
|
||||
Object.values(keybindingStore.getUserKeybindings())
|
||||
@@ -5,7 +5,7 @@ import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
|
||||
describe('nodeOrganizationService', () => {
|
||||
const createMockNodeDef = (overrides: Partial<ComfyNodeDefImpl> = {}) => {
|
||||
const createMockNodeDef = (overrides: any = {}) => {
|
||||
const mockNodeDef = {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
@@ -273,7 +273,7 @@ describe('nodeOrganizationService', () => {
|
||||
it('should handle unknown source type', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
nodeSource: {
|
||||
type: 'unknown' as NodeSourceType,
|
||||
type: 'unknown' as any,
|
||||
className: 'unknown',
|
||||
displayText: 'Unknown',
|
||||
badgeText: '?'
|
||||
|
||||
@@ -1,34 +1,20 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
|
||||
import { SortableAlgoliaField } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
type RegistryNodePack = components['schemas']['Node']
|
||||
|
||||
type GlobalWithAlgolia = typeof globalThis & {
|
||||
__ALGOLIA_APP_ID__: string
|
||||
__ALGOLIA_API_KEY__: string
|
||||
}
|
||||
|
||||
// Mock global Algolia constants
|
||||
const globalWithAlgolia = globalThis as GlobalWithAlgolia
|
||||
globalWithAlgolia.__ALGOLIA_APP_ID__ = 'test-app-id'
|
||||
globalWithAlgolia.__ALGOLIA_API_KEY__ = 'test-api-key'
|
||||
;(global as any).__ALGOLIA_APP_ID__ = 'test-app-id'
|
||||
;(global as any).__ALGOLIA_API_KEY__ = 'test-api-key'
|
||||
|
||||
// Mock algoliasearch
|
||||
vi.mock('algoliasearch/dist/lite/builds/browser', () => ({
|
||||
liteClient: vi.fn()
|
||||
}))
|
||||
|
||||
interface MockSearchClient {
|
||||
search: Mock
|
||||
}
|
||||
|
||||
describe('useAlgoliaSearchProvider', () => {
|
||||
let mockSearchClient: MockSearchClient
|
||||
let mockSearchClient: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -38,11 +24,7 @@ describe('useAlgoliaSearchProvider', () => {
|
||||
search: vi.fn()
|
||||
}
|
||||
|
||||
vi.mocked(algoliasearch).mockReturnValue(
|
||||
mockSearchClient as Partial<
|
||||
ReturnType<typeof algoliasearch>
|
||||
> as ReturnType<typeof algoliasearch>
|
||||
)
|
||||
vi.mocked(algoliasearch).mockReturnValue(mockSearchClient)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -270,7 +252,7 @@ describe('useAlgoliaSearchProvider', () => {
|
||||
})
|
||||
|
||||
describe('getSortValue', () => {
|
||||
const testPack: Partial<RegistryNodePack> = {
|
||||
const testPack = {
|
||||
id: '1',
|
||||
name: 'Test Pack',
|
||||
downloads: 100,
|
||||
@@ -297,10 +279,7 @@ describe('useAlgoliaSearchProvider', () => {
|
||||
|
||||
const createdTimestamp = new Date('2024-01-01T10:00:00Z').getTime()
|
||||
expect(
|
||||
provider.getSortValue(
|
||||
testPack as RegistryNodePack,
|
||||
SortableAlgoliaField.Created
|
||||
)
|
||||
provider.getSortValue(testPack as any, SortableAlgoliaField.Created)
|
||||
).toBe(createdTimestamp)
|
||||
|
||||
const updatedTimestamp = new Date('2024-01-15T10:00:00Z').getTime()
|
||||
@@ -310,35 +289,23 @@ describe('useAlgoliaSearchProvider', () => {
|
||||
})
|
||||
|
||||
it('should handle missing values', () => {
|
||||
const incompletePack: Partial<RegistryNodePack> = {
|
||||
id: '1',
|
||||
name: 'Incomplete'
|
||||
}
|
||||
const incompletePack = { id: '1', name: 'Incomplete' }
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
|
||||
expect(
|
||||
provider.getSortValue(
|
||||
incompletePack as RegistryNodePack,
|
||||
SortableAlgoliaField.Downloads
|
||||
)
|
||||
provider.getSortValue(incompletePack, SortableAlgoliaField.Downloads)
|
||||
).toBe(0)
|
||||
expect(
|
||||
provider.getSortValue(
|
||||
incompletePack as RegistryNodePack,
|
||||
SortableAlgoliaField.Publisher
|
||||
)
|
||||
provider.getSortValue(incompletePack, SortableAlgoliaField.Publisher)
|
||||
).toBe('')
|
||||
expect(
|
||||
provider.getSortValue(
|
||||
incompletePack as RegistryNodePack,
|
||||
incompletePack as any,
|
||||
SortableAlgoliaField.Created
|
||||
)
|
||||
).toBe(0)
|
||||
expect(
|
||||
provider.getSortValue(
|
||||
incompletePack as RegistryNodePack,
|
||||
SortableAlgoliaField.Updated
|
||||
)
|
||||
provider.getSortValue(incompletePack, SortableAlgoliaField.Updated)
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,31 +14,20 @@ describe('useComfyRegistrySearchProvider', () => {
|
||||
const mockListAllPacksCall = vi.fn()
|
||||
const mockListAllPacksClear = vi.fn()
|
||||
|
||||
const createMockStore = (
|
||||
params: Partial<ReturnType<typeof useComfyRegistryStore>> = {}
|
||||
) => {
|
||||
return {
|
||||
search: {
|
||||
call: mockSearchCall,
|
||||
clear: mockSearchClear,
|
||||
cancel: vi.fn()
|
||||
},
|
||||
listAllPacks: {
|
||||
call: mockListAllPacksCall,
|
||||
clear: mockListAllPacksClear,
|
||||
cancel: vi.fn()
|
||||
},
|
||||
...params
|
||||
} as Partial<ReturnType<typeof useComfyRegistryStore>> as ReturnType<
|
||||
typeof useComfyRegistryStore
|
||||
>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup store mock
|
||||
vi.mocked(useComfyRegistryStore).mockReturnValue(createMockStore())
|
||||
vi.mocked(useComfyRegistryStore).mockReturnValue({
|
||||
search: {
|
||||
call: mockSearchCall,
|
||||
clear: mockSearchClear
|
||||
},
|
||||
listAllPacks: {
|
||||
call: mockListAllPacksCall,
|
||||
clear: mockListAllPacksClear
|
||||
}
|
||||
} as any)
|
||||
})
|
||||
|
||||
describe('searchPacks', () => {
|
||||
|
||||
@@ -123,6 +123,7 @@ The following table lists ALL 46 store instances in the system as of 2025-09-01:
|
||||
| graphStore.ts | useCanvasStore | Manages the graph canvas state and interactions | Core |
|
||||
| helpCenterStore.ts | useHelpCenterStore | Manages help center visibility and state | UI |
|
||||
| imagePreviewStore.ts | useNodeOutputStore | Manages node outputs and execution results | Media |
|
||||
| keybindingStore.ts | useKeybindingStore | Manages keyboard shortcuts | Input |
|
||||
| maintenanceTaskStore.ts | useMaintenanceTaskStore | Handles system maintenance tasks | System |
|
||||
| menuItemStore.ts | useMenuItemStore | Handles menu items and their state | UI |
|
||||
| modelStore.ts | useModelStore | Manages AI models information | Models |
|
||||
|
||||
@@ -507,12 +507,12 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
|
||||
mockIsCloud.value = false
|
||||
})
|
||||
|
||||
const createMockAsset = (id: string) => ({
|
||||
const createMockAsset = (id: string, tags: string[] = ['models']) => ({
|
||||
id,
|
||||
name: `asset-${id}`,
|
||||
size: 100,
|
||||
created_at: new Date().toISOString(),
|
||||
tags: ['models'],
|
||||
tags,
|
||||
preview_url: `http://test.com/${id}`
|
||||
})
|
||||
|
||||
@@ -751,4 +751,48 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
|
||||
expect(store.getAssets('tag:models')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasCategory', () => {
|
||||
it('should return true for loaded categories', async () => {
|
||||
const store = useAssetsStore()
|
||||
const assets = [createMockAsset('asset-1')]
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(assets)
|
||||
await store.updateModelsForNodeType('CheckpointLoaderSimple')
|
||||
|
||||
expect(store.hasCategory('checkpoints')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for tag-based category when tag: prefix is not used', async () => {
|
||||
const store = useAssetsStore()
|
||||
const assets = [createMockAsset('asset-1')]
|
||||
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValue(assets)
|
||||
await store.updateModelsForTag('models')
|
||||
|
||||
// hasCategory('models') checks for both 'models' and 'tag:models'
|
||||
expect(store.hasCategory('models')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for unloaded categories', () => {
|
||||
const store = useAssetsStore()
|
||||
|
||||
expect(store.hasCategory('checkpoints')).toBe(false)
|
||||
expect(store.hasCategory('unknown-category')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false after category is invalidated', async () => {
|
||||
const store = useAssetsStore()
|
||||
const assets = [createMockAsset('asset-1')]
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(assets)
|
||||
await store.updateModelsForNodeType('CheckpointLoaderSimple')
|
||||
|
||||
expect(store.hasCategory('checkpoints')).toBe(true)
|
||||
|
||||
store.invalidateCategory('checkpoints')
|
||||
|
||||
expect(store.hasCategory('checkpoints')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -375,6 +375,18 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
return modelStateByCategory.value.has(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a category exists in the cache.
|
||||
* Checks both direct category keys and tag-prefixed keys.
|
||||
* @param category The category to check (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function hasCategory(category: string): boolean {
|
||||
return (
|
||||
modelStateByCategory.value.has(category) ||
|
||||
modelStateByCategory.value.has(`tag:${category}`)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to fetch and cache assets for a category.
|
||||
* Loads first batch immediately, then progressively loads remaining batches.
|
||||
@@ -608,17 +620,61 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
|
||||
* Refreshes all node types that provide this category plus tag-based caches
|
||||
* @param category The model category to invalidate (e.g., 'checkpoints')
|
||||
*/
|
||||
async function invalidateModelsForCategory(
|
||||
category: string
|
||||
): Promise<void> {
|
||||
const providers = modelToNodeStore
|
||||
.getAllNodeProviders(category)
|
||||
.filter((provider) => provider.nodeDef?.name)
|
||||
|
||||
const nodeTypeUpdates = providers.map((provider) =>
|
||||
updateModelsForNodeType(provider.nodeDef.name)
|
||||
)
|
||||
|
||||
const tagUpdates = [
|
||||
updateModelsForTag(category),
|
||||
updateModelsForTag('models')
|
||||
]
|
||||
|
||||
await Promise.allSettled([...nodeTypeUpdates, ...tagUpdates])
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific asset from a category's cache.
|
||||
* Used for optimistic updates after asset deletion.
|
||||
* @param assetId The asset ID to remove
|
||||
* @param category The model category (e.g., 'loras')
|
||||
*/
|
||||
function removeAssetFromCache(assetId: string, category: string): void {
|
||||
const state = modelStateByCategory.value.get(category)
|
||||
if (state) {
|
||||
state.assets.delete(assetId)
|
||||
assetsArrayCache.delete(category)
|
||||
}
|
||||
|
||||
assetsArrayCache.delete(`tag:${category}`)
|
||||
assetsArrayCache.delete('tag:models')
|
||||
}
|
||||
|
||||
return {
|
||||
getAssets,
|
||||
isLoading,
|
||||
getError,
|
||||
hasMore,
|
||||
hasAssetKey,
|
||||
hasCategory,
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags
|
||||
updateAssetTags,
|
||||
invalidateModelsForCategory,
|
||||
removeAssetFromCache
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,11 +685,14 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
getError: () => undefined,
|
||||
hasMore: () => false,
|
||||
hasAssetKey: () => false,
|
||||
hasCategory: () => false,
|
||||
updateModelsForNodeType: async () => {},
|
||||
invalidateCategory: () => {},
|
||||
updateModelsForTag: async () => {},
|
||||
updateAssetMetadata: async () => {},
|
||||
updateAssetTags: async () => {}
|
||||
updateAssetTags: async () => {},
|
||||
invalidateModelsForCategory: async () => {},
|
||||
removeAssetFromCache: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,11 +702,14 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
getError,
|
||||
hasMore,
|
||||
hasAssetKey,
|
||||
hasCategory,
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags
|
||||
updateAssetTags,
|
||||
invalidateModelsForCategory,
|
||||
removeAssetFromCache
|
||||
} = getModelState()
|
||||
|
||||
// Watch for completed downloads and refresh model caches
|
||||
@@ -718,12 +780,15 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
getError,
|
||||
hasMore,
|
||||
hasAssetKey,
|
||||
hasCategory,
|
||||
|
||||
// Model assets - actions
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags
|
||||
updateAssetTags,
|
||||
invalidateModelsForCategory,
|
||||
removeAssetFromCache
|
||||
}
|
||||
})
|
||||
|
||||
@@ -127,9 +127,7 @@ describe('useComfyRegistryStore', () => {
|
||||
}
|
||||
|
||||
vi.mocked(useComfyRegistryService).mockReturnValue(
|
||||
mockRegistryService as Partial<
|
||||
ReturnType<typeof useComfyRegistryService>
|
||||
> as ReturnType<typeof useComfyRegistryService>
|
||||
mockRegistryService as any
|
||||
)
|
||||
})
|
||||
|
||||
@@ -179,7 +177,7 @@ describe('useComfyRegistryStore', () => {
|
||||
const store = useComfyRegistryStore()
|
||||
vi.spyOn(store.getPackById, 'call').mockResolvedValueOnce(null)
|
||||
|
||||
const result = await store.getPackById.call(null!)
|
||||
const result = await store.getPackById.call(null as any)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockRegistryService.getPackById).not.toHaveBeenCalled()
|
||||
|
||||
@@ -2,10 +2,11 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
import { useKeybindingStore } from './keybindingStore'
|
||||
import type { KeybindingImpl } from './keybindingStore'
|
||||
|
||||
export interface ComfyCommand {
|
||||
id: string
|
||||
function: (metadata?: Record<string, unknown>) => void | Promise<void>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
// Mock DOM widget for testing
|
||||
const createMockDOMWidget = (id: string) => {
|
||||
@@ -16,7 +15,7 @@ const createMockDOMWidget = (id: string) => {
|
||||
title: 'Test Node',
|
||||
pos: [0, 0],
|
||||
size: [200, 100]
|
||||
} as Partial<LGraphNode> as LGraphNode,
|
||||
} as any,
|
||||
name: 'test_widget',
|
||||
type: 'text',
|
||||
value: 'test',
|
||||
@@ -24,7 +23,7 @@ const createMockDOMWidget = (id: string) => {
|
||||
y: 0,
|
||||
margin: 10,
|
||||
isVisible: () => true,
|
||||
containerNode: undefined
|
||||
containerNode: undefined as any
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
@@ -9,8 +11,6 @@ const mockNodeIdToNodeLocatorId = vi.fn()
|
||||
const mockNodeLocatorIdToNodeExecutionId = vi.fn()
|
||||
|
||||
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
// Mock the workflowStore
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
@@ -72,11 +72,12 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
|
||||
nodes: []
|
||||
}
|
||||
|
||||
const mockNode = createMockLGraphNode({
|
||||
const mockNode = {
|
||||
id: 123,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: mockSubgraph
|
||||
})
|
||||
} as any
|
||||
|
||||
// Mock app.rootGraph.getNodeById to return the mock node
|
||||
vi.mocked(app.rootGraph.getNodeById).mockReturnValue(mockNode)
|
||||
|
||||
@@ -177,11 +178,11 @@ describe('useExecutionStore - Node Error Lookups', () => {
|
||||
nodes: []
|
||||
}
|
||||
|
||||
const mockNode = createMockLGraphNode({
|
||||
const mockNode = {
|
||||
id: 123,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: mockSubgraph
|
||||
})
|
||||
} as any
|
||||
|
||||
vi.mocked(app.rootGraph.getNodeById).mockReturnValue(mockNode)
|
||||
|
||||
|
||||
@@ -1,28 +1,12 @@
|
||||
import { FirebaseError } from 'firebase/app'
|
||||
import type { User, UserCredential } from 'firebase/auth'
|
||||
import * as firebaseAuth from 'firebase/auth'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import type { Mock } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as vuefire from 'vuefire'
|
||||
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
// Hoisted mocks for dynamic imports
|
||||
const { mockDistributionTypes } = vi.hoisted(() => ({
|
||||
mockDistributionTypes: {
|
||||
isCloud: true,
|
||||
isDesktop: true
|
||||
}
|
||||
}))
|
||||
|
||||
type MockUser = Omit<User, 'getIdToken'> & {
|
||||
getIdToken: Mock
|
||||
}
|
||||
|
||||
type MockAuth = Record<string, unknown>
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn()
|
||||
@@ -99,20 +83,35 @@ vi.mock('@/stores/toastStore', () => ({
|
||||
// Mock useDialogService
|
||||
vi.mock('@/services/dialogService')
|
||||
|
||||
const mockDistributionTypes = vi.hoisted(() => ({
|
||||
isCloud: false,
|
||||
isDesktop: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
|
||||
|
||||
const mockApiKeyStore = vi.hoisted(() => ({
|
||||
getAuthHeader: vi.fn().mockReturnValue(null)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
useApiKeyAuthStore: () => mockApiKeyStore
|
||||
}))
|
||||
|
||||
describe('useFirebaseAuthStore', () => {
|
||||
let store: ReturnType<typeof useFirebaseAuthStore>
|
||||
let authStateCallback: (user: User | null) => void
|
||||
let idTokenCallback: (user: User | null) => void
|
||||
let authStateCallback: (user: any) => void
|
||||
let idTokenCallback: (user: any) => void
|
||||
|
||||
const mockAuth: MockAuth = {
|
||||
const mockAuth = {
|
||||
/* mock Auth object */
|
||||
}
|
||||
|
||||
const mockUser: MockUser = {
|
||||
const mockUser = {
|
||||
uid: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
getIdToken: vi.fn().mockResolvedValue('mock-id-token')
|
||||
} as Partial<User> as MockUser
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
@@ -124,18 +123,14 @@ describe('useFirebaseAuthStore', () => {
|
||||
})
|
||||
|
||||
// Mock useFirebaseAuth to return our mock auth object
|
||||
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(
|
||||
mockAuth as Partial<
|
||||
ReturnType<typeof vuefire.useFirebaseAuth>
|
||||
> as ReturnType<typeof vuefire.useFirebaseAuth>
|
||||
)
|
||||
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(mockAuth as any)
|
||||
|
||||
// Mock onAuthStateChanged to capture the callback and simulate initial auth state
|
||||
vi.mocked(firebaseAuth.onAuthStateChanged).mockImplementation(
|
||||
(_, callback) => {
|
||||
authStateCallback = callback as (user: User | null) => void
|
||||
authStateCallback = callback as (user: any) => void
|
||||
// Call the callback with our mock user
|
||||
;(callback as (user: User | null) => void)(mockUser)
|
||||
;(callback as (user: any) => void)(mockUser)
|
||||
// Return an unsubscribe function
|
||||
return vi.fn()
|
||||
}
|
||||
@@ -168,26 +163,21 @@ describe('useFirebaseAuthStore', () => {
|
||||
})
|
||||
|
||||
describe('token refresh events', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
|
||||
beforeEach(() => {
|
||||
mockDistributionTypes.isCloud = true
|
||||
mockDistributionTypes.isDesktop = true
|
||||
|
||||
vi.mocked(firebaseAuth.onIdTokenChanged).mockImplementation(
|
||||
(_auth, callback) => {
|
||||
idTokenCallback = callback as (user: User | null) => void
|
||||
idTokenCallback = callback as (user: any) => void
|
||||
return vi.fn()
|
||||
}
|
||||
)
|
||||
|
||||
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(
|
||||
mockAuth as Partial<
|
||||
ReturnType<typeof vuefire.useFirebaseAuth>
|
||||
> as ReturnType<typeof vuefire.useFirebaseAuth>
|
||||
)
|
||||
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(mockAuth as any)
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const storeModule = await import('@/stores/firebaseAuthStore')
|
||||
store = storeModule.useFirebaseAuthStore()
|
||||
store = useFirebaseAuthStore()
|
||||
})
|
||||
|
||||
it("should not increment tokenRefreshTrigger on the user's first ID token event", () => {
|
||||
@@ -202,14 +192,14 @@ describe('useFirebaseAuthStore', () => {
|
||||
})
|
||||
|
||||
it('should not increment when ID token event is for a different user UID', () => {
|
||||
const otherUser = { uid: 'other-user-id' } as Partial<User> as User
|
||||
const otherUser = { uid: 'other-user-id' }
|
||||
idTokenCallback?.(mockUser)
|
||||
idTokenCallback?.(otherUser)
|
||||
expect(store.tokenRefreshTrigger).toBe(0)
|
||||
})
|
||||
|
||||
it('should increment after switching to a new UID and receiving a second event for that UID', () => {
|
||||
const otherUser = { uid: 'other-user-id' } as Partial<User> as User
|
||||
const otherUser = { uid: 'other-user-id' }
|
||||
idTokenCallback?.(mockUser)
|
||||
idTokenCallback?.(otherUser)
|
||||
idTokenCallback?.(otherUser)
|
||||
@@ -248,7 +238,7 @@ describe('useFirebaseAuthStore', () => {
|
||||
// Now, succeed on next attempt
|
||||
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValueOnce({
|
||||
user: mockUser
|
||||
} as Partial<UserCredential> as UserCredential)
|
||||
} as any)
|
||||
|
||||
await store.login('test@example.com', 'correct-password')
|
||||
})
|
||||
@@ -257,7 +247,7 @@ describe('useFirebaseAuthStore', () => {
|
||||
it('should login with valid credentials', async () => {
|
||||
const mockUserCredential = { user: mockUser }
|
||||
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
|
||||
mockUserCredential as Partial<UserCredential> as UserCredential
|
||||
mockUserCredential as any
|
||||
)
|
||||
|
||||
const result = await store.login('test@example.com', 'password')
|
||||
@@ -293,7 +283,7 @@ describe('useFirebaseAuthStore', () => {
|
||||
// Set up multiple login promises
|
||||
const mockUserCredential = { user: mockUser }
|
||||
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
|
||||
mockUserCredential as Partial<UserCredential> as UserCredential
|
||||
mockUserCredential as any
|
||||
)
|
||||
|
||||
const loginPromise1 = store.login('user1@example.com', 'password1')
|
||||
@@ -311,7 +301,7 @@ describe('useFirebaseAuthStore', () => {
|
||||
it('should register a new user', async () => {
|
||||
const mockUserCredential = { user: mockUser }
|
||||
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
|
||||
mockUserCredential as Partial<UserCredential> as UserCredential
|
||||
mockUserCredential as any
|
||||
)
|
||||
|
||||
const result = await store.register('new@example.com', 'password')
|
||||
@@ -388,7 +378,7 @@ describe('useFirebaseAuthStore', () => {
|
||||
// Setup mock for login
|
||||
const mockUserCredential = { user: mockUser }
|
||||
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
|
||||
mockUserCredential as Partial<UserCredential> as UserCredential
|
||||
mockUserCredential as any
|
||||
)
|
||||
|
||||
// Login
|
||||
@@ -464,6 +454,9 @@ describe('useFirebaseAuthStore', () => {
|
||||
// This test reproduces the issue where getAuthHeader fails due to network errors
|
||||
// when Firebase Auth tries to refresh tokens offline
|
||||
|
||||
// Configure mockApiKeyStore to return null (no API key fallback)
|
||||
mockApiKeyStore.getAuthHeader.mockReturnValue(null)
|
||||
|
||||
// Setup user with network error on token refresh
|
||||
mockUser.getIdToken.mockReset()
|
||||
const networkError = new FirebaseError(
|
||||
@@ -482,7 +475,7 @@ describe('useFirebaseAuthStore', () => {
|
||||
it('should sign in with Google', async () => {
|
||||
const mockUserCredential = { user: mockUser }
|
||||
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
|
||||
mockUserCredential as Partial<UserCredential> as UserCredential
|
||||
mockUserCredential as any
|
||||
)
|
||||
|
||||
const result = await store.loginWithGoogle()
|
||||
@@ -515,7 +508,7 @@ describe('useFirebaseAuthStore', () => {
|
||||
it('should sign in with Github', async () => {
|
||||
const mockUserCredential = { user: mockUser }
|
||||
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
|
||||
mockUserCredential as Partial<UserCredential> as UserCredential
|
||||
mockUserCredential as any
|
||||
)
|
||||
|
||||
const result = await store.loginWithGithub()
|
||||
@@ -547,7 +540,7 @@ describe('useFirebaseAuthStore', () => {
|
||||
it('should handle concurrent social login attempts correctly', async () => {
|
||||
const mockUserCredential = { user: mockUser }
|
||||
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
|
||||
mockUserCredential as Partial<UserCredential> as UserCredential
|
||||
mockUserCredential as any
|
||||
)
|
||||
|
||||
const googleLoginPromise = store.loginWithGoogle()
|
||||
|
||||
@@ -2,8 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { KeybindingImpl, useKeybindingStore } from '@/stores/keybindingStore'
|
||||
|
||||
describe('useKeybindingStore', () => {
|
||||
beforeEach(() => {
|
||||
@@ -177,14 +176,18 @@ describe('useKeybindingStore', () => {
|
||||
combo: { key: 'I', ctrl: true }
|
||||
})
|
||||
|
||||
// Add default keybinding
|
||||
store.addDefaultKeybinding(defaultKeybinding)
|
||||
expect(store.keybindings).toHaveLength(1)
|
||||
|
||||
// Unset the default keybinding
|
||||
store.unsetKeybinding(defaultKeybinding)
|
||||
expect(store.keybindings).toHaveLength(0)
|
||||
|
||||
// Add the same keybinding as a user keybinding
|
||||
store.addUserKeybinding(defaultKeybinding)
|
||||
|
||||
// Check that the keybinding is back and not in the unset list
|
||||
expect(store.keybindings).toHaveLength(1)
|
||||
expect(store.getKeybinding(defaultKeybinding.combo)).toEqual(
|
||||
defaultKeybinding
|
||||
@@ -197,7 +200,10 @@ describe('useKeybindingStore', () => {
|
||||
commandId: 'test.command',
|
||||
combo: { key: 'J', ctrl: true }
|
||||
})
|
||||
// Add default keybinding.
|
||||
// This can happen when we change default keybindings.
|
||||
store.addDefaultKeybinding(keybinding)
|
||||
// Add user keybinding.
|
||||
store.addUserKeybinding(keybinding)
|
||||
|
||||
expect(store.keybindings).toHaveLength(1)
|
||||
@@ -205,6 +211,10 @@ describe('useKeybindingStore', () => {
|
||||
})
|
||||
|
||||
it('Should keep previously customized keybindings after default keybindings change', () => {
|
||||
// Initially command 'foo' was bound to 'K, Ctrl'. User unset it and bound the
|
||||
// command to 'A, Ctrl'.
|
||||
// Now we change the default keybindings of 'foo' to 'A, Ctrl'.
|
||||
// The user customized keybinding should be kept.
|
||||
const store = useKeybindingStore()
|
||||
|
||||
const userUnsetKeybindings = [
|
||||
@@ -381,15 +391,18 @@ describe('useKeybindingStore', () => {
|
||||
it('should handle complex scenario with both unset and user keybindings', () => {
|
||||
const store = useKeybindingStore()
|
||||
|
||||
// Create default keybinding
|
||||
const defaultKeybinding = new KeybindingImpl({
|
||||
commandId: 'test.command',
|
||||
combo: { key: 'Q', ctrl: true }
|
||||
})
|
||||
store.addDefaultKeybinding(defaultKeybinding)
|
||||
|
||||
// Unset default keybinding
|
||||
store.unsetKeybinding(defaultKeybinding)
|
||||
expect(store.keybindings).toHaveLength(0)
|
||||
|
||||
// Add user keybinding with different combo
|
||||
const userKeybinding = new KeybindingImpl({
|
||||
commandId: 'test.command',
|
||||
combo: { key: 'R', alt: true }
|
||||
@@ -400,6 +413,7 @@ describe('useKeybindingStore', () => {
|
||||
userKeybinding
|
||||
)
|
||||
|
||||
// Reset keybinding to default
|
||||
const result = store.resetKeybindingForCommand('test.command')
|
||||
|
||||
expect(result).toBe(true)
|
||||
@@ -1,20 +1,140 @@
|
||||
import { groupBy } from 'es-toolkit/compat'
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, toRaw } from 'vue'
|
||||
|
||||
import type { KeyComboImpl } from './keyCombo'
|
||||
import type { KeybindingImpl } from './keybinding'
|
||||
import { RESERVED_BY_TEXT_INPUT } from '@/constants/reservedKeyCombos'
|
||||
import type { KeyCombo, Keybinding } from '@/schemas/keyBindingSchema'
|
||||
|
||||
export class KeybindingImpl implements Keybinding {
|
||||
commandId: string
|
||||
combo: KeyComboImpl
|
||||
targetElementId?: string
|
||||
|
||||
constructor(obj: Keybinding) {
|
||||
this.commandId = obj.commandId
|
||||
this.combo = new KeyComboImpl(obj.combo)
|
||||
this.targetElementId = obj.targetElementId
|
||||
}
|
||||
|
||||
equals(other: unknown): boolean {
|
||||
const raw = toRaw(other)
|
||||
|
||||
return raw instanceof KeybindingImpl
|
||||
? this.commandId === raw.commandId &&
|
||||
this.combo.equals(raw.combo) &&
|
||||
this.targetElementId === raw.targetElementId
|
||||
: false
|
||||
}
|
||||
}
|
||||
|
||||
export class KeyComboImpl implements KeyCombo {
|
||||
key: string
|
||||
// ctrl or meta(cmd on mac)
|
||||
ctrl: boolean
|
||||
alt: boolean
|
||||
shift: boolean
|
||||
|
||||
constructor(obj: KeyCombo) {
|
||||
this.key = obj.key
|
||||
this.ctrl = obj.ctrl ?? false
|
||||
this.alt = obj.alt ?? false
|
||||
this.shift = obj.shift ?? false
|
||||
}
|
||||
|
||||
static fromEvent(event: KeyboardEvent) {
|
||||
return new KeyComboImpl({
|
||||
key: event.key,
|
||||
ctrl: event.ctrlKey || event.metaKey,
|
||||
alt: event.altKey,
|
||||
shift: event.shiftKey
|
||||
})
|
||||
}
|
||||
|
||||
equals(other: unknown): boolean {
|
||||
const raw = toRaw(other)
|
||||
|
||||
return raw instanceof KeyComboImpl
|
||||
? this.key.toUpperCase() === raw.key.toUpperCase() &&
|
||||
this.ctrl === raw.ctrl &&
|
||||
this.alt === raw.alt &&
|
||||
this.shift === raw.shift
|
||||
: false
|
||||
}
|
||||
|
||||
serialize(): string {
|
||||
return `${this.key.toUpperCase()}:${this.ctrl}:${this.alt}:${this.shift}`
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.getKeySequences().join(' + ')
|
||||
}
|
||||
|
||||
get hasModifier(): boolean {
|
||||
return this.ctrl || this.alt || this.shift
|
||||
}
|
||||
|
||||
get isModifier(): boolean {
|
||||
return ['Control', 'Meta', 'Alt', 'Shift'].includes(this.key)
|
||||
}
|
||||
|
||||
get modifierCount(): number {
|
||||
const modifiers = [this.ctrl, this.alt, this.shift]
|
||||
return modifiers.reduce((acc, cur) => acc + Number(cur), 0)
|
||||
}
|
||||
|
||||
get isShiftOnly(): boolean {
|
||||
return this.shift && this.modifierCount === 1
|
||||
}
|
||||
|
||||
get isReservedByTextInput(): boolean {
|
||||
return (
|
||||
!this.hasModifier ||
|
||||
this.isShiftOnly ||
|
||||
RESERVED_BY_TEXT_INPUT.has(this.toString())
|
||||
)
|
||||
}
|
||||
|
||||
getKeySequences(): string[] {
|
||||
const sequences: string[] = []
|
||||
if (this.ctrl) {
|
||||
sequences.push('Ctrl')
|
||||
}
|
||||
if (this.alt) {
|
||||
sequences.push('Alt')
|
||||
}
|
||||
if (this.shift) {
|
||||
sequences.push('Shift')
|
||||
}
|
||||
sequences.push(this.key)
|
||||
return sequences
|
||||
}
|
||||
}
|
||||
|
||||
export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
/**
|
||||
* Default keybindings provided by core and extensions.
|
||||
*/
|
||||
const defaultKeybindings = ref<Record<string, KeybindingImpl>>({})
|
||||
/**
|
||||
* User-defined keybindings.
|
||||
*/
|
||||
const userKeybindings = ref<Record<string, KeybindingImpl>>({})
|
||||
/**
|
||||
* User-defined keybindings that unset default keybindings.
|
||||
*/
|
||||
const userUnsetKeybindings = ref<Record<string, KeybindingImpl>>({})
|
||||
|
||||
/**
|
||||
* Get user-defined keybindings.
|
||||
*/
|
||||
function getUserKeybindings() {
|
||||
return userKeybindings.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-defined keybindings that unset default keybindings.
|
||||
*/
|
||||
function getUserUnsetKeybindings() {
|
||||
return userUnsetKeybindings.value
|
||||
}
|
||||
@@ -47,7 +167,7 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
|
||||
const keybindingsByCommandId = computed<Record<string, KeybindingImpl[]>>(
|
||||
() => {
|
||||
return groupBy(keybindings.value, 'commandId')
|
||||
return _.groupBy(keybindings.value, 'commandId')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -58,13 +178,26 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
const defaultKeybindingsByCommandId = computed<
|
||||
Record<string, KeybindingImpl[]>
|
||||
>(() => {
|
||||
return groupBy(Object.values(defaultKeybindings.value), 'commandId')
|
||||
return _.groupBy(Object.values(defaultKeybindings.value), 'commandId')
|
||||
})
|
||||
|
||||
function getKeybindingByCommandId(commandId: string) {
|
||||
return getKeybindingsByCommandId(commandId)[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a keybinding to the specified target reference.
|
||||
*
|
||||
* @param target - A ref that holds a record of keybindings. The keys represent
|
||||
* serialized key combos, and the values are `KeybindingImpl` objects.
|
||||
* @param keybinding - The keybinding to add, represented as a `KeybindingImpl` object.
|
||||
* @param options - An options object.
|
||||
* @param options.existOk - If true, allows overwriting an existing keybinding with the
|
||||
* same combo. Defaults to false.
|
||||
*
|
||||
* @throws {Error} Throws an error if a keybinding with the same combo already exists in
|
||||
* the target and `existOk` is false.
|
||||
*/
|
||||
function addKeybinding(
|
||||
target: Ref<Record<string, KeybindingImpl>>,
|
||||
keybinding: KeybindingImpl,
|
||||
@@ -90,6 +223,7 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
const userUnsetKeybinding =
|
||||
userUnsetKeybindings.value[keybinding.combo.serialize()]
|
||||
|
||||
// User is adding back a keybinding that was an unsetted default keybinding.
|
||||
if (
|
||||
keybinding.equals(defaultKeybinding) &&
|
||||
keybinding.equals(userUnsetKeybinding)
|
||||
@@ -98,6 +232,7 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Unset keybinding on default keybinding if it exists and is not the same as userUnsetKeybinding
|
||||
if (defaultKeybinding && !defaultKeybinding.equals(userUnsetKeybinding)) {
|
||||
unsetKeybinding(defaultKeybinding)
|
||||
}
|
||||
@@ -127,6 +262,11 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
console.warn(`Unset unknown keybinding: ${JSON.stringify(keybinding)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the keybinding on given command if it is different from the current keybinding.
|
||||
*
|
||||
* @returns true if the keybinding is updated, false otherwise.
|
||||
*/
|
||||
function updateKeybindingOnCommand(keybinding: KeybindingImpl): boolean {
|
||||
const currentKeybinding = getKeybindingByCommandId(keybinding.commandId)
|
||||
if (currentKeybinding?.equals(keybinding)) {
|
||||
@@ -144,11 +284,18 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
userUnsetKeybindings.value = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the keybinding for a given command to its default value.
|
||||
*
|
||||
* @param commandId - The commandId of the keybind to be reset
|
||||
* @returns `true` if changes were made, `false` if not
|
||||
*/
|
||||
function resetKeybindingForCommand(commandId: string): boolean {
|
||||
const currentKeybinding = getKeybindingByCommandId(commandId)
|
||||
const defaultKeybinding =
|
||||
defaultKeybindingsByCommandId.value[commandId]?.[0]
|
||||
|
||||
// No default keybinding exists, need to remove any user binding
|
||||
if (!defaultKeybinding) {
|
||||
if (currentKeybinding) {
|
||||
unsetKeybinding(currentKeybinding)
|
||||
@@ -157,14 +304,17 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
return false
|
||||
}
|
||||
|
||||
// Current binding equals default binding, no changes needed
|
||||
if (currentKeybinding?.equals(defaultKeybinding)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Unset current keybinding if exists
|
||||
if (currentKeybinding) {
|
||||
unsetKeybinding(currentKeybinding)
|
||||
}
|
||||
|
||||
// Remove the unset record if it exists
|
||||
const serializedCombo = defaultKeybinding.combo.serialize()
|
||||
if (
|
||||
userUnsetKeybindings.value[serializedCombo]?.equals(defaultKeybinding)
|
||||
@@ -7,19 +7,14 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
/** (Internal helper) finds a value in a metadata object from any of a list of keys. */
|
||||
function _findInMetadata(
|
||||
metadata: Record<string, string | null>,
|
||||
...keys: string[]
|
||||
): string | null {
|
||||
function _findInMetadata(metadata: any, ...keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
if (key in metadata) {
|
||||
const value = metadata[key]
|
||||
return value || null
|
||||
return metadata[key]
|
||||
}
|
||||
for (const k in metadata) {
|
||||
if (k.endsWith(key)) {
|
||||
const value = metadata[k]
|
||||
return value || null
|
||||
return metadata[k]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('useModelToNodeStore', () => {
|
||||
it('should not register provider when nodeDef is undefined', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const providerWithoutNodeDef = new ModelNodeProvider(
|
||||
undefined!,
|
||||
undefined as any,
|
||||
'custom_key'
|
||||
)
|
||||
|
||||
@@ -507,11 +507,15 @@ describe('useModelToNodeStore', () => {
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
// These should not throw but return undefined
|
||||
expect(modelToNodeStore.getCategoryForNodeType(null!)).toBeUndefined()
|
||||
expect(
|
||||
modelToNodeStore.getCategoryForNodeType(undefined!)
|
||||
modelToNodeStore.getCategoryForNodeType(null as any)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
modelToNodeStore.getCategoryForNodeType(undefined as any)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
modelToNodeStore.getCategoryForNodeType(123 as any)
|
||||
).toBeUndefined()
|
||||
expect(modelToNodeStore.getCategoryForNodeType('123')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should be case-sensitive for node type matching', () => {
|
||||
|
||||
@@ -89,7 +89,7 @@ export class ComfyNodeDefImpl
|
||||
* @internal
|
||||
* Migrate default input options to forceInput.
|
||||
*/
|
||||
private static _migrateDefaultInput(nodeDef: ComfyNodeDefV1): ComfyNodeDefV1 {
|
||||
static #migrateDefaultInput(nodeDef: ComfyNodeDefV1): ComfyNodeDefV1 {
|
||||
const def = _.cloneDeep(nodeDef)
|
||||
def.input ??= {}
|
||||
// For required inputs, now we have the input socket always present. Specifying
|
||||
@@ -118,7 +118,7 @@ export class ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
constructor(def: ComfyNodeDefV1) {
|
||||
const obj = ComfyNodeDefImpl._migrateDefaultInput(def)
|
||||
const obj = ComfyNodeDefImpl.#migrateDefaultInput(def)
|
||||
|
||||
/**
|
||||
* Assign extra fields to `this` for compatibility with group node feature.
|
||||
|
||||
@@ -38,11 +38,7 @@ function createHistoryJob(createTime: number, id: string): JobListItem {
|
||||
|
||||
const createTaskOutput = (
|
||||
nodeId: string = 'node-1',
|
||||
images: {
|
||||
type?: 'output' | 'input' | 'temp'
|
||||
filename?: string
|
||||
subfolder?: string
|
||||
}[] = []
|
||||
images: any[] = []
|
||||
): TaskOutput => ({
|
||||
[nodeId]: {
|
||||
images
|
||||
@@ -494,7 +490,7 @@ describe('useQueueStore', () => {
|
||||
it('should recreate TaskItemImpl when outputs_count changes', async () => {
|
||||
// Initial load without outputs_count
|
||||
const jobWithoutOutputsCount = createHistoryJob(10, 'job-1')
|
||||
delete jobWithoutOutputsCount.outputs_count
|
||||
delete (jobWithoutOutputsCount as any).outputs_count
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue([jobWithoutOutputsCount])
|
||||
|
||||
@@ -7,9 +7,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import type { Subgraph } from '@/lib/litegraph/src/LGraph'
|
||||
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
const mockCanvas = {
|
||||
@@ -40,7 +38,7 @@ vi.mock('@/scripts/app', () => {
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: () => app.canvas
|
||||
getCanvas: () => (app as any).canvas
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -65,8 +63,7 @@ describe('useSubgraphNavigationStore', () => {
|
||||
} as ComfyWorkflow
|
||||
|
||||
// Set the active workflow (cast to bypass TypeScript check in test)
|
||||
workflowStore.activeWorkflow =
|
||||
mockWorkflow as typeof workflowStore.activeWorkflow
|
||||
workflowStore.activeWorkflow = mockWorkflow as any
|
||||
|
||||
// Simulate being in a subgraph by restoring state
|
||||
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
|
||||
@@ -75,9 +72,7 @@ describe('useSubgraphNavigationStore', () => {
|
||||
|
||||
// Simulate a change to the workflow's internal state
|
||||
// (e.g., changeTracker.activeState being reassigned)
|
||||
mockWorkflow.changeTracker = {
|
||||
activeState: {}
|
||||
} as typeof mockWorkflow.changeTracker
|
||||
mockWorkflow.changeTracker = { activeState: {} } as any
|
||||
|
||||
// The navigation stack should NOT be cleared because the path hasn't changed
|
||||
expect(navigationStore.exportState()).toHaveLength(2)
|
||||
@@ -92,15 +87,14 @@ describe('useSubgraphNavigationStore', () => {
|
||||
const workflow1 = {
|
||||
path: 'workflow1.json',
|
||||
filename: 'workflow1.json',
|
||||
changeTracker: createMockChangeTracker({
|
||||
changeTracker: {
|
||||
restore: vi.fn(),
|
||||
store: vi.fn()
|
||||
})
|
||||
} as Partial<ComfyWorkflow> as ComfyWorkflow
|
||||
}
|
||||
} as unknown as ComfyWorkflow
|
||||
|
||||
// Set the active workflow
|
||||
workflowStore.activeWorkflow =
|
||||
workflow1 as typeof workflowStore.activeWorkflow
|
||||
workflowStore.activeWorkflow = workflow1 as any
|
||||
|
||||
// Simulate the restore process that happens when loading a workflow
|
||||
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
|
||||
@@ -114,14 +108,13 @@ describe('useSubgraphNavigationStore', () => {
|
||||
const workflow2 = {
|
||||
path: 'workflow2.json',
|
||||
filename: 'workflow2.json',
|
||||
changeTracker: createMockChangeTracker({
|
||||
changeTracker: {
|
||||
restore: vi.fn(),
|
||||
store: vi.fn()
|
||||
})
|
||||
} as Partial<ComfyWorkflow> as ComfyWorkflow
|
||||
}
|
||||
} as unknown as ComfyWorkflow
|
||||
|
||||
workflowStore.activeWorkflow =
|
||||
workflow2 as typeof workflowStore.activeWorkflow
|
||||
workflowStore.activeWorkflow = workflow2 as any
|
||||
|
||||
// Simulate the restore process for workflow2
|
||||
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
|
||||
@@ -131,8 +124,7 @@ describe('useSubgraphNavigationStore', () => {
|
||||
expect(navigationStore.exportState()).toHaveLength(0)
|
||||
|
||||
// Switch back to workflow1
|
||||
workflowStore.activeWorkflow =
|
||||
workflow1 as typeof workflowStore.activeWorkflow
|
||||
workflowStore.activeWorkflow = workflow1 as any
|
||||
|
||||
// Simulate the restore process for workflow1 again
|
||||
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
|
||||
@@ -146,18 +138,17 @@ describe('useSubgraphNavigationStore', () => {
|
||||
it('should clear navigation when activeSubgraph becomes undefined', async () => {
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
|
||||
|
||||
// Create mock subgraph and graph structure
|
||||
const mockSubgraph = {
|
||||
id: 'subgraph-1',
|
||||
rootGraph: app.graph,
|
||||
rootGraph: (app as any).graph,
|
||||
_nodes: [],
|
||||
nodes: []
|
||||
} as Partial<Subgraph> as Subgraph
|
||||
}
|
||||
|
||||
// Add the subgraph to the graph's subgraphs map
|
||||
app.graph.subgraphs.set('subgraph-1', mockSubgraph)
|
||||
;(app as any).graph.subgraphs.set('subgraph-1', mockSubgraph)
|
||||
|
||||
// First set an active workflow
|
||||
const mockWorkflow = {
|
||||
@@ -165,14 +156,13 @@ describe('useSubgraphNavigationStore', () => {
|
||||
filename: 'test-workflow.json'
|
||||
} as ComfyWorkflow
|
||||
|
||||
workflowStore.activeWorkflow =
|
||||
mockWorkflow as typeof workflowStore.activeWorkflow
|
||||
workflowStore.activeWorkflow = mockWorkflow as any
|
||||
|
||||
// Mock findSubgraphPathById to return the correct path
|
||||
vi.mocked(findSubgraphPathById).mockReturnValue(['subgraph-1'])
|
||||
|
||||
// Set canvas.subgraph and trigger update to set activeSubgraph
|
||||
app.canvas.subgraph = mockSubgraph
|
||||
;(app as any).canvas.subgraph = mockSubgraph
|
||||
workflowStore.updateActiveGraph()
|
||||
|
||||
// Wait for Vue's reactivity to process the change
|
||||
@@ -183,7 +173,7 @@ describe('useSubgraphNavigationStore', () => {
|
||||
expect(navigationStore.exportState()).toEqual(['subgraph-1'])
|
||||
|
||||
// Clear canvas.subgraph and trigger update (simulating navigating back to root)
|
||||
app.canvas.subgraph = undefined
|
||||
;(app as any).canvas.subgraph = null
|
||||
workflowStore.updateActiveGraph()
|
||||
|
||||
// Wait for Vue's reactivity to process the change
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user