feat: add WidgetSelectToggle and enhance webcam widget with dynamic controls

Implements a proper Vue toggle widget component and enhances the webcam widget to dynamically show/hide related controls based on camera state, with automatic restoration on component unmount.
This commit is contained in:
Johnpaul
2025-11-21 20:06:23 +01:00
parent 450b8b9954
commit 0ac768722c
3 changed files with 171 additions and 2 deletions

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | boolean>
}>()
const modelValue = defineModel<string | number | boolean>({ required: true })
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
interface ToggleOption {
label: string
value: string | number | boolean
}
const options = computed<ToggleOption[]>(() => {
// Get options from widget spec or widget options
const widgetOptions = props.widget.options?.values || props.widget.spec?.[0]
if (Array.isArray(widgetOptions)) {
// If options are strings/numbers, convert to {label, value} format
return widgetOptions.map((opt) => {
if (
typeof opt === 'object' &&
opt !== null &&
'label' in opt &&
'value' in opt
) {
return opt as ToggleOption
}
return { label: String(opt), value: opt }
})
}
// Default options for boolean widgets
if (typeof modelValue.value === 'boolean') {
return [
{ label: 'On', value: true },
{ label: 'Off', value: false }
]
}
// Fallback default options
return [
{ label: 'Yes', value: true },
{ label: 'No', value: false }
]
})
function handleSelect(value: string | number | boolean) {
modelValue.value = value
}
</script>
<template>
<WidgetLayoutField :widget>
<div
v-bind="filteredProps"
:class="cn(WidgetInputBaseClass, 'flex gap-0.5 p-0.5 w-full')"
role="group"
:aria-label="widget.name"
>
<button
v-for="option in options"
:key="String(option.value)"
type="button"
:class="
cn(
'flex-1 px-2 py-1 text-xs font-medium rounded transition-all duration-150',
'bg-transparent border-none',
'focus:outline-none',
modelValue === option.value
? 'bg-interface-menu-component-surface-selected text-primary'
: 'text-secondary hover:bg-interface-menu-component-surface-hovered'
)
"
:aria-pressed="modelValue === option.value"
@click="handleSelect(option.value)"
>
{{ option.label }}
</button>
</div>
</WidgetLayoutField>
</template>

View File

@@ -7,7 +7,6 @@
@click="handleTurnOnCamera"
>
{{ t('g.turnOnCamera', 'Turn on Camera') }}
<i-lucide:video class="ml-1" />
</Button>
</div>
<LODFallback />
@@ -16,10 +15,11 @@
<script setup lang="ts">
import { Button } from 'primevue'
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
import { app } from '@/scripts/app'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -32,11 +32,22 @@ const props = defineProps<{
const isCameraOn = ref(false)
// Store original widget states for restoration
const originalWidgets = ref<IBaseWidget[]>([])
const litegraphNode = computed(() => {
if (!props.nodeId || !app.rootGraph) return null
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
})
function storeOriginalWidgets() {
const node = litegraphNode.value
if (!node?.widgets) return
// Deep clone the original widgets to preserve their state
originalWidgets.value = [...node.widgets]
}
function hideWidgets() {
const node = litegraphNode.value
if (!node?.widgets) return
@@ -48,6 +59,24 @@ function hideWidgets() {
)
if (shouldHide) {
// Special handling for capture_on_queue widget
if (widget.name === 'capture_on_queue') {
return {
...widget,
type: 'selectToggle',
label: 'Capture Image',
value: widget.value ?? false,
options: {
...widget.options,
hidden: true,
values: [
{ label: 'On Run', value: true },
{ label: 'Manually', value: false }
]
}
}
}
return {
...widget,
options: {
@@ -62,6 +91,14 @@ function hideWidgets() {
node.widgets = newWidgets
}
function restoreWidgets() {
const node = litegraphNode.value
if (!node?.widgets || originalWidgets.value.length === 0) return
// Restore the original widgets
node.widgets = originalWidgets.value
}
function showWidgets() {
const node = litegraphNode.value
if (!node?.widgets) return
@@ -73,6 +110,24 @@ function showWidgets() {
)
if (shouldShow) {
// Special handling for capture_on_queue widget
if (widget.name === 'capture_on_queue') {
return {
...widget,
type: 'selectToggle',
label: 'Capture Image',
value: widget.value ?? false,
options: {
...widget.options,
hidden: false,
values: [
{ label: 'On Run', value: true },
{ label: 'Manually', value: false }
]
}
}
}
return {
...widget,
options: {
@@ -108,7 +163,13 @@ async function handleTurnOnCamera() {
}
onMounted(() => {
// Store original widget states before modifying them
storeOriginalWidgets()
// Hide all widgets initially until camera is turned on
hideWidgets()
})
onUnmounted(() => {
restoreWidgets()
})
</script>

View File

@@ -54,6 +54,9 @@ const WidgetAudioUI = defineAsyncComponent(
const WidgetWebcam = defineAsyncComponent(
() => import('../components/WidgetWebcam.vue')
)
const WidgetSelectToggle = defineAsyncComponent(
() => import('../components/WidgetSelectToggle.vue')
)
const Load3D = defineAsyncComponent(
() => import('@/components/load3d/Load3D.vue')
)
@@ -164,6 +167,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
essential: false
}
],
[
'selectToggle',
{
component: WidgetSelectToggle,
aliases: ['SELECT_TOGGLE'],
essential: false
}
],
['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }]
]