Files
ComfyUI_frontend/src/renderer/extensions/vueNodes/widgets/components/WidgetFileUpload.vue
Johnpaul Chiwetelu 265f1257e7 Updated node tokens (#6569)
This pull request updates the design system color tokens and refactors
node and widget component styles throughout the codebase to use new,
more consistent CSS variables. The changes ensure that node and widget
components are styled using unified design tokens, improving
maintainability and theme support for both light and dark modes.

**Design System Token Updates**

* Added new component and node-related CSS variables for background,
border, foreground, and widget states in both light and dark themes in
`style.css`.
[[1]](diffhunk://#diff-71b6b57a56095b04e47c797a5016149b76b27971cab04b93f033f1f846e0f5a0R246-R256)
[[2]](diffhunk://#diff-71b6b57a56095b04e47c797a5016149b76b27971cab04b93f033f1f846e0f5a0R354-R364)
* Introduced `--color-graphite-400` and adjusted several existing color
assignments for better palette consistency.
[[1]](diffhunk://#diff-71b6b57a56095b04e47c797a5016149b76b27971cab04b93f033f1f846e0f5a0R76)
[[2]](diffhunk://#diff-71b6b57a56095b04e47c797a5016149b76b27971cab04b93f033f1f846e0f5a0L304-R316)
* Updated semantic CSS variables to reference the new component/node
tokens for easier usage in components.
* Changed `--secondary-background-hover` to match
`--secondary-background` for improved hover consistency.

**Component Refactoring: Node and Widget Styles**

* Refactored Vue component classes and inline styles to use the new CSS
variables for node backgrounds, borders, and widget states, replacing
legacy variables like `bg-node-component-surface` and
`border-node-component-border` with `bg-component-node-background` and
`border-component-node-border`.
[[1]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2L11-R14)
[[2]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2L39-R39)
[[3]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2L384-R384)
[[4]](diffhunk://#diff-19537a67677431ecdc9aec43877d28814e37edf0e45b0b0b484ea08832cad299L5-R13)
* Updated widget dropdowns, select, and input components to use
`text-component-node-foreground-secondary` for icons and foregrounds,
and new background variables for buttons and inputs.
[[1]](diffhunk://#diff-489229f88dfdfd5d883a3ef7fad6effa0790a18a831d5a9d84642dfb246962a2L29-R29)
[[2]](diffhunk://#diff-489229f88dfdfd5d883a3ef7fad6effa0790a18a831d5a9d84642dfb246962a2L100-R100)
[[3]](diffhunk://#diff-661a09de2721335e118a693b25d09922ada0ccbd0a51284691ed784fbe18874eL13-R13)
[[4]](diffhunk://#diff-2856391d03b0d38db1ed922b5034a05bc32e978c51f8175057d84cf82399d986L13-R13)
[[5]](diffhunk://#diff-4ee47848821aff71b6da0a1bb7fb8976e7879d706f71ff2ab3c5b046f5ef528cL10-R10)
[[6]](diffhunk://#diff-8b7ed2ce6194a262fb1e950294699cb8722630920362143a765802b602ae5fc8L106-R113)
[[7]](diffhunk://#diff-8b7ed2ce6194a262fb1e950294699cb8722630920362143a765802b602ae5fc8L119-R123)
[[8]](diffhunk://#diff-597a77456bf4b0c2d390fc46a930f37156b2f26ca030259b6703e5d39ff6b20eL37-R53)
[[9]](diffhunk://#diff-29348fa2e5b8cec1301a99bdec241379aeefc1747cceeb0c39b7df452ca635ffL7-R7)

**Service Layer Updates**

* Updated the color palette service mapping to use the new CSS variable
names for node and widget colors, ensuring consistency across the
application.
* 



https://github.com/user-attachments/assets/d9535f9a-b459-49bf-b2fe-ed872916fa4e



These changes collectively modernize the styling approach for node and
widget components, making it easier to maintain and extend theme
support.

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-05 01:13:17 -07:00

327 lines
9.5 KiB
Vue

<template>
<!-- Replace entire widget with image preview when image is loaded -->
<!-- Edge-to-edge: -mx-2 removes the parent's p-2 (8px) padding on each side -->
<div
v-if="hasImageFile"
class="relative -mx-2"
style="width: calc(100% + 1rem)"
>
<!-- Select section above image -->
<div class="mb-2 flex items-center justify-between gap-4 px-2">
<label
v-if="widget.name"
class="text-secondary min-w-[4em] truncate text-xs"
>{{ widget.name }}</label
>
<!-- Group select and folder button together on the right -->
<div class="flex items-center gap-1">
<!-- TODO: finish once we finish value bindings with Litegraph -->
<Select
:model-value="selectedFile?.name"
:options="[selectedFile?.name || '']"
:disabled="true"
:aria-label="`${$t('g.selectedFile')}: ${selectedFile?.name || $t('g.none')}`"
v-bind="transformCompatProps"
class="max-w-[20em] min-w-[8em] text-xs"
size="small"
:pt="{
option: 'text-xs',
dropdownIcon: 'text-component-node-foreground-secondary'
}"
/>
<Button
icon="pi pi-folder"
size="small"
class="!h-8 !w-8"
@click="triggerFileInput"
/>
</div>
</div>
<!-- Image preview -->
<!-- TODO: change hardcoded colors when design system incorporated -->
<div class="group relative">
<img :src="imageUrl" :alt="selectedFile?.name" class="h-auto w-full" />
<!-- Darkening overlay on hover -->
<div
class="bg-opacity-0 group-hover:bg-opacity-20 pointer-events-none absolute inset-0 bg-black transition-all duration-200"
/>
<!-- Control buttons in top right on hover -->
<div
class="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
>
<!-- Edit button -->
<button
:aria-label="$t('g.editImage')"
class="flex h-6 w-6 items-center justify-center rounded border-none transition-all duration-150 focus:outline-none"
style="background-color: #262729"
@click="handleEdit"
>
<i class="pi pi-pencil text-xs text-white"></i>
</button>
<!-- Delete button -->
<button
:aria-label="$t('g.deleteImage')"
class="flex h-6 w-6 items-center justify-center rounded border-none transition-all duration-150 focus:outline-none"
style="background-color: #262729"
@click="clearFile"
>
<i class="pi pi-times text-xs text-white"></i>
</button>
</div>
</div>
</div>
<!-- Audio preview when audio file is loaded -->
<div
v-else-if="hasAudioFile"
class="relative -mx-2"
style="width: calc(100% + 1rem)"
>
<!-- Select section above audio player -->
<div class="mb-2 flex items-center justify-between gap-4 px-2">
<label
v-if="widget.name"
class="text-secondary min-w-[4em] truncate text-xs"
>{{ widget.name }}</label
>
<!-- Group select and folder button together on the right -->
<div class="flex items-center gap-1">
<Select
:model-value="selectedFile?.name"
:options="[selectedFile?.name || '']"
:disabled="true"
:aria-label="`${$t('g.selectedFile')}: ${selectedFile?.name || $t('g.none')}`"
v-bind="transformCompatProps"
class="max-w-[20em] min-w-[8em] text-xs"
size="small"
:pt="{
option: 'text-xs',
dropdownIcon: 'text-component-node-foreground-secondary'
}"
/>
<Button
icon="pi pi-folder"
size="small"
class="!h-8 !w-8"
@click="triggerFileInput"
/>
</div>
</div>
<!-- Audio player -->
<div class="group relative px-2">
<div
class="flex items-center gap-4 rounded-lg bg-charcoal-800 p-4"
style="border: 1px solid #262729"
>
<!-- Audio icon -->
<div class="flex-shrink-0">
<i class="pi pi-volume-up text-2xl opacity-60"></i>
</div>
<!-- File info and controls -->
<div class="flex-1">
<div class="mb-1 text-sm font-medium">{{ selectedFile?.name }}</div>
<div class="text-xs opacity-60">
{{
selectedFile ? (selectedFile.size / 1024).toFixed(1) + ' KB' : ''
}}
</div>
</div>
<!-- Control buttons -->
<div class="flex gap-1">
<!-- Delete button -->
<button
:aria-label="$t('g.deleteAudioFile')"
class="flex h-8 w-8 items-center justify-center rounded border-none transition-all duration-150 hover:bg-charcoal-600 focus:outline-none"
@click="clearFile"
>
<i class="pi pi-times text-sm text-white"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Show normal file upload UI when no image or audio is loaded -->
<div
v-else
class="flex w-full flex-col gap-1 rounded-lg border border-solid p-1"
:style="{ borderColor: '#262729' }"
>
<div
class="rounded-md border border-dashed p-1 transition-colors duration-200 hover:border-slate-300"
:style="{ borderColor: '#262729' }"
>
<div class="flex w-full flex-col items-center gap-2 py-4">
<span class="text-xs opacity-60"> {{ $t('Drop your file or') }} </span>
<div>
<Button
label="Browse Files"
size="small"
severity="secondary"
class="text-xs"
@click="triggerFileInput"
/>
</div>
</div>
</div>
</div>
<!-- Hidden file input always available for both states -->
<input
ref="fileInputRef"
type="file"
class="hidden"
:accept="widget.options?.accept"
:aria-label="`${$t('g.upload')} ${widget.name || $t('g.file')}`"
:multiple="false"
@change="handleFileChange"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Select from 'primevue/select'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const { widget, modelValue } = defineProps<{
widget: SimplifiedWidget<File[] | null>
modelValue: File[] | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: File[] | null]
}>()
const { localValue, onChange } = useWidgetValue({
widget,
modelValue,
defaultValue: null,
emit
})
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
const fileInputRef = ref<HTMLInputElement | null>(null)
// Since we only support single file, get the first file
const selectedFile = computed(() => {
const files = localValue.value || []
return files.length > 0 ? files[0] : null
})
// Quick file type detection for testing
const detectFileType = (file: File) => {
const type = file.type?.toLowerCase() || ''
const name = file.name?.toLowerCase() || ''
if (
type.startsWith('image/') ||
name.match(/\.(jpg|jpeg|png|gif|webp|svg)$/)
) {
return 'image'
}
if (type.startsWith('video/') || name.match(/\.(mp4|webm|ogg|mov)$/)) {
return 'video'
}
if (type.startsWith('audio/') || name.match(/\.(mp3|wav|ogg|flac)$/)) {
return 'audio'
}
if (type === 'application/pdf' || name.endsWith('.pdf')) {
return 'pdf'
}
if (type.includes('zip') || name.match(/\.(zip|rar|7z|tar|gz)$/)) {
return 'archive'
}
return 'file'
}
// Check if we have an image file
const hasImageFile = computed(() => {
return selectedFile.value && detectFileType(selectedFile.value) === 'image'
})
// Check if we have an audio file
const hasAudioFile = computed(() => {
return selectedFile.value && detectFileType(selectedFile.value) === 'audio'
})
// Get image URL for preview
const imageUrl = computed(() => {
if (hasImageFile.value && selectedFile.value) {
return URL.createObjectURL(selectedFile.value)
}
return ''
})
// // Get audio URL for playback
// const audioUrl = computed(() => {
// if (hasAudioFile.value && selectedFile.value) {
// return URL.createObjectURL(selectedFile.value)
// }
// return ''
// })
// Clean up image URL when file changes
watch(imageUrl, (newUrl, oldUrl) => {
if (oldUrl && oldUrl !== newUrl) {
URL.revokeObjectURL(oldUrl)
}
})
const triggerFileInput = () => {
fileInputRef.value?.click()
}
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files.length > 0) {
// Since we only support single file, take the first one
const file = target.files[0]
// Use the composable's onChange handler with an array
onChange([file])
// Reset input to allow selecting same file again
target.value = ''
}
}
const clearFile = () => {
// Clear the file
onChange(null)
// Reset file input
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
const handleEdit = () => {
// TODO: hook up with maskeditor
}
// Clear file input when value is cleared externally
watch(localValue, (newValue) => {
if (!newValue || newValue.length === 0) {
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
})
// Clean up image URL on unmount
onUnmounted(() => {
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value)
}
})
</script>