mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-22 15:29:44 +00:00
Compare commits
27 Commits
claude/sla
...
v1.41.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d23c8026d0 | ||
|
|
a309281ac5 | ||
|
|
e9bf113686 | ||
|
|
1ab48b42a7 | ||
|
|
4689581674 | ||
|
|
e4b456bb2c | ||
|
|
482ad401d4 | ||
|
|
9082f6bc3c | ||
|
|
7c34a0e0f6 | ||
|
|
8c3738fb77 | ||
|
|
aee207f16c | ||
|
|
164379bf4b | ||
|
|
9108b7535a | ||
|
|
2ff14fadc2 | ||
|
|
87341f2c6e | ||
|
|
02a38110cd | ||
|
|
09989b7aff | ||
|
|
0f455c73bb | ||
|
|
a94574d379 | ||
|
|
78fe639540 | ||
|
|
e333ad459e | ||
|
|
aab09b5f99 | ||
|
|
1f0ca18737 | ||
|
|
3b5649232d | ||
|
|
724827d8cc | ||
|
|
be70f6c1e6 | ||
|
|
514425b560 |
10
.github/workflows/release-draft-create.yaml
vendored
10
.github/workflows/release-draft-create.yaml
vendored
@@ -53,7 +53,13 @@ jobs:
|
||||
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
# Desktop-specific release artifact with desktop distribution flags.
|
||||
DISTRIBUTION=desktop pnpm build
|
||||
pnpm zipdist ./dist ./dist-desktop.zip
|
||||
|
||||
# Default release artifact for core/PyPI.
|
||||
NX_SKIP_NX_CACHE=true pnpm build
|
||||
pnpm zipdist
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -62,6 +68,7 @@ jobs:
|
||||
path: |
|
||||
dist/
|
||||
dist.zip
|
||||
dist-desktop.zip
|
||||
|
||||
draft_release:
|
||||
needs: build
|
||||
@@ -79,6 +86,7 @@ jobs:
|
||||
with:
|
||||
files: |
|
||||
dist.zip
|
||||
dist-desktop.zip
|
||||
tag_name: v${{ needs.build.outputs.version }}
|
||||
target_commitish: ${{ github.event.pull_request.base.ref }}
|
||||
make_latest: >-
|
||||
|
||||
@@ -40,12 +40,12 @@
|
||||
"block-no-empty": true,
|
||||
"no-descending-specificity": null,
|
||||
"no-duplicate-at-import-rules": true,
|
||||
"at-rule-disallowed-list": ["apply"],
|
||||
"at-rule-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreAtRules": [
|
||||
"tailwind",
|
||||
"apply",
|
||||
"layer",
|
||||
"config",
|
||||
"theme",
|
||||
|
||||
@@ -61,8 +61,7 @@
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite build --config vite.config.mts"
|
||||
"command": "vite build --config apps/desktop-ui/vite.config.mts"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
|
||||
@@ -4,3 +4,39 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.p-button-secondary {
|
||||
border: none;
|
||||
background-color: var(--color-neutral-600);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.p-button-secondary:hover {
|
||||
background-color: var(--color-neutral-550);
|
||||
}
|
||||
|
||||
.p-button-secondary:active {
|
||||
background-color: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
background-color: var(--color-coral-red-600);
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
background-color: var(--color-coral-red-500);
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
background-color: var(--color-coral-red-400);
|
||||
}
|
||||
|
||||
.task-div .p-card {
|
||||
transition: opacity var(--default-transition-duration);
|
||||
--p-card-background: var(--p-button-secondary-background);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.task-div .p-card:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -101,13 +101,15 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
/* xterm renders its internal DOM outside Vue templates, so :deep selectors are
|
||||
* required to style those generated nodes.
|
||||
*/
|
||||
:deep(.p-terminal) .xterm {
|
||||
@apply overflow-hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.p-terminal) .xterm-screen {
|
||||
@apply bg-neutral-900 overflow-hidden;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-neutral-900);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
option-value="value"
|
||||
:disabled="isSwitching"
|
||||
:pt="dropdownPt"
|
||||
:size="props.size"
|
||||
:size="size"
|
||||
class="language-selector"
|
||||
@change="onLocaleChange"
|
||||
>
|
||||
@@ -36,16 +36,10 @@ import { i18n, loadLocale, st } from '@/i18n'
|
||||
type VariantKey = 'dark' | 'light'
|
||||
type SizeKey = 'small' | 'large'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: VariantKey
|
||||
size?: SizeKey
|
||||
}>(),
|
||||
{
|
||||
variant: 'dark',
|
||||
size: 'small'
|
||||
}
|
||||
)
|
||||
const { variant = 'dark', size = 'small' } = defineProps<{
|
||||
variant?: VariantKey
|
||||
size?: SizeKey
|
||||
}>()
|
||||
|
||||
const dropdownId = `language-select-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
@@ -104,10 +98,8 @@ const VARIANT_PRESETS = {
|
||||
const selectedLocale = ref<string>(i18n.global.locale.value)
|
||||
const isSwitching = ref(false)
|
||||
|
||||
const sizePreset = computed(() => SIZE_PRESETS[props.size as SizeKey])
|
||||
const variantPreset = computed(
|
||||
() => VARIANT_PRESETS[props.variant as VariantKey]
|
||||
)
|
||||
const sizePreset = computed(() => SIZE_PRESETS[size])
|
||||
const variantPreset = computed(() => VARIANT_PRESETS[variant])
|
||||
|
||||
const dropdownPt = computed(() => ({
|
||||
root: {
|
||||
@@ -195,13 +187,17 @@ async function onLocaleChange(event: SelectChangeEvent) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.p-dropdown-panel .p-dropdown-item) {
|
||||
@apply transition-colors;
|
||||
transition-property: color, background-color, border-color;
|
||||
transition-duration: var(--default-transition-duration);
|
||||
}
|
||||
|
||||
:deep(.p-dropdown) {
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-yellow/60 focus-visible:ring-offset-2;
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--color-neutral-900),
|
||||
0 0 0 4px color-mix(in srgb, var(--color-brand-yellow) 60%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -269,26 +269,43 @@ const onFocus = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.location-picker-accordion) {
|
||||
@apply px-12;
|
||||
padding-inline: calc(var(--spacing) * 12);
|
||||
|
||||
.p-accordionpanel {
|
||||
@apply border-0 bg-transparent;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.p-accordionheader {
|
||||
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
|
||||
margin-top: calc(var(--spacing) * 2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-neutral-800) 50%,
|
||||
transparent
|
||||
);
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-radius 0.5s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-neutral-700) 50%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* When panel is expanded, adjust header border radius */
|
||||
.p-accordionpanel-active {
|
||||
.p-accordionheader {
|
||||
@apply rounded-t-xl rounded-b-none;
|
||||
border-top-left-radius: var(--radius-xl);
|
||||
border-top-right-radius: var(--radius-xl);
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.p-accordionheader-toggle-icon {
|
||||
@@ -299,11 +316,24 @@ const onFocus = async () => {
|
||||
}
|
||||
|
||||
.p-accordioncontent {
|
||||
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
|
||||
border: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: var(--radius-xl);
|
||||
border-bottom-left-radius: var(--radius-xl);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-neutral-800) 50%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.p-accordioncontent-content {
|
||||
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
|
||||
background-color: transparent;
|
||||
padding-top: calc(var(--spacing) * 3);
|
||||
padding-right: calc(var(--spacing) * 5);
|
||||
padding-bottom: calc(var(--spacing) * 5);
|
||||
padding-left: calc(var(--spacing) * 5);
|
||||
}
|
||||
|
||||
/* Override default chevron icons to use up/down */
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
<template>
|
||||
<div
|
||||
class="task-div relative grid min-h-52 max-w-48"
|
||||
:class="{ 'opacity-75': isLoading }"
|
||||
:class="
|
||||
cn(
|
||||
'task-div group/task-card relative grid min-h-52 max-w-48',
|
||||
isLoading && 'opacity-75'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Card
|
||||
class="relative h-full max-w-48 overflow-hidden"
|
||||
:class="{ 'opacity-65': runner.state !== 'error' }"
|
||||
:class="
|
||||
cn(
|
||||
'relative h-full max-w-48 overflow-hidden',
|
||||
runner.state !== 'error' && 'opacity-65'
|
||||
)
|
||||
"
|
||||
:pt="cardPt"
|
||||
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
|
||||
>
|
||||
<template #header>
|
||||
@@ -43,7 +52,7 @@
|
||||
|
||||
<i
|
||||
v-if="!isLoading && runner.state === 'OK'"
|
||||
class="task-card-ok pi pi-check"
|
||||
class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 col-span-full row-span-full z-10 text-[4rem] text-green-500 opacity-100 transition-opacity group-hover/task-card:opacity-20 [text-shadow:0.25rem_0_0.5rem_black]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -55,6 +64,7 @@ import { computed } from 'vue'
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useMinLoadingDurationRef } from '@/utils/refUtil'
|
||||
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
@@ -83,51 +93,9 @@ const reactiveExecuting = computed(() => !!runner.value.executing)
|
||||
|
||||
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
|
||||
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
|
||||
|
||||
const cardPt = {
|
||||
header: { class: 'z-0' },
|
||||
body: { class: 'z-[1] grow justify-between' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.task-card-ok {
|
||||
@apply text-green-500 absolute -right-4 -bottom-4 opacity-100 row-span-full col-span-full transition-opacity;
|
||||
|
||||
font-size: 4rem;
|
||||
text-shadow: 0.25rem 0 0.5rem black;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.p-card {
|
||||
@apply transition-opacity;
|
||||
|
||||
--p-card-background: var(--p-button-secondary-background);
|
||||
opacity: 0.9;
|
||||
|
||||
&.opacity-65 {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.p-card-header) {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
:deep(.p-card-body) {
|
||||
z-index: 1;
|
||||
flex-grow: 1;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.task-div {
|
||||
> i {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover > i {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col rounded-lg p-6 justify-between">
|
||||
<h1 class="font-inter font-semibold text-xl m-0 italic">
|
||||
{{ t(`desktopDialogs.${id}.title`, title) }}
|
||||
{{ $t(`desktopDialogs.${id}.title`, title) }}
|
||||
</h1>
|
||||
<p class="whitespace-pre-wrap">
|
||||
{{ t(`desktopDialogs.${id}.message`, message) }}
|
||||
{{ $t(`desktopDialogs.${id}.message`, message) }}
|
||||
</p>
|
||||
<div class="flex w-full gap-2">
|
||||
<Button
|
||||
@@ -12,7 +12,7 @@
|
||||
:key="button.label"
|
||||
class="rounded-lg first:mr-auto"
|
||||
:label="
|
||||
t(
|
||||
$t(
|
||||
`desktopDialogs.${id}.buttons.${normalizeI18nKey(button.label)}`,
|
||||
button.label
|
||||
)
|
||||
@@ -31,7 +31,6 @@ import { useRoute } from 'vue-router'
|
||||
|
||||
import { getDialog } from '@/constants/desktopDialogs'
|
||||
import type { DialogAction } from '@/constants/desktopDialogs'
|
||||
import { t } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -41,31 +40,3 @@ const handleButtonClick = async (button: DialogAction) => {
|
||||
await electronAPI().Dialog.clickButton(button.returnValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.p-button-secondary {
|
||||
@apply text-white border-none bg-neutral-600;
|
||||
}
|
||||
|
||||
.p-button-secondary:hover {
|
||||
@apply bg-neutral-550;
|
||||
}
|
||||
|
||||
.p-button-secondary:active {
|
||||
@apply bg-neutral-500;
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
@apply bg-coral-red-600;
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
@apply bg-coral-red-500;
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
@apply bg-coral-red-400;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<div class="relative m-8 text-center">
|
||||
<!-- Header -->
|
||||
<h1 class="download-bg pi-download text-4xl font-bold">
|
||||
{{ t('desktopUpdate.title') }}
|
||||
{{ $t('desktopUpdate.title') }}
|
||||
</h1>
|
||||
|
||||
<div class="m-8">
|
||||
<span>{{ t('desktopUpdate.description') }}</span>
|
||||
<span>{{ $t('desktopUpdate.description') }}</span>
|
||||
</div>
|
||||
|
||||
<ProgressSpinner class="m-8 w-48 h-48" />
|
||||
@@ -19,7 +19,7 @@
|
||||
<Button
|
||||
style="transform: translateX(-50%)"
|
||||
class="fixed bottom-0 left-1/2 my-8"
|
||||
:label="t('maintenance.consoleLogs')"
|
||||
:label="$t('maintenance.consoleLogs')"
|
||||
icon="pi pi-desktop"
|
||||
icon-pos="left"
|
||||
severity="secondary"
|
||||
@@ -28,8 +28,8 @@
|
||||
|
||||
<TerminalOutputDrawer
|
||||
v-model="terminalVisible"
|
||||
:header="t('g.terminal')"
|
||||
:default-message="t('desktopUpdate.terminalDefaultMessage')"
|
||||
:header="$t('g.terminal')"
|
||||
:default-message="$t('desktopUpdate.terminalDefaultMessage')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,7 +44,6 @@ import Toast from 'primevue/toast'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
import BaseViewTemplate from './templates/BaseViewTemplate.vue'
|
||||
@@ -61,10 +60,10 @@ onUnmounted(() => electron.Validation.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.download-bg::before {
|
||||
@apply m-0 absolute text-muted;
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-family: 'primeicons', sans-serif;
|
||||
top: -2rem;
|
||||
right: 2rem;
|
||||
|
||||
@@ -183,33 +183,37 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
:deep(.p-steppanel) {
|
||||
@apply mt-8 flex justify-center bg-transparent;
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Remove default padding/margin from StepPanels to make scrollbar flush */
|
||||
:deep(.p-steppanels) {
|
||||
@apply p-0 m-0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Ensure StepPanel content container has no top/bottom padding */
|
||||
:deep(.p-steppanel-content) {
|
||||
@apply p-0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Custom overlay scrollbar for WebKit browsers (Electron, Chrome) */
|
||||
:deep(.p-steppanels::-webkit-scrollbar) {
|
||||
@apply w-4;
|
||||
width: calc(var(--spacing) * 4);
|
||||
}
|
||||
|
||||
:deep(.p-steppanels::-webkit-scrollbar-track) {
|
||||
@apply bg-transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.p-steppanels::-webkit-scrollbar-thumb) {
|
||||
@apply bg-white/20 rounded-lg border-[4px] border-transparent;
|
||||
border: 4px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: color-mix(in srgb, var(--color-white) 20%, transparent);
|
||||
background-clip: content-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -114,12 +114,12 @@ import Tag from 'primevue/tag'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import RefreshButton from '@/components/common/RefreshButton.vue'
|
||||
import StatusTag from '@/components/maintenance/StatusTag.vue'
|
||||
import TaskListPanel from '@/components/maintenance/TaskListPanel.vue'
|
||||
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceFilter } from '@/types/desktop/maintenanceTypes'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
@@ -129,6 +129,7 @@ import BaseViewTemplate from './templates/BaseViewTemplate.vue'
|
||||
|
||||
const electron = electronAPI()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
const { clearResolved, processUpdate, refreshDesktopTasks } = taskStore
|
||||
|
||||
@@ -220,14 +221,14 @@ onUnmounted(() => electron.Validation.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
:deep(.p-tag) {
|
||||
--p-tag-gap: 0.375rem;
|
||||
}
|
||||
|
||||
.backspan::before {
|
||||
@apply m-0 absolute text-muted;
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-family: 'primeicons', sans-serif;
|
||||
top: -2rem;
|
||||
right: -2rem;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseViewTemplate>
|
||||
<div class="sad-container">
|
||||
<div class="sad-container grid items-center justify-evenly">
|
||||
<!-- Right side image -->
|
||||
<img
|
||||
class="sad-girl"
|
||||
@@ -79,10 +79,7 @@ const continueToInstall = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.sad-container {
|
||||
@apply grid items-center justify-evenly;
|
||||
grid-template-columns: 25rem 1fr;
|
||||
|
||||
& > * {
|
||||
|
||||
@@ -232,8 +232,6 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
/* Hide the xterm scrollbar completely */
|
||||
:deep(.p-terminal) .xterm-viewport {
|
||||
overflow: hidden !important;
|
||||
|
||||
@@ -44,6 +44,12 @@ export const TestIds = {
|
||||
node: {
|
||||
titleInput: 'node-title-input'
|
||||
},
|
||||
selectionToolbox: {
|
||||
colorPickerButton: 'color-picker-button',
|
||||
colorPickerCurrentColor: 'color-picker-current-color',
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red'
|
||||
},
|
||||
widgets: {
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
@@ -74,6 +80,7 @@ export type TestIdValue =
|
||||
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
|
||||
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
|
||||
| (typeof TestIds.node)[keyof typeof TestIds.node]
|
||||
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
|
||||
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
||||
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||
| Exclude<
|
||||
|
||||
@@ -104,15 +104,13 @@ test.describe('Missing models warning', () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
|
||||
const downloadButton = missingModelsWarning.getByText('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
|
||||
// Check that the copy URL button is also visible for Desktop environment
|
||||
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
|
||||
await expect(copyUrlButton).toBeVisible()
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display a warning when missing models are found in node properties', async ({
|
||||
@@ -123,15 +121,13 @@ test.describe('Missing models warning', () => {
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
|
||||
const downloadButton = missingModelsWarning.getByText('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
|
||||
// Check that the copy URL button is also visible for Desktop environment
|
||||
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
|
||||
await expect(copyUrlButton).toBeVisible()
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not display a warning when no missing models are found', async ({
|
||||
@@ -172,8 +168,10 @@ test.describe('Missing models warning', () => {
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).not.toBeVisible()
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not display warning when model metadata exists but widget values have changed', async ({
|
||||
@@ -186,8 +184,10 @@ test.describe('Missing models warning', () => {
|
||||
)
|
||||
|
||||
// The missing models warning should NOT appear
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).not.toBeVisible()
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).not.toBeVisible()
|
||||
})
|
||||
|
||||
// Flaky test after parallelization
|
||||
@@ -199,13 +199,15 @@ test.describe('Missing models warning', () => {
|
||||
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
|
||||
const downloadButton = comfyPage.page.getByText('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await downloadButton.click()
|
||||
await downloadAllButton.click()
|
||||
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
|
||||
@@ -229,13 +231,14 @@ test.describe('Missing models warning', () => {
|
||||
test('Should disable warning dialog when checkbox is checked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await checkbox.click()
|
||||
const changeSettingPromise = comfyPage.page.waitForRequest(
|
||||
'**/api/settings/Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
await closeButton.click()
|
||||
await checkbox.click()
|
||||
await changeSettingPromise
|
||||
|
||||
await closeButton.click()
|
||||
|
||||
const settingValue = await comfyPage.settings.getSetting(
|
||||
'Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 135 KiB |
@@ -1,6 +1,8 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
const test = comfyPageFixture
|
||||
|
||||
@@ -10,6 +12,17 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
const BLUE_COLOR = 'rgb(51, 51, 85)'
|
||||
const RED_COLOR = 'rgb(85, 51, 51)'
|
||||
|
||||
const getColorPickerButton = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
|
||||
|
||||
const getColorPickerCurrentColor = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerCurrentColor)
|
||||
|
||||
const getColorPickerGroup = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByRole('group').filter({
|
||||
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
})
|
||||
|
||||
test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
@@ -132,28 +145,24 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
// Color picker button should be visible
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
const colorPickerButton = getColorPickerButton(comfyPage)
|
||||
await expect(colorPickerButton).toBeVisible()
|
||||
|
||||
// Click color picker button
|
||||
await colorPickerButton.click()
|
||||
|
||||
// Color picker dropdown should be visible
|
||||
const colorPickerDropdown = comfyPage.page.locator(
|
||||
'.color-picker-container'
|
||||
)
|
||||
await expect(colorPickerDropdown).toBeVisible()
|
||||
const colorPickerGroup = getColorPickerGroup(comfyPage)
|
||||
await expect(colorPickerGroup).toBeVisible()
|
||||
|
||||
// Select a color (e.g., blue)
|
||||
const blueColorOption = colorPickerDropdown.locator(
|
||||
'i[data-testid="blue"]'
|
||||
const blueColorOption = colorPickerGroup.getByTestId(
|
||||
TestIds.selectionToolbox.colorBlue
|
||||
)
|
||||
await blueColorOption.click()
|
||||
|
||||
// Dropdown should close after selection
|
||||
await expect(colorPickerDropdown).not.toBeVisible()
|
||||
await expect(colorPickerGroup).not.toBeVisible()
|
||||
|
||||
// Node should have the selected color class/style
|
||||
// Note: Exact verification method depends on how color is applied to nodes
|
||||
@@ -172,22 +181,21 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
const colorPickerButton = getColorPickerButton(comfyPage)
|
||||
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
|
||||
|
||||
// Initially should show default color
|
||||
await expect(colorPickerButton).not.toHaveAttribute('color')
|
||||
|
||||
// Click color picker and select a color
|
||||
await colorPickerButton.click()
|
||||
const redColorOption = comfyPage.page.locator(
|
||||
'.color-picker-container i[data-testid="red"]'
|
||||
const redColorOption = getColorPickerGroup(comfyPage).getByTestId(
|
||||
TestIds.selectionToolbox.colorRed
|
||||
)
|
||||
await redColorOption.click()
|
||||
|
||||
// Button should now show the selected color
|
||||
await expect(colorPickerButton).toHaveCSS('color', RED_COLOR)
|
||||
await expect(colorPickerCurrentColor).toHaveCSS('color', RED_COLOR)
|
||||
})
|
||||
|
||||
test('color picker shows mixed state for differently colored selections', async ({
|
||||
@@ -195,17 +203,17 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// Select first node and color it
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
// Select second node and color it differently
|
||||
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="red"]')
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorRed)
|
||||
.click()
|
||||
|
||||
// Select both nodes
|
||||
@@ -215,9 +223,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
])
|
||||
|
||||
// Color picker should show null/mixed state
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
const colorPickerButton = getColorPickerButton(comfyPage)
|
||||
await expect(colorPickerButton).not.toHaveAttribute('color')
|
||||
})
|
||||
|
||||
@@ -226,9 +232,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// First color a node
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
.click()
|
||||
|
||||
// Clear selection
|
||||
@@ -238,10 +244,8 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
// Color picker button should show the correct color
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
await expect(colorPickerButton).toHaveCSS('color', BLUE_COLOR)
|
||||
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
|
||||
await expect(colorPickerCurrentColor).toHaveCSS('color', BLUE_COLOR)
|
||||
})
|
||||
|
||||
test('colorization via color picker can be undone', async ({
|
||||
@@ -249,9 +253,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// Select a node and color it
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
.click()
|
||||
|
||||
// Undo the colorization
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 88 KiB |
@@ -2,6 +2,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../../fixtures/selectors'
|
||||
|
||||
test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -19,10 +20,16 @@ test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
|
||||
})
|
||||
await loadCheckpointNode.getByText('Load Checkpoint').click()
|
||||
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container')
|
||||
.locator('i[data-testid="blue"]')
|
||||
const colorPickerButton = comfyPage.page.getByTestId(
|
||||
TestIds.selectionToolbox.colorPickerButton
|
||||
)
|
||||
await colorPickerButton.click()
|
||||
|
||||
const colorPickerGroup = comfyPage.page.getByRole('group').filter({
|
||||
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
})
|
||||
await colorPickerGroup
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
.click()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
|
||||
@@ -39,6 +39,10 @@ Prefer Vue native options when available:
|
||||
- Use inline Tailwind CSS only (no `<style>` blocks)
|
||||
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
|
||||
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
|
||||
- Exception: when third-party libraries render runtime DOM outside Vue templates
|
||||
(for example xterm internals inside PrimeVue terminal wrappers), scoped
|
||||
`:deep()` selectors are allowed. Add a brief inline comment explaining why the
|
||||
exception is required.
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ export default {
|
||||
'tests-ui/**': () =>
|
||||
'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
|
||||
|
||||
'./**/*.{css,vue}': (stagedFiles: string[]) => {
|
||||
const joinedPaths = toJoinedRelativePaths(stagedFiles)
|
||||
return [`pnpm exec stylelint --allow-empty-input ${joinedPaths}`]
|
||||
},
|
||||
|
||||
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => {
|
||||
@@ -22,12 +27,17 @@ export default {
|
||||
}
|
||||
|
||||
function formatAndEslint(fileNames: string[]) {
|
||||
// Convert absolute paths to relative paths for better ESLint resolution
|
||||
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
|
||||
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
const joinedPaths = toJoinedRelativePaths(fileNames)
|
||||
return [
|
||||
`pnpm exec oxfmt --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
}
|
||||
|
||||
function toJoinedRelativePaths(fileNames: string[]) {
|
||||
const relativePaths = fileNames.map((f) =>
|
||||
path.relative(process.cwd(), f).replace(/\\/g, '/')
|
||||
)
|
||||
return relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.3",
|
||||
"version": "1.41.6",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -29,10 +29,10 @@
|
||||
"knip": "knip --cache",
|
||||
"lint:fix:no-cache": "oxlint src --type-aware --fix && eslint src --fix",
|
||||
"lint:fix": "oxlint src --type-aware --fix && eslint src --cache --fix",
|
||||
"lint:no-cache": "oxlint src --type-aware && eslint src",
|
||||
"lint:no-cache": "pnpm exec stylelint '{apps,packages,src}/**/*.{css,vue}' && oxlint src --type-aware && eslint src",
|
||||
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
|
||||
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
|
||||
"lint": "oxlint src --type-aware && eslint src --cache",
|
||||
"lint": "pnpm stylelint && oxlint src --type-aware && eslint src --cache",
|
||||
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
|
||||
"locale": "lobe-i18n locale",
|
||||
"oxlint": "oxlint src --type-aware",
|
||||
|
||||
@@ -156,6 +156,7 @@
|
||||
:root {
|
||||
--fg-color: #000;
|
||||
--bg-color: #fff;
|
||||
--default-transition-duration: 0.1s;
|
||||
--comfy-menu-bg: #353535;
|
||||
--comfy-menu-secondary-bg: #292929;
|
||||
--comfy-topbar-height: 2.5rem;
|
||||
|
||||
@@ -3952,7 +3952,7 @@ export interface components {
|
||||
* @description The subscription tier level
|
||||
* @enum {string}
|
||||
*/
|
||||
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
|
||||
SubscriptionTier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
|
||||
/**
|
||||
* @description The subscription billing duration
|
||||
* @enum {string}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import zipdir from 'zip-dir'
|
||||
|
||||
zipdir('./dist', { saveTo: './dist.zip' }, function (err, buffer) {
|
||||
const sourceDir = process.argv[2] || './dist'
|
||||
const outputPath = process.argv[3] || './dist.zip'
|
||||
|
||||
zipdir(sourceDir, { saveTo: outputPath }, function (err, buffer) {
|
||||
if (err) {
|
||||
console.error('Error zipping "dist" directory:', err)
|
||||
console.error(`Error zipping "${sourceDir}" directory:`, err)
|
||||
} else {
|
||||
console.log('Successfully zipped "dist" directory.')
|
||||
process.stdout.write(
|
||||
`Successfully zipped "${sourceDir}" directory to "${outputPath}".\n`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,17 +2,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
downloadFile,
|
||||
extractFilenameFromContentDisposition
|
||||
extractFilenameFromContentDisposition,
|
||||
openFileInNewTab
|
||||
} from '@/base/common/downloadUtil'
|
||||
|
||||
let mockIsCloud = false
|
||||
const { mockIsCloud } = vi.hoisted(() => ({
|
||||
mockIsCloud: { value: false }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({ addAlert: vi.fn() }))
|
||||
}))
|
||||
|
||||
// Global stubs
|
||||
const createObjectURLSpy = vi
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
@@ -26,7 +37,7 @@ describe('downloadUtil', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsCloud = false
|
||||
mockIsCloud.value = false
|
||||
fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
|
||||
@@ -154,7 +165,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('streams downloads via blob when running in cloud', async () => {
|
||||
mockIsCloud = true
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -173,6 +184,7 @@ describe('downloadUtil', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -183,7 +195,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('logs an error when cloud fetch fails', async () => {
|
||||
mockIsCloud = true
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchMock.mockResolvedValue({
|
||||
@@ -197,14 +209,15 @@ describe('downloadUtil', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve()
|
||||
await Promise.resolve() // let fetchAsBlob throw
|
||||
await Promise.resolve() // let .catch handler run
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses filename from Content-Disposition header in cloud mode', async () => {
|
||||
mockIsCloud = true
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -223,6 +236,7 @@ describe('downloadUtil', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -231,7 +245,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('uses RFC 5987 filename from Content-Disposition header', async () => {
|
||||
mockIsCloud = true
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -253,6 +267,7 @@ describe('downloadUtil', () => {
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -260,7 +275,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('falls back to provided filename when Content-Disposition is missing', async () => {
|
||||
mockIsCloud = true
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -278,6 +293,7 @@ describe('downloadUtil', () => {
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -285,6 +301,99 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('openFileInNewTab', () => {
|
||||
let windowOpenSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens URL directly when not in cloud mode', async () => {
|
||||
mockIsCloud.value = false
|
||||
const testUrl = 'https://example.com/image.png'
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(testUrl, '_blank')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens blank tab synchronously then navigates to blob URL in cloud mode', async () => {
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith('', '_blank')
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
|
||||
expect(mockTab.location.href).toBe('blob:mock-url')
|
||||
})
|
||||
|
||||
it('revokes blob URL after timeout in cloud mode', async () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab('https://example.com/image.png')
|
||||
|
||||
expect(revokeObjectURLSpy).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
})
|
||||
|
||||
it('closes blank tab and logs error when cloud fetch fails', async () => {
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
expect(mockTab.close).toHaveBeenCalled()
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('revokes blob URL immediately if tab was closed by user', async () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab('https://example.com/image.png')
|
||||
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
expect(mockTab.location.href).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFilenameFromContentDisposition', () => {
|
||||
it('returns null for null header', () => {
|
||||
expect(extractFilenameFromContentDisposition(null)).toBeNull()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Utility functions for downloading files
|
||||
*/
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
// Constants
|
||||
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
|
||||
@@ -112,14 +114,23 @@ export function extractFilenameFromContentDisposition(
|
||||
return null
|
||||
}
|
||||
|
||||
const downloadViaBlobFetch = async (
|
||||
/**
|
||||
* Fetch a URL and return its body as a Blob.
|
||||
* Shared by download and open-in-new-tab cloud paths.
|
||||
*/
|
||||
async function fetchAsBlob(url: string): Promise<Response> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status}`)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function downloadViaBlobFetch(
|
||||
href: string,
|
||||
fallbackFilename: string
|
||||
): Promise<void> => {
|
||||
const response = await fetch(href)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${href}: ${response.status}`)
|
||||
}
|
||||
): Promise<void> {
|
||||
const response = await fetchAsBlob(href)
|
||||
|
||||
// Try to get filename from Content-Disposition header (set by backend)
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
@@ -129,3 +140,44 @@ const downloadViaBlobFetch = async (
|
||||
const blob = await response.blob()
|
||||
downloadBlob(headerFilename ?? fallbackFilename, blob)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a file URL in a new browser tab.
|
||||
* On cloud, fetches the resource as a blob first to avoid GCS redirects
|
||||
* that would trigger an auto-download instead of displaying the file.
|
||||
*
|
||||
* Opens the tab synchronously to preserve the user-gesture context
|
||||
* (browsers block window.open after an await), then navigates it to
|
||||
* the blob URL once the fetch completes.
|
||||
*/
|
||||
export async function openFileInNewTab(url: string): Promise<void> {
|
||||
if (!isCloud) {
|
||||
window.open(url, '_blank')
|
||||
return
|
||||
}
|
||||
|
||||
// Open immediately to preserve user-gesture activation.
|
||||
const tab = window.open('', '_blank')
|
||||
|
||||
try {
|
||||
const response = await fetchAsBlob(url)
|
||||
const blob = await response.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
if (tab && !tab.closed) {
|
||||
tab.location.href = blobUrl
|
||||
// Revoke after the tab has had time to load the blob.
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
|
||||
} else {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
tab?.close()
|
||||
console.error('Failed to open image:', error)
|
||||
useToastStore().addAlert(
|
||||
t('toastMessages.errorOpenImage', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="
|
||||
!focusMode && (sidebarLocation === 'left' || rightSidePanelVisible)
|
||||
!focusMode && (sidebarLocation === 'left' || showOffsideSplitter)
|
||||
"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
@@ -85,7 +85,7 @@
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="
|
||||
!focusMode && (sidebarLocation === 'right' || rightSidePanelVisible)
|
||||
!focusMode && (sidebarLocation === 'right' || showOffsideSplitter)
|
||||
"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
@@ -124,6 +124,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
@@ -144,9 +145,13 @@ const unifiedWidth = computed(() =>
|
||||
|
||||
const { focusMode } = storeToRefs(workspaceStore)
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
|
||||
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
|
||||
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
|
||||
const showOffsideSplitter = computed(
|
||||
() => rightSidePanelVisible.value || appModeStore.mode === 'builder:select'
|
||||
)
|
||||
|
||||
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
|
||||
|
||||
|
||||
@@ -103,13 +103,12 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-terminal) .xterm {
|
||||
@apply overflow-hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.p-terminal) .xterm-screen {
|
||||
@apply bg-neutral-900 overflow-hidden;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-neutral-900);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -195,8 +195,6 @@ onUpdated(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.subgraph-breadcrumb:not(:empty) {
|
||||
flex: auto;
|
||||
flex-shrink: 10000;
|
||||
@@ -205,7 +203,7 @@ onUpdated(() => {
|
||||
|
||||
.subgraph-breadcrumb,
|
||||
:deep(.p-breadcrumb) {
|
||||
@apply overflow-hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb) {
|
||||
@@ -214,7 +212,10 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item) {
|
||||
@apply flex items-center overflow-hidden h-8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
height: calc(var(--spacing) * 8);
|
||||
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
|
||||
border: 1px solid transparent;
|
||||
background-color: transparent;
|
||||
@@ -236,7 +237,7 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:hover) {
|
||||
@apply rounded-lg;
|
||||
border-radius: var(--radius-lg);
|
||||
border-color: var(--interface-stroke);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
}
|
||||
@@ -270,18 +271,16 @@ onUpdated(() => {
|
||||
</style>
|
||||
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
|
||||
.p-breadcrumb-item,
|
||||
.p-breadcrumb-separator {
|
||||
@apply hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item:nth-last-child(3),
|
||||
.p-breadcrumb-separator:nth-last-child(2),
|
||||
.p-breadcrumb-item:nth-last-child(1) {
|
||||
@apply flex;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,9 +78,7 @@ interface Props {
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
const { item, isActive = false } = defineProps<Props>()
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
@@ -103,7 +101,7 @@ const rename = async (
|
||||
) => {
|
||||
if (newName && newName !== initialName) {
|
||||
// Synchronize the node titles with the new name
|
||||
props.item.updateTitle?.(newName)
|
||||
item.updateTitle?.(newName)
|
||||
|
||||
if (workflowStore.activeSubgraph) {
|
||||
workflowStore.activeSubgraph.name = newName
|
||||
@@ -127,13 +125,13 @@ const rename = async (
|
||||
}
|
||||
}
|
||||
|
||||
const isRoot = props.item.key === 'root'
|
||||
const isRoot = item.key === 'root'
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (hasMissingNodes.value && isRoot) {
|
||||
return t('breadcrumbsMenu.missingNodesWarning')
|
||||
}
|
||||
return props.item.label
|
||||
return item.label
|
||||
})
|
||||
|
||||
const startRename = async () => {
|
||||
@@ -145,7 +143,7 @@ const startRename = async () => {
|
||||
}
|
||||
|
||||
isEditing.value = true
|
||||
itemLabel.value = props.item.label as string
|
||||
itemLabel.value = item.label as string
|
||||
void nextTick(() => {
|
||||
if (itemInputRef.value?.$el) {
|
||||
itemInputRef.value.$el.focus()
|
||||
@@ -165,12 +163,12 @@ const handleClick = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
if (event.detail === 1) {
|
||||
if (props.isActive) {
|
||||
if (isActive) {
|
||||
menu.value?.toggle(event)
|
||||
} else {
|
||||
props.item.command?.({ item: props.item, originalEvent: event })
|
||||
item.command?.({ item: item, originalEvent: event })
|
||||
}
|
||||
} else if (props.isActive && event.detail === 2) {
|
||||
} else if (isActive && event.detail === 2) {
|
||||
menu.value?.hide()
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
@@ -180,7 +178,7 @@ const handleClick = (event: MouseEvent) => {
|
||||
|
||||
const inputBlur = async (doRename: boolean) => {
|
||||
if (doRename) {
|
||||
await rename(itemLabel.value, props.item.label as string)
|
||||
await rename(itemLabel.value, item.label as string)
|
||||
}
|
||||
|
||||
isEditing.value = false
|
||||
@@ -188,19 +186,19 @@ const inputBlur = async (doRename: boolean) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.p-breadcrumb-item-link,
|
||||
.p-breadcrumb-item-icon {
|
||||
@apply select-none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item-link {
|
||||
@apply overflow-hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item-label {
|
||||
@apply whitespace-nowrap text-ellipsis overflow-hidden;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.active-breadcrumb-item {
|
||||
|
||||
324
src/components/builder/AppBuilder.vue
Normal file
324
src/components/builder/AppBuilder.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
import { remove } from 'es-toolkit'
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
const canvasStore = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { t } = useI18n()
|
||||
const canvas: LGraphCanvas = canvasStore.getCanvas()
|
||||
|
||||
const hoveringSelectable = ref(false)
|
||||
|
||||
workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||
|
||||
const inputsWithState = computed(() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
if (!node || !widget) return { nodeId, widgetName }
|
||||
|
||||
const input = node.inputs.find((i) => i.widget?.name === widget.name)
|
||||
const rename = input && (() => renameWidget(widget, input))
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
label: widget.label,
|
||||
subLabel: node.title,
|
||||
rename
|
||||
}
|
||||
})
|
||||
)
|
||||
const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
appModeStore.selectedOutputs.map((nodeId) => [
|
||||
nodeId,
|
||||
app.rootGraph.getNodeById(nodeId)?.title ?? String(nodeId)
|
||||
])
|
||||
)
|
||||
|
||||
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
|
||||
const newLabel = await useDialogService().prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewNamePrompt'),
|
||||
defaultValue: widget.label,
|
||||
placeholder: widget.name
|
||||
})
|
||||
if (newLabel === null) return
|
||||
widget.label = newLabel || undefined
|
||||
input.label = newLabel || undefined
|
||||
widget.callback?.(widget.value)
|
||||
useCanvasStore().canvas?.setDirty(true)
|
||||
}
|
||||
|
||||
function getHovered(
|
||||
e: MouseEvent
|
||||
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
|
||||
const { graph } = canvas
|
||||
if (!canvas || !graph) return
|
||||
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
if (!e) return
|
||||
|
||||
canvas.adjustMouseEvent(e)
|
||||
const node = graph.getNodeOnPos(e.canvasX, e.canvasY)
|
||||
if (!node) return
|
||||
|
||||
const widget = node.getWidgetOnPos(e.canvasX, e.canvasY, false)
|
||||
|
||||
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
|
||||
}
|
||||
|
||||
function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
if (!node) return
|
||||
|
||||
const titleOffset =
|
||||
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
|
||||
|
||||
if (!widgetName)
|
||||
return {
|
||||
width: `${node.size[0]}px`,
|
||||
height: `${node.size[1] + titleOffset}px`,
|
||||
left: `${node.pos[0]}px`,
|
||||
top: `${node.pos[1] - titleOffset}px`
|
||||
}
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (!widget) return
|
||||
|
||||
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
|
||||
const marginX = margin ?? BaseWidget.margin
|
||||
const height =
|
||||
(widget.computedHeight !== undefined
|
||||
? widget.computedHeight - 4
|
||||
: LiteGraph.NODE_WIDGET_HEIGHT) - (margin ? 2 * margin - 4 : 0)
|
||||
return {
|
||||
width: `${node.size[0] - marginX * 2}px`,
|
||||
height: `${height}px`,
|
||||
left: `${node.pos[0] + marginX}px`,
|
||||
top: `${node.pos[1] + widget.y + (margin ?? 0)}px`
|
||||
}
|
||||
}
|
||||
|
||||
function handleDown(e: MouseEvent) {
|
||||
const [node] = getHovered(e) ?? []
|
||||
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
|
||||
}
|
||||
function handleClick(e: MouseEvent) {
|
||||
const [node, widget] = getHovered(e) ?? []
|
||||
if (!node) return canvasInteractions.forwardEventToCanvas(e)
|
||||
|
||||
if (!widget) {
|
||||
if (!node.constructor.nodeData?.output_node)
|
||||
return canvasInteractions.forwardEventToCanvas(e)
|
||||
const index = appModeStore.selectedOutputs.findIndex((id) => id === node.id)
|
||||
if (index === -1) appModeStore.selectedOutputs.push(node.id)
|
||||
else appModeStore.selectedOutputs.splice(index, 1)
|
||||
return
|
||||
}
|
||||
|
||||
const index = appModeStore.selectedInputs.findIndex(
|
||||
([nodeId, widgetName]) => node.id === nodeId && widget.name === widgetName
|
||||
)
|
||||
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
function nodeToDisplayTuple(
|
||||
n: LGraphNode
|
||||
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
|
||||
return [
|
||||
n.id,
|
||||
getBounding(n.id),
|
||||
appModeStore.selectedOutputs.some((id) => n.id === id)
|
||||
]
|
||||
}
|
||||
|
||||
const renderedOutputs = computed(() => {
|
||||
void appModeStore.selectedOutputs.length
|
||||
return canvas
|
||||
.graph!.nodes.filter((n) => n.constructor.nodeData?.output_node)
|
||||
.map(nodeToDisplayTuple)
|
||||
})
|
||||
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
|
||||
`${nodeId}: ${widgetName}`,
|
||||
getBounding(nodeId, widgetName)
|
||||
])
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
|
||||
{{ t('linearMode.builder.title') }}
|
||||
<Button class="ml-auto" @click="appModeStore.exitBuilder">
|
||||
{{ t('linearMode.builder.exit') }}
|
||||
</Button>
|
||||
</div>
|
||||
<PropertiesAccordionItem
|
||||
:label="t('nodeHelpPage.inputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedInputs.length"
|
||||
class="border-border-subtle border-b"
|
||||
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.inputs') }}
|
||||
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
<DraggableList v-slot="{ dragClass }" v-model="appModeStore.selectedInputs">
|
||||
<IoItem
|
||||
v-for="{
|
||||
nodeId,
|
||||
widgetName,
|
||||
label,
|
||||
subLabel,
|
||||
rename
|
||||
} in inputsWithState"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="cn(dragClass, 'bg-primary-background/30 p-2 my-2 rounded-lg')"
|
||||
:title="label ?? widgetName"
|
||||
:sub-title="subLabel"
|
||||
:rename
|
||||
:remove="
|
||||
() =>
|
||||
remove(
|
||||
appModeStore.selectedInputs,
|
||||
([id, name]) => nodeId === id && widgetName === name
|
||||
)
|
||||
"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<PropertiesAccordionItem
|
||||
:label="t('nodeHelpPage.outputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedOutputs.length"
|
||||
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.outputs') }}
|
||||
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
<DraggableList
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedOutputs"
|
||||
>
|
||||
<IoItem
|
||||
v-for="([key, title], index) in outputsWithState"
|
||||
:key
|
||||
:class="
|
||||
cn(
|
||||
dragClass,
|
||||
'bg-warning-background/40 p-2 my-2 rounded-lg',
|
||||
index === 0 && 'ring-warning-background ring-2'
|
||||
)
|
||||
"
|
||||
:title
|
||||
:sub-title="String(key)"
|
||||
:remove="() => remove(appModeStore.selectedOutputs, (k) => k === key)"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'absolute w-full h-full pointer-events-auto',
|
||||
hoveringSelectable ? 'cursor-pointer' : 'cursor-grab'
|
||||
)
|
||||
"
|
||||
@pointerdown="handleDown"
|
||||
@pointermove="hoveringSelectable = !!getHovered($event)"
|
||||
@click="handleClick"
|
||||
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<TransformPane :canvas="canvasStore.getCanvas()">
|
||||
<div
|
||||
v-for="[key, style] in renderedInputs"
|
||||
:key
|
||||
:style="toValue(style)"
|
||||
class="fixed bg-primary-background/30 rounded-lg"
|
||||
/>
|
||||
<div
|
||||
v-for="[key, style, isSelected] in renderedOutputs"
|
||||
:key
|
||||
:style="toValue(style)"
|
||||
:class="
|
||||
cn(
|
||||
'fixed ring-warning-background ring-5 rounded-2xl',
|
||||
!isSelected && 'ring-warning-background/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="absolute top-0 right-0 size-8">
|
||||
<div
|
||||
v-if="isSelected"
|
||||
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg"
|
||||
>
|
||||
<i class="icon-[lucide--check] bg-text-foreground size-full" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TransformPane>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -62,6 +62,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { AppMode } from '@/stores/appModeStore'
|
||||
@@ -75,6 +76,14 @@ import type { BuilderToolbarStep } from './types'
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
void appModeStore.exitBuilder()
|
||||
})
|
||||
|
||||
const activeStep = computed(() =>
|
||||
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
|
||||
)
|
||||
|
||||
46
src/components/builder/IoItem.vue
Normal file
46
src/components/builder/IoItem.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { rename, remove } = defineProps<{
|
||||
title: string
|
||||
subTitle?: string
|
||||
rename?: () => void
|
||||
remove?: () => void
|
||||
}>()
|
||||
|
||||
const entries = computed(() => {
|
||||
const items = []
|
||||
if (rename)
|
||||
items.push({
|
||||
label: t('g.rename'),
|
||||
command: rename,
|
||||
icon: 'icon-[lucide--pencil]'
|
||||
})
|
||||
if (remove)
|
||||
items.push({
|
||||
label: t('g.delete'),
|
||||
command: remove,
|
||||
icon: 'icon-[lucide--trash-2]'
|
||||
})
|
||||
return items
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="p-2 my-2 rounded-lg flex items-center-safe">
|
||||
<span class="mr-auto" v-text="title" />
|
||||
<span class="text-muted-foreground mr-2 text-end" v-text="subTitle" />
|
||||
<Popover :entries>
|
||||
<template #button>
|
||||
<Button variant="muted-textonly">
|
||||
<i class="icon-[lucide--ellipsis]" />
|
||||
</Button>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,12 +33,10 @@ export function useBuilderSave() {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Update this to show the save dialog if it is temp OR if the user has not saved app mode before.
|
||||
// If they have saved app mode before, just save the workflow, but use the initial app mode state not current.
|
||||
|
||||
if (!workflow.isTemporary) {
|
||||
if (!workflow.isTemporary && workflow.activeState.extra?.linearMode) {
|
||||
try {
|
||||
workflow.changeTracker?.checkState()
|
||||
appModeStore.saveSelectedToWorkflow()
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
|
||||
} catch {
|
||||
@@ -75,6 +73,7 @@ export function useBuilderSave() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
appModeStore.saveSelectedToWorkflow()
|
||||
const saved = await workflowService.saveWorkflowAs(workflow, {
|
||||
filename,
|
||||
openAsApp
|
||||
|
||||
59
src/components/common/DraggableList.vue
Normal file
59
src/components/common/DraggableList.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import { onBeforeUnmount, ref, useTemplateRef, watchPostEffect } from 'vue'
|
||||
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
|
||||
const modelValue = defineModel<T[]>({ required: true })
|
||||
const draggableList = ref<DraggableList>()
|
||||
const draggableItems = useTemplateRef('draggableItems')
|
||||
|
||||
watchPostEffect(() => {
|
||||
void modelValue.value.length
|
||||
draggableList.value?.dispose()
|
||||
if (!draggableItems.value?.children?.length) return
|
||||
draggableList.value = new DraggableList(
|
||||
draggableItems.value,
|
||||
'.draggable-item'
|
||||
)
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem
|
||||
}
|
||||
}
|
||||
const newPosition = reorderedItems.indexOf(this.draggableItem)
|
||||
const itemList = modelValue.value
|
||||
const [item] = itemList.splice(oldPosition, 1)
|
||||
itemList.splice(newPosition, 0, item)
|
||||
modelValue.value = [...itemList]
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
draggableList.value?.dispose()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
|
||||
<slot
|
||||
drag-class="draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,148 +0,0 @@
|
||||
<!-- A Electron-backed download button with a label, size hint and progress bar -->
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<i v-if="status === 'completed'" class="pi pi-check text-green-500" />
|
||||
<div class="file-info">
|
||||
<div class="file-details">
|
||||
<span class="file-type" :title="hint">{{ label }}</span>
|
||||
</div>
|
||||
<div v-if="props.error" class="file-error">
|
||||
{{ props.error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-action flex flex-row items-center gap-2">
|
||||
<Button
|
||||
v-if="status === null || status === 'error'"
|
||||
class="file-action-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerDownload"
|
||||
>
|
||||
<i class="pi pi-download" />
|
||||
{{ $t('g.downloadWithSize', { size: fileSize }) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="(status === null || status === 'error') && !!props.url"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="copyURL"
|
||||
>
|
||||
{{ $t('g.copyURL') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="status === 'in_progress' || status === 'paused'"
|
||||
class="flex flex-row items-center gap-2"
|
||||
>
|
||||
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
|
||||
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
|
||||
-->
|
||||
<ProgressBar
|
||||
class="flex-1"
|
||||
:value="downloadProgress"
|
||||
:show-value="downloadProgress > 10"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="status === 'in_progress'"
|
||||
v-tooltip.top="t('electronFileDownload.pause')"
|
||||
class="file-action-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerPauseDownload"
|
||||
>
|
||||
<i class="pi pi-pause-circle" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="status === 'paused'"
|
||||
v-tooltip.top="t('electronFileDownload.resume')"
|
||||
class="file-action-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:aria-label="t('electronFileDownload.resume')"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerResumeDownload"
|
||||
>
|
||||
<i class="pi pi-play-circle" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.top="t('electronFileDownload.cancel')"
|
||||
class="file-action-button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
:aria-label="t('electronFileDownload.cancel')"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerCancelDownload"
|
||||
>
|
||||
<i class="pi pi-times-circle" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
url: string
|
||||
hint?: string
|
||||
label?: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const label = computed(() => props.label || props.url.split('/').pop())
|
||||
const hint = computed(() => props.hint || props.url)
|
||||
const download = useDownload(props.url)
|
||||
const downloadProgress = ref<number>(0)
|
||||
const status = ref<string | null>(null)
|
||||
const fileSize = computed(() =>
|
||||
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
|
||||
)
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const electronDownloadStore = useElectronDownloadStore()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const [savePath, filename] = props.label.split('/')
|
||||
|
||||
electronDownloadStore.$subscribe((_, { downloads }) => {
|
||||
const download = downloads.find((download) => props.url === download.url)
|
||||
|
||||
if (download) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
downloadProgress.value = Number((download.progress * 100).toFixed(1))
|
||||
// @ts-expect-error fixme ts strict error
|
||||
status.value = download.status
|
||||
}
|
||||
})
|
||||
|
||||
const triggerDownload = async () => {
|
||||
await electronDownloadStore.start({
|
||||
url: props.url,
|
||||
savePath: savePath.trim(),
|
||||
filename: filename.trim()
|
||||
})
|
||||
}
|
||||
|
||||
const triggerCancelDownload = () => electronDownloadStore.cancel(props.url)
|
||||
const triggerPauseDownload = () => electronDownloadStore.pause(props.url)
|
||||
const triggerResumeDownload = () => electronDownloadStore.resume(props.url)
|
||||
|
||||
const copyURL = async () => {
|
||||
await copyToClipboard(props.url)
|
||||
}
|
||||
</script>
|
||||
@@ -1,69 +0,0 @@
|
||||
<!-- A file download button with a label and a size hint -->
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div>
|
||||
<div>
|
||||
<span :title="hint">{{ label }}</span>
|
||||
</div>
|
||||
<Message
|
||||
v-if="props.error"
|
||||
severity="error"
|
||||
icon="pi pi-exclamation-triangle"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
class="my-2 h-min max-w-xs px-1"
|
||||
:title="props.error"
|
||||
:pt="{
|
||||
text: { class: 'overflow-hidden text-ellipsis' }
|
||||
}"
|
||||
>
|
||||
{{ props.error }}
|
||||
</Message>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:disabled="!!props.error"
|
||||
:title="props.url"
|
||||
@click="download.triggerBrowserDownload"
|
||||
>
|
||||
{{ $t('g.downloadWithSize', { size: fileSize }) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="secondary" :disabled="!!props.error" @click="copyURL">
|
||||
{{ $t('g.copyURL') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Message from 'primevue/message'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
url: string
|
||||
hint?: string
|
||||
label?: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const label = computed(() => props.label || props.url.split('/').pop())
|
||||
|
||||
const hint = computed(() => props.hint || props.url)
|
||||
const download = useDownload(props.url)
|
||||
const fileSize = computed(() =>
|
||||
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
|
||||
)
|
||||
const copyURL = async () => {
|
||||
await copyToClipboard(props.url)
|
||||
}
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
@@ -117,20 +117,18 @@ function getFormComponent(item: FormItem): Component {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.form-input :deep(.input-slider) .p-inputnumber input,
|
||||
.form-input :deep(.input-slider) .slider-part {
|
||||
@apply w-20;
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.form-input :deep(.input-knob) .p-inputnumber input,
|
||||
.form-input :deep(.input-knob) .knob-part {
|
||||
@apply w-32;
|
||||
width: 8rem;
|
||||
}
|
||||
|
||||
.form-input :deep(.p-inputtext),
|
||||
.form-input :deep(.p-select) {
|
||||
@apply w-44;
|
||||
width: 11rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -133,8 +133,6 @@ const wrapperStyle = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.p-inputtext) {
|
||||
--p-form-field-padding-x: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Chip removable @remove="$emit('remove', $event)">
|
||||
<Badge size="small" :class="badgeClass">
|
||||
<Chip removable @remove="emit('remove', $event)">
|
||||
<Badge size="small" :class="semanticBadgeClass">
|
||||
{{ badge }}
|
||||
</Badge>
|
||||
{{ text }}
|
||||
@@ -10,6 +10,7 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from 'primevue/badge'
|
||||
import Chip from 'primevue/chip'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface SearchFilter {
|
||||
text: string
|
||||
@@ -18,26 +19,19 @@ export interface SearchFilter {
|
||||
id: string | number
|
||||
}
|
||||
|
||||
defineProps<Omit<SearchFilter, 'id'>>()
|
||||
defineEmits(['remove'])
|
||||
const semanticClassMap: Record<string, string> = {
|
||||
'i-badge': 'bg-green-500 text-white',
|
||||
'o-badge': 'bg-red-500 text-white',
|
||||
'c-badge': 'bg-blue-500 text-white',
|
||||
's-badge': 'bg-yellow-500'
|
||||
}
|
||||
|
||||
const props = defineProps<Omit<SearchFilter, 'id'>>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'remove', event: Event): void
|
||||
}>()
|
||||
|
||||
const semanticBadgeClass = computed(() => {
|
||||
return semanticClassMap[props.badgeClass] ?? props.badgeClass
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.i-badge) {
|
||||
@apply bg-green-500 text-white;
|
||||
}
|
||||
|
||||
:deep(.o-badge) {
|
||||
@apply bg-red-500 text-white;
|
||||
}
|
||||
|
||||
:deep(.c-badge) {
|
||||
@apply bg-blue-500 text-white;
|
||||
}
|
||||
|
||||
:deep(.s-badge) {
|
||||
@apply bg-yellow-500;
|
||||
}
|
||||
</style>
|
||||
|
||||
113
src/components/curve/CurveEditor.test.ts
Normal file
113
src/components/curve/CurveEditor.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
|
||||
function mountEditor(points: CurvePoint[], extraProps = {}) {
|
||||
return mount(CurveEditor, {
|
||||
props: { modelValue: points, ...extraProps }
|
||||
})
|
||||
}
|
||||
|
||||
function getCurvePath(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.find('[data-testid="curve-path"]')
|
||||
}
|
||||
|
||||
describe('CurveEditor', () => {
|
||||
it('renders SVG with curve path', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(wrapper.find('svg').exists()).toBe(true)
|
||||
const curvePath = getCurvePath(wrapper)
|
||||
expect(curvePath.exists()).toBe(true)
|
||||
expect(curvePath.attributes('d')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a circle for each control point', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[0.5, 0.7],
|
||||
[1, 1]
|
||||
])
|
||||
expect(wrapper.findAll('circle')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders histogram path when provided', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = i + 1
|
||||
const wrapper = mountEditor(
|
||||
[
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
],
|
||||
{ histogram }
|
||||
)
|
||||
const histogramPath = wrapper.find('[data-testid="histogram-path"]')
|
||||
expect(histogramPath.exists()).toBe(true)
|
||||
expect(histogramPath.attributes('d')).toContain('M0,1')
|
||||
})
|
||||
|
||||
it('does not render histogram path when not provided', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns empty path with fewer than 2 points', () => {
|
||||
const wrapper = mountEditor([[0.5, 0.5]])
|
||||
expect(getCurvePath(wrapper).attributes('d')).toBe('')
|
||||
})
|
||||
|
||||
it('generates path starting with M and containing L segments', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[0.5, 0.8],
|
||||
[1, 1]
|
||||
])
|
||||
const d = getCurvePath(wrapper).attributes('d')!
|
||||
expect(d).toMatch(/^M/)
|
||||
expect(d).toContain('L')
|
||||
})
|
||||
|
||||
it('curve path only spans the x-range of control points', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0.2, 0.3],
|
||||
[0.8, 0.9]
|
||||
])
|
||||
const d = getCurvePath(wrapper).attributes('d')!
|
||||
const xValues = d
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => parseFloat(s.split(',')[0]))
|
||||
expect(Math.min(...xValues)).toBeCloseTo(0.2, 2)
|
||||
expect(Math.max(...xValues)).toBeCloseTo(0.8, 2)
|
||||
})
|
||||
|
||||
it('deletes a point on right-click but keeps minimum 2', async () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.5, 0.5],
|
||||
[1, 1]
|
||||
]
|
||||
const wrapper = mountEditor(points)
|
||||
expect(wrapper.findAll('circle')).toHaveLength(3)
|
||||
|
||||
await wrapper.findAll('circle')[1].trigger('pointerdown', {
|
||||
button: 2,
|
||||
pointerId: 1
|
||||
})
|
||||
expect(wrapper.findAll('circle')).toHaveLength(2)
|
||||
|
||||
await wrapper.findAll('circle')[0].trigger('pointerdown', {
|
||||
button: 2,
|
||||
pointerId: 1
|
||||
})
|
||||
expect(wrapper.findAll('circle')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
103
src/components/curve/CurveEditor.vue
Normal file
103
src/components/curve/CurveEditor.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<svg
|
||||
ref="svgRef"
|
||||
viewBox="-0.04 -0.04 1.08 1.08"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
class="aspect-square w-full cursor-crosshair rounded-[5px] bg-node-component-surface"
|
||||
@pointerdown.stop="handleSvgPointerDown"
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<line
|
||||
v-for="v in [0.25, 0.5, 0.75]"
|
||||
:key="'h' + v"
|
||||
:x1="0"
|
||||
:y1="v"
|
||||
:x2="1"
|
||||
:y2="v"
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.1"
|
||||
stroke-width="0.003"
|
||||
/>
|
||||
<line
|
||||
v-for="v in [0.25, 0.5, 0.75]"
|
||||
:key="'v' + v"
|
||||
:x1="v"
|
||||
:y1="0"
|
||||
:x2="v"
|
||||
:y2="1"
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.1"
|
||||
stroke-width="0.003"
|
||||
/>
|
||||
|
||||
<line
|
||||
x1="0"
|
||||
y1="1"
|
||||
x2="1"
|
||||
y2="0"
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.15"
|
||||
stroke-width="0.003"
|
||||
/>
|
||||
|
||||
<path
|
||||
v-if="histogramPath"
|
||||
data-testid="histogram-path"
|
||||
:d="histogramPath"
|
||||
:fill="curveColor"
|
||||
fill-opacity="0.15"
|
||||
stroke="none"
|
||||
/>
|
||||
|
||||
<path
|
||||
data-testid="curve-path"
|
||||
:d="curvePath"
|
||||
fill="none"
|
||||
:stroke="curveColor"
|
||||
stroke-width="0.008"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<circle
|
||||
v-for="(point, i) in modelValue"
|
||||
:key="i"
|
||||
:cx="point[0]"
|
||||
:cy="1 - point[1]"
|
||||
r="0.02"
|
||||
:fill="curveColor"
|
||||
stroke="white"
|
||||
stroke-width="0.004"
|
||||
class="cursor-grab"
|
||||
@pointerdown.stop="startDrag(i, $event)"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import { useCurveEditor } from '@/composables/useCurveEditor'
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { histogramToPath } from './curveUtils'
|
||||
|
||||
const { curveColor = 'white', histogram } = defineProps<{
|
||||
curveColor?: string
|
||||
histogram?: Uint32Array | null
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
required: true
|
||||
})
|
||||
|
||||
const svgRef = useTemplateRef<SVGSVGElement>('svgRef')
|
||||
|
||||
const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
|
||||
svgRef,
|
||||
modelValue
|
||||
})
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
histogram ? histogramToPath(histogram) : ''
|
||||
)
|
||||
</script>
|
||||
16
src/components/curve/WidgetCurve.vue
Normal file
16
src/components/curve/WidgetCurve.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<CurveEditor v-model="modelValue" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
default: () => [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
]
|
||||
})
|
||||
</script>
|
||||
141
src/components/curve/curveUtils.test.ts
Normal file
141
src/components/curve/curveUtils.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import {
|
||||
createMonotoneInterpolator,
|
||||
curvesToLUT,
|
||||
histogramToPath
|
||||
} from './curveUtils'
|
||||
|
||||
describe('createMonotoneInterpolator', () => {
|
||||
it('returns 0 for empty points', () => {
|
||||
const interpolate = createMonotoneInterpolator([])
|
||||
expect(interpolate(0.5)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns constant for single point', () => {
|
||||
const interpolate = createMonotoneInterpolator([[0.5, 0.7]])
|
||||
expect(interpolate(0)).toBe(0.7)
|
||||
expect(interpolate(1)).toBe(0.7)
|
||||
})
|
||||
|
||||
it('passes through control points exactly', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.5, 0.8],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.8, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(1, 5)
|
||||
})
|
||||
|
||||
it('clamps to endpoint values outside range', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0.2, 0.3],
|
||||
[0.8, 0.9]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBe(0.3)
|
||||
expect(interpolate(1)).toBe(0.9)
|
||||
})
|
||||
|
||||
it('produces monotone output for monotone input', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.25, 0.2],
|
||||
[0.5, 0.5],
|
||||
[0.75, 0.8],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
|
||||
let prev = -Infinity
|
||||
for (let x = 0; x <= 1; x += 0.01) {
|
||||
const y = interpolate(x)
|
||||
expect(y).toBeGreaterThanOrEqual(prev)
|
||||
prev = y
|
||||
}
|
||||
})
|
||||
|
||||
it('handles unsorted input points', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
[0.5, 0.5]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.5, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(1, 5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('curvesToLUT', () => {
|
||||
it('returns a 256-entry Uint8Array', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(lut).toBeInstanceOf(Uint8Array)
|
||||
expect(lut.length).toBe(256)
|
||||
})
|
||||
|
||||
it('produces identity LUT for diagonal curve', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBeCloseTo(i, 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('clamps output to [0, 255]', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[0.5, 1.5],
|
||||
[1, 1]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBeGreaterThanOrEqual(0)
|
||||
expect(lut[i]).toBeLessThanOrEqual(255)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('histogramToPath', () => {
|
||||
it('returns empty string for empty histogram', () => {
|
||||
expect(histogramToPath(new Uint32Array(0))).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string when all bins are zero', () => {
|
||||
expect(histogramToPath(new Uint32Array(256))).toBe('')
|
||||
})
|
||||
|
||||
it('returns a closed SVG path for valid histogram', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = i + 1
|
||||
const path = histogramToPath(histogram)
|
||||
expect(path).toMatch(/^M0,1/)
|
||||
expect(path).toMatch(/L1,1 Z$/)
|
||||
})
|
||||
|
||||
it('normalizes using 99.5th percentile to suppress outliers', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = 100
|
||||
histogram[255] = 100000
|
||||
const path = histogramToPath(histogram)
|
||||
// Most bins should map to y=0 (1 - 100/100 = 0) since
|
||||
// the 99.5th percentile is 100, not the outlier 100000
|
||||
const yValues = path
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => parseFloat(s.split(',')[1]))
|
||||
.filter((y) => !isNaN(y))
|
||||
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
|
||||
expect(nearZero.length).toBeGreaterThan(200)
|
||||
})
|
||||
})
|
||||
120
src/components/curve/curveUtils.ts
Normal file
120
src/components/curve/curveUtils.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
/**
|
||||
* Monotone cubic Hermite interpolation.
|
||||
* Produces a smooth curve that passes through all control points
|
||||
* without overshooting (monotone property).
|
||||
*
|
||||
* Returns a function that evaluates y for any x in [0, 1].
|
||||
*/
|
||||
export function createMonotoneInterpolator(
|
||||
points: CurvePoint[]
|
||||
): (x: number) => number {
|
||||
if (points.length === 0) return () => 0
|
||||
if (points.length === 1) return () => points[0][1]
|
||||
|
||||
const sorted = [...points].sort((a, b) => a[0] - b[0])
|
||||
const n = sorted.length
|
||||
const xs = sorted.map((p) => p[0])
|
||||
const ys = sorted.map((p) => p[1])
|
||||
|
||||
const deltas: number[] = []
|
||||
const slopes: number[] = []
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const dx = xs[i + 1] - xs[i]
|
||||
deltas.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx)
|
||||
}
|
||||
|
||||
slopes.push(deltas[0] ?? 0)
|
||||
for (let i = 1; i < n - 1; i++) {
|
||||
if (deltas[i - 1] * deltas[i] <= 0) {
|
||||
slopes.push(0)
|
||||
} else {
|
||||
slopes.push((deltas[i - 1] + deltas[i]) / 2)
|
||||
}
|
||||
}
|
||||
slopes.push(deltas[n - 2] ?? 0)
|
||||
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
if (deltas[i] === 0) {
|
||||
slopes[i] = 0
|
||||
slopes[i + 1] = 0
|
||||
} else {
|
||||
const alpha = slopes[i] / deltas[i]
|
||||
const beta = slopes[i + 1] / deltas[i]
|
||||
const s = alpha * alpha + beta * beta
|
||||
if (s > 9) {
|
||||
const t = 3 / Math.sqrt(s)
|
||||
slopes[i] = t * alpha * deltas[i]
|
||||
slopes[i + 1] = t * beta * deltas[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (x: number): number => {
|
||||
if (x <= xs[0]) return ys[0]
|
||||
if (x >= xs[n - 1]) return ys[n - 1]
|
||||
|
||||
let lo = 0
|
||||
let hi = n - 1
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (xs[mid] <= x) lo = mid
|
||||
else hi = mid
|
||||
}
|
||||
|
||||
const dx = xs[hi] - xs[lo]
|
||||
if (dx === 0) return ys[lo]
|
||||
|
||||
const t = (x - xs[lo]) / dx
|
||||
const t2 = t * t
|
||||
const t3 = t2 * t
|
||||
|
||||
const h00 = 2 * t3 - 3 * t2 + 1
|
||||
const h10 = t3 - 2 * t2 + t
|
||||
const h01 = -2 * t3 + 3 * t2
|
||||
const h11 = t3 - t2
|
||||
|
||||
return (
|
||||
h00 * ys[lo] +
|
||||
h10 * dx * slopes[lo] +
|
||||
h01 * ys[hi] +
|
||||
h11 * dx * slopes[hi]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a 256-bin histogram into an SVG path string.
|
||||
* Normalizes using the 99.5th percentile to avoid outlier spikes.
|
||||
*/
|
||||
export function histogramToPath(histogram: Uint32Array): string {
|
||||
if (!histogram.length) return ''
|
||||
|
||||
const sorted = Array.from(histogram).sort((a, b) => a - b)
|
||||
const max = sorted[Math.floor(255 * 0.995)]
|
||||
if (max === 0) return ''
|
||||
|
||||
const step = 1 / 255
|
||||
let d = 'M0,1'
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = i * step
|
||||
const y = 1 - Math.min(1, histogram[i] / max)
|
||||
d += ` L${x.toFixed(4)},${y.toFixed(4)}`
|
||||
}
|
||||
d += ' L1,1 Z'
|
||||
return d
|
||||
}
|
||||
|
||||
export function curvesToLUT(points: CurvePoint[]): Uint8Array {
|
||||
const lut = new Uint8Array(256)
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = i / 255
|
||||
const y = interpolate(x)
|
||||
lut[i] = Math.max(0, Math.min(255, Math.round(y * 255)))
|
||||
}
|
||||
|
||||
return lut
|
||||
}
|
||||
@@ -71,20 +71,30 @@ function getDialogPt(item: {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.global-dialog {
|
||||
max-width: calc(100vw - 1rem);
|
||||
}
|
||||
|
||||
.global-dialog .p-dialog-header {
|
||||
@apply p-2 2xl:p-[var(--p-dialog-header-padding)];
|
||||
@apply pb-0;
|
||||
padding: calc(var(--spacing) * 2);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.global-dialog .p-dialog-content {
|
||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||
@apply pt-0;
|
||||
padding: calc(var(--spacing) * 2);
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.global-dialog .p-dialog-header {
|
||||
padding: var(--p-dialog-header-padding);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.global-dialog .p-dialog-content {
|
||||
padding: var(--p-dialog-content-padding);
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace mode: wider settings dialog */
|
||||
|
||||
173
src/components/dialog/content/MissingModelsContent.vue
Normal file
173
src/components/dialog/content/MissingModelsContent.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[490px] flex-col border-t border-border-default"
|
||||
>
|
||||
<div class="flex h-full w-full flex-col gap-4 p-4">
|
||||
<p class="m-0 text-sm leading-5 text-muted-foreground">
|
||||
{{ $t('missingModelsDialog.description') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex max-h-[300px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
|
||||
>
|
||||
<div
|
||||
v-for="model in processedModels"
|
||||
:key="model.name"
|
||||
class="flex items-center justify-between px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 overflow-hidden">
|
||||
<span
|
||||
class="min-w-0 truncate text-sm text-foreground"
|
||||
:title="model.name"
|
||||
>
|
||||
{{ model.name }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex h-4 shrink-0 items-center rounded-full bg-muted-foreground/20 px-1.5 text-xxxs font-semibold uppercase text-muted-foreground"
|
||||
>
|
||||
{{ model.badgeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
v-if="model.isDownloadable && fileSizes.get(model.url)"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatSize(fileSizes.get(model.url)) }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="model.isDownloadable"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:title="model.url"
|
||||
:aria-label="$t('g.download')"
|
||||
@click="downloadModel(model, paths)"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:title="model.url"
|
||||
:aria-label="$t('g.copyURL')"
|
||||
@click="void copyToClipboard(model.url)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="totalDownloadSize > 0"
|
||||
class="sticky bottom-0 flex items-center justify-between border-t border-border-default bg-secondary-background px-3 py-2"
|
||||
>
|
||||
<span class="text-xs font-medium text-muted-foreground">
|
||||
{{ $t('missingModelsDialog.totalSize') }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ formatSize(totalDownloadSize) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="m-0 text-xs leading-5 text-muted-foreground whitespace-pre-line"
|
||||
>
|
||||
{{ $t('missingModelsDialog.footerDescription') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="hasCustomModels"
|
||||
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p
|
||||
class="m-0 text-xs font-semibold leading-5 text-warning-background"
|
||||
>
|
||||
{{ $t('missingModelsDialog.customModelsWarning') }}
|
||||
</p>
|
||||
<p class="m-0 text-xs leading-5 text-warning-background">
|
||||
{{ $t('missingModelsDialog.customModelsInstruction') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
import type { ModelWithUrl } from './missingModelsUtils'
|
||||
import {
|
||||
downloadModel,
|
||||
getBadgeLabel,
|
||||
hasValidDirectory,
|
||||
isModelDownloadable
|
||||
} from './missingModelsUtils'
|
||||
|
||||
const { missingModels, paths } = defineProps<{
|
||||
missingModels: ModelWithUrl[]
|
||||
paths: Record<string, string[]>
|
||||
}>()
|
||||
|
||||
interface ProcessedModel {
|
||||
name: string
|
||||
url: string
|
||||
directory: string
|
||||
badgeLabel: string
|
||||
isDownloadable: boolean
|
||||
}
|
||||
|
||||
const processedModels = computed<ProcessedModel[]>(() =>
|
||||
missingModels.map((model) => ({
|
||||
name: model.name,
|
||||
url: model.url,
|
||||
directory: model.directory,
|
||||
badgeLabel: getBadgeLabel(model.directory),
|
||||
isDownloadable:
|
||||
hasValidDirectory(model, paths) && isModelDownloadable(model)
|
||||
}))
|
||||
)
|
||||
|
||||
const hasCustomModels = computed(() =>
|
||||
processedModels.value.some((m) => !m.isDownloadable)
|
||||
)
|
||||
|
||||
const fileSizes = reactive(new Map<string, number>())
|
||||
|
||||
const totalDownloadSize = computed(() =>
|
||||
processedModels.value
|
||||
.filter((model) => model.isDownloadable)
|
||||
.reduce((total, model) => total + (fileSizes.get(model.url) ?? 0), 0)
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const downloadableUrls = processedModels.value
|
||||
.filter((m) => m.isDownloadable)
|
||||
.map((m) => m.url)
|
||||
|
||||
await Promise.allSettled(
|
||||
downloadableUrls.map(async (url) => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
if (!response.ok) return
|
||||
const size = response.headers.get('content-length')
|
||||
if (size) fileSizes.set(url, parseInt(size, 10))
|
||||
} catch {
|
||||
// Silently skip size fetch failures
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
103
src/components/dialog/content/MissingModelsFooter.vue
Normal file
103
src/components/dialog/content/MissingModelsFooter.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-2 px-4 py-2">
|
||||
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
id="doNotAskAgainModels"
|
||||
v-model="doNotAskAgain"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<label for="doNotAskAgainModels">{{
|
||||
$t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
<i18n-t
|
||||
v-if="doNotAskAgain"
|
||||
keypath="missingModelsDialog.reEnableInSettings"
|
||||
tag="span"
|
||||
class="ml-6 text-sm text-muted-foreground"
|
||||
>
|
||||
<template #link>
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="cursor-pointer p-0 text-sm text-muted-foreground underline hover:bg-transparent"
|
||||
@click="openShowMissingModelsSetting"
|
||||
>
|
||||
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button variant="secondary" size="md" @click="handleAction">
|
||||
{{ buttonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import type { ModelWithUrl } from './missingModelsUtils'
|
||||
import {
|
||||
downloadModel,
|
||||
hasValidDirectory,
|
||||
isModelDownloadable
|
||||
} from './missingModelsUtils'
|
||||
|
||||
const { missingModels, paths } = defineProps<{
|
||||
missingModels: ModelWithUrl[]
|
||||
paths: Record<string, string[]>
|
||||
}>()
|
||||
|
||||
const DIALOG_KEY = 'global-missing-models-warning'
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
watch(doNotAskAgain, (value) => {
|
||||
void useSettingStore().set('Comfy.Workflow.ShowMissingModelsWarning', !value)
|
||||
})
|
||||
|
||||
function openShowMissingModelsSetting() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
|
||||
}
|
||||
|
||||
const downloadableModels = computed(() =>
|
||||
missingModels.filter(
|
||||
(model) => hasValidDirectory(model, paths) && isModelDownloadable(model)
|
||||
)
|
||||
)
|
||||
|
||||
const hasDownloadable = computed(() => downloadableModels.value.length > 0)
|
||||
|
||||
const hasCustom = computed(
|
||||
() => downloadableModels.value.length < missingModels.length
|
||||
)
|
||||
|
||||
const buttonLabel = computed(() => {
|
||||
if (hasDownloadable.value && hasCustom.value)
|
||||
return t('missingModelsDialog.downloadAvailable')
|
||||
if (hasDownloadable.value) return t('missingModelsDialog.downloadAll')
|
||||
return t('missingModelsDialog.gotIt')
|
||||
})
|
||||
|
||||
function handleAction() {
|
||||
if (hasDownloadable.value) {
|
||||
for (const model of downloadableModels.value) {
|
||||
downloadModel(model, paths)
|
||||
}
|
||||
}
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
</script>
|
||||
10
src/components/dialog/content/MissingModelsHeader.vue
Normal file
10
src/components/dialog/content/MissingModelsHeader.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--triangle-alert] text-warning-background"></i>
|
||||
<p class="m-0 text-sm">
|
||||
{{ $t('missingModelsDialog.title') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,177 +0,0 @@
|
||||
<template>
|
||||
<NoResultsPlaceholder
|
||||
class="pb-0"
|
||||
icon="pi pi-exclamation-circle"
|
||||
:title="t('missingModelsDialog.missingModels')"
|
||||
:message="t('missingModelsDialog.missingModelsMessage')"
|
||||
/>
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
id="doNotAskAgain"
|
||||
v-model="doNotAskAgain"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<label for="doNotAskAgain">{{
|
||||
t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
<i18n-t
|
||||
v-if="doNotAskAgain"
|
||||
keypath="missingModelsDialog.reEnableInSettings"
|
||||
tag="span"
|
||||
class="text-sm text-muted-foreground ml-6"
|
||||
>
|
||||
<template #link>
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
|
||||
@click="openShowMissingModelsSetting"
|
||||
>
|
||||
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<ListBox :options="missingModels" class="comfy-missing-models">
|
||||
<template #option="{ option }">
|
||||
<Suspense v-if="isDesktop">
|
||||
<ElectronFileDownload
|
||||
:url="option.url"
|
||||
:label="option.label"
|
||||
:error="option.error"
|
||||
/>
|
||||
</Suspense>
|
||||
<FileDownload
|
||||
v-else
|
||||
:url="option.url"
|
||||
:label="option.label"
|
||||
:error="option.error"
|
||||
/>
|
||||
</template>
|
||||
</ListBox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ListBox from 'primevue/listbox'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
|
||||
import FileDownload from '@/components/common/FileDownload.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
// TODO: Read this from server internal API rather than hardcoding here
|
||||
// as some installations may wish to use custom sources
|
||||
const allowedSources = [
|
||||
'https://civitai.com/',
|
||||
'https://huggingface.co/',
|
||||
'http://localhost:' // Included for testing usage only
|
||||
]
|
||||
const allowedSuffixes = ['.safetensors', '.sft']
|
||||
// Models that fail above conditions but are still allowed
|
||||
const whiteListedUrls = new Set([
|
||||
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
|
||||
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
|
||||
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
|
||||
])
|
||||
|
||||
interface ModelInfo {
|
||||
name: string
|
||||
directory: string
|
||||
url: string
|
||||
downloading?: boolean
|
||||
completed?: boolean
|
||||
progress?: number
|
||||
error?: string
|
||||
folder_path?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
missingModels: ModelInfo[]
|
||||
paths: Record<string, string[]>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
function openShowMissingModelsSetting() {
|
||||
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
|
||||
}
|
||||
|
||||
const modelDownloads = ref<Record<string, ModelInfo>>({})
|
||||
const missingModels = computed(() => {
|
||||
return props.missingModels.map((model) => {
|
||||
const paths = props.paths[model.directory]
|
||||
if (!paths) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
url: model.url,
|
||||
error: 'Invalid directory specified (does this require custom nodes?)'
|
||||
}
|
||||
}
|
||||
const downloadInfo: ModelInfo = modelDownloads.value[model.name] ?? {
|
||||
downloading: false,
|
||||
completed: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
name: model.name,
|
||||
directory: model.directory,
|
||||
url: model.url,
|
||||
folder_path: paths[0]
|
||||
}
|
||||
modelDownloads.value[model.name] = downloadInfo
|
||||
if (!whiteListedUrls.has(model.url)) {
|
||||
if (!allowedSources.some((source) => model.url.startsWith(source))) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
url: model.url,
|
||||
error: `Download not allowed from source '${model.url}', only allowed from '${allowedSources.join("', '")}'`
|
||||
}
|
||||
}
|
||||
if (!allowedSuffixes.some((suffix) => model.name.endsWith(suffix))) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
url: model.url,
|
||||
error: `Only allowed suffixes are: '${allowedSuffixes.join("', '")}'`
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
url: model.url,
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
downloading: downloadInfo.downloading,
|
||||
completed: downloadInfo.completed,
|
||||
progress: downloadInfo.progress,
|
||||
error: downloadInfo.error,
|
||||
name: model.name,
|
||||
paths: paths,
|
||||
folderPath: downloadInfo.folder_path
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
if (doNotAskAgain.value) {
|
||||
await useSettingStore().set(
|
||||
'Comfy.Workflow.ShowMissingModelsWarning',
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comfy-missing-models {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
83
src/components/dialog/content/missingModelsUtils.ts
Normal file
83
src/components/dialog/content/missingModelsUtils.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
|
||||
const ALLOWED_SOURCES = [
|
||||
'https://civitai.com/',
|
||||
'https://huggingface.co/',
|
||||
'http://localhost:'
|
||||
] as const
|
||||
|
||||
const ALLOWED_SUFFIXES = [
|
||||
'.safetensors',
|
||||
'.sft',
|
||||
'.ckpt',
|
||||
'.pth',
|
||||
'.pt'
|
||||
] as const
|
||||
|
||||
const WHITE_LISTED_URLS: ReadonlySet<string> = new Set([
|
||||
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
|
||||
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
|
||||
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
|
||||
])
|
||||
|
||||
const DIRECTORY_BADGE_MAP = {
|
||||
vae: 'VAE',
|
||||
diffusion_models: 'DIFFUSION',
|
||||
text_encoders: 'TEXT ENCODER',
|
||||
loras: 'LORA',
|
||||
checkpoints: 'CHECKPOINT'
|
||||
} as const
|
||||
|
||||
export interface ModelWithUrl {
|
||||
name: string
|
||||
url: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
export function isModelDownloadable(model: ModelWithUrl): boolean {
|
||||
if (WHITE_LISTED_URLS.has(model.url)) return true
|
||||
if (!ALLOWED_SOURCES.some((source) => model.url.startsWith(source)))
|
||||
return false
|
||||
if (!ALLOWED_SUFFIXES.some((suffix) => model.name.endsWith(suffix)))
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function hasValidDirectory(
|
||||
model: ModelWithUrl,
|
||||
paths: Record<string, string[]>
|
||||
): boolean {
|
||||
return !!paths[model.directory]
|
||||
}
|
||||
|
||||
export function getBadgeLabel(directory: string): string {
|
||||
if (directory in DIRECTORY_BADGE_MAP) {
|
||||
return DIRECTORY_BADGE_MAP[directory as keyof typeof DIRECTORY_BADGE_MAP]
|
||||
}
|
||||
return directory.toUpperCase()
|
||||
}
|
||||
|
||||
export function downloadModel(
|
||||
model: ModelWithUrl,
|
||||
paths: Record<string, string[]>
|
||||
): void {
|
||||
if (!isDesktop) {
|
||||
const link = document.createElement('a')
|
||||
link.href = model.url
|
||||
link.download = model.name
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener noreferrer'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
|
||||
const modelPaths = paths[model.directory]
|
||||
if (modelPaths?.[0]) {
|
||||
void useElectronDownloadStore().start({
|
||||
url: model.url,
|
||||
savePath: modelPaths[0],
|
||||
filename: model.name
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,9 @@
|
||||
}"
|
||||
@row-dblclick="editKeybinding($event.data)"
|
||||
>
|
||||
<Column field="actions" header="">
|
||||
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
|
||||
<template #body="slotProps">
|
||||
<div class="actions invisible flex flex-row">
|
||||
<div class="actions flex flex-row">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
@@ -56,6 +56,7 @@
|
||||
:header="$t('g.command')"
|
||||
sortable
|
||||
class="max-w-64 2xl:max-w-full"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div class="truncate" :title="slotProps.data.id">
|
||||
@@ -63,7 +64,11 @@
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="keybinding" :header="$t('g.keybinding')">
|
||||
<Column
|
||||
field="keybinding"
|
||||
:header="$t('g.keybinding')"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<KeyComboDisplay
|
||||
v-if="slotProps.data.keybinding"
|
||||
@@ -75,7 +80,11 @@
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="source" :header="$t('g.source')">
|
||||
<Column
|
||||
field="source"
|
||||
:header="$t('g.source')"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<span class="overflow-hidden text-ellipsis">{{
|
||||
slotProps.data.source || '-'
|
||||
@@ -293,17 +302,3 @@ async function resetAllKeybindings() {
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-datatable-tbody) > tr > td {
|
||||
@apply p-1;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
:deep(.p-datatable-row-selected) .actions,
|
||||
:deep(.p-datatable-selectable-row:hover) .actions {
|
||||
@apply visible;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -98,16 +98,17 @@ describe('SignInForm', () => {
|
||||
await nextTick()
|
||||
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
'span.text-muted.text-base.font-medium.select-none'
|
||||
)
|
||||
|
||||
expect(forgotPasswordSpan.classes()).toContain('text-link-disabled')
|
||||
expect(forgotPasswordSpan.classes()).toContain('cursor-not-allowed')
|
||||
expect(forgotPasswordSpan.classes()).toContain('opacity-50')
|
||||
})
|
||||
|
||||
it('shows toast and focuses email input when clicked while disabled', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
'span.text-muted.text-base.font-medium.select-none'
|
||||
)
|
||||
|
||||
// Mock getElementById to track focus
|
||||
@@ -152,7 +153,7 @@ describe('SignInForm', () => {
|
||||
)
|
||||
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
'span.text-muted.text-base.font-medium.select-none'
|
||||
)
|
||||
|
||||
// Click the forgot password link
|
||||
|
||||
@@ -34,10 +34,13 @@
|
||||
{{ t('auth.login.passwordLabel') }}
|
||||
</label>
|
||||
<span
|
||||
class="cursor-pointer text-base font-medium text-muted select-none"
|
||||
:class="{
|
||||
'text-link-disabled': !$form.email?.value || $form.email?.invalid
|
||||
}"
|
||||
:class="
|
||||
cn('text-base font-medium text-muted select-none', {
|
||||
'cursor-not-allowed opacity-50':
|
||||
!$form.email?.value || $form.email?.invalid,
|
||||
'cursor-pointer': $form.email?.value && !$form.email?.invalid
|
||||
})
|
||||
"
|
||||
@click="handleForgotPassword($form.email?.value, $form.email?.valid)"
|
||||
>
|
||||
{{ t('auth.login.forgotPassword') }}
|
||||
@@ -89,6 +92,7 @@ import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthAction
|
||||
import { signInSchema } from '@/schemas/signInSchema'
|
||||
import type { SignInData } from '@/schemas/signInSchema'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
@@ -126,11 +130,3 @@ const handleForgotPassword = async (
|
||||
await firebaseAuthActions.sendPasswordReset(email)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
.text-link-disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template v-if="showUI" #right-side-panel>
|
||||
<NodePropertiesPanel v-if="!appModeStore.isBuilderMode" />
|
||||
<AppBuilder v-if="appModeStore.mode === 'builder:select'" />
|
||||
<NodePropertiesPanel v-else-if="!appModeStore.isBuilderMode" />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<GraphCanvasMenu
|
||||
@@ -126,6 +127,7 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
|
||||
@@ -8,20 +8,38 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
// Import after mocks
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { defaultGraph } from '@/scripts/defaultGraph'
|
||||
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
function createMockWorkflow(
|
||||
overrides: Partial<LoadedComfyWorkflow> = {}
|
||||
): LoadedComfyWorkflow {
|
||||
return {
|
||||
changeTracker: {
|
||||
const workflow = new ComfyWorkflow({
|
||||
path: 'workflows/color-picker-test.json',
|
||||
modified: 0,
|
||||
size: 0
|
||||
})
|
||||
|
||||
const changeTracker = Object.assign(
|
||||
new ChangeTracker(workflow, structuredClone(defaultGraph)),
|
||||
{
|
||||
checkState: vi.fn() as Mock
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const workflowOverrides = {
|
||||
changeTracker,
|
||||
...overrides
|
||||
} as Partial<LoadedComfyWorkflow> as LoadedComfyWorkflow
|
||||
} satisfies Partial<LoadedComfyWorkflow>
|
||||
|
||||
return Object.assign(workflow, workflowOverrides) as LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
// Mock the litegraph module
|
||||
@@ -110,12 +128,14 @@ describe('ColorPickerButton', () => {
|
||||
const wrapper = createWrapper()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
|
||||
|
||||
await button.trigger('click')
|
||||
expect(wrapper.find('.color-picker-container').exists()).toBe(true)
|
||||
const picker = wrapper.findComponent({ name: 'SelectButton' })
|
||||
expect(picker.exists()).toBe(true)
|
||||
expect(picker.findAll('button').length).toBeGreaterThan(0)
|
||||
|
||||
await button.trigger('click')
|
||||
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,13 +11,17 @@
|
||||
@click="() => (showColorPicker = !showColorPicker)"
|
||||
>
|
||||
<div class="flex items-center gap-1 px-0">
|
||||
<i class="pi pi-circle-fill" :style="{ color: currentColor ?? '' }" />
|
||||
<i
|
||||
class="pi pi-circle-fill"
|
||||
data-testid="color-picker-current-color"
|
||||
:style="{ color: currentColor ?? '' }"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down]" />
|
||||
</div>
|
||||
</Button>
|
||||
<div
|
||||
v-if="showColorPicker"
|
||||
class="color-picker-container absolute -top-10 left-1/2"
|
||||
class="absolute -top-10 left-1/2 -translate-x-1/2"
|
||||
>
|
||||
<SelectButton
|
||||
:model-value="selectedColorOption"
|
||||
@@ -159,13 +163,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../assets/css/style.css';
|
||||
|
||||
.color-picker-container {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton) {
|
||||
@apply py-2 px-1;
|
||||
padding: calc(var(--spacing) * 2) var(--spacing);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
<div
|
||||
v-show="widgetState.visible"
|
||||
ref="widgetElement"
|
||||
class="dom-widget"
|
||||
class="dom-widget h-full w-full"
|
||||
:title="tooltip"
|
||||
:style="style"
|
||||
>
|
||||
<component
|
||||
:is="widget.component"
|
||||
v-if="isComponentWidget(widget)"
|
||||
class="h-full w-full"
|
||||
:model-value="widget.value"
|
||||
:widget="widget"
|
||||
v-bind="widget.props"
|
||||
@@ -174,6 +175,8 @@ const mountElementIfVisible = () => {
|
||||
if (widgetElement.value.contains(widget.element)) {
|
||||
return
|
||||
}
|
||||
|
||||
widget.element.classList.add('h-full', 'w-full')
|
||||
widgetElement.value.appendChild(widget.element)
|
||||
}
|
||||
|
||||
@@ -196,11 +199,3 @@ watch(
|
||||
|
||||
whenever(() => !canvasStore.linearMode, mountElementIfVisible)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../assets/css/style.css';
|
||||
|
||||
.dom-widget > * {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,9 +24,7 @@ interface Props {
|
||||
modelValue: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
step: 1
|
||||
})
|
||||
const { label, min, max, step = 1, modelValue } = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
|
||||
@@ -8,11 +8,14 @@
|
||||
<!-- Markdown fetched successfully -->
|
||||
<div
|
||||
v-else-if="!error"
|
||||
class="markdown-content"
|
||||
class="markdown-content overflow-visible text-sm leading-(--text-sm--line-height)"
|
||||
v-html="renderedHelpHtml"
|
||||
/>
|
||||
<!-- Fallback: markdown not found or fetch error -->
|
||||
<div v-else class="fallback-content space-y-6 text-sm">
|
||||
<div
|
||||
v-else
|
||||
class="fallback-content space-y-6 text-sm leading-(--text-sm--line-height)"
|
||||
>
|
||||
<p v-if="node.description">
|
||||
<strong>{{ $t('g.description') }}:</strong> {{ node.description }}
|
||||
</p>
|
||||
@@ -22,48 +25,52 @@
|
||||
<strong>{{ $t('nodeHelpPage.inputs') }}:</strong>
|
||||
</p>
|
||||
<!-- Using plain HTML table instead of DataTable for consistent styling with markdown content -->
|
||||
<table class="overflow-x-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="input in inputList" :key="input.name">
|
||||
<td>
|
||||
<code>{{ input.name }}</code>
|
||||
</td>
|
||||
<td>{{ input.type }}</td>
|
||||
<td>{{ input.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="input in inputList" :key="input.name">
|
||||
<td>
|
||||
<code>{{ input.name }}</code>
|
||||
</td>
|
||||
<td>{{ input.type }}</td>
|
||||
<td>{{ input.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="outputList.length">
|
||||
<p>
|
||||
<strong>{{ $t('nodeHelpPage.outputs') }}:</strong>
|
||||
</p>
|
||||
<table class="overflow-x-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="output in outputList" :key="output.name">
|
||||
<td>
|
||||
<code>{{ output.name }}</code>
|
||||
</td>
|
||||
<td>{{ output.type }}</td>
|
||||
<td>{{ output.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="output in outputList" :key="output.name">
|
||||
<td>
|
||||
<code>{{ output.name }}</code>
|
||||
</td>
|
||||
<td>{{ output.type }}</td>
|
||||
<td>{{ output.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,39 +107,59 @@ const outputList = computed(() =>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference './../../assets/css/style.css';
|
||||
|
||||
.node-help-content :deep(:is(img, video)) {
|
||||
@apply max-w-full h-auto block mb-4;
|
||||
}
|
||||
|
||||
.markdown-content,
|
||||
.fallback-content {
|
||||
@apply text-sm overflow-visible;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
}
|
||||
|
||||
.markdown-content :deep(h1),
|
||||
.fallback-content h1 {
|
||||
@apply text-[22px] font-bold mt-8 mb-4 first:mt-0;
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.markdown-content :deep(h2),
|
||||
.fallback-content h2 {
|
||||
@apply text-[18px] font-bold mt-8 mb-4 first:mt-0;
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.markdown-content :deep(h3),
|
||||
.fallback-content h3 {
|
||||
@apply text-[16px] font-bold mt-8 mb-4 first:mt-0;
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.markdown-content :deep(h4),
|
||||
.fallback-content h4 {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.markdown-content :deep(h5),
|
||||
.fallback-content h5 {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.markdown-content :deep(h6),
|
||||
.fallback-content h4,
|
||||
.fallback-content h5,
|
||||
.fallback-content h6 {
|
||||
@apply mt-8 mb-4 first:mt-0;
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.markdown-content :deep(td),
|
||||
@@ -155,7 +182,8 @@ const outputList = computed(() =>
|
||||
.markdown-content :deep(ol),
|
||||
.fallback-content ul,
|
||||
.fallback-content ol {
|
||||
@apply pl-8 my-2;
|
||||
margin-block: calc(var(--spacing) * 2);
|
||||
padding-left: calc(var(--spacing) * 8);
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul ul),
|
||||
@@ -166,36 +194,42 @@ const outputList = computed(() =>
|
||||
.fallback-content ol ol,
|
||||
.fallback-content ul ol,
|
||||
.fallback-content ol ul {
|
||||
@apply pl-6 my-2;
|
||||
margin-block: calc(var(--spacing) * 2);
|
||||
padding-left: calc(var(--spacing) * 6);
|
||||
}
|
||||
|
||||
.markdown-content :deep(li),
|
||||
.fallback-content li {
|
||||
@apply my-2;
|
||||
margin-block: calc(var(--spacing) * 2);
|
||||
}
|
||||
|
||||
.markdown-content :deep(*:first-child),
|
||||
.fallback-content > *:first-child {
|
||||
@apply mt-0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(code),
|
||||
.fallback-content code {
|
||||
color: var(--code-text-color);
|
||||
background-color: var(--code-bg-color);
|
||||
@apply rounded px-1.5 py-0.5;
|
||||
border-radius: var(--radius);
|
||||
padding: calc(var(--spacing) * 0.5) calc(var(--spacing) * 1.5);
|
||||
}
|
||||
|
||||
.markdown-content :deep(table),
|
||||
.fallback-content table {
|
||||
@apply w-full border-collapse;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.fallback-content table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.markdown-content :deep(th),
|
||||
.markdown-content :deep(td),
|
||||
.fallback-content th,
|
||||
.fallback-content td {
|
||||
@apply px-2 py-2;
|
||||
padding: calc(var(--spacing) * 2);
|
||||
}
|
||||
|
||||
.markdown-content :deep(tr),
|
||||
@@ -215,16 +249,22 @@ const outputList = computed(() =>
|
||||
|
||||
.markdown-content :deep(pre),
|
||||
.fallback-content pre {
|
||||
@apply rounded p-4 my-4 overflow-x-auto;
|
||||
margin-block: calc(var(--spacing) * 4);
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius);
|
||||
padding: calc(var(--spacing) * 4);
|
||||
background-color: var(--code-block-bg-color);
|
||||
|
||||
code {
|
||||
@apply bg-transparent p-0;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
color: var(--p-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-content :deep(table) {
|
||||
@apply overflow-x-auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -75,15 +75,10 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
type OverlayState = 'hidden' | 'active' | 'expanded'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
expanded?: boolean
|
||||
menuHovered?: boolean
|
||||
}>(),
|
||||
{
|
||||
menuHovered: false
|
||||
}
|
||||
)
|
||||
const { expanded, menuHovered = false } = defineProps<{
|
||||
expanded?: boolean
|
||||
menuHovered?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:expanded', value: boolean): void
|
||||
@@ -106,13 +101,12 @@ const {
|
||||
currentNodeProgressStyle
|
||||
} = useQueueProgress()
|
||||
const isHovered = ref(false)
|
||||
const isOverlayHovered = computed(() => isHovered.value || props.menuHovered)
|
||||
const isOverlayHovered = computed(() => isHovered.value || menuHovered)
|
||||
const internalExpanded = ref(false)
|
||||
const isExpanded = computed({
|
||||
get: () =>
|
||||
props.expanded === undefined ? internalExpanded.value : props.expanded,
|
||||
get: () => (expanded === undefined ? internalExpanded.value : expanded),
|
||||
set: (value) => {
|
||||
if (props.expanded === undefined) {
|
||||
if (expanded === undefined) {
|
||||
internalExpanded.value = value
|
||||
}
|
||||
emit('update:expanded', value)
|
||||
|
||||
@@ -17,10 +17,7 @@
|
||||
@mouseenter="onPopoverEnter"
|
||||
@mouseleave="onPopoverLeave"
|
||||
>
|
||||
<JobDetailsPopover
|
||||
:job-id="props.jobId"
|
||||
:workflow-id="props.workflowId"
|
||||
/>
|
||||
<JobDetailsPopover :job-id="jobId" :workflow-id="workflowId" />
|
||||
</div>
|
||||
</Teleport>
|
||||
<Teleport to="body">
|
||||
@@ -36,7 +33,7 @@
|
||||
>
|
||||
<QueueAssetPreview
|
||||
:image-url="iconImageUrl!"
|
||||
:name="props.title"
|
||||
:name="title"
|
||||
:time-label="rightText || undefined"
|
||||
@image-click="emit('view')"
|
||||
/>
|
||||
@@ -49,23 +46,20 @@
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
props.state === 'running' &&
|
||||
hasAnyProgressPercent(
|
||||
props.progressTotalPercent,
|
||||
props.progressCurrentPercent
|
||||
)
|
||||
state === 'running' &&
|
||||
hasAnyProgressPercent(progressTotalPercent, progressCurrentPercent)
|
||||
"
|
||||
:class="progressBarContainerClass"
|
||||
>
|
||||
<div
|
||||
v-if="hasProgressPercent(props.progressTotalPercent)"
|
||||
v-if="hasProgressPercent(progressTotalPercent)"
|
||||
:class="progressBarPrimaryClass"
|
||||
:style="progressPercentStyle(props.progressTotalPercent)"
|
||||
:style="progressPercentStyle(progressTotalPercent)"
|
||||
/>
|
||||
<div
|
||||
v-if="hasProgressPercent(props.progressCurrentPercent)"
|
||||
v-if="hasProgressPercent(progressCurrentPercent)"
|
||||
:class="progressBarSecondaryClass"
|
||||
:style="progressPercentStyle(props.progressCurrentPercent)"
|
||||
:style="progressPercentStyle(progressCurrentPercent)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -93,8 +87,8 @@
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 min-w-0 flex-1">
|
||||
<div class="truncate opacity-90" :title="props.title">
|
||||
<slot name="primary">{{ props.title }}</slot>
|
||||
<div class="truncate opacity-90" :title="title">
|
||||
<slot name="primary">{{ title }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +125,7 @@
|
||||
class="inline-flex items-center gap-2 pr-1"
|
||||
>
|
||||
<Button
|
||||
v-if="props.state === 'failed' && computedShowClear"
|
||||
v-if="state === 'failed' && computedShowClear"
|
||||
v-tooltip.top="deleteTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
@@ -142,8 +136,8 @@
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="
|
||||
props.state !== 'completed' &&
|
||||
props.state !== 'running' &&
|
||||
state !== 'completed' &&
|
||||
state !== 'running' &&
|
||||
computedShowClear
|
||||
"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
@@ -155,14 +149,14 @@
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="props.state === 'completed'"
|
||||
v-else-if="state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emit('view')"
|
||||
>{{ t('menuLabels.View') }}</Button
|
||||
>
|
||||
<Button
|
||||
v-if="props.showMenu !== undefined ? props.showMenu : true"
|
||||
v-if="showMenu !== undefined ? showMenu : true"
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
@@ -172,17 +166,13 @@
|
||||
<i class="icon-[lucide--more-horizontal] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="props.state !== 'running'"
|
||||
key="secondary"
|
||||
class="pr-2"
|
||||
>
|
||||
<slot name="secondary">{{ props.rightText }}</slot>
|
||||
<div v-else-if="state !== 'running'" key="secondary" class="pr-2">
|
||||
<slot name="secondary">{{ rightText }}</slot>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- Running job cancel button - always visible -->
|
||||
<Button
|
||||
v-if="props.state === 'running' && computedShowClear"
|
||||
v-if="state === 'running' && computedShowClear"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
@@ -209,34 +199,33 @@ import type { JobState } from '@/types/queue'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
jobId: string
|
||||
workflowId?: string
|
||||
state: JobState
|
||||
title: string
|
||||
rightText?: string
|
||||
iconName?: string
|
||||
iconImageUrl?: string
|
||||
showClear?: boolean
|
||||
showMenu?: boolean
|
||||
progressTotalPercent?: number
|
||||
progressCurrentPercent?: number
|
||||
activeDetailsId?: string | null
|
||||
}>(),
|
||||
{
|
||||
workflowId: undefined,
|
||||
rightText: '',
|
||||
iconName: undefined,
|
||||
iconImageUrl: undefined,
|
||||
showClear: undefined,
|
||||
showMenu: undefined,
|
||||
progressTotalPercent: undefined,
|
||||
progressCurrentPercent: undefined,
|
||||
runningNodeName: undefined,
|
||||
activeDetailsId: null
|
||||
}
|
||||
)
|
||||
const {
|
||||
jobId,
|
||||
workflowId,
|
||||
state,
|
||||
title,
|
||||
rightText = '',
|
||||
iconName,
|
||||
iconImageUrl,
|
||||
showClear,
|
||||
showMenu,
|
||||
progressTotalPercent,
|
||||
progressCurrentPercent,
|
||||
activeDetailsId = null
|
||||
} = defineProps<{
|
||||
jobId: string
|
||||
workflowId?: string
|
||||
state: JobState
|
||||
title: string
|
||||
rightText?: string
|
||||
iconName?: string
|
||||
iconImageUrl?: string
|
||||
showClear?: boolean
|
||||
showMenu?: boolean
|
||||
progressTotalPercent?: number
|
||||
progressCurrentPercent?: number
|
||||
activeDetailsId?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancel'): void
|
||||
@@ -262,14 +251,14 @@ const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
|
||||
const rowRef = ref<HTMLDivElement | null>(null)
|
||||
const showDetails = computed(() => props.activeDetailsId === props.jobId)
|
||||
const showDetails = computed(() => activeDetailsId === jobId)
|
||||
|
||||
const onRowEnter = () => {
|
||||
if (!isPreviewVisible.value) emit('details-enter', props.jobId)
|
||||
if (!isPreviewVisible.value) emit('details-enter', jobId)
|
||||
}
|
||||
const onRowLeave = () => emit('details-leave', props.jobId)
|
||||
const onPopoverEnter = () => emit('details-enter', props.jobId)
|
||||
const onPopoverLeave = () => emit('details-leave', props.jobId)
|
||||
const onRowLeave = () => emit('details-leave', jobId)
|
||||
const onPopoverEnter = () => emit('details-enter', jobId)
|
||||
const onPopoverLeave = () => emit('details-leave', jobId)
|
||||
|
||||
const isPreviewVisible = ref(false)
|
||||
const previewHideTimer = ref<number | null>(null)
|
||||
@@ -286,9 +275,7 @@ const clearPreviewShowTimer = () => {
|
||||
previewShowTimer.value = null
|
||||
}
|
||||
}
|
||||
const canShowPreview = computed(
|
||||
() => props.state === 'completed' && !!props.iconImageUrl
|
||||
)
|
||||
const canShowPreview = computed(() => state === 'completed' && !!iconImageUrl)
|
||||
const scheduleShowPreview = () => {
|
||||
if (!canShowPreview.value) return
|
||||
clearPreviewHideTimer()
|
||||
@@ -343,23 +330,23 @@ watch(
|
||||
const isHovered = ref(false)
|
||||
|
||||
const iconClass = computed(() => {
|
||||
if (props.iconName) return props.iconName
|
||||
return iconForJobState(props.state)
|
||||
if (iconName) return iconName
|
||||
return iconForJobState(state)
|
||||
})
|
||||
|
||||
const shouldSpin = computed(
|
||||
() =>
|
||||
props.state === 'pending' &&
|
||||
state === 'pending' &&
|
||||
iconClass.value === iconForJobState('pending') &&
|
||||
!props.iconImageUrl
|
||||
!iconImageUrl
|
||||
)
|
||||
|
||||
const computedShowClear = computed(() => {
|
||||
if (props.showClear !== undefined) return props.showClear
|
||||
return props.state !== 'completed'
|
||||
if (showClear !== undefined) return showClear
|
||||
return state !== 'completed'
|
||||
})
|
||||
|
||||
const emitDetailsLeave = () => emit('details-leave', props.jobId)
|
||||
const emitDetailsLeave = () => emit('details-leave', jobId)
|
||||
|
||||
const onCancelClick = () => {
|
||||
emitDetailsLeave()
|
||||
@@ -372,7 +359,7 @@ const onDeleteClick = () => {
|
||||
}
|
||||
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
const shouldShowMenu = props.showMenu !== undefined ? props.showMenu : true
|
||||
const shouldShowMenu = showMenu !== undefined ? showMenu : true
|
||||
if (shouldShowMenu) emit('menu', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -11,21 +11,22 @@ interface Props {
|
||||
disable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
duration: 150,
|
||||
easingEnter: 'ease-in-out',
|
||||
easingLeave: 'ease-in-out',
|
||||
opacityClosed: 0,
|
||||
opacityOpened: 1
|
||||
})
|
||||
const {
|
||||
duration = 150,
|
||||
easingEnter = 'ease-in-out',
|
||||
easingLeave = 'ease-in-out',
|
||||
opacityClosed = 0,
|
||||
opacityOpened = 1,
|
||||
disable
|
||||
} = defineProps<Props>()
|
||||
|
||||
const closed = '0px'
|
||||
|
||||
const isMounted = ref(false)
|
||||
onMounted(() => (isMounted.value = true))
|
||||
|
||||
const duration = computed(() =>
|
||||
isMounted.value && !props.disable ? props.duration : 0
|
||||
const animationDuration = computed(() =>
|
||||
isMounted.value && !disable ? duration : 0
|
||||
)
|
||||
|
||||
interface initialStyle {
|
||||
@@ -95,7 +96,7 @@ function getEnterKeyframes(height: string, initialStyle: initialStyle) {
|
||||
return [
|
||||
{
|
||||
height: closed,
|
||||
opacity: props.opacityClosed,
|
||||
opacity: opacityClosed,
|
||||
paddingTop: closed,
|
||||
paddingBottom: closed,
|
||||
borderTopWidth: closed,
|
||||
@@ -105,7 +106,7 @@ function getEnterKeyframes(height: string, initialStyle: initialStyle) {
|
||||
},
|
||||
{
|
||||
height,
|
||||
opacity: props.opacityOpened,
|
||||
opacity: opacityOpened,
|
||||
paddingTop: initialStyle.paddingTop,
|
||||
paddingBottom: initialStyle.paddingBottom,
|
||||
borderTopWidth: initialStyle.borderTopWidth,
|
||||
@@ -121,7 +122,7 @@ function enterTransition(element: Element, done: () => void) {
|
||||
const initialStyle = getElementStyle(HTMLElement)
|
||||
const height = prepareElement(HTMLElement, initialStyle)
|
||||
const keyframes = getEnterKeyframes(height, initialStyle)
|
||||
const options = { duration: duration.value, easing: props.easingEnter }
|
||||
const options = { duration: animationDuration.value, easing: easingEnter }
|
||||
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
|
||||
}
|
||||
|
||||
@@ -132,7 +133,7 @@ function leaveTransition(element: Element, done: () => void) {
|
||||
HTMLElement.style.height = height
|
||||
HTMLElement.style.overflow = 'hidden'
|
||||
const keyframes = getEnterKeyframes(height, initialStyle).reverse()
|
||||
const options = { duration: duration.value, easing: props.easingLeave }
|
||||
const options = { duration: animationDuration.value, easing: easingLeave }
|
||||
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
demoteWidget,
|
||||
@@ -17,10 +18,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
|
||||
@@ -30,9 +31,6 @@ const promotionStore = usePromotionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const draggableItems = ref()
|
||||
|
||||
const promotionEntries = computed(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
@@ -195,54 +193,9 @@ function showRecommended() {
|
||||
}
|
||||
}
|
||||
|
||||
function setDraggableState() {
|
||||
draggableList.value?.dispose()
|
||||
if (searchQuery.value || !draggableItems.value?.children?.length) return
|
||||
draggableList.value = new DraggableList(
|
||||
draggableItems.value,
|
||||
'.draggable-item'
|
||||
)
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem
|
||||
}
|
||||
}
|
||||
const newPosition = reorderedItems.indexOf(this.draggableItem)
|
||||
const aw = activeWidgets.value
|
||||
const [w] = aw.splice(oldPosition, 1)
|
||||
aw.splice(newPosition, 0, w)
|
||||
activeWidgets.value = aw
|
||||
}
|
||||
}
|
||||
watch(filteredActive, () => {
|
||||
setDraggableState()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setDraggableState()
|
||||
if (activeNode.value) pruneDisconnected(activeNode.value)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
draggableList.value?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -280,19 +233,18 @@ onBeforeUnmount(() => {
|
||||
{{ $t('subgraphStore.hideAll') }}</a
|
||||
>
|
||||
</div>
|
||||
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
|
||||
<DraggableList v-slot="{ dragClass }" v-model="activeWidgets">
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredActive"
|
||||
:key="toKey([node, widget])"
|
||||
class="bg-comfy-menu-bg"
|
||||
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
:is-shown="true"
|
||||
:is-draggable="!searchQuery"
|
||||
:is-physical="node.id === -1"
|
||||
:is-draggable="!searchQuery"
|
||||
@toggle-visibility="demote([node, widget])"
|
||||
/>
|
||||
</div>
|
||||
</DraggableList>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -29,8 +29,7 @@ function getIcon() {
|
||||
cn(
|
||||
'flex py-1 px-2 break-all rounded items-center gap-1',
|
||||
'bg-node-component-surface',
|
||||
props.isDraggable &&
|
||||
'draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing hover:ring-1 ring-accent-background',
|
||||
props.isDraggable && 'hover:ring-1 ring-accent-background',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="_content">
|
||||
<div class="flex flex-col gap-2">
|
||||
<SelectButton
|
||||
v-model="selectedFilter"
|
||||
class="filter-type-select"
|
||||
@@ -16,7 +16,7 @@
|
||||
auto-filter-focus
|
||||
/>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<div class="flex flex-col items-end pt-4">
|
||||
<Button type="button" @click="submit">{{ $t('g.add') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -67,15 +67,3 @@ const submit = () => {
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
._content {
|
||||
@apply flex flex-col space-y-2;
|
||||
}
|
||||
|
||||
._footer {
|
||||
@apply flex flex-col pt-4 items-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -255,8 +255,6 @@ onMounted(() => {
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
@reference "tailwindcss";
|
||||
|
||||
.floating-sidebar {
|
||||
padding: var(--sidebar-padding);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
:aria-label="computedTooltip"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<div class="side-bar-button-content">
|
||||
<div class="side-bar-button-content flex flex-col items-center gap-2">
|
||||
<slot name="icon">
|
||||
<div class="sidebar-icon-wrapper relative">
|
||||
<i
|
||||
@@ -40,9 +40,11 @@
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
<span v-if="label && !isSmall" class="side-bar-button-label">{{
|
||||
st(label, label)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="label && !isSmall"
|
||||
class="side-bar-button-label text-center text-[10px]"
|
||||
>{{ st(label, label) }}</span
|
||||
>
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -104,8 +106,6 @@ const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.side-bar-button {
|
||||
width: var(--sidebar-width);
|
||||
height: var(--sidebar-item-height);
|
||||
@@ -117,12 +117,7 @@ const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
|
||||
height: var(--sidebar-width);
|
||||
}
|
||||
|
||||
.side-bar-button-content {
|
||||
@apply flex flex-col items-center gap-2;
|
||||
}
|
||||
|
||||
.side-bar-button-label {
|
||||
@apply text-[10px] text-center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
|
||||
<Toolbar
|
||||
class="min-h-16 bg-transparent rounded-none border-x-0 border-t-0 px-2 2xl:px-4"
|
||||
:pt="sidebarPt"
|
||||
>
|
||||
<template #start>
|
||||
<span class="truncate font-bold" :title="props.title">
|
||||
@@ -20,7 +21,7 @@
|
||||
</template>
|
||||
<template #end>
|
||||
<div
|
||||
class="touch:w-auto touch:opacity-100 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
|
||||
class="touch:w-auto touch:opacity-100 [&_.p-button]:py-1 2xl:[&_.p-button]:py-2 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
|
||||
>
|
||||
<slot name="tool-buttons" />
|
||||
</div>
|
||||
@@ -54,19 +55,10 @@ const props = defineProps<{
|
||||
title: string
|
||||
class?: string
|
||||
}>()
|
||||
const sidebarPt = {
|
||||
start: 'min-w-0 flex-1 overflow-hidden'
|
||||
}
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
provide(SidebarContainerKey, containerRef)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-toolbar-end) .p-button {
|
||||
@apply py-1 2xl:py-2;
|
||||
}
|
||||
|
||||
:deep(.p-toolbar-start) {
|
||||
@apply min-w-0 flex-1 overflow-hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="node-lib-node-container"
|
||||
class="node-lib-node-container h-full w-full"
|
||||
data-testid="node-tree-leaf"
|
||||
:data-node-name="nodeDef.display_name"
|
||||
>
|
||||
@@ -206,11 +206,3 @@ onUnmounted(() => {
|
||||
nodeContentElement.value?.removeEventListener('mouseleave', handleMouseLeave)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
.node-lib-node-container {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,20 +16,17 @@ import type { TopbarBadge as TopbarBadgeType } from '@/types/comfy'
|
||||
|
||||
import TopbarBadge from './TopbarBadge.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
displayMode?: 'full' | 'compact' | 'icon-only'
|
||||
reverseOrder?: boolean
|
||||
noPadding?: boolean
|
||||
backgroundColor?: string
|
||||
}>(),
|
||||
{
|
||||
displayMode: 'full',
|
||||
reverseOrder: false,
|
||||
noPadding: false,
|
||||
backgroundColor: 'var(--comfy-menu-bg)'
|
||||
}
|
||||
)
|
||||
const {
|
||||
displayMode = 'full',
|
||||
reverseOrder = false,
|
||||
noPadding = false,
|
||||
backgroundColor = 'var(--comfy-menu-bg)'
|
||||
} = defineProps<{
|
||||
displayMode?: 'full' | 'compact' | 'icon-only'
|
||||
reverseOrder?: boolean
|
||||
noPadding?: boolean
|
||||
backgroundColor?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -113,12 +113,13 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
}))
|
||||
|
||||
// Mock the useSubscriptionDialog composable
|
||||
const mockSubscriptionDialogShow = vi.fn()
|
||||
const mockShowPricingTable = vi.fn()
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: vi.fn(() => ({
|
||||
show: mockSubscriptionDialogShow,
|
||||
show: vi.fn(),
|
||||
showPricingTable: mockShowPricingTable,
|
||||
hide: vi.fn()
|
||||
}))
|
||||
})
|
||||
@@ -318,8 +319,8 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
|
||||
await plansPricingItem.trigger('click')
|
||||
|
||||
// Verify subscription dialog show was called
|
||||
expect(mockSubscriptionDialogShow).toHaveBeenCalled()
|
||||
// Verify showPricingTable was called
|
||||
expect(mockShowPricingTable).toHaveBeenCalled()
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
|
||||
@@ -195,7 +195,10 @@ const formattedBalance = computed(() => {
|
||||
const canUpgrade = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
return (
|
||||
tier === 'FOUNDERS_EDITION' || tier === 'STANDARD' || tier === 'CREATOR'
|
||||
tier === 'FREE' ||
|
||||
tier === 'FOUNDERS_EDITION' ||
|
||||
tier === 'STANDARD' ||
|
||||
tier === 'CREATOR'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -205,7 +208,7 @@ const handleOpenUserSettings = () => {
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.show()
|
||||
subscriptionDialog.showPricingTable()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
|
||||
@@ -129,21 +129,19 @@ import { computed, ref } from 'vue'
|
||||
import type { TopbarBadge } from '@/types/comfy'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
badge: TopbarBadge
|
||||
displayMode?: 'full' | 'compact' | 'icon-only'
|
||||
reverseOrder?: boolean
|
||||
noPadding?: boolean
|
||||
backgroundColor?: string
|
||||
}>(),
|
||||
{
|
||||
displayMode: 'full',
|
||||
reverseOrder: false,
|
||||
noPadding: false,
|
||||
backgroundColor: 'var(--comfy-menu-bg)'
|
||||
}
|
||||
)
|
||||
const {
|
||||
badge,
|
||||
displayMode = 'full',
|
||||
reverseOrder = false,
|
||||
noPadding = false,
|
||||
backgroundColor = 'var(--comfy-menu-bg)'
|
||||
} = defineProps<{
|
||||
badge: TopbarBadge
|
||||
displayMode?: 'full' | 'compact' | 'icon-only'
|
||||
reverseOrder?: boolean
|
||||
noPadding?: boolean
|
||||
backgroundColor?: string
|
||||
}>()
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
|
||||
@@ -151,10 +149,10 @@ const togglePopover = (event: Event) => {
|
||||
popover.value?.toggle(event)
|
||||
}
|
||||
|
||||
const variant = computed(() => props.badge.variant ?? 'info')
|
||||
const variant = computed(() => badge.variant ?? 'info')
|
||||
|
||||
const menuBackgroundStyle = computed(() => ({
|
||||
backgroundColor: props.backgroundColor
|
||||
backgroundColor: backgroundColor
|
||||
}))
|
||||
|
||||
const labelClasses = computed(() => {
|
||||
@@ -184,8 +182,8 @@ const textClasses = computed(() => {
|
||||
const iconColorClass = computed(() => textClasses.value)
|
||||
|
||||
const iconClass = computed(() => {
|
||||
if (props.badge.icon) {
|
||||
return props.badge.icon
|
||||
if (badge.icon) {
|
||||
return badge.icon
|
||||
}
|
||||
switch (variant.value) {
|
||||
case 'error':
|
||||
|
||||
@@ -19,16 +19,10 @@ import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore'
|
||||
|
||||
import TopbarBadge from './TopbarBadge.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
reverseOrder?: boolean
|
||||
noPadding?: boolean
|
||||
}>(),
|
||||
{
|
||||
reverseOrder: false,
|
||||
noPadding: false
|
||||
}
|
||||
)
|
||||
const { reverseOrder = false, noPadding = false } = defineProps<{
|
||||
reverseOrder?: boolean
|
||||
noPadding?: boolean
|
||||
}>()
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const isXl = breakpoints.greaterOrEqual('xl')
|
||||
|
||||
@@ -140,54 +140,65 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.workflow-preview-content {
|
||||
@apply flex flex-col rounded-xl overflow-hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-xl);
|
||||
max-width: var(--popover-width);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.workflow-preview-thumbnail {
|
||||
@apply relative p-2;
|
||||
position: relative;
|
||||
padding: calc(var(--spacing) * 2);
|
||||
}
|
||||
|
||||
.workflow-preview-thumbnail img {
|
||||
@apply shadow-md;
|
||||
box-shadow: var(--shadow-md);
|
||||
background-color: color-mix(in srgb, var(--comfy-menu-bg) 70%, black);
|
||||
}
|
||||
|
||||
.dark-theme .workflow-preview-thumbnail img {
|
||||
@apply shadow-lg;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.workflow-preview-footer {
|
||||
@apply pt-1 pb-2 px-3;
|
||||
padding-top: calc(var(--spacing) * 1);
|
||||
padding-right: calc(var(--spacing) * 3);
|
||||
padding-bottom: calc(var(--spacing) * 2);
|
||||
padding-left: calc(var(--spacing) * 3);
|
||||
}
|
||||
|
||||
.workflow-preview-name {
|
||||
@apply block text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--fg-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.workflow-popover-fade {
|
||||
--p-popover-background: transparent;
|
||||
--p-popover-content-padding: 0;
|
||||
@apply bg-transparent rounded-xl shadow-lg;
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: transparent;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transition: opacity 0.15s ease-out !important;
|
||||
}
|
||||
|
||||
.workflow-popover-fade.p-popover-flipped {
|
||||
@apply -translate-y-full;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.dark-theme .workflow-popover-fade {
|
||||
@apply shadow-2xl;
|
||||
box-shadow: var(--shadow-2xl);
|
||||
}
|
||||
|
||||
.workflow-popover-fade.p-popover::after,
|
||||
|
||||
@@ -300,63 +300,72 @@ onUpdated(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.workflow-tabs-container {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton) {
|
||||
@apply p-0 bg-transparent rounded-none shrink relative border-0 border-r border-solid;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
border: 0;
|
||||
border-right-style: solid;
|
||||
border-right-width: 1px;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border-right-color: var(--border-color);
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.overflow-arrow {
|
||||
@apply px-2 rounded-none;
|
||||
border-radius: 0;
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
}
|
||||
|
||||
.overflow-arrow[disabled] {
|
||||
@apply opacity-25;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton > .p-togglebutton-content) {
|
||||
@apply max-w-full;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:deep(.workflow-tab) {
|
||||
@apply max-w-full;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton::before) {
|
||||
@apply hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton:first-child) {
|
||||
@apply border-l border-solid;
|
||||
border-left-style: solid;
|
||||
border-left-width: 1px;
|
||||
border-left-color: var(--border-color);
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton:not(:first-child)) {
|
||||
@apply border-l-0;
|
||||
border-left-width: 0;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton.p-togglebutton-checked) {
|
||||
@apply border-b border-solid h-full;
|
||||
height: 100%;
|
||||
border-bottom-style: solid;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: var(--p-button-text-primary-color);
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton:not(.p-togglebutton-checked)) {
|
||||
@apply opacity-75;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton-checked) .close-button,
|
||||
:deep(.p-togglebutton:hover) .close-button {
|
||||
@apply visible;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
:deep(.p-scrollpanel-content) {
|
||||
@apply h-full;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.workflow-tabs) {
|
||||
@@ -366,11 +375,12 @@ onUpdated(() => {
|
||||
/* Scrollbar half opacity to avoid blocking the active tab bottom border */
|
||||
:deep(.p-scrollpanel:hover .p-scrollpanel-bar),
|
||||
:deep(.p-scrollpanel:active .p-scrollpanel-bar) {
|
||||
@apply opacity-50;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
:deep(.p-selectbutton) {
|
||||
@apply rounded-none h-full;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.workflow-tabs-container-desktop {
|
||||
@@ -378,7 +388,7 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
.window-actions-spacer {
|
||||
@apply flex-auto;
|
||||
flex: auto;
|
||||
/* If we are using custom titlebar, then we need to add a gap for the user to drag the window */
|
||||
--window-actions-spacer-width: min(75px, env(titlebar-area-width, 0) * 9999);
|
||||
min-width: var(--window-actions-spacer-width);
|
||||
|
||||
@@ -70,6 +70,11 @@ export interface BillingState {
|
||||
* Equivalent to `subscription.value?.isActive ?? false`
|
||||
*/
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
/**
|
||||
* Whether the current billing context has a FREE tier subscription.
|
||||
* Workspace-aware: reflects the active workspace's tier, not the user's personal tier.
|
||||
*/
|
||||
isFreeTier: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
|
||||
@@ -120,6 +120,8 @@ function useBillingContextInternal(): BillingContext {
|
||||
toValue(activeContext.value.isActiveSubscription)
|
||||
)
|
||||
|
||||
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
|
||||
|
||||
function getMaxSeats(tierKey: TierKey): number {
|
||||
if (type.value === 'legacy') return 1
|
||||
|
||||
@@ -238,6 +240,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
getMaxSeats,
|
||||
|
||||
initialize,
|
||||
|
||||
@@ -40,6 +40,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const isActiveSubscription = computed(() => legacyIsActiveSubscription.value)
|
||||
const isFreeTier = computed(() => subscriptionTier.value === 'FREE')
|
||||
|
||||
const subscription = computed<SubscriptionInfo | null>(() => {
|
||||
if (!legacyIsActiveSubscription.value && !subscriptionTier.value) {
|
||||
@@ -85,6 +86,10 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
error.value = null
|
||||
try {
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
// Re-fetch balance if free tier credits were just lazily granted
|
||||
if (isFreeTier.value && balance.value?.amountMicros === 0) {
|
||||
await fetchBalance()
|
||||
}
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
error.value =
|
||||
@@ -173,6 +178,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
@@ -23,7 +23,7 @@ export function useImageMenuOptions() {
|
||||
if (!img) return
|
||||
const url = new URL(img.src)
|
||||
url.searchParams.delete('preview')
|
||||
window.open(url.toString(), '_blank')
|
||||
void openFileInNewTab(url.toString())
|
||||
}
|
||||
|
||||
const copyImage = async (node: LGraphNode) => {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
type ModelType =
|
||||
| 'Checkpoint'
|
||||
| 'TextualInversion'
|
||||
| 'Hypernetwork'
|
||||
| 'AestheticGradient'
|
||||
| 'LORA'
|
||||
| 'Controlnet'
|
||||
| 'Poses'
|
||||
|
||||
interface CivitaiFileMetadata {
|
||||
fp?: 'fp16' | 'fp32'
|
||||
size?: 'full' | 'pruned'
|
||||
format?: 'SafeTensor' | 'PickleTensor' | 'Other'
|
||||
}
|
||||
|
||||
interface CivitaiModelFile {
|
||||
name: string
|
||||
id: number
|
||||
sizeKB: number
|
||||
type: string
|
||||
downloadUrl: string
|
||||
metadata: CivitaiFileMetadata
|
||||
}
|
||||
|
||||
interface CivitaiModel {
|
||||
name: string
|
||||
type: ModelType
|
||||
}
|
||||
|
||||
interface CivitaiModelVersionResponse {
|
||||
id: number
|
||||
name: string
|
||||
model: CivitaiModel
|
||||
modelId: number
|
||||
files: CivitaiModelFile[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable to manage Civitai model
|
||||
* @param url - The URL of the Civitai model, where the model ID is the last part of the URL's pathname
|
||||
* @see https://developer.civitai.com/docs/api/public-rest
|
||||
* @example
|
||||
* const { fileSize, isLoading, error, modelData } =
|
||||
* useCivitaiModel('https://civitai.com/api/download/models/16576?type=Model&format=SafeTensor&size=full&fp=fp16')
|
||||
*/
|
||||
export function useCivitaiModel(url: string) {
|
||||
const createModelVersionUrl = (modelId: string): string =>
|
||||
`https://civitai.com/api/v1/model-versions/${modelId}`
|
||||
|
||||
const extractModelIdFromUrl = (): string | null => {
|
||||
const urlObj = new URL(url)
|
||||
return urlObj.pathname.split('/').pop() || null
|
||||
}
|
||||
|
||||
const fetchModelData =
|
||||
async (): Promise<CivitaiModelVersionResponse | null> => {
|
||||
const modelId = extractModelIdFromUrl()
|
||||
if (!modelId) return null
|
||||
|
||||
const apiUrl = createModelVersionUrl(modelId)
|
||||
const res = await fetch(apiUrl)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
const findMatchingFileSize = (): number | null => {
|
||||
const matchingFile = modelData.value?.files?.find(
|
||||
(file) => file.downloadUrl && url.startsWith(file.downloadUrl)
|
||||
)
|
||||
|
||||
return matchingFile?.sizeKB ? matchingFile.sizeKB << 10 : null
|
||||
}
|
||||
|
||||
const {
|
||||
state: modelData,
|
||||
isLoading,
|
||||
error
|
||||
} = useAsyncState(fetchModelData, null, {
|
||||
immediate: true
|
||||
})
|
||||
|
||||
const fileSize = computed(() =>
|
||||
!isLoading.value ? findMatchingFileSize() : null
|
||||
)
|
||||
|
||||
return {
|
||||
fileSize,
|
||||
isLoading,
|
||||
error,
|
||||
modelData
|
||||
}
|
||||
}
|
||||
140
src/composables/useCurveEditor.ts
Normal file
140
src/composables/useCurveEditor.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { createMonotoneInterpolator } from '@/components/curve/curveUtils'
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
interface UseCurveEditorOptions {
|
||||
svgRef: Ref<SVGSVGElement | null>
|
||||
modelValue: Ref<CurvePoint[]>
|
||||
}
|
||||
|
||||
export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
|
||||
const dragIndex = ref(-1)
|
||||
let cleanupDrag: (() => void) | null = null
|
||||
|
||||
const curvePath = computed(() => {
|
||||
const points = modelValue.value
|
||||
if (points.length < 2) return ''
|
||||
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
const xMin = points[0][0]
|
||||
const xMax = points[points.length - 1][0]
|
||||
const segments = 128
|
||||
const parts: string[] = []
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const x = xMin + (xMax - xMin) * (i / segments)
|
||||
const y = 1 - interpolate(x)
|
||||
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(4)},${y.toFixed(4)}`)
|
||||
}
|
||||
return parts.join('')
|
||||
})
|
||||
|
||||
function svgCoords(e: PointerEvent): [number, number] {
|
||||
const svg = svgRef.value
|
||||
if (!svg) return [0, 0]
|
||||
|
||||
const ctm = svg.getScreenCTM()
|
||||
if (!ctm) return [0, 0]
|
||||
|
||||
const svgPt = new DOMPoint(e.clientX, e.clientY).matrixTransform(
|
||||
ctm.inverse()
|
||||
)
|
||||
return [
|
||||
Math.max(0, Math.min(1, svgPt.x)),
|
||||
Math.max(0, Math.min(1, 1 - svgPt.y))
|
||||
]
|
||||
}
|
||||
|
||||
function findNearestPoint(x: number, y: number): number {
|
||||
const threshold2 = 0.04 * 0.04
|
||||
let nearest = -1
|
||||
let minDist2 = threshold2
|
||||
for (let i = 0; i < modelValue.value.length; i++) {
|
||||
const dx = modelValue.value[i][0] - x
|
||||
const dy = modelValue.value[i][1] - y
|
||||
const dist2 = dx * dx + dy * dy
|
||||
if (dist2 < minDist2) {
|
||||
minDist2 = dist2
|
||||
nearest = i
|
||||
}
|
||||
}
|
||||
return nearest
|
||||
}
|
||||
|
||||
function handleSvgPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
|
||||
const [x, y] = svgCoords(e)
|
||||
|
||||
const nearby = findNearestPoint(x, y)
|
||||
if (nearby >= 0) {
|
||||
startDrag(nearby, e)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.ctrlKey) return
|
||||
|
||||
const newPoint: CurvePoint = [x, y]
|
||||
const newPoints: CurvePoint[] = [...modelValue.value, newPoint]
|
||||
newPoints.sort((a, b) => a[0] - b[0])
|
||||
modelValue.value = newPoints
|
||||
|
||||
startDrag(newPoints.indexOf(newPoint), e)
|
||||
}
|
||||
|
||||
function startDrag(index: number, e: PointerEvent) {
|
||||
cleanupDrag?.()
|
||||
|
||||
if (e.button === 2 || (e.button === 0 && e.ctrlKey)) {
|
||||
if (modelValue.value.length > 2) {
|
||||
const newPoints = [...modelValue.value]
|
||||
newPoints.splice(index, 1)
|
||||
modelValue.value = newPoints
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
dragIndex.value = index
|
||||
const svg = svgRef.value
|
||||
if (!svg) return
|
||||
|
||||
svg.setPointerCapture(e.pointerId)
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (dragIndex.value < 0) return
|
||||
const [x, y] = svgCoords(ev)
|
||||
const movedPoint: CurvePoint = [x, y]
|
||||
const newPoints = [...modelValue.value]
|
||||
newPoints[dragIndex.value] = movedPoint
|
||||
newPoints.sort((a, b) => a[0] - b[0])
|
||||
modelValue.value = newPoints
|
||||
dragIndex.value = newPoints.indexOf(movedPoint)
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
if (dragIndex.value < 0) return
|
||||
dragIndex.value = -1
|
||||
svg.removeEventListener('pointermove', onMove)
|
||||
svg.removeEventListener('pointerup', endDrag)
|
||||
svg.removeEventListener('lostpointercapture', endDrag)
|
||||
cleanupDrag = null
|
||||
}
|
||||
|
||||
cleanupDrag = endDrag
|
||||
|
||||
svg.addEventListener('pointermove', onMove)
|
||||
svg.addEventListener('pointerup', endDrag)
|
||||
svg.addEventListener('lostpointercapture', endDrag)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDrag?.()
|
||||
})
|
||||
|
||||
return {
|
||||
curvePath,
|
||||
handleSvgPointerDown,
|
||||
startDrag
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useCivitaiModel } from '@/composables/useCivitaiModel'
|
||||
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
|
||||
|
||||
export function useDownload(url: string, fileName?: string) {
|
||||
const fileSize = ref<number | null>(null)
|
||||
const error = ref<Error | null>(null)
|
||||
|
||||
const setFileSize = (size: number) => {
|
||||
fileSize.value = size
|
||||
}
|
||||
|
||||
const fetchFileSize = async () => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
if (!response.ok) throw new Error('Failed to fetch file size')
|
||||
|
||||
const size = response.headers.get('content-length')
|
||||
if (size) {
|
||||
setFileSize(parseInt(size))
|
||||
} else {
|
||||
console.error('"content-length" header not found')
|
||||
return null
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching file size:', e)
|
||||
error.value = e instanceof Error ? e : new Error(String(e))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger browser download
|
||||
*/
|
||||
const triggerBrowserDownload = () => {
|
||||
const link = document.createElement('a')
|
||||
if (url.includes('huggingface.co') && error.value) {
|
||||
// If model is a gated HF model, send user to the repo page so they can sign in first
|
||||
link.href = downloadUrlToHfRepoUrl(url)
|
||||
} else {
|
||||
link.href = url
|
||||
link.download = fileName || url.split('/').pop() || 'download'
|
||||
}
|
||||
link.target = '_blank' // Opens in new tab if download attribute is not supported
|
||||
link.rel = 'noopener noreferrer' // Security best practice for _blank links
|
||||
link.click()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isCivitaiModelUrl(url)) {
|
||||
const { fileSize: civitaiSize, error: civitaiErr } = useCivitaiModel(url)
|
||||
whenever(civitaiSize, setFileSize)
|
||||
// Try falling back to normal fetch if using Civitai API fails
|
||||
whenever(civitaiErr, fetchFileSize, { once: true })
|
||||
} else {
|
||||
// Fetch file size in the background
|
||||
void fetchFileSize()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
triggerBrowserDownload,
|
||||
fileSize
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
|
||||
import MissingModelsContent from '@/components/dialog/content/MissingModelsContent.vue'
|
||||
import MissingModelsFooter from '@/components/dialog/content/MissingModelsFooter.vue'
|
||||
import MissingModelsHeader from '@/components/dialog/content/MissingModelsHeader.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -14,11 +16,14 @@ export function useMissingModelsDialog() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show(props: ComponentAttrs<typeof MissingModelsWarning>) {
|
||||
function show(props: ComponentAttrs<typeof MissingModelsContent>) {
|
||||
showSmallLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: MissingModelsWarning,
|
||||
props
|
||||
headerComponent: MissingModelsHeader,
|
||||
footerComponent: MissingModelsFooter,
|
||||
component: MissingModelsContent,
|
||||
props,
|
||||
footerProps: props
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,22 @@ import type {
|
||||
LGraphGroup,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { createNode, isImageNode } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
createNode,
|
||||
isAudioNode,
|
||||
isImageNode,
|
||||
isVideoNode
|
||||
} from '@/utils/litegraphUtil'
|
||||
import {
|
||||
cloneDataTransfer,
|
||||
pasteAudioNode,
|
||||
pasteAudioNodes,
|
||||
pasteImageNode,
|
||||
pasteImageNodes,
|
||||
pasteVideoNode,
|
||||
pasteVideoNodes,
|
||||
usePaste
|
||||
} from './usePaste'
|
||||
|
||||
@@ -38,6 +46,13 @@ function createAudioFile(
|
||||
return new File([''], name, { type })
|
||||
}
|
||||
|
||||
function createVideoFile(
|
||||
name: string = 'test.mp4',
|
||||
type: string = 'video/mp4'
|
||||
): File {
|
||||
return new File([''], name, { type })
|
||||
}
|
||||
|
||||
function createDataTransfer(files: File[] = []): DataTransfer {
|
||||
const dataTransfer = new DataTransfer()
|
||||
files.forEach((file) => dataTransfer.items.add(file))
|
||||
@@ -203,6 +218,198 @@ describe('pasteImageNodes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteAudioNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should create new LoadAudio node when no audio node provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode)
|
||||
|
||||
const file = createAudioFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await pasteAudioNode(mockCanvas, dataTransfer.items)
|
||||
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadAudio')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should use existing audio node when provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const file = createAudioFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await pasteAudioNode(mockCanvas, dataTransfer.items, mockNode)
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should filter non-audio items', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const audioFile = createAudioFile()
|
||||
const textFile = new File([''], 'test.txt', { type: 'text/plain' })
|
||||
const dataTransfer = createDataTransfer([textFile, audioFile])
|
||||
|
||||
await pasteAudioNode(mockCanvas, dataTransfer.items, mockNode)
|
||||
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(audioFile)
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([audioFile])
|
||||
})
|
||||
|
||||
it('should do nothing when no audio files present', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const dataTransfer = createDataTransfer()
|
||||
|
||||
await pasteAudioNode(mockCanvas, dataTransfer.items, mockNode)
|
||||
|
||||
expect(mockNode.pasteFile).not.toHaveBeenCalled()
|
||||
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteAudioNodes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should create multiple nodes for multiple audio files', async () => {
|
||||
const mockNode1 = createMockNode()
|
||||
const mockNode2 = createMockNode()
|
||||
vi.mocked(createNode)
|
||||
.mockResolvedValueOnce(mockNode1)
|
||||
.mockResolvedValueOnce(mockNode2)
|
||||
|
||||
const file1 = createAudioFile('file1.mp3')
|
||||
const file2 = createAudioFile('file2.wav', 'audio/wav')
|
||||
|
||||
const result = await pasteAudioNodes(mockCanvas, [file1, file2])
|
||||
|
||||
expect(createNode).toHaveBeenCalledTimes(2)
|
||||
expect(createNode).toHaveBeenNthCalledWith(1, mockCanvas, 'LoadAudio')
|
||||
expect(createNode).toHaveBeenNthCalledWith(2, mockCanvas, 'LoadAudio')
|
||||
expect(mockNode1.pasteFile).toHaveBeenCalledWith(file1)
|
||||
expect(mockNode2.pasteFile).toHaveBeenCalledWith(file2)
|
||||
expect(result).toEqual([mockNode1, mockNode2])
|
||||
})
|
||||
|
||||
it('should handle empty file list', async () => {
|
||||
const result = await pasteAudioNodes(mockCanvas, [])
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle single audio file', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode)
|
||||
|
||||
const file = createAudioFile()
|
||||
const result = await pasteAudioNodes(mockCanvas, [file])
|
||||
|
||||
expect(createNode).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual([mockNode])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteVideoNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should create new LoadVideo node when no video node provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode)
|
||||
|
||||
const file = createVideoFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await pasteVideoNode(mockCanvas, dataTransfer.items)
|
||||
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadVideo')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should use existing video node when provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const file = createVideoFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await pasteVideoNode(mockCanvas, dataTransfer.items, mockNode)
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should filter non-video items', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const videoFile = createVideoFile()
|
||||
const imageFile = createImageFile()
|
||||
const dataTransfer = createDataTransfer([imageFile, videoFile])
|
||||
|
||||
await pasteVideoNode(mockCanvas, dataTransfer.items, mockNode)
|
||||
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(videoFile)
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([videoFile])
|
||||
})
|
||||
|
||||
it('should do nothing when no video files present', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const dataTransfer = createDataTransfer()
|
||||
|
||||
await pasteVideoNode(mockCanvas, dataTransfer.items, mockNode)
|
||||
|
||||
expect(mockNode.pasteFile).not.toHaveBeenCalled()
|
||||
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteVideoNodes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should create multiple nodes for multiple video files', async () => {
|
||||
const mockNode1 = createMockNode()
|
||||
const mockNode2 = createMockNode()
|
||||
vi.mocked(createNode)
|
||||
.mockResolvedValueOnce(mockNode1)
|
||||
.mockResolvedValueOnce(mockNode2)
|
||||
|
||||
const file1 = createVideoFile('file1.mp4')
|
||||
const file2 = createVideoFile('file2.webm', 'video/webm')
|
||||
|
||||
const result = await pasteVideoNodes(mockCanvas, [file1, file2])
|
||||
|
||||
expect(createNode).toHaveBeenCalledTimes(2)
|
||||
expect(createNode).toHaveBeenNthCalledWith(1, mockCanvas, 'LoadVideo')
|
||||
expect(createNode).toHaveBeenNthCalledWith(2, mockCanvas, 'LoadVideo')
|
||||
expect(mockNode1.pasteFile).toHaveBeenCalledWith(file1)
|
||||
expect(mockNode2.pasteFile).toHaveBeenCalledWith(file2)
|
||||
expect(result).toEqual([mockNode1, mockNode2])
|
||||
})
|
||||
|
||||
it('should handle empty file list', async () => {
|
||||
const result = await pasteVideoNodes(mockCanvas, [])
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle single video file', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode)
|
||||
|
||||
const file = createVideoFile()
|
||||
const result = await pasteVideoNodes(mockCanvas, [file])
|
||||
|
||||
expect(createNode).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual([mockNode])
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePaste', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -230,9 +437,9 @@ describe('usePaste', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle audio paste', async () => {
|
||||
it('should handle audio paste using createNode helper', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode)
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode)
|
||||
|
||||
usePaste()
|
||||
|
||||
@@ -242,7 +449,68 @@ describe('usePaste', () => {
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadAudio')
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadAudio')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
|
||||
it('should paste audio onto selected LoadAudio node', async () => {
|
||||
const mockNode = createMockLGraphNode({
|
||||
is_selected: true,
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn()
|
||||
})
|
||||
mockCanvas.current_node = mockNode
|
||||
vi.mocked(isAudioNode).mockReturnValue(true)
|
||||
|
||||
usePaste()
|
||||
|
||||
const file = createAudioFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle video paste', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode)
|
||||
|
||||
usePaste()
|
||||
|
||||
const file = createVideoFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadVideo')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
|
||||
it('should paste video onto selected LoadVideo node', async () => {
|
||||
const mockNode = createMockLGraphNode({
|
||||
is_selected: true,
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn()
|
||||
})
|
||||
mockCanvas.current_node = mockNode
|
||||
vi.mocked(isVideoNode).mockReturnValue(true)
|
||||
|
||||
usePaste()
|
||||
|
||||
const file = createVideoFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
@@ -273,7 +541,7 @@ describe('usePaste', () => {
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(LiteGraph.createNode).not.toHaveBeenCalled()
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use existing image node when selected', () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -113,6 +112,68 @@ export async function pasteImageNodes(
|
||||
return nodes
|
||||
}
|
||||
|
||||
export async function pasteAudioNode(
|
||||
canvas: LGraphCanvas,
|
||||
items: DataTransferItemList,
|
||||
audioNode: LGraphNode | null = null
|
||||
): Promise<LGraphNode | null> {
|
||||
if (!audioNode) {
|
||||
audioNode = await createNode(canvas, 'LoadAudio')
|
||||
}
|
||||
pasteItemsOnNode(items, audioNode, 'audio')
|
||||
return audioNode
|
||||
}
|
||||
|
||||
export async function pasteAudioNodes(
|
||||
canvas: LGraphCanvas,
|
||||
fileList: File[]
|
||||
): Promise<LGraphNode[]> {
|
||||
const nodes: LGraphNode[] = []
|
||||
|
||||
for (const file of fileList) {
|
||||
const transfer = new DataTransfer()
|
||||
transfer.items.add(file)
|
||||
const node = await pasteAudioNode(canvas, transfer.items)
|
||||
|
||||
if (node) {
|
||||
nodes.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
export async function pasteVideoNode(
|
||||
canvas: LGraphCanvas,
|
||||
items: DataTransferItemList,
|
||||
videoNode: LGraphNode | null = null
|
||||
): Promise<LGraphNode | null> {
|
||||
if (!videoNode) {
|
||||
videoNode = await createNode(canvas, 'LoadVideo')
|
||||
}
|
||||
pasteItemsOnNode(items, videoNode, 'video')
|
||||
return videoNode
|
||||
}
|
||||
|
||||
export async function pasteVideoNodes(
|
||||
canvas: LGraphCanvas,
|
||||
fileList: File[]
|
||||
): Promise<LGraphNode[]> {
|
||||
const nodes: LGraphNode[] = []
|
||||
|
||||
for (const file of fileList) {
|
||||
const transfer = new DataTransfer()
|
||||
transfer.items.add(file)
|
||||
const node = await pasteVideoNode(canvas, transfer.items)
|
||||
|
||||
if (node) {
|
||||
nodes.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
|
||||
*/
|
||||
@@ -132,7 +193,6 @@ export const usePaste = () => {
|
||||
const { canvas } = canvasStore
|
||||
if (!canvas) return
|
||||
|
||||
const { graph } = canvas
|
||||
let data: DataTransfer | string | null = e.clipboardData
|
||||
if (!data) throw new Error('No clipboard data on clipboard event')
|
||||
data = cloneDataTransfer(data)
|
||||
@@ -146,7 +206,9 @@ export const usePaste = () => {
|
||||
const isVideoNodeSelected = isNodeSelected && isVideoNode(currentNode)
|
||||
const isAudioNodeSelected = isNodeSelected && isAudioNode(currentNode)
|
||||
|
||||
let audioNode: LGraphNode | null = isAudioNodeSelected ? currentNode : null
|
||||
const audioNode: LGraphNode | null = isAudioNodeSelected
|
||||
? currentNode
|
||||
: null
|
||||
const imageNode: LGraphNode | null = isImageNodeSelected
|
||||
? currentNode
|
||||
: null
|
||||
@@ -160,24 +222,10 @@ export const usePaste = () => {
|
||||
await pasteImageNode(canvas as LGraphCanvas, items, imageNode)
|
||||
return
|
||||
} else if (item.type.startsWith('video/')) {
|
||||
if (!videoNode) {
|
||||
// No video node selected: add a new one
|
||||
// TODO: when video node exists
|
||||
} else {
|
||||
pasteItemsOnNode(items, videoNode, 'video')
|
||||
return
|
||||
}
|
||||
await pasteVideoNode(canvas as LGraphCanvas, items, videoNode)
|
||||
return
|
||||
} else if (item.type.startsWith('audio/')) {
|
||||
if (!audioNode) {
|
||||
// No audio node selected: add a new one
|
||||
const newNode = LiteGraph.createNode('LoadAudio')
|
||||
if (newNode) {
|
||||
newNode.pos = [canvas.graph_mouse[0], canvas.graph_mouse[1]]
|
||||
audioNode = graph?.add(newNode) ?? null
|
||||
}
|
||||
graph?.change()
|
||||
}
|
||||
pasteItemsOnNode(items, audioNode, 'audio')
|
||||
await pasteAudioNode(canvas as LGraphCanvas, items, audioNode)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,22 +26,18 @@ useExtensionService().registerExtension({
|
||||
const { a_images: aImages, b_images: bImages } = output
|
||||
const rand = app.getRandParam()
|
||||
|
||||
const beforeUrl =
|
||||
aImages && aImages.length > 0
|
||||
? api.apiURL(`/view?${new URLSearchParams(aImages[0])}${rand}`)
|
||||
: ''
|
||||
const afterUrl =
|
||||
bImages && bImages.length > 0
|
||||
? api.apiURL(`/view?${new URLSearchParams(bImages[0])}${rand}`)
|
||||
: ''
|
||||
const toUrl = (params: Record<string, string>) =>
|
||||
api.apiURL(`/view?${new URLSearchParams(params)}${rand}`)
|
||||
|
||||
const beforeImages =
|
||||
aImages && aImages.length > 0 ? aImages.map(toUrl) : []
|
||||
const afterImages =
|
||||
bImages && bImages.length > 0 ? bImages.map(toUrl) : []
|
||||
|
||||
const widget = node.widgets?.find((w) => w.type === 'imagecompare')
|
||||
|
||||
if (widget) {
|
||||
widget.value = {
|
||||
before: beforeUrl,
|
||||
after: afterUrl
|
||||
}
|
||||
widget.value = { beforeImages, afterImages }
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,27 +2,13 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import {
|
||||
type ComfyNodeDef,
|
||||
type InputSpec,
|
||||
isComboInputSpecV1
|
||||
isMediaUploadComboInput
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { app } from '../../scripts/app'
|
||||
|
||||
// Adds an upload button to the nodes
|
||||
|
||||
const isMediaUploadComboInput = (inputSpec: InputSpec) => {
|
||||
const [inputName, inputOptions] = inputSpec
|
||||
if (!inputOptions) return false
|
||||
|
||||
const isUploadInput =
|
||||
inputOptions['image_upload'] === true ||
|
||||
inputOptions['video_upload'] === true ||
|
||||
inputOptions['animated_image_upload'] === true
|
||||
|
||||
return (
|
||||
isUploadInput && (isComboInputSpecV1(inputSpec) || inputName === 'COMBO')
|
||||
)
|
||||
}
|
||||
|
||||
const createUploadInput = (
|
||||
imageInputName: string,
|
||||
imageInputOptions: InputSpec
|
||||
|
||||
@@ -4187,7 +4187,12 @@ export class LGraphNode
|
||||
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/2652
|
||||
// TODO: Move the layout logic before drawing of the node shape, so we don't
|
||||
// need to trigger extra round of rendering.
|
||||
if (y > bodyHeight) {
|
||||
// In Vue mode, the DOM is the source of truth for node sizing — the
|
||||
// ResizeObserver feeds measurements back to the layout store. Allowing
|
||||
// LiteGraph to also call setSize() here creates an infinite feedback loop
|
||||
// (LG grows node → CSS min-height increases → textarea fills extra space →
|
||||
// ResizeObserver reports larger size → LG grows node again).
|
||||
if (!LiteGraph.vueNodesMode && y > bodyHeight) {
|
||||
this.setSize([this.size[0], y])
|
||||
this.graph.setDirtyCanvas(false, true)
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ export type IWidget =
|
||||
| IAssetWidget
|
||||
| IImageCropWidget
|
||||
| IBoundingBoxWidget
|
||||
| ICurveWidget
|
||||
|
||||
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
|
||||
type: 'toggle'
|
||||
@@ -328,6 +329,13 @@ export interface IBoundingBoxWidget extends IBaseWidget<Bounds, 'boundingbox'> {
|
||||
value: Bounds
|
||||
}
|
||||
|
||||
export type CurvePoint = [x: number, y: number]
|
||||
|
||||
export interface ICurveWidget extends IBaseWidget<CurvePoint[], 'curve'> {
|
||||
type: 'curve'
|
||||
value: CurvePoint[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
|
||||
* Override linkedWidgets[]
|
||||
@@ -359,7 +367,6 @@ export interface IBaseWidget<
|
||||
/** Widget type (see {@link TWidgetType}) */
|
||||
type: TType
|
||||
value?: TValue
|
||||
vueTrack?: () => void
|
||||
|
||||
/**
|
||||
* Whether the widget value is persisted in the workflow JSON
|
||||
|
||||
16
src/lib/litegraph/src/widgets/CurveWidget.ts
Normal file
16
src/lib/litegraph/src/widgets/CurveWidget.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ICurveWidget } from '../types/widgets'
|
||||
import { BaseWidget } from './BaseWidget'
|
||||
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
|
||||
|
||||
export class CurveWidget
|
||||
extends BaseWidget<ICurveWidget>
|
||||
implements ICurveWidget
|
||||
{
|
||||
override type = 'curve' as const
|
||||
|
||||
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
|
||||
this.drawVueOnlyWarning(ctx, options, 'Curve')
|
||||
}
|
||||
|
||||
onClick(_options: WidgetEventOptions): void {}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { ButtonWidget } from './ButtonWidget'
|
||||
import { ChartWidget } from './ChartWidget'
|
||||
import { ColorWidget } from './ColorWidget'
|
||||
import { ComboWidget } from './ComboWidget'
|
||||
import { CurveWidget } from './CurveWidget'
|
||||
import { FileUploadWidget } from './FileUploadWidget'
|
||||
import { GalleriaWidget } from './GalleriaWidget'
|
||||
import { GradientSliderWidget } from './GradientSliderWidget'
|
||||
@@ -56,6 +57,7 @@ export type WidgetTypeMap = {
|
||||
asset: AssetWidget
|
||||
imagecrop: ImageCropWidget
|
||||
boundingbox: BoundingBoxWidget
|
||||
curve: CurveWidget
|
||||
[key: string]: BaseWidget
|
||||
}
|
||||
|
||||
@@ -132,6 +134,8 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
|
||||
return toClass(ImageCropWidget, narrowedWidget, node)
|
||||
case 'boundingbox':
|
||||
return toClass(BoundingBoxWidget, narrowedWidget, node)
|
||||
case 'curve':
|
||||
return toClass(CurveWidget, narrowedWidget, node)
|
||||
default: {
|
||||
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
|
||||
}
|
||||
|
||||
@@ -225,6 +225,7 @@
|
||||
"login": {
|
||||
"andText": "و",
|
||||
"backToLogin": "العودة إلى تسجيل الدخول",
|
||||
"backToSocialLogin": "سجّل باستخدام Google أو Github بدلاً من ذلك",
|
||||
"confirmPasswordLabel": "تأكيد كلمة المرور",
|
||||
"confirmPasswordPlaceholder": "أدخل نفس كلمة المرور مرة أخرى",
|
||||
"didntReceiveEmail": "لم تستلم البريد الإلكتروني؟ اتصل بنا على",
|
||||
@@ -233,6 +234,9 @@
|
||||
"failed": "فشل تسجيل الدخول",
|
||||
"forgotPassword": "هل نسيت كلمة المرور؟",
|
||||
"forgotPasswordError": "فشل في إرسال بريد إعادة تعيين كلمة المرور",
|
||||
"freeTierBadge": "مؤهل للخطة المجانية",
|
||||
"freeTierDescription": "سجّل باستخدام Google للحصول على {credits} رصيد مجاني كل شهر. لا حاجة لبطاقة.",
|
||||
"freeTierDescriptionGeneric": "سجّل باستخدام Google للحصول على رصيد مجاني كل شهر. لا حاجة لبطاقة.",
|
||||
"insecureContextWarning": "هذا الاتصال غير آمن (HTTP) - قد يتم اعتراض بيانات اعتمادك من قبل المهاجمين إذا تابعت تسجيل الدخول.",
|
||||
"loginButton": "تسجيل الدخول",
|
||||
"loginWithGithub": "تسجيل الدخول باستخدام Github",
|
||||
@@ -251,11 +255,13 @@
|
||||
"sendResetLink": "إرسال رابط إعادة التعيين",
|
||||
"signInOrSignUp": "تسجيل الدخول / إنشاء حساب",
|
||||
"signUp": "إنشاء حساب",
|
||||
"signUpFreeTierPromo": "جديد هنا؟ {signUp} باستخدام Google للحصول على {credits} رصيد مجاني كل شهر.",
|
||||
"success": "تم تسجيل الدخول بنجاح",
|
||||
"termsLink": "شروط الاستخدام",
|
||||
"termsText": "بالنقر على \"التالي\" أو \"إنشاء حساب\"، فإنك توافق على",
|
||||
"title": "تسجيل الدخول إلى حسابك",
|
||||
"useApiKey": "مفتاح API الخاص بـ Comfy",
|
||||
"useEmailInstead": "استخدم البريد الإلكتروني بدلاً من ذلك",
|
||||
"userAvatar": "صورة المستخدم"
|
||||
},
|
||||
"loginButton": {
|
||||
@@ -282,6 +288,7 @@
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "هل لديك حساب بالفعل؟",
|
||||
"emailLabel": "البريد الإلكتروني",
|
||||
"emailNotEligibleForFreeTier": "التسجيل بالبريد الإلكتروني غير مؤهل للخطة المجانية.",
|
||||
"emailPlaceholder": "أدخل بريدك الإلكتروني",
|
||||
"passwordLabel": "كلمة المرور",
|
||||
"passwordPlaceholder": "أدخل كلمة مرور جديدة",
|
||||
@@ -294,6 +301,9 @@
|
||||
"title": "إنشاء حساب"
|
||||
}
|
||||
},
|
||||
"batch": {
|
||||
"index": "{current} / {total}"
|
||||
},
|
||||
"billingOperation": {
|
||||
"subscriptionFailed": "فشل تحديث الاشتراك",
|
||||
"subscriptionProcessing": "جارٍ معالجة الدفع — يتم إعداد مساحة العمل...",
|
||||
@@ -315,8 +325,36 @@
|
||||
"deleteBlueprint": "حذف المخطط",
|
||||
"deleteWorkflow": "حذف سير العمل",
|
||||
"duplicate": "تكرار",
|
||||
"enterAppMode": "الدخول إلى وضع التطبيق",
|
||||
"enterNewName": "أدخل اسمًا جديدًا",
|
||||
"missingNodesWarning": "يحتوي سير العمل على عقد غير مدعومة (مظللة باللون الأحمر)."
|
||||
"exitAppMode": "الخروج من وضع التطبيق",
|
||||
"missingNodesWarning": "يحتوي سير العمل على عقد غير مدعومة (مظللة باللون الأحمر).",
|
||||
"workflowActions": "إجراءات سير العمل"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "تطبيق",
|
||||
"appDescription": "يفتح كتطبيق بشكل افتراضي",
|
||||
"arrange": "معاينة",
|
||||
"arrangeDescription": "مراجعة تخطيط التطبيق",
|
||||
"connectOutput": "توصيل مخرج",
|
||||
"connectOutputBody1": "يجب توصيل مخرج واحد على الأقل قبل حفظ التطبيق.",
|
||||
"connectOutputBody2": "انتقل إلى خطوة 'تحديد' وانقر على عقد المخرجات لإضافتها هنا.",
|
||||
"filename": "اسم الملف",
|
||||
"label": "منشئ التطبيقات",
|
||||
"nodeGraph": "رسم العقد",
|
||||
"nodeGraphDescription": "يفتح كرسم عقد بشكل افتراضي",
|
||||
"save": "حفظ",
|
||||
"saveAs": "حفظ باسم",
|
||||
"saveAsLabel": "احفظ سير العمل هذا كـ ...",
|
||||
"saveDescription": "حفظ وإنهاء",
|
||||
"saveSuccess": "تم الحفظ بنجاح",
|
||||
"saveSuccessAppMessage": "تم حفظ '{name}'. سيفتح في وضع التطبيق بشكل افتراضي من الآن فصاعدًا.",
|
||||
"saveSuccessAppPrompt": "هل ترغب في عرضه الآن؟",
|
||||
"saveSuccessGraphMessage": "تم حفظ '{name}'. سيفتح كرسم عقد بشكل افتراضي.",
|
||||
"select": "تحديد",
|
||||
"selectDescription": "اختيار المدخلات/المخرجات",
|
||||
"switchToSelect": "الانتقال إلى التحديد",
|
||||
"viewApp": "عرض التطبيق"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "فشل النسخ إلى الحافظة",
|
||||
@@ -879,6 +917,7 @@
|
||||
"enableSelected": "تفعيل المحدد",
|
||||
"enabled": "ممكّن",
|
||||
"enabling": "جارٍ التمكين",
|
||||
"enter": "إدخال",
|
||||
"enterBaseName": "أدخل الاسم الأساسي",
|
||||
"enterNewName": "أدخل الاسم الجديد",
|
||||
"enterNewNamePrompt": "أدخل اسمًا جديدًا:",
|
||||
@@ -1149,6 +1188,8 @@
|
||||
"star": "نجمة"
|
||||
},
|
||||
"imageCompare": {
|
||||
"batchLabelA": "أ: ",
|
||||
"batchLabelB": "ب: ",
|
||||
"noImages": "لا توجد صور للمقارنة"
|
||||
},
|
||||
"imageCrop": {
|
||||
@@ -1282,7 +1323,35 @@
|
||||
"helpFix": "المساعدة في الإصلاح"
|
||||
},
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "منشئ التطبيقات",
|
||||
"apps": "التطبيقات"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "عقدة واحدة على الأقل",
|
||||
"connectAtLeastOne": "قم بتوصيل {atLeastOne} عقدة مخرجات حتى يتمكن المستخدمون من رؤية النتائج بعد التشغيل.",
|
||||
"noOutputs": "لم تتم إضافة أي مخرجات بعد",
|
||||
"outputExamples": "أمثلة: 'حفظ صورة' أو 'حفظ فيديو'",
|
||||
"outputs": "المخرجات",
|
||||
"resultsLabel": "سيتم عرض النتائج الناتجة من عقدة/عقد المخرجات المحددة هنا بعد تشغيل هذا التطبيق",
|
||||
"switchToSelect": "انتقل إلى خطوة 'تحديد' وانقر على عقد المخرجات لإضافتها هنا.",
|
||||
"switchToSelectButton": "الانتقال إلى التحديد"
|
||||
},
|
||||
"beta": "وضع التطبيق تجريبي - أرسل ملاحظاتك",
|
||||
"builder": {
|
||||
"exit": "خروج من البناء",
|
||||
"exitConfirmMessage": "لديك تغييرات غير محفوظة ستفقد\nهل تريد الخروج بدون حفظ؟",
|
||||
"exitConfirmTitle": "الخروج من بناء التطبيق؟",
|
||||
"inputsDesc": "سيتفاعل المستخدمون مع هذه المدخلات ويعدلونها لإنشاء النتائج.",
|
||||
"inputsExample": "أمثلة: \"تحميل صورة\"، \"موجه نصي\"، \"خطوات\"",
|
||||
"noInputs": "لم تتم إضافة أي مدخلات بعد",
|
||||
"noOutputs": "لم تتم إضافة أي عقد إخراج بعد",
|
||||
"outputsDesc": "وصل عقدة إخراج واحدة على الأقل حتى يتمكن المستخدمون من رؤية النتائج بعد التشغيل.",
|
||||
"outputsExample": "أمثلة: \"حفظ صورة\" أو \"حفظ فيديو\"",
|
||||
"promptAddInputs": "انقر على معلمات العقدة لإضافتها هنا كمدخلات",
|
||||
"promptAddOutputs": "انقر على عقد الإخراج لإضافتها هنا. هذه ستكون النتائج المُولدة.",
|
||||
"title": "وضع بناء التطبيق"
|
||||
},
|
||||
"downloadAll": "تنزيل الكل",
|
||||
"dragAndDropImage": "اسحب وأسقط صورة",
|
||||
"graphMode": "وضع الرسم البياني",
|
||||
@@ -1291,11 +1360,13 @@
|
||||
"reuseParameters": "إعادة استخدام المعلمات",
|
||||
"runCount": "عدد مرات التشغيل:",
|
||||
"welcome": {
|
||||
"intro": "عرض مبسط يخفي مخطط العقد حتى تتمكن من التركيز على الإبداع.",
|
||||
"layout": "على اليسار، سترى الصور والفيديوهات والمخرجات التي تم إنشاؤها. على اليمين، فقط عناصر التحكم التي تحتاجها. كل ما هو معقد يبقى بعيدًا عن الأنظار.",
|
||||
"backToWorkflow": "العودة إلى سير العمل",
|
||||
"buildApp": "إنشاء تطبيق",
|
||||
"controls": "تظهر المخرجات في الأسفل، وعناصر التحكم على اليمين. كل شيء آخر يبقى بعيدًا.",
|
||||
"getStarted": "انقر على {runButton} للبدء.",
|
||||
"message": "عرض مبسط يخفي رسم العقد حتى تتمكن من التركيز على الإنشاء.",
|
||||
"sharing": "المشاركة سهلة: أنشئ سير العمل الخاص بك، افتح وضع التطبيق، انقر بزر الماوس الأيمن على علامة التبويب، ثم صدّر. عندما يفتح الآخرون ملفك، سيتم تشغيله مباشرة في هذا العرض النظيف. يمكنك مشاركة سير عمل قوي كأداة بسيطة دون الحاجة لفهم مخططات العقد.",
|
||||
"title": "مرحبًا بك في وضع التطبيق",
|
||||
"widget": "إذا كنت تريد التحكم في الإعدادات الظاهرة، حوّل العقد العليا إلى مخطط فرعي، ثم استخدم ترقية عناصر التحكم في الأدوات أعلاه لاختيار ما يتم عرضه."
|
||||
"title": "مرحبًا بك في وضع التطبيق"
|
||||
}
|
||||
},
|
||||
"load3d": {
|
||||
@@ -1818,11 +1889,18 @@
|
||||
"showLinks": "إظهار الروابط"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"customModelsInstruction": "ستحتاج إلى العثور عليها وتنزيلها يدويًا. ابحث عنها عبر الإنترنت (جرّب Civitai أو Hugging Face) أو تواصل مع مزود سير العمل الأصلي.",
|
||||
"customModelsWarning": "بعض هذه النماذج مخصصة ولا نتعرف عليها.",
|
||||
"description": "يتطلب سير العمل هذا نماذج لم تقم بتنزيلها بعد.",
|
||||
"doNotAskAgain": "عدم العرض مرة أخرى",
|
||||
"missingModels": "نماذج مفقودة",
|
||||
"missingModelsMessage": "عند تحميل الرسم البياني، لم يتم العثور على النماذج التالية",
|
||||
"downloadAll": "تنزيل الكل",
|
||||
"downloadAvailable": "التنزيل متاح",
|
||||
"footerDescription": "قم بتنزيل هذه النماذج وضعها في المجلد الصحيح.\nالعُقد التي تفتقد إلى النماذج مميزة باللون الأحمر على اللوحة.",
|
||||
"gotIt": "حسنًا، فهمت",
|
||||
"reEnableInSettings": "إعادة التفعيل في {link}",
|
||||
"reEnableInSettingsLink": "الإعدادات"
|
||||
"reEnableInSettingsLink": "الإعدادات",
|
||||
"title": "هذا سير العمل يفتقد إلى النماذج",
|
||||
"totalSize": "إجمالي حجم التنزيل:"
|
||||
},
|
||||
"missingNodes": {
|
||||
"cloud": {
|
||||
@@ -1845,6 +1923,14 @@
|
||||
"tooltip": "أنت تستخدم إصدارًا ليليًا من ComfyUI. يرجى استخدام زر الملاحظات لمشاركة آرائك حول هذه الميزات."
|
||||
}
|
||||
},
|
||||
"nightlySurvey": {
|
||||
"accept": "بكل سرور، سأساعد!",
|
||||
"description": "لقد استخدمت هذه الميزة. هل يمكنك تخصيص لحظة لمشاركة ملاحظاتك؟",
|
||||
"dontAskAgain": "لا تسأل مرة أخرى",
|
||||
"loadError": "فشل في تحميل الاستبيان. يرجى المحاولة لاحقًا.",
|
||||
"notNow": "ليس الآن",
|
||||
"title": "ساعدنا في التحسين"
|
||||
},
|
||||
"nodeCategories": {
|
||||
"": "",
|
||||
"3d": "ثلاثي الأبعاد",
|
||||
@@ -2599,14 +2685,17 @@
|
||||
"cannotDeleteGlobal": "لا يمكن حذف المخططات المثبتة",
|
||||
"confirmDelete": "سيؤدي هذا الإجراء إلى إزالة المخطط نهائيًا من مكتبتك",
|
||||
"confirmDeleteTitle": "حذف المخطط؟",
|
||||
"disconnected": "غير متصل",
|
||||
"enterDescription": "أدخل وصفًا",
|
||||
"enterSearchAliases": "أدخل الأسماء المستعارة للبحث (مفصولة بفواصل)",
|
||||
"hidden": "معاملات مخفية / متداخلة",
|
||||
"hideAll": "إخفاء الكل",
|
||||
"linked": "(مرتبط)",
|
||||
"loadFailure": "فشل تحميل مخططات الرسم البياني الفرعي",
|
||||
"overwriteBlueprint": "سيؤدي الحفظ إلى استبدال المخطط الحالي بالتغييرات الخاصة بك",
|
||||
"overwriteBlueprintTitle": "استبدال المخطط الحالي؟",
|
||||
"promoteOutsideSubgraph": "لا يمكن ترقية عنصر واجهة المستخدم عند عدم وجوده في الرسم البياني الفرعي",
|
||||
"promoteWidget": "ترقية الأداة: {name}",
|
||||
"publish": "نشر الرسم البياني الفرعي",
|
||||
"publishSuccess": "تم الحفظ في مكتبة العقد",
|
||||
"publishSuccessMessage": "يمكنك العثور على مخطط الرسم البياني الفرعي الخاص بك في مكتبة العقد ضمن \"مخططات الرسم البياني الفرعي\"",
|
||||
@@ -2614,7 +2703,8 @@
|
||||
"searchAliases": "بحث عن الأسماء المستعارة",
|
||||
"showAll": "إظهار الكل",
|
||||
"showRecommended": "إظهار العناصر الموصى بها",
|
||||
"shown": "معروض على العقدة"
|
||||
"shown": "معروض على العقدة",
|
||||
"unpromoteWidget": "إلغاء ترقية الأداة: {name}"
|
||||
},
|
||||
"subscription": {
|
||||
"addApiCredits": "إضافة رصيد API",
|
||||
@@ -2622,7 +2712,9 @@
|
||||
"addCreditsLabel": "أضف المزيد من الرصيد في أي وقت",
|
||||
"benefits": {
|
||||
"benefit1": "رصيد شهري للعقد الشريكة - تجديد عند الحاجة",
|
||||
"benefit2": "حتى 30 دقيقة وقت تشغيل لكل مهمة"
|
||||
"benefit1FreeTier": "رصيد شهري أكثر، مع إمكانية الشحن في أي وقت",
|
||||
"benefit2": "حتى 30 دقيقة وقت تشغيل لكل مهمة",
|
||||
"benefit3": "استخدم نماذجك الخاصة (Creator & Pro)"
|
||||
},
|
||||
"beta": "نسخة تجريبية",
|
||||
"billedMonthly": "يتم الفوترة شهريًا",
|
||||
@@ -2660,6 +2752,21 @@
|
||||
"description": "اختر الخطة الأنسب لك",
|
||||
"descriptionWorkspace": "اختر أفضل خطة لمساحة العمل الخاصة بك",
|
||||
"expiresDate": "ينتهي في {date}",
|
||||
"freeTier": {
|
||||
"description": "تشمل خطتك المجانية {credits} رصيد شهري لتجربة Comfy Cloud.",
|
||||
"descriptionGeneric": "تشمل خطتك المجانية رصيدًا شهريًا لتجربة Comfy Cloud.",
|
||||
"nextRefresh": "سيتم تجديد رصيدك في {date}.",
|
||||
"outOfCredits": {
|
||||
"subtitle": "اشترك لفتح الشحن والمزيد",
|
||||
"title": "لقد نفد رصيدك المجاني"
|
||||
},
|
||||
"subscribeCta": "اشترك للمزيد",
|
||||
"title": "أنت على الخطة المجانية",
|
||||
"topUpBlocked": {
|
||||
"title": "افتح الشحن والمزيد"
|
||||
},
|
||||
"upgradeCta": "عرض الخطط"
|
||||
},
|
||||
"gpuLabel": "RTX 6000 Pro (ذاكرة 96GB VRAM)",
|
||||
"haveQuestions": "هل لديك أسئلة أو ترغب في معرفة المزيد عن المؤسسات؟",
|
||||
"invoiceHistory": "سجل الفواتير",
|
||||
@@ -2670,6 +2777,7 @@
|
||||
"maxDuration": {
|
||||
"creator": "30 دقيقة",
|
||||
"founder": "30 دقيقة",
|
||||
"free": "٣٠ دقيقة",
|
||||
"pro": "ساعة واحدة",
|
||||
"standard": "30 دقيقة"
|
||||
},
|
||||
@@ -2742,6 +2850,9 @@
|
||||
"founder": {
|
||||
"name": "إصدار المؤسس"
|
||||
},
|
||||
"free": {
|
||||
"name": "مجاني"
|
||||
},
|
||||
"pro": {
|
||||
"name": "احترافي"
|
||||
},
|
||||
@@ -2852,6 +2963,7 @@
|
||||
"emptyCanvas": "لوحة فارغة",
|
||||
"errorCopyImage": "خطأ في نسخ الصورة: {error}",
|
||||
"errorLoadingModel": "خطأ في تحميل النموذج",
|
||||
"errorOpenImage": "حدث خطأ أثناء فتح الصورة: {error}",
|
||||
"errorSaveSetting": "خطأ في حفظ الإعداد {id}: {err}",
|
||||
"exportSuccess": "تم تصدير النموذج بنجاح كـ {format}",
|
||||
"failedExecutionPathResolution": "تعذر حل المسار إلى العُقَد المحددة",
|
||||
|
||||
@@ -696,8 +696,7 @@
|
||||
"tooltip": "الحد الأقصى لعدد الصور التي سيتم توليدها عندما يكون sequential_image_generation='auto'. إجمالي الصور (المدخلة + المولدة) لا يمكن أن يتجاوز 15."
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "اسم النموذج"
|
||||
"name": "model"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
@@ -4749,6 +4748,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageMergeTileList": {
|
||||
"display_name": "دمج قائمة القطع إلى صورة",
|
||||
"inputs": {
|
||||
"final_height": {
|
||||
"name": "final_height"
|
||||
},
|
||||
"final_width": {
|
||||
"name": "final_width"
|
||||
},
|
||||
"image_list": {
|
||||
"name": "image_list"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "overlap"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageOnlyCheckpointLoader": {
|
||||
"display_name": "محمل نقطة تحقق الصور فقط (نموذج img2vid)",
|
||||
"inputs": {
|
||||
@@ -5327,6 +5348,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingAvatarNode": {
|
||||
"description": "إنشاء مقاطع فيديو رقمية بأسلوب البث المباشر لإنسان رقمي من صورة واحدة وملف صوتي.",
|
||||
"display_name": "Kling Avatar 2.0",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"image": {
|
||||
"name": "صورة",
|
||||
"tooltip": "صورة مرجعية للأفاتار. يجب ألا يقل العرض والارتفاع عن ٣٠٠ بكسل. يجب أن تكون نسبة الأبعاد بين ١:٢.٥ و٢.٥:١."
|
||||
},
|
||||
"mode": {
|
||||
"name": "الوضع"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "موجه",
|
||||
"tooltip": "موجه اختياري لتحديد حركات الأفاتار، المشاعر، وحركات الكاميرا."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "تتحكم البذرة فيما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
},
|
||||
"sound_file": {
|
||||
"name": "ملف صوتي",
|
||||
"tooltip": "إدخال صوتي. يجب أن تتراوح المدة بين ٢ و٣٠٠ ثانية."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingCameraControlI2VNode": {
|
||||
"description": "تحويل الصور الثابتة إلى فيديوهات سينمائية مع حركات كاميرا احترافية تحاكي التصوير السينمائي الحقيقي. تحكم في زووم، دوران، تحريك الكاميرا، الميل، والرؤية من منظور الشخص الأول مع الحفاظ على تركيز الصورة الأصلية.",
|
||||
"display_name": "تحكم كاميرا كليغ: صورة إلى فيديو",
|
||||
@@ -14002,6 +14056,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageToTileList": {
|
||||
"description": "يقوم بتقسيم صورة إلى قائمة دفعات من القطع مع تداخل محدد.",
|
||||
"display_name": "تجزئة الصورة إلى قائمة قطع",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "overlap"
|
||||
},
|
||||
"tile_height": {
|
||||
"name": "tile_height"
|
||||
},
|
||||
"tile_width": {
|
||||
"name": "tile_width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageWithAlpha": {
|
||||
"display_name": "فصل الصورة مع ألفا",
|
||||
"inputs": {
|
||||
|
||||
@@ -1749,8 +1749,15 @@
|
||||
"doNotAskAgain": "Don't show this again",
|
||||
"reEnableInSettings": "Re-enable in {link}",
|
||||
"reEnableInSettingsLink": "Settings",
|
||||
"missingModels": "Missing Models",
|
||||
"missingModelsMessage": "When loading the graph, the following models were not found"
|
||||
"title": "This workflow is missing models",
|
||||
"description": "This workflow requires models you haven't downloaded yet.",
|
||||
"totalSize": "Total download size:",
|
||||
"downloadAll": "Download all",
|
||||
"downloadAvailable": "Download available",
|
||||
"gotIt": "Ok, got it",
|
||||
"footerDescription": "Download and place these models in the correct folder.\nNodes with missing models are highlighted red on the canvas.",
|
||||
"customModelsWarning": "Some of these are custom models that we don't recognize.",
|
||||
"customModelsInstruction": "You'll need to find and download them manually. Search for them online (try Civitai or HuggingFace) or contact the original workflow provider."
|
||||
},
|
||||
"versionMismatchWarning": {
|
||||
"title": "Version Compatibility Warning",
|
||||
@@ -1796,7 +1803,12 @@
|
||||
"errorNotSupported": "Clipboard API not supported in your browser"
|
||||
},
|
||||
"imageCompare": {
|
||||
"noImages": "No images to compare"
|
||||
"noImages": "No images to compare",
|
||||
"batchLabelA": "A:",
|
||||
"batchLabelB": "B:"
|
||||
},
|
||||
"batch": {
|
||||
"index": "{current} / {total}"
|
||||
},
|
||||
"load3d": {
|
||||
"switchCamera": "Switch Camera",
|
||||
@@ -1905,6 +1917,7 @@
|
||||
"nodeDefinitionsUpdated": "Node definitions updated",
|
||||
"errorSaveSetting": "Error saving setting {id}: {err}",
|
||||
"errorCopyImage": "Error copying image: {error}",
|
||||
"errorOpenImage": "Error opening image: {error}",
|
||||
"noTemplatesToExport": "No templates to export",
|
||||
"failedToFetchLogs": "Failed to fetch server logs",
|
||||
"migrateToLitegraphReroute": "Reroute nodes will be removed in future versions. Click to migrate to litegraph-native reroute.",
|
||||
@@ -1974,6 +1987,7 @@
|
||||
"newUser": "New here?",
|
||||
"userAvatar": "User Avatar",
|
||||
"signUp": "Sign up",
|
||||
"signUpFreeTierPromo": "New here? {signUp} with Google to get {credits} free credits every month.",
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "Enter your email",
|
||||
"passwordLabel": "Password",
|
||||
@@ -1998,7 +2012,12 @@
|
||||
"failed": "Login failed",
|
||||
"insecureContextWarning": "This connection is insecure (HTTP) - your credentials may be intercepted by attackers if you proceed to login.",
|
||||
"questionsContactPrefix": "Questions? Contact us at",
|
||||
"noAssociatedUser": "There is no Comfy user associated with the provided API key"
|
||||
"noAssociatedUser": "There is no Comfy user associated with the provided API key",
|
||||
"useEmailInstead": "Use email instead",
|
||||
"freeTierBadge": "Eligible for Free Tier",
|
||||
"freeTierDescription": "Sign up with Google to get {credits} free credits every month. No card needed.",
|
||||
"freeTierDescriptionGeneric": "Sign up with Google to get free credits every month. No card needed.",
|
||||
"backToSocialLogin": "Sign up with Google or Github instead"
|
||||
},
|
||||
"signup": {
|
||||
"title": "Create an account",
|
||||
@@ -2012,7 +2031,8 @@
|
||||
"signUpWithGoogle": "Sign up with Google",
|
||||
"signUpWithGithub": "Sign up with Github",
|
||||
"regionRestrictionChina": "In accordance with local regulatory requirements, our services are temporarily unavailable to users located in China.",
|
||||
"personalDataConsentLabel": "I agree to the processing of my personal data."
|
||||
"personalDataConsentLabel": "I agree to the processing of my personal data.",
|
||||
"emailNotEligibleForFreeTier": "Email sign-up is not eligible for Free Tier."
|
||||
},
|
||||
"signOut": {
|
||||
"signOut": "Log Out",
|
||||
@@ -2203,10 +2223,15 @@
|
||||
"invoiceHistory": "Invoice history",
|
||||
"benefits": {
|
||||
"benefit1": "Monthly credits for Partner Nodes — top up when needed",
|
||||
"benefit2": "Up to 30 min runtime per job"
|
||||
"benefit1FreeTier": "More monthly credits, top up anytime",
|
||||
"benefit2": "Up to 1 hour runtime per job on Pro",
|
||||
"benefit3": "Bring your own models (Creator & Pro)"
|
||||
},
|
||||
"yearlyDiscount": "20% DISCOUNT",
|
||||
"tiers": {
|
||||
"free": {
|
||||
"name": "Free"
|
||||
},
|
||||
"founder": {
|
||||
"name": "Founder's Edition"
|
||||
},
|
||||
@@ -2240,6 +2265,21 @@
|
||||
"haveQuestions": "Have questions or wondering about enterprise?",
|
||||
"contactUs": "Contact us",
|
||||
"viewEnterprise": "View enterprise",
|
||||
"freeTier": {
|
||||
"title": "You're on the Free plan",
|
||||
"description": "Your free plan includes {credits} credits each month to try Comfy Cloud.",
|
||||
"descriptionGeneric": "Your free plan includes a monthly credit allowance to try Comfy Cloud.",
|
||||
"nextRefresh": "Your credits refresh on {date}.",
|
||||
"subscribeCta": "Subscribe for more",
|
||||
"outOfCredits": {
|
||||
"title": "You're out of free credits",
|
||||
"subtitle": "Subscribe to unlock top-ups and more"
|
||||
},
|
||||
"topUpBlocked": {
|
||||
"title": "Unlock top-ups and more"
|
||||
},
|
||||
"upgradeCta": "View plans"
|
||||
},
|
||||
"partnerNodesCredits": "Partner nodes pricing",
|
||||
"plansAndPricing": "Plans & pricing",
|
||||
"managePlan": "Manage plan",
|
||||
@@ -2267,6 +2307,7 @@
|
||||
"upgradeTo": "Upgrade to {plan}",
|
||||
"changeTo": "Change to {plan}",
|
||||
"maxDuration": {
|
||||
"free": "30 min",
|
||||
"standard": "30 min",
|
||||
"creator": "30 min",
|
||||
"pro": "1 hr",
|
||||
@@ -2966,6 +3007,20 @@
|
||||
"switchToSelectButton": "Switch to Select",
|
||||
"outputs": "Outputs",
|
||||
"resultsLabel": "Results generated from the selected output node(s) will be shown here after running this app"
|
||||
},
|
||||
"builder": {
|
||||
"title": "App builder mode",
|
||||
"exit": "Exit builder",
|
||||
"exitConfirmTitle": "Exit app builder?",
|
||||
"exitConfirmMessage": "You have unsaved changes that will be lost\nExit without saving?",
|
||||
"promptAddInputs": "Click on node parameters to add them here as inputs",
|
||||
"noInputs": "No inputs added yet",
|
||||
"inputsDesc": "Users will interact and adjust these to generate their outputs.",
|
||||
"inputsExample": "Examples: “Load image”, “Text prompt”, “Steps”",
|
||||
"promptAddOutputs": "Click on output nodes to add them here. These will be the generated results.",
|
||||
"noOutputs": "No output nodes added yet",
|
||||
"outputsDesc": "Connect at least one output node so users can see results after running.",
|
||||
"outputsExample": "Examples: “Save Image” or “Save Video”"
|
||||
}
|
||||
},
|
||||
"missingNodes": {
|
||||
|
||||
@@ -673,12 +673,11 @@
|
||||
}
|
||||
},
|
||||
"ByteDanceSeedreamNode": {
|
||||
"display_name": "ByteDance Seedream 4.5",
|
||||
"display_name": "ByteDance Seedream 5.0",
|
||||
"description": "Unified text-to-image generation and precise single-sentence editing at up to 4K resolution.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Model name"
|
||||
"name": "model"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
@@ -690,7 +689,7 @@
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "Input image(s) for image-to-image generation. List of 1-10 images for single or multi-reference generation."
|
||||
"tooltip": "Input image(s) for image-to-image generation. Reference image(s) for single or multi-reference generation."
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
@@ -4749,6 +4748,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageMergeTileList": {
|
||||
"display_name": "Merge List of Tiles to Image",
|
||||
"inputs": {
|
||||
"image_list": {
|
||||
"name": "image_list"
|
||||
},
|
||||
"final_width": {
|
||||
"name": "final_width"
|
||||
},
|
||||
"final_height": {
|
||||
"name": "final_height"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "overlap"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageOnlyCheckpointLoader": {
|
||||
"display_name": "Image Only Checkpoint Loader (img2vid model)",
|
||||
"inputs": {
|
||||
@@ -5213,6 +5234,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingAvatarNode": {
|
||||
"display_name": "Kling Avatar 2.0",
|
||||
"description": "Generate broadcast-style digital human videos from a single photo and an audio file.",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "Avatar reference image. Width and height must be at least 300px. Aspect ratio must be between 1:2.5 and 2.5:1."
|
||||
},
|
||||
"sound_file": {
|
||||
"name": "sound_file",
|
||||
"tooltip": "Audio input. Must be between 2 and 300 seconds in duration."
|
||||
},
|
||||
"mode": {
|
||||
"name": "mode"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Optional prompt to define avatar actions, emotions, and camera movements."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingCameraControlI2VNode": {
|
||||
"display_name": "Kling Image to Video (Camera Control)",
|
||||
"description": "Transform still images into cinematic videos with professional camera movements that simulate real-world cinematography. Control virtual camera actions including zoom, rotation, pan, tilt, and first-person view, while maintaining focus on your original image.",
|
||||
@@ -13919,6 +13973,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageToTileList": {
|
||||
"display_name": "Split Image into List of Tiles",
|
||||
"description": "Splits an image into a batched list of tiles with a specified overlap.",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"tile_width": {
|
||||
"name": "tile_width"
|
||||
},
|
||||
"tile_height": {
|
||||
"name": "tile_height"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "overlap"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageWithAlpha": {
|
||||
"display_name": "Split Image with Alpha",
|
||||
"inputs": {
|
||||
|
||||
@@ -225,6 +225,7 @@
|
||||
"login": {
|
||||
"andText": "y",
|
||||
"backToLogin": "Volver al inicio de sesión",
|
||||
"backToSocialLogin": "Regístrate con Google o Github en su lugar",
|
||||
"confirmPasswordLabel": "Confirmar contraseña",
|
||||
"confirmPasswordPlaceholder": "Ingresa la misma contraseña nuevamente",
|
||||
"didntReceiveEmail": "¿No recibiste el correo? Contáctanos en",
|
||||
@@ -233,6 +234,9 @@
|
||||
"failed": "Inicio de sesión fallido",
|
||||
"forgotPassword": "¿Olvidaste tu contraseña?",
|
||||
"forgotPasswordError": "No se pudo enviar el correo electrónico para restablecer la contraseña",
|
||||
"freeTierBadge": "Elegible para el plan gratuito",
|
||||
"freeTierDescription": "Regístrate con Google para obtener {credits} créditos gratis cada mes. No se necesita tarjeta.",
|
||||
"freeTierDescriptionGeneric": "Regístrate con Google para obtener créditos gratis cada mes. No se necesita tarjeta.",
|
||||
"insecureContextWarning": "Esta conexión no es segura (HTTP): tus credenciales pueden ser interceptadas por atacantes si continúas con el inicio de sesión.",
|
||||
"loginButton": "Iniciar sesión",
|
||||
"loginWithGithub": "Iniciar sesión con Github",
|
||||
@@ -251,11 +255,13 @@
|
||||
"sendResetLink": "Enviar enlace de restablecimiento",
|
||||
"signInOrSignUp": "Iniciar sesión / Registrarse",
|
||||
"signUp": "Regístrate",
|
||||
"signUpFreeTierPromo": "¿Nuevo aquí? {signUp} con Google para obtener {credits} créditos gratis cada mes.",
|
||||
"success": "Inicio de sesión exitoso",
|
||||
"termsLink": "Términos de uso",
|
||||
"termsText": "Al hacer clic en \"Siguiente\" o \"Registrarse\", aceptas nuestros",
|
||||
"title": "Inicia sesión en tu cuenta",
|
||||
"useApiKey": "Clave API de Comfy",
|
||||
"useEmailInstead": "Usar correo electrónico en su lugar",
|
||||
"userAvatar": "Avatar de usuario"
|
||||
},
|
||||
"loginButton": {
|
||||
@@ -282,6 +288,7 @@
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
|
||||
"emailLabel": "Correo electrónico",
|
||||
"emailNotEligibleForFreeTier": "El registro por correo electrónico no es elegible para el plan gratuito.",
|
||||
"emailPlaceholder": "Ingresa tu correo electrónico",
|
||||
"passwordLabel": "Contraseña",
|
||||
"passwordPlaceholder": "Ingresa una nueva contraseña",
|
||||
@@ -294,6 +301,9 @@
|
||||
"title": "Crea una cuenta"
|
||||
}
|
||||
},
|
||||
"batch": {
|
||||
"index": "{current} / {total}"
|
||||
},
|
||||
"billingOperation": {
|
||||
"subscriptionFailed": "Error al actualizar la suscripción",
|
||||
"subscriptionProcessing": "Procesando pago — configurando tu espacio de trabajo...",
|
||||
@@ -315,8 +325,36 @@
|
||||
"deleteBlueprint": "Eliminar Plano",
|
||||
"deleteWorkflow": "Eliminar flujo de trabajo",
|
||||
"duplicate": "Duplicar",
|
||||
"enterAppMode": "Entrar en modo aplicación",
|
||||
"enterNewName": "Ingrese un nuevo nombre",
|
||||
"missingNodesWarning": "El flujo de trabajo contiene nodos no compatibles (resaltados en rojo)."
|
||||
"exitAppMode": "Salir del modo aplicación",
|
||||
"missingNodesWarning": "El flujo de trabajo contiene nodos no compatibles (resaltados en rojo).",
|
||||
"workflowActions": "Acciones del flujo de trabajo"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "Aplicación",
|
||||
"appDescription": "Se abre como una aplicación por defecto",
|
||||
"arrange": "Vista previa",
|
||||
"arrangeDescription": "Revisar el diseño de la aplicación",
|
||||
"connectOutput": "Conectar una salida",
|
||||
"connectOutputBody1": "Tu aplicación necesita al menos una salida conectada antes de poder guardarse.",
|
||||
"connectOutputBody2": "Cambia al paso 'Seleccionar' y haz clic en los nodos de salida para agregarlos aquí.",
|
||||
"filename": "Nombre de archivo",
|
||||
"label": "Constructor de aplicaciones",
|
||||
"nodeGraph": "Grafo de nodos",
|
||||
"nodeGraphDescription": "Se abre como grafo de nodos por defecto",
|
||||
"save": "Guardar",
|
||||
"saveAs": "Guardar como",
|
||||
"saveAsLabel": "Guardar este flujo de trabajo como...",
|
||||
"saveDescription": "Guardar y finalizar",
|
||||
"saveSuccess": "Guardado exitosamente",
|
||||
"saveSuccessAppMessage": "'{name}' ha sido guardado. Se abrirá en modo aplicación por defecto de ahora en adelante.",
|
||||
"saveSuccessAppPrompt": "¿Te gustaría verlo ahora?",
|
||||
"saveSuccessGraphMessage": "'{name}' ha sido guardado. Se abrirá como grafo de nodos por defecto.",
|
||||
"select": "Seleccionar",
|
||||
"selectDescription": "Elegir entradas/salidas",
|
||||
"switchToSelect": "Cambiar a Seleccionar",
|
||||
"viewApp": "Ver aplicación"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "Error al copiar al portapapeles",
|
||||
@@ -879,6 +917,7 @@
|
||||
"enableSelected": "Habilitar seleccionados",
|
||||
"enabled": "Habilitado",
|
||||
"enabling": "Habilitando",
|
||||
"enter": "Entrar",
|
||||
"enterBaseName": "Introduce el nombre base",
|
||||
"enterNewName": "Introduce el nuevo nombre",
|
||||
"enterNewNamePrompt": "Introduce un nuevo nombre:",
|
||||
@@ -1149,6 +1188,8 @@
|
||||
"star": "Estrella"
|
||||
},
|
||||
"imageCompare": {
|
||||
"batchLabelA": "A:",
|
||||
"batchLabelB": "B:",
|
||||
"noImages": "No hay imágenes para comparar"
|
||||
},
|
||||
"imageCrop": {
|
||||
@@ -1282,7 +1323,35 @@
|
||||
"helpFix": "Ayuda a Solucionar Esto"
|
||||
},
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "Constructor de aplicaciones",
|
||||
"apps": "Aplicaciones"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "al menos uno",
|
||||
"connectAtLeastOne": "Conecta {atLeastOne} nodo de salida para que los usuarios puedan ver los resultados después de ejecutar.",
|
||||
"noOutputs": "Aún no se han añadido salidas",
|
||||
"outputExamples": "Ejemplos: 'Guardar imagen' o 'Guardar video'",
|
||||
"outputs": "Salidas",
|
||||
"resultsLabel": "Los resultados generados por el/los nodo(s) de salida seleccionados se mostrarán aquí después de ejecutar esta aplicación",
|
||||
"switchToSelect": "Cambia al paso 'Seleccionar' y haz clic en los nodos de salida para agregarlos aquí.",
|
||||
"switchToSelectButton": "Cambiar a Seleccionar"
|
||||
},
|
||||
"beta": "Modo App Beta - Enviar comentarios",
|
||||
"builder": {
|
||||
"exit": "Salir del constructor",
|
||||
"exitConfirmMessage": "Tienes cambios sin guardar que se perderán\n¿Salir sin guardar?",
|
||||
"exitConfirmTitle": "¿Salir del constructor de aplicaciones?",
|
||||
"inputsDesc": "Los usuarios interactuarán y ajustarán estos para generar sus resultados.",
|
||||
"inputsExample": "Ejemplos: “Cargar imagen”, “Prompt de texto”, “Pasos”",
|
||||
"noInputs": "Aún no se han agregado entradas",
|
||||
"noOutputs": "Aún no se han agregado nodos de salida",
|
||||
"outputsDesc": "Conecta al menos un nodo de salida para que los usuarios vean los resultados después de ejecutar.",
|
||||
"outputsExample": "Ejemplos: “Guardar imagen” o “Guardar video”",
|
||||
"promptAddInputs": "Haz clic en los parámetros del nodo para agregarlos aquí como entradas",
|
||||
"promptAddOutputs": "Haz clic en los nodos de salida para agregarlos aquí. Estos serán los resultados generados.",
|
||||
"title": "Modo constructor de aplicaciones"
|
||||
},
|
||||
"downloadAll": "Descargar todo",
|
||||
"dragAndDropImage": "Arrastra y suelta una imagen",
|
||||
"graphMode": "Modo gráfico",
|
||||
@@ -1291,11 +1360,13 @@
|
||||
"reuseParameters": "Reutilizar parámetros",
|
||||
"runCount": "Número de ejecuciones:",
|
||||
"welcome": {
|
||||
"intro": "Una vista simplificada que oculta el grafo de nodos para que puedas concentrarte en crear.",
|
||||
"layout": "A la izquierda, verás tus imágenes, videos y resultados generados. A la derecha, solo los controles necesarios. Todo lo complejo queda fuera de la vista.",
|
||||
"backToWorkflow": "Volver al flujo de trabajo",
|
||||
"buildApp": "Crear aplicación",
|
||||
"controls": "Tus resultados aparecen abajo, tus controles están a la derecha. Todo lo demás se mantiene fuera del camino.",
|
||||
"getStarted": "Haz clic en {runButton} para comenzar.",
|
||||
"message": "Una vista simplificada que oculta el grafo de nodos para que puedas concentrarte en crear.",
|
||||
"sharing": "Compartir es fácil: crea tu flujo de trabajo, abre el Modo App, haz clic derecho en la pestaña y exporta. Cuando otros abran tu archivo, se lanzará directamente en esta vista limpia. Puedes compartir flujos de trabajo potentes como herramientas simples sin que nadie tenga que entender grafos de nodos.",
|
||||
"title": "Bienvenido al Modo App",
|
||||
"widget": "Si quieres controlar qué ajustes aparecen, convierte tus nodos principales en un subgrafo y luego usa la promoción de widgets en la barra de herramientas sobre él para elegir qué se expone."
|
||||
"title": "Bienvenido al Modo App"
|
||||
}
|
||||
},
|
||||
"load3d": {
|
||||
@@ -1818,11 +1889,18 @@
|
||||
"showLinks": "Mostrar enlaces"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"customModelsInstruction": "Tendrás que encontrarlos y descargarlos manualmente. Búscalos en línea (prueba Civitai o Hugging Face) o contacta al proveedor original del flujo de trabajo.",
|
||||
"customModelsWarning": "Algunos de estos son modelos personalizados que no reconocemos.",
|
||||
"description": "Este flujo de trabajo requiere modelos que aún no has descargado.",
|
||||
"doNotAskAgain": "No mostrar esto de nuevo",
|
||||
"missingModels": "Modelos faltantes",
|
||||
"missingModelsMessage": "Al cargar el gráfico, no se encontraron los siguientes modelos",
|
||||
"downloadAll": "Descargar todo",
|
||||
"downloadAvailable": "Descargar disponibles",
|
||||
"footerDescription": "Descarga y coloca estos modelos en la carpeta correcta.\nLos nodos con modelos faltantes están resaltados en rojo en el lienzo.",
|
||||
"gotIt": "Entendido",
|
||||
"reEnableInSettings": "Vuelve a habilitar en {link}",
|
||||
"reEnableInSettingsLink": "Configuración"
|
||||
"reEnableInSettingsLink": "Configuración",
|
||||
"title": "Faltan modelos en este flujo de trabajo",
|
||||
"totalSize": "Tamaño total de descarga:"
|
||||
},
|
||||
"missingNodes": {
|
||||
"cloud": {
|
||||
@@ -1845,6 +1923,14 @@
|
||||
"tooltip": "Estás usando una versión nightly de ComfyUI. Por favor, utiliza el botón de comentarios para compartir tus opiniones sobre estas funciones."
|
||||
}
|
||||
},
|
||||
"nightlySurvey": {
|
||||
"accept": "¡Claro, ayudaré!",
|
||||
"description": "Has estado usando esta función. ¿Te tomarías un momento para compartir tus comentarios?",
|
||||
"dontAskAgain": "No preguntar de nuevo",
|
||||
"loadError": "No se pudo cargar la encuesta. Por favor, inténtalo de nuevo más tarde.",
|
||||
"notNow": "Ahora no",
|
||||
"title": "Ayúdanos a mejorar"
|
||||
},
|
||||
"nodeCategories": {
|
||||
"": "",
|
||||
"3d": "3d",
|
||||
@@ -2599,14 +2685,17 @@
|
||||
"cannotDeleteGlobal": "No se pueden eliminar los blueprints instalados",
|
||||
"confirmDelete": "Esta acción eliminará permanentemente el subgrafo de tu biblioteca",
|
||||
"confirmDeleteTitle": "¿Eliminar subgrafo?",
|
||||
"disconnected": "Desconectado",
|
||||
"enterDescription": "Introduce una descripción",
|
||||
"enterSearchAliases": "Introduce alias de búsqueda (separados por comas)",
|
||||
"hidden": "Parámetros ocultos/anidados",
|
||||
"hideAll": "Ocultar todo",
|
||||
"linked": "(Vinculado)",
|
||||
"loadFailure": "No se pudieron cargar los subgrafos",
|
||||
"overwriteBlueprint": "Guardar sobrescribirá el subgrafo actual con tus cambios",
|
||||
"overwriteBlueprintTitle": "¿Sobrescribir subgrafo existente?",
|
||||
"promoteOutsideSubgraph": "No se puede promocionar widget cuando no está en subgrafo",
|
||||
"promoteWidget": "Promocionar widget: {name}",
|
||||
"publish": "Publicar subgrafo",
|
||||
"publishSuccess": "Guardado en la biblioteca de nodos",
|
||||
"publishSuccessMessage": "Puedes encontrar tu subgrafo en la biblioteca de nodos bajo \"Subgraph Blueprints\"",
|
||||
@@ -2614,7 +2703,8 @@
|
||||
"searchAliases": "Buscar alias",
|
||||
"showAll": "Mostrar todo",
|
||||
"showRecommended": "Mostrar widgets recomendados",
|
||||
"shown": "Mostrado en el nodo"
|
||||
"shown": "Mostrado en el nodo",
|
||||
"unpromoteWidget": "Dejar de promocionar widget: {name}"
|
||||
},
|
||||
"subscription": {
|
||||
"addApiCredits": "Agregar créditos de API",
|
||||
@@ -2622,7 +2712,9 @@
|
||||
"addCreditsLabel": "Agrega más créditos cuando quieras",
|
||||
"benefits": {
|
||||
"benefit1": "Créditos mensuales para Nodos de Socio — recarga cuando sea necesario",
|
||||
"benefit2": "Hasta 30 min de tiempo de ejecución por trabajo"
|
||||
"benefit1FreeTier": "Más créditos mensuales, recarga en cualquier momento",
|
||||
"benefit2": "Hasta 30 min de tiempo de ejecución por trabajo",
|
||||
"benefit3": "Usa tus propios modelos (Creator & Pro)"
|
||||
},
|
||||
"beta": "BETA",
|
||||
"billedMonthly": "Facturado mensualmente",
|
||||
@@ -2660,6 +2752,21 @@
|
||||
"description": "Elige el mejor plan para ti",
|
||||
"descriptionWorkspace": "Elige el mejor plan para tu espacio de trabajo",
|
||||
"expiresDate": "Caduca el {date}",
|
||||
"freeTier": {
|
||||
"description": "Tu plan gratuito incluye {credits} créditos cada mes para probar Comfy Cloud.",
|
||||
"descriptionGeneric": "Tu plan gratuito incluye una asignación mensual de créditos para probar Comfy Cloud.",
|
||||
"nextRefresh": "Tus créditos se renovarán el {date}.",
|
||||
"outOfCredits": {
|
||||
"subtitle": "Suscríbete para desbloquear recargas y más",
|
||||
"title": "Te has quedado sin créditos gratuitos"
|
||||
},
|
||||
"subscribeCta": "Suscríbete para más",
|
||||
"title": "Estás en el plan gratuito",
|
||||
"topUpBlocked": {
|
||||
"title": "Desbloquea recargas y más"
|
||||
},
|
||||
"upgradeCta": "Ver planes"
|
||||
},
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
"haveQuestions": "¿Tienes preguntas o buscas soluciones empresariales?",
|
||||
"invoiceHistory": "Historial de facturas",
|
||||
@@ -2670,6 +2777,7 @@
|
||||
"maxDuration": {
|
||||
"creator": "30 min",
|
||||
"founder": "30 min",
|
||||
"free": "30 min",
|
||||
"pro": "1 h",
|
||||
"standard": "30 min"
|
||||
},
|
||||
@@ -2742,6 +2850,9 @@
|
||||
"founder": {
|
||||
"name": "Edición Fundador"
|
||||
},
|
||||
"free": {
|
||||
"name": "Gratis"
|
||||
},
|
||||
"pro": {
|
||||
"name": "Pro"
|
||||
},
|
||||
@@ -2852,6 +2963,7 @@
|
||||
"emptyCanvas": "Lienzo vacío",
|
||||
"errorCopyImage": "Error al copiar la imagen: {error}",
|
||||
"errorLoadingModel": "Error al cargar el modelo",
|
||||
"errorOpenImage": "Error al abrir la imagen: {error}",
|
||||
"errorSaveSetting": "Error al guardar la configuración {id}: {err}",
|
||||
"exportSuccess": "Modelo exportado exitosamente como {format}",
|
||||
"failedExecutionPathResolution": "No se pudo resolver la ruta a los nodos seleccionados",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user