Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6f4c335
WIP fix record audio button color token
Myestery Nov 19, 2025
74aadb3
feat: add webcam widget with camera control and dynamic visibility fo…
Myestery Nov 21, 2025
450b8b9
feat: add new webcam widget Vue component.
Myestery Nov 21, 2025
0ac7687
feat: add WidgetSelectToggle and enhance webcam widget with dynamic c…
Myestery Nov 21, 2025
7fc51c6
fix: prevent Vue reactivity from breaking LiteGraph widget private fi…
Myestery Nov 24, 2025
a65b063
feat: add live camera preview with stop-on-hover to webcam widget
Myestery Nov 24, 2025
e1693dc
feat: add capture button widget to webcam component
Myestery Nov 25, 2025
a455c7b
feat: add iconClass option to widget interface
Myestery Nov 25, 2025
48c21f6
feat: enhance WidgetButton component with icon support
Myestery Nov 25, 2025
ca335ba
Merge branch 'main' into webcam-capture
Myestery Nov 25, 2025
16b6b5b
Merge remote-tracking branch 'origin/main' into webcam-capture
Myestery Nov 25, 2025
59761eb
feat: Add 'Capture photo' button with icon to webcam widget and updat…
Myestery Nov 25, 2025
f9b7e51
feat: enable webcam image capture, display captured image, and manage…
Myestery Nov 25, 2025
ad85956
feat: set default width and height values for webcam widgets if not a…
Myestery Nov 25, 2025
dc60b54
feat: Implement image widget serialization to support auto-capture on…
Myestery Nov 25, 2025
cfbb4c7
fix: preserve widget object identity for proper reactivity in webcam …
Myestery Nov 26, 2025
4b62872
feat: hide capture button when 'On Run' mode is selected in webcam wi…
Myestery Nov 26, 2025
68b6159
feat: add refreshVueWidgets to sync widget changes with Vue state
Myestery Nov 26, 2025
cd5f6fd
feat: restart camera when switching to 'On Run' mode in webcam widget
Myestery Nov 26, 2025
af8ad95
fix: hide widgets on mount and improve stop button styling
Myestery Nov 26, 2025
f8ede78
Merge branch 'main' into webcam-capture
Myestery Nov 26, 2025
335b72b
Fix linting
Myestery Nov 26, 2025
f7f0c05
fix(security): properly cleanup MediaStream on camera access failure
Myestery Nov 27, 2025
7b109df
refactor: extract magic numbers into named constants
Myestery Nov 27, 2025
8031a83
fix: prevent race condition in camera initialization
Myestery Nov 27, 2025
51ab154
fix: properly cleanup video event listeners on unmount
Myestery Nov 27, 2025
e092986
apply review comments
Myestery Nov 27, 2025
1e384c6
refactor: use vue-i18n for toggle option labels
Myestery Nov 27, 2025
1c37a36
refactor: use instance-specific canvas and video elements
Myestery Nov 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/composables/graph/useGraphNodeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ export interface GraphNodeManager {
// Access to original LiteGraph nodes (non-reactive)
getNode(id: string): LGraphNode | undefined

// Update widget options (e.g., hidden, disabled) - triggers Vue reactivity
updateVueWidgetOptions(
nodeId: string,
widgetName: string,
options: Record<string, unknown>
): void

// Refresh Vue widgets from LiteGraph node - use after modifying node.widgets
refreshVueWidgets(nodeId: string): void

// Lifecycle methods
cleanup(): void
}
Expand Down Expand Up @@ -298,6 +308,67 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}

/**
* Updates Vue state when widget options change (e.g., hidden, disabled)
*/
const updateVueWidgetOptions = (
nodeId: string,
widgetName: string,
options: Record<string, unknown>
): void => {
try {
const currentData = vueNodeData.get(nodeId)
if (!currentData?.widgets) return

const updatedWidgets = currentData.widgets.map((w) =>
w.name === widgetName
? { ...w, options: { ...w.options, ...options } }
: w
)
// Create a completely new object to ensure Vue reactivity triggers
const updatedData = {
...currentData,
widgets: updatedWidgets
}

vueNodeData.set(nodeId, updatedData)
} catch (error) {
console.error('Error updating widget options:', error)
}
}

/**
* Refreshes Vue widget state from LiteGraph node widgets.
* Use this after directly modifying node.widgets to sync Vue state.
*/
const refreshVueWidgets = (nodeId: string): void => {
try {
const node = nodeRefs.get(nodeId)
const currentData = vueNodeData.get(nodeId)
if (!node || !currentData) return

// Re-extract widgets from node
const slotMetadata = new Map<string, WidgetSlotMetadata>()
node.inputs?.forEach((input, index) => {
if (!input?.widget?.name) return
slotMetadata.set(input.widget.name, {
index,
linked: input.link != null
})
})

const freshWidgets =
node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []

vueNodeData.set(nodeId, {
...currentData,
widgets: freshWidgets
})
} catch (error) {
console.error('Error refreshing Vue widgets:', error)
}
}

/**
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
*/
Expand Down Expand Up @@ -624,6 +695,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
return {
vueNodeData,
getNode,
updateVueWidgetOptions,
refreshVueWidgets,
cleanup
}
}
15 changes: 15 additions & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@
"save": "Save",
"saving": "Saving",
"no": "No",
"yes": "Yes",
"on": "On",
"off": "Off",
"cancel": "Cancel",
"close": "Close",
"pressKeysForNewBinding": "Press keys for new binding",
Expand Down Expand Up @@ -144,6 +147,18 @@
"control_before_generate": "control before generate",
"choose_file_to_upload": "choose file to upload",
"capture": "capture",
"capturePhoto": "Capture Photo",
"captureModeOnRun": "On Run",
"captureModeManual": "Manually",
"capturedImage": "Captured Image",
"retakePhoto": "Retake photo",
"clickToStopLivePreview": "Click to stop live preview",
"failedToCaptureImage": "Failed to capture image",
"noWebcamImageCaptured": "No webcam image captured",
"errorCapturingImage": "Error capturing image: {error}",
"unableToLoadWebcam": "Unable to load webcam: {error}",
"webcamRequiresTLS": "Unable to load webcam. TLS is required when not on localhost. Error: {error}",
"turnOnCamera": "Turn on Camera",
"nodes": "Nodes",
"community": "Community",
"all": "All",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ const processedWidgets = computed((): ProcessedWidget[] => {

for (const widget of widgets) {
// Skip if widget is in the hidden list for this node type
if (widget.options?.hidden) continue
if (widget.options?.hidden) {
continue
}
if (widget.options?.canvasOnly) continue
if (!widget.type) continue
if (!shouldRenderAsVue(widget)) continue
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col gap-1">
<Button
class="text-base-foreground w-full border-0 bg-component-node-widget-background p-2"
class="text-base-foreground w-full border-0 bg-component-node-widget-background p-2 my-2"
v-bind="filteredProps"
:aria-label="widget.label"
size="small"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed } from 'vue'

import { t } from '@/i18n'
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: t('g.on', 'On'), value: true },
{ label: t('g.off', 'Off'), value: false }
]
}

// Fallback default options
return [
{ label: t('g.yes', 'Yes'), value: true },
{ label: t('g.no', '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-base-foreground'
: 'text-muted-foreground hover:bg-interface-menu-component-surface-hovered'
)
"
:aria-pressed="modelValue === option.value"
@click="handleSelect(option.value)"
>
{{ option.label }}
</button>
</div>
</WidgetLayoutField>
</template>
Loading
Loading