mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-10 18:10:08 +00:00
This pull request introduces a comprehensive update to the design system's color management, focusing on establishing semantic color tokens for both light and dark themes. It replaces many hardcoded color values and legacy CSS classes throughout the codebase with new semantic CSS variables, ensuring consistent theming and easier future maintenance. The changes affect core CSS files as well as numerous Vue components, aligning their styling with the new design system. **Design System Foundation** * Added a wide range of new color variables to `style.css`, including base colors (e.g., `--color-white`, `--color-black`), additional shades for sand, azure, cobalt, gold, coral, and magenta, and new alpha (transparency) colors. [[1]](diffhunk://#diff-71b6b57a56095b04e47c797a5016149b76b27971cab04b93f033f1f846e0f5a0L52-R87) [[2]](diffhunk://#diff-71b6b57a56095b04e47c797a5016149b76b27971cab04b93f033f1f846e0f5a0R119-R123) * Introduced semantic color tokens for both light and dark modes (`--base-background`, `--primary-background`, `--destructive-background`, etc.), mapping them to the new base colors for consistent usage across the application. [[1]](diffhunk://#diff-71b6b57a56095b04e47c797a5016149b76b27971cab04b93f033f1f846e0f5a0R219-R239) [[2]](diffhunk://#diff-71b6b57a56095b04e47c797a5016149b76b27971cab04b93f033f1f846e0f5a0R301-R321) * Exposed semantic tokens as CSS variables (e.g., `--color-base-foreground`, `--color-secondary-background`) for use throughout the app. **Component Refactoring to Semantic Tokens** * Updated Vue components and their tests to use the new semantic color classes (e.g., `bg-base-background`, `text-base-foreground`, `bg-secondary-background`) instead of hardcoded colors or legacy dark-theme classes. This affects components such as `WorkflowTemplateSelectorDialog.vue`, `BypassButton.vue`, `ExecuteButton.vue`, `MenuOptionItem.vue`, `AssetCard.vue`, `MediaAssetMoreMenu.vue`, `MediaTitle.vue`, `WidgetFileUpload.vue`, `WidgetRecordAudio.vue`, `AudioPreviewPlayer.vue`, and `FormDropdownMenuActions.vue`. [[1]](diffhunk://#diff-2c860bdc48e907b1b85dbef846599d8376dd02cff90f49e490eebe61371fecedL149-R149) [[2]](diffhunk://#diff-8ec606ef1100f3a56945ed24cbdc1965050932cd744d4172a3868cdfd23894c0L95-R95) [[3]](diffhunk://#diff-80b781aeba31712968ae157bb70194e4b72bc73430d1cca6a79d718d839daed6L10-R10) [[4]](diffhunk://#diff-55fd9056d35e50249dc9f2280017dc99294221fdbe56d8399cea60f8bac499b5L7-R7) [[5]](diffhunk://#diff-c5e6830e63e2441d2dc70d2ecf7c9b56d0a93821f827e9c5377fc10ae6016f18L30-R32) [[6]](diffhunk://#diff-a1091d53a4b5d493e045aab5960188d2e7c3b80002e7178427268835fadb5809L30-R30) [[7]](diffhunk://#diff-ccdb389a5e355d525dcfa26ecd77519297b6232dd34522411c8bfdd4cde05a1cL6-R6) [[8]](diffhunk://#diff-7ef9ebd48e6f38a644c1a4e7bae1c7bb818bb959b2d20985974824e299ea5c34L3-R3) [[9]](diffhunk://#diff-489229f88dfdfd5d883a3ef7fad6effa0790a18a831d5a9d84642dfb246962a2L115-R115) [[10]](diffhunk://#diff-7bee4b453fc869f546e7150a6e39992ab6442987f80c10f8260b8f3715491997L51-R51) [[11]](diffhunk://#diff-29348fa2e5b8cec1301a99bdec241379aeefc1747cceeb0c39b7df452ca635ffL41-R41) [[12]](diffhunk://#diff-d464ebe3a44bec4fda7155e5605bf173612aca409250b7ef6b78920a89ae2044L24-R24) **Consistency and Maintainability** * Ensured hover, active, and selected states use semantic background and foreground colors for both light and dark themes, improving visual consistency and simplifying future updates. [[1]](diffhunk://#diff-2c860bdc48e907b1b85dbef846599d8376dd02cff90f49e490eebe61371fecedL183-R183) [[2]](diffhunk://#diff-a1091d53a4b5d493e045aab5960188d2e7c3b80002e7178427268835fadb5809L115-R114) [[3]](diffhunk://#diff-ccdb389a5e355d525dcfa26ecd77519297b6232dd34522411c8bfdd4cde05a1cL18-R18) [[4]](diffhunk://#diff-ccdb389a5e355d525dcfa26ecd77519297b6232dd34522411c8bfdd4cde05a1cL29-R29) [[5]](diffhunk://#diff-ccdb389a5e355d525dcfa26ecd77519297b6232dd34522411c8bfdd4cde05a1cL43-R43) [[6]](diffhunk://#diff-ccdb389a5e355d525dcfa26ecd77519297b6232dd34522411c8bfdd4cde05a1cL55-R55) [[7]](diffhunk://#diff-ccdb389a5e355d525dcfa26ecd77519297b6232dd34522411c8bfdd4cde05a1cL69-R69) [[8]](diffhunk://#diff-ccdb389a5e355d525dcfa26ecd77519297b6232dd34522411c8bfdd4cde05a1cL83-R83) [[9]](diffhunk://#diff-2c860bdc48e907b1b85dbef846599d8376dd02cff90f49e490eebe61371fecedL328-R328) [[10]](diffhunk://#diff-489229f88dfdfd5d883a3ef7fad6effa0790a18a831d5a9d84642dfb246962a2L138-R138) [[11]](diffhunk://#diff-29348fa2e5b8cec1301a99bdec241379aeefc1747cceeb0c39b7df452ca635ffL119-R119) [[12]](diffhunk://#diff-29348fa2e5b8cec1301a99bdec241379aeefc1747cceeb0c39b7df452ca635ffL137-R137) [[13]](diffhunk://#diff-d464ebe3a44bec4fda7155e5605bf173612aca409250b7ef6b78920a89ae2044L53-R53) [[14]](diffhunk://#diff-d464ebe3a44bec4fda7155e5605bf173612aca409250b7ef6b78920a89ae2044L153-R153) **Removal of Legacy Styles** * Removed legacy dark-theme class usage and hardcoded color values, replacing them with semantic tokens to unify the styling approach. [[1]](diffhunk://#diff-c5e6830e63e2441d2dc70d2ecf7c9b56d0a93821f827e9c5377fc10ae6016f18L30-R32) [[2]](diffhunk://#diff-d464ebe3a44bec4fda7155e5605bf173612aca409250b7ef6b78920a89ae2044L24-R24) [[3]](diffhunk://#diff-d464ebe3a44bec4fda7155e5605bf173612aca409250b7ef6b78920a89ae2044L53-R53) [[4]](diffhunk://#diff-d464ebe3a44bec4fda7155e5605bf173612aca409250b7ef6b78920a89ae2044L153-R153) These changes lay the groundwork for a scalable and maintainable design system, making it easier to implement future theme changes and ensuring a consistent look and feel across all components.
327 lines
9.5 KiB
Vue
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-button-icon'
|
|
}"
|
|
/>
|
|
<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-button-icon'
|
|
}"
|
|
/>
|
|
<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>
|