mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 09:57:33 +00:00
Compare commits
11 Commits
command-bo
...
vue-node-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3166acb825 | ||
|
|
7e153cf10b | ||
|
|
e488b2abce | ||
|
|
20e4427602 | ||
|
|
33e99da325 | ||
|
|
a7461c49c7 | ||
|
|
102590c2c2 | ||
|
|
928dfc6b8e | ||
|
|
593ac576da | ||
|
|
0858356dcf | ||
|
|
471018a962 |
53
.claude/commands/create-widget.md
Normal file
53
.claude/commands/create-widget.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Create a Vue Widget for ComfyUI
|
||||
|
||||
Your task is to create a new Vue widget for ComfyUI based on the widget specification: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
Follow the comprehensive guide in `vue-widget-conversion/vue-widget-guide.md` to create the widget. This guide contains step-by-step instructions, examples from actual PRs, and best practices.
|
||||
|
||||
### Key Steps to Follow:
|
||||
|
||||
1. **Understand the Widget Type**
|
||||
- Analyze what type of widget is needed: $ARGUMENTS
|
||||
- Identify the data type (string, number, array, object, etc.)
|
||||
- Determine if it needs special behaviors (execution state awareness, dynamic management, etc.)
|
||||
|
||||
2. **Component Creation**
|
||||
- Create Vue component in `src/components/graph/widgets/`
|
||||
- REQUIRED: Use PrimeVue components (reference `vue-widget-conversion/primevue-components.md`)
|
||||
- Use Composition API with `<script setup>`
|
||||
- Implement proper v-model binding with `defineModel`
|
||||
|
||||
3. **Composable Pattern**
|
||||
- Always create widget constructor composable in `src/composables/widgets/`
|
||||
- Only create node-level composable in `src/composables/node/` if the widget needs dynamic management
|
||||
- Follow the dual composable pattern explained in the guide
|
||||
|
||||
4. **Registration**
|
||||
- Register in `src/scripts/widgets.ts`
|
||||
- Use appropriate widget type name
|
||||
|
||||
5. **Testing**
|
||||
- Create unit tests for composables
|
||||
- Test with actual nodes that use the widget
|
||||
|
||||
### Important Requirements:
|
||||
|
||||
- **Always use PrimeVue components** - Check `vue-widget-conversion/primevue-components.md` for available components
|
||||
- Use TypeScript with proper types
|
||||
- Follow Vue 3 Composition API patterns
|
||||
- Use Tailwind CSS for styling (no custom CSS unless absolutely necessary)
|
||||
- Implement proper error handling and validation
|
||||
- Consider performance (use v-show vs v-if appropriately)
|
||||
|
||||
### Before Starting:
|
||||
|
||||
1. First read through the entire guide at `vue-widget-conversion/vue-widget-guide.md`
|
||||
2. Check existing widget implementations for similar patterns
|
||||
3. Identify which PrimeVue component(s) best fit the widget requirements
|
||||
|
||||
### Widget Specification to Implement:
|
||||
$ARGUMENTS
|
||||
|
||||
Begin by analyzing the widget requirements and proposing an implementation plan based on the guide.
|
||||
89
copy-widget-resources.sh
Executable file
89
copy-widget-resources.sh
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to copy vue-widget-conversion folder and .claude/commands/create-widget.md
|
||||
# to another local copy of the same repository
|
||||
|
||||
# Check if destination directory was provided
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <destination-repo-path>"
|
||||
echo "Example: $0 /home/c_byrne/projects/comfyui-frontend-testing/ComfyUI_frontend-clone-8"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the destination directory from first argument
|
||||
DEST_DIR="$1"
|
||||
|
||||
# Source files/directories (relative to script location)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SOURCE_WIDGET_DIR="$SCRIPT_DIR/vue-widget-conversion"
|
||||
SOURCE_COMMAND_FILE="$SCRIPT_DIR/.claude/commands/create-widget.md"
|
||||
|
||||
# Destination paths
|
||||
DEST_WIDGET_DIR="$DEST_DIR/vue-widget-conversion"
|
||||
DEST_COMMAND_DIR="$DEST_DIR/.claude/commands"
|
||||
DEST_COMMAND_FILE="$DEST_COMMAND_DIR/create-widget.md"
|
||||
|
||||
# Check if destination directory exists
|
||||
if [ ! -d "$DEST_DIR" ]; then
|
||||
echo "Error: Destination directory does not exist: $DEST_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if source vue-widget-conversion directory exists
|
||||
if [ ! -d "$SOURCE_WIDGET_DIR" ]; then
|
||||
echo "Error: Source vue-widget-conversion directory not found: $SOURCE_WIDGET_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if source command file exists
|
||||
if [ ! -f "$SOURCE_COMMAND_FILE" ]; then
|
||||
echo "Error: Source command file not found: $SOURCE_COMMAND_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Copying widget resources to: $DEST_DIR"
|
||||
|
||||
# Copy vue-widget-conversion directory
|
||||
echo "Copying vue-widget-conversion directory..."
|
||||
if [ -d "$DEST_WIDGET_DIR" ]; then
|
||||
echo " Warning: Destination vue-widget-conversion already exists. Overwriting..."
|
||||
rm -rf "$DEST_WIDGET_DIR"
|
||||
fi
|
||||
cp -r "$SOURCE_WIDGET_DIR" "$DEST_WIDGET_DIR"
|
||||
echo " ✓ Copied vue-widget-conversion directory"
|
||||
|
||||
# Create .claude/commands directory if it doesn't exist
|
||||
echo "Creating .claude/commands directory structure..."
|
||||
mkdir -p "$DEST_COMMAND_DIR"
|
||||
echo " ✓ Created .claude/commands directory"
|
||||
|
||||
# Copy create-widget.md command
|
||||
echo "Copying create-widget.md command..."
|
||||
cp "$SOURCE_COMMAND_FILE" "$DEST_COMMAND_FILE"
|
||||
echo " ✓ Copied create-widget.md command"
|
||||
|
||||
# Verify the copy was successful
|
||||
echo ""
|
||||
echo "Verification:"
|
||||
if [ -d "$DEST_WIDGET_DIR" ] && [ -f "$DEST_WIDGET_DIR/vue-widget-guide.md" ] && [ -f "$DEST_WIDGET_DIR/primevue-components.md" ]; then
|
||||
echo " ✓ vue-widget-conversion directory copied successfully"
|
||||
echo " - vue-widget-guide.md exists"
|
||||
echo " - primevue-components.md exists"
|
||||
if [ -f "$DEST_WIDGET_DIR/primevue-components.json" ]; then
|
||||
echo " - primevue-components.json exists"
|
||||
fi
|
||||
else
|
||||
echo " ✗ Error: vue-widget-conversion directory copy may have failed"
|
||||
fi
|
||||
|
||||
if [ -f "$DEST_COMMAND_FILE" ]; then
|
||||
echo " ✓ create-widget.md command copied successfully"
|
||||
else
|
||||
echo " ✗ Error: create-widget.md command copy may have failed"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Copy complete! Widget resources are now available in: $DEST_DIR"
|
||||
echo ""
|
||||
echo "You can now use the widget creation command in the destination repo:"
|
||||
echo " /project:create-widget <widget specification>"
|
||||
@@ -37,7 +37,8 @@
|
||||
<SelectionOverlay v-if="selectionToolboxEnabled">
|
||||
<SelectionToolbox />
|
||||
</SelectionOverlay>
|
||||
<DomWidgets />
|
||||
<DomWidgets v-if="!vueNodeRenderingEnabled" />
|
||||
<VueNodeOverlay v-if="vueNodeRenderingEnabled" />
|
||||
</template>
|
||||
<SubgraphBreadcrumb />
|
||||
</template>
|
||||
@@ -56,11 +57,13 @@ import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import VueNodeOverlay from '@/components/graph/nodes/VueNodeOverlay.vue'
|
||||
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useTestPhantomNodes } from '@/composables/nodeRendering/useTestPhantomNodes'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
|
||||
import { useCopy } from '@/composables/useCopy'
|
||||
@@ -110,6 +113,19 @@ const tooltipEnabled = computed(() => settingStore.get('Comfy.EnableTooltips'))
|
||||
const selectionToolboxEnabled = computed(() =>
|
||||
settingStore.get('Comfy.Canvas.SelectionToolbox')
|
||||
)
|
||||
// Temporarily enable Vue node rendering for testing
|
||||
const vueNodeRenderingEnabled = computed(() => true)
|
||||
|
||||
// Use test helper for automatic phantom mode enabling
|
||||
useTestPhantomNodes()
|
||||
|
||||
// Debug logging
|
||||
watchEffect(() => {
|
||||
console.log(
|
||||
'🖼️ GraphCanvas: Vue node rendering enabled:',
|
||||
vueNodeRenderingEnabled.value
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
@@ -277,6 +293,7 @@ onMounted(async () => {
|
||||
useWorkflowAutoSave()
|
||||
|
||||
comfyApp.vueAppReady = true
|
||||
console.log('🖼️ GraphCanvas: comfyApp.vueAppReady:', comfyApp.vueAppReady)
|
||||
|
||||
workspaceStore.spinner = true
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
@@ -310,6 +327,7 @@ onMounted(async () => {
|
||||
window.graph = comfyApp.graph
|
||||
|
||||
comfyAppReady.value = true
|
||||
console.log('🖼️ GraphCanvas: comfyAppReady:', comfyAppReady.value)
|
||||
|
||||
comfyApp.canvas.onSelectionChange = useChainCallback(
|
||||
comfyApp.canvas.onSelectionChange,
|
||||
|
||||
441
src/components/graph/nodes/VueNode.vue
Normal file
441
src/components/graph/nodes/VueNode.vue
Normal file
@@ -0,0 +1,441 @@
|
||||
<template>
|
||||
<div
|
||||
ref="nodeRef"
|
||||
class="_sb_node_preview vue-node"
|
||||
:style="nodeStyle"
|
||||
@mousedown="onMouseDown"
|
||||
@contextmenu="onContextMenu"
|
||||
>
|
||||
<div class="_sb_table">
|
||||
<!-- Node header - exactly like NodePreview -->
|
||||
<div
|
||||
class="node_header"
|
||||
:style="{
|
||||
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR as string,
|
||||
color: litegraphColors.NODE_TITLE_COLOR as string
|
||||
}"
|
||||
>
|
||||
<div class="_sb_dot headdot" />
|
||||
{{ (node as any).title }}
|
||||
</div>
|
||||
|
||||
<!-- Node slot I/O - using flexbox for proper positioning -->
|
||||
<div
|
||||
v-for="[slotInput, slotOutput] in slotPairs"
|
||||
:key="((slotInput as any)?.name || '') + ((slotOutput as any)?.name || '')"
|
||||
class="slot-row-flex"
|
||||
>
|
||||
<!-- Left side input slot -->
|
||||
<div class="slot-left" v-if="slotInput">
|
||||
<div :class="['_sb_dot', (slotInput as any)?.type]" />
|
||||
<span class="slot-text">{{ (slotInput as any)?.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Right side output slot -->
|
||||
<div
|
||||
class="slot-right"
|
||||
v-if="slotOutput"
|
||||
:style="{
|
||||
color: litegraphColors.NODE_TEXT_COLOR as string
|
||||
}"
|
||||
>
|
||||
<span class="slot-text">{{ (slotOutput as any)?.name }}</span>
|
||||
<div :class="['_sb_dot', (slotOutput as any)?.type]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Widgets using existing widget components -->
|
||||
<VueNodeBody
|
||||
:widgets="nodeWidgets"
|
||||
:node="node"
|
||||
@widget-change="onWidgetChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import _ from 'lodash'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { NodeInteractionEvent } from '@/composables/nodeRendering/useNodeInteractionProxy'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import VueNodeBody from './VueNodeBody.vue'
|
||||
|
||||
interface VueNodeProps {
|
||||
node: LGraphNode
|
||||
selected: boolean
|
||||
executing: boolean
|
||||
canvasScale: number
|
||||
canvasOffset: { x: number, y: number }
|
||||
updateTrigger?: number // Add update trigger to force reactivity
|
||||
}
|
||||
|
||||
const props = defineProps<VueNodeProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
interaction: [event: NodeInteractionEvent]
|
||||
}>()
|
||||
|
||||
const nodeRef = ref<HTMLElement>()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const litegraphColors = computed(
|
||||
() => colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
)
|
||||
|
||||
// Get canvas position conversion utilities
|
||||
const canvasPositionConversion = computed(() => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
if (!lgCanvas?.canvas) return null
|
||||
|
||||
return useCanvasPositionConversion(lgCanvas.canvas, lgCanvas)
|
||||
})
|
||||
|
||||
// Slot pairs - filter out inputs that have corresponding widgets
|
||||
const slotPairs = computed(() => {
|
||||
const allInputs = (props.node as any).inputs || []
|
||||
const outputs = (props.node as any).outputs || []
|
||||
|
||||
// Get widget names to filter out inputs that have widgets
|
||||
const nodeWidgetNames = new Set((props.node as any).widgets?.map((w: any) => w.name) || [])
|
||||
|
||||
// Only show inputs that DON'T have corresponding widgets
|
||||
const slotInputs = allInputs.filter((input: any) => !nodeWidgetNames.has(input.name))
|
||||
|
||||
return _.zip(slotInputs, outputs)
|
||||
})
|
||||
|
||||
// Extract widgets from the node
|
||||
const nodeWidgets = computed(() => {
|
||||
return (props.node as any).widgets || []
|
||||
})
|
||||
|
||||
// Dragging will be handled by LiteGraph's phantom node
|
||||
|
||||
// Node styling based on position and state - using proper canvas position conversion
|
||||
const nodeStyle = computed(() => {
|
||||
try {
|
||||
// Access update trigger to make this reactive to graph changes
|
||||
props.updateTrigger
|
||||
|
||||
const positionConverter = canvasPositionConversion.value
|
||||
if (!positionConverter) {
|
||||
console.warn('🚨 VueNode: No position converter available')
|
||||
return {
|
||||
position: 'fixed' as const,
|
||||
left: '100px',
|
||||
top: '100px',
|
||||
width: '200px',
|
||||
minHeight: '100px',
|
||||
backgroundColor: '#ff0000',
|
||||
border: '2px solid #ffffff',
|
||||
zIndex: 999
|
||||
}
|
||||
}
|
||||
|
||||
// Get node position and size in graph space
|
||||
const nodeAny = props.node as any
|
||||
const nodePos: [number, number] = [
|
||||
nodeAny.pos?.[0] ?? 0,
|
||||
nodeAny.pos?.[1] ?? 0
|
||||
]
|
||||
const nodeWidth = nodeAny.size?.[0] ?? 200
|
||||
const nodeHeight = nodeAny.size?.[1] ?? 100
|
||||
|
||||
// Convert from canvas coordinates to client coordinates (absolute positioning)
|
||||
const [clientX, clientY] = positionConverter.canvasPosToClientPos(nodePos)
|
||||
|
||||
// Get the current scale from the canvas
|
||||
const lgCanvas = canvasStore.canvas
|
||||
const scale = lgCanvas?.ds?.scale ?? 1
|
||||
|
||||
// Use original dimensions for positioning, apply scale via CSS transform
|
||||
const scaledWidth = nodeWidth
|
||||
const scaledHeight = nodeHeight
|
||||
|
||||
// Validate coordinates
|
||||
if (!isFinite(clientX) || !isFinite(clientY) || scaledWidth <= 0 || scaledHeight <= 0) {
|
||||
return {
|
||||
position: 'fixed' as const,
|
||||
left: '100px',
|
||||
top: '100px',
|
||||
width: '200px',
|
||||
minHeight: '100px',
|
||||
backgroundColor: '#ff0000',
|
||||
border: '2px solid #ffffff',
|
||||
zIndex: 999
|
||||
}
|
||||
}
|
||||
|
||||
// Use colors from palette for authentic LiteGraph appearance
|
||||
const nodeAnyForColors = props.node as any
|
||||
const bgColor = nodeAnyForColors.bgcolor || litegraphColors.value?.NODE_DEFAULT_BGCOLOR || '#353535'
|
||||
const borderColor = props.selected
|
||||
? litegraphColors.value?.NODE_BOX_OUTLINE_COLOR || '#FFF'
|
||||
: (nodeAnyForColors.boxcolor || litegraphColors.value?.NODE_DEFAULT_BOXCOLOR || '#666')
|
||||
|
||||
return {
|
||||
position: 'fixed' as const, // Use fixed positioning like other overlays
|
||||
left: `${clientX}px`,
|
||||
top: `${clientY}px`,
|
||||
minWidth: `${scaledWidth}px`,
|
||||
width: 'auto', // Allow width to expand for content
|
||||
minHeight: `${scaledHeight}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: '0 0', // Scale from top-left corner
|
||||
zIndex: props.selected ? 10 : 1,
|
||||
backgroundColor: bgColor,
|
||||
borderColor: borderColor,
|
||||
borderWidth: props.selected ? '2px' : '1px',
|
||||
borderStyle: 'solid',
|
||||
fontSize: `${litegraphColors.value?.NODE_TEXT_SIZE || 14}px`,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
position: 'fixed' as const,
|
||||
left: '100px',
|
||||
top: '100px',
|
||||
width: '200px',
|
||||
minHeight: '100px',
|
||||
backgroundColor: '#ff0000',
|
||||
border: '2px solid #ffffff',
|
||||
zIndex: 999
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Note: nodeClasses could be used for conditional CSS classes if needed
|
||||
|
||||
// Event handlers
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
// Check if the click is on a widget element
|
||||
const target = event.target as HTMLElement
|
||||
const isOnWidget = target.closest('.widget-content') !== null
|
||||
|
||||
// If clicking on a widget, don't emit the mouse down event for dragging
|
||||
if (isOnWidget) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('interaction', {
|
||||
type: 'mousedown',
|
||||
nodeId: String((props.node as any).id),
|
||||
originalEvent: event
|
||||
})
|
||||
}
|
||||
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
emit('interaction', {
|
||||
type: 'contextmenu',
|
||||
nodeId: String((props.node as any).id),
|
||||
originalEvent: event
|
||||
})
|
||||
}
|
||||
|
||||
// Note: onSlotInteraction and onTitleEdit available for future use
|
||||
|
||||
const onWidgetChange = (widgetIndex: number, value: any) => {
|
||||
const nodeAny = props.node as any
|
||||
if (nodeAny.widgets?.[widgetIndex]) {
|
||||
nodeAny.widgets[widgetIndex].value = value
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Copy ALL styles from NodePreview.vue exactly */
|
||||
.slot_row {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* Original N-Sidebar styles */
|
||||
._sb_dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: grey;
|
||||
}
|
||||
|
||||
.node_header {
|
||||
line-height: 1;
|
||||
padding: 8px 13px 7px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 15px;
|
||||
text-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.headdot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
float: inline-start;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.IMAGE {
|
||||
background-color: #64b5f6;
|
||||
}
|
||||
|
||||
.VAE {
|
||||
background-color: #ff6e6e;
|
||||
}
|
||||
|
||||
.LATENT {
|
||||
background-color: #ff9cf9;
|
||||
}
|
||||
|
||||
.MASK {
|
||||
background-color: #81c784;
|
||||
}
|
||||
|
||||
.CONDITIONING {
|
||||
background-color: #ffa931;
|
||||
}
|
||||
|
||||
.CLIP {
|
||||
background-color: #ffd500;
|
||||
}
|
||||
|
||||
.MODEL {
|
||||
background-color: #b39ddb;
|
||||
}
|
||||
|
||||
.CONTROL_NET {
|
||||
background-color: #a5d6a7;
|
||||
}
|
||||
|
||||
._sb_node_preview {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: small;
|
||||
color: var(--descrip-text);
|
||||
border: 1px solid var(--descrip-text);
|
||||
min-width: 200px;
|
||||
width: max-content; /* Allow expansion for wide content */
|
||||
height: fit-content;
|
||||
z-index: 9999;
|
||||
border-radius: 12px;
|
||||
overflow: visible; /* Allow content to be visible outside bounds */
|
||||
font-size: 12px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
._sb_node_preview ._sb_description {
|
||||
margin: 10px;
|
||||
padding: 6px;
|
||||
background: var(--border-color);
|
||||
border-radius: 5px;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
._sb_table {
|
||||
display: grid;
|
||||
grid-column-gap: 10px;
|
||||
/* Spazio tra le colonne */
|
||||
width: 100%;
|
||||
/* Imposta la larghezza della tabella al 100% del contenitore */
|
||||
}
|
||||
|
||||
._sb_row {
|
||||
display: grid;
|
||||
grid-template-columns: 10px 1fr 1fr 1fr 10px;
|
||||
grid-column-gap: 10px;
|
||||
align-items: center;
|
||||
padding-left: 9px;
|
||||
padding-right: 9px;
|
||||
}
|
||||
|
||||
._sb_row_string {
|
||||
grid-template-columns: 10px 1fr 1fr 10fr 1fr;
|
||||
}
|
||||
|
||||
._sb_col {
|
||||
border: 0 solid #000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
flex-wrap: nowrap;
|
||||
align-content: flex-start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
._sb_inherit {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
._long_field {
|
||||
background: var(--bg-color);
|
||||
border: 2px solid var(--border-color);
|
||||
margin: 5px 5px 0 5px;
|
||||
border-radius: 10px;
|
||||
line-height: 1.7;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
._sb_arrow {
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
._sb_preview_badge {
|
||||
text-align: center;
|
||||
background: var(--comfy-input-bg);
|
||||
font-weight: bold;
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
/* Additional styles for Vue node functionality */
|
||||
.vue-node {
|
||||
position: fixed; /* Use fixed positioning for proper overlay behavior */
|
||||
pointer-events: none; /* Let mouse events pass through to phantom nodes */
|
||||
}
|
||||
|
||||
.vue-node .widget-content {
|
||||
pointer-events: auto; /* Enable interaction with widgets only */
|
||||
}
|
||||
|
||||
.vue-node:hover {
|
||||
z-index: 10000; /* Bring to front on hover */
|
||||
}
|
||||
|
||||
.slot-text {
|
||||
font-size: 10px; /* Smaller font for slot labels */
|
||||
}
|
||||
|
||||
/* New flexbox slot layout */
|
||||
.slot-row-flex {
|
||||
position: relative;
|
||||
min-height: 20px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.slot-left {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.slot-right {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
79
src/components/graph/nodes/VueNodeBody.vue
Normal file
79
src/components/graph/nodes/VueNodeBody.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<!-- Render Vue component widgets only -->
|
||||
<div>
|
||||
<div
|
||||
v-for="widget in vueComponentWidgets"
|
||||
:key="`vue-widget-${widget.name}`"
|
||||
class="_sb_row _long_field"
|
||||
>
|
||||
<div class="_sb_col widget-content">
|
||||
<component
|
||||
:is="widget.component"
|
||||
:model-value="widget.value"
|
||||
:widget="widget"
|
||||
v-bind="widget.props"
|
||||
v-if="widgetsShouldShow"
|
||||
@update:model-value="updateWidgetValue(widget, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { BaseWidget } from '@comfyorg/litegraph'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { isComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
const widgetsShouldShow = ref(true)
|
||||
|
||||
app.api.addEventListener('graphChanged', () => {
|
||||
widgetsShouldShow.value = app.canvas.ds.scale > .55
|
||||
})
|
||||
|
||||
console.log('app.canvas.ds.scale', app.canvas.ds.scale)
|
||||
interface VueNodeBodyProps {
|
||||
widgets: BaseWidget[]
|
||||
node: LGraphNode
|
||||
}
|
||||
|
||||
const props = defineProps<VueNodeBodyProps>()
|
||||
|
||||
// Note: emit available for future widget change events if needed
|
||||
|
||||
// Get Vue component widgets only
|
||||
const vueComponentWidgets = computed(() => {
|
||||
return props.widgets.filter((widget: any) => isComponentWidget(widget))
|
||||
})
|
||||
|
||||
// Update widget value when component emits changes
|
||||
const updateWidgetValue = (widget: any, value: any) => {
|
||||
if (widget.options?.setValue) {
|
||||
widget.options.setValue(value)
|
||||
}
|
||||
// Also trigger the widget's callback if it exists
|
||||
if (widget.callback) {
|
||||
widget.callback(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: onWidgetChange available for future use if needed
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vue-node-body {
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.widget-container {
|
||||
/* Widget containers use flexbox for natural sizing */
|
||||
}
|
||||
|
||||
.legacy-widget {
|
||||
/* Styling for non-Vue widgets */
|
||||
border: 1px dashed #ccc;
|
||||
}
|
||||
</style>
|
||||
142
src/components/graph/nodes/VueNodeHeader.vue
Normal file
142
src/components/graph/nodes/VueNodeHeader.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div
|
||||
class="vue-node-header flex items-center justify-between px-3 py-2"
|
||||
:style="headerStyle"
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-grow">
|
||||
<!-- Collapse dot (like original LiteGraph) -->
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full cursor-pointer"
|
||||
:style="{ backgroundColor: dotColor }"
|
||||
@click="toggleCollapse"
|
||||
/>
|
||||
|
||||
<!-- Editable title -->
|
||||
<EditableText
|
||||
v-model="editableTitle"
|
||||
class="font-medium flex-grow"
|
||||
:style="titleStyle"
|
||||
@update:model-value="onTitleUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Node controls (minimized to match LiteGraph style) -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Pin indicator (small, unobtrusive) -->
|
||||
<div
|
||||
v-if="node.pinned"
|
||||
class="w-2 h-2 rounded-full"
|
||||
:style="{ backgroundColor: litegraphColors.NODE_TITLE_COLOR }"
|
||||
title="Pinned"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
|
||||
interface VueNodeHeaderProps {
|
||||
node: LGraphNode
|
||||
title: string
|
||||
nodeType?: string
|
||||
}
|
||||
|
||||
const props = defineProps<VueNodeHeaderProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'title-edit': [title: string]
|
||||
}>()
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const litegraphColors = computed(
|
||||
() => colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
)
|
||||
|
||||
// Local editable title
|
||||
const editableTitle = ref(props.title)
|
||||
|
||||
// Watch for external title changes
|
||||
watch(() => props.title, (newTitle) => {
|
||||
editableTitle.value = newTitle
|
||||
})
|
||||
|
||||
// Header styling to match LiteGraph
|
||||
const headerStyle = computed(() => {
|
||||
try {
|
||||
const headerColor = props.node.color || litegraphColors.value?.NODE_DEFAULT_COLOR || '#333'
|
||||
return {
|
||||
backgroundColor: headerColor,
|
||||
borderTopLeftRadius: '4px',
|
||||
borderTopRightRadius: '4px',
|
||||
fontSize: `${litegraphColors.value?.NODE_TEXT_SIZE || 14}px`,
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ VueNodeHeader: Error in headerStyle:', error)
|
||||
return {
|
||||
backgroundColor: '#333',
|
||||
borderTopLeftRadius: '4px',
|
||||
borderTopRightRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Title styling to match LiteGraph
|
||||
const titleStyle = computed(() => {
|
||||
try {
|
||||
const selected = (props.node as any).selected || false
|
||||
const titleColor = selected
|
||||
? litegraphColors.value?.NODE_SELECTED_TITLE_COLOR || '#FFF'
|
||||
: litegraphColors.value?.NODE_TITLE_COLOR || '#999'
|
||||
|
||||
return {
|
||||
color: titleColor,
|
||||
fontSize: `${litegraphColors.value?.NODE_TEXT_SIZE || 14}px`,
|
||||
fontWeight: 'normal',
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ VueNodeHeader: Error in titleStyle:', error)
|
||||
return {
|
||||
color: '#999',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'normal',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Dot color (collapse indicator)
|
||||
const dotColor = computed(() => {
|
||||
try {
|
||||
return litegraphColors.value?.NODE_TITLE_COLOR || '#999'
|
||||
} catch (error) {
|
||||
console.warn('⚠️ VueNodeHeader: Error in dotColor:', error)
|
||||
return '#999'
|
||||
}
|
||||
})
|
||||
|
||||
const onTitleUpdate = (newTitle: string) => {
|
||||
emit('title-edit', newTitle)
|
||||
}
|
||||
|
||||
const toggleCollapse = () => {
|
||||
// Use node collapse method instead of setting property directly
|
||||
if (props.node.collapse) {
|
||||
props.node.collapse()
|
||||
} else {
|
||||
// Fallback to manual property setting if method doesn't exist
|
||||
;(props.node as any).collapsed = !props.node.collapsed
|
||||
}
|
||||
// Trigger canvas redraw
|
||||
props.node.setDirtyCanvas?.(true, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
144
src/components/graph/nodes/VueNodeOverlay.vue
Normal file
144
src/components/graph/nodes/VueNodeOverlay.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div
|
||||
class="vue-node-overlay absolute inset-0 pointer-events-none overflow-hidden"
|
||||
>
|
||||
<VueNode
|
||||
v-for="node in phantomNodes"
|
||||
:key="node.id"
|
||||
:node="node"
|
||||
:selected="isNodeSelected(node.id)"
|
||||
:executing="isNodeExecuting(node.id)"
|
||||
:canvas-scale="canvasScale"
|
||||
:canvas-offset="canvasOffset"
|
||||
:update-trigger="graphUpdateTrigger"
|
||||
@interaction="handleNodeInteraction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
|
||||
import { useNodeInteractionProxy } from '@/composables/nodeRendering/useNodeInteractionProxy'
|
||||
import { api } from '@/scripts/api'
|
||||
import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
import VueNode from './VueNode.vue'
|
||||
|
||||
const { handleNodeInteraction } = useNodeInteractionProxy()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
// Reactive trigger for graph changes
|
||||
const graphUpdateTrigger = ref(0)
|
||||
|
||||
// Force update phantom nodes when graph changes
|
||||
const forceUpdate = () => {
|
||||
graphUpdateTrigger.value++
|
||||
}
|
||||
|
||||
// Get phantom nodes directly from canvas with reactive trigger
|
||||
const phantomNodes = computed(() => {
|
||||
// Access reactive trigger to ensure computed re-runs on graph changes
|
||||
graphUpdateTrigger.value
|
||||
|
||||
if (!canvasStore.canvas?.graph) {
|
||||
return []
|
||||
}
|
||||
|
||||
const allNodes = canvasStore.canvas.graph._nodes
|
||||
const phantomNodes = allNodes.filter(
|
||||
(node: any) => node.phantom_mode === true
|
||||
)
|
||||
|
||||
// Register widgets for phantom nodes if not already registered
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
phantomNodes.forEach((node: any) => {
|
||||
if (node.widgets) {
|
||||
node.widgets.forEach((widget: any) => {
|
||||
// Check if it's a DOM widget that needs registration
|
||||
if (
|
||||
(isDOMWidget(widget) || isComponentWidget(widget)) &&
|
||||
widget.id &&
|
||||
!domWidgetStore.widgetStates.has(widget.id)
|
||||
) {
|
||||
domWidgetStore.registerWidget(widget)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return phantomNodes
|
||||
})
|
||||
|
||||
// Simple animation frame updates - always running for smooth dragging
|
||||
let rafId: number | null = null
|
||||
|
||||
const startFrameUpdates = () => {
|
||||
const updateEveryFrame = () => {
|
||||
forceUpdate()
|
||||
rafId = requestAnimationFrame(updateEveryFrame)
|
||||
}
|
||||
updateEveryFrame()
|
||||
}
|
||||
|
||||
const stopFrameUpdates = () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for graph changes
|
||||
onMounted(() => {
|
||||
// Listen to API events for graph changes (now includes ds changes)
|
||||
api.addEventListener('graphChanged', forceUpdate)
|
||||
|
||||
// Start continuous frame updates for smooth dragging
|
||||
startFrameUpdates()
|
||||
|
||||
// Initial update
|
||||
forceUpdate()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
api.removeEventListener('graphChanged', forceUpdate)
|
||||
stopFrameUpdates()
|
||||
})
|
||||
|
||||
// Get canvas transform directly from canvas
|
||||
const canvasScale = computed(() => {
|
||||
return canvasStore.canvas?.ds?.scale || 1
|
||||
})
|
||||
|
||||
const canvasOffset = computed(() => {
|
||||
const canvas = canvasStore.canvas
|
||||
return {
|
||||
x: canvas?.ds?.offset?.[0] || 0,
|
||||
y: canvas?.ds?.offset?.[1] || 0
|
||||
}
|
||||
})
|
||||
|
||||
// Check if node is selected
|
||||
const isNodeSelected = (nodeId: string) => {
|
||||
return canvasStore.selectedItems.some(
|
||||
(item: any) => item.id === Number(nodeId)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if node is executing
|
||||
const isNodeExecuting = (nodeId: string) => {
|
||||
return executionStore.executingNodeId === Number(nodeId)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vue-node-overlay {
|
||||
/* Ensure overlay doesn't interfere with canvas interactions */
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
143
src/components/graph/nodes/VueNodeSlots.vue
Normal file
143
src/components/graph/nodes/VueNodeSlots.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="vue-node-slots">
|
||||
<!-- Input slots -->
|
||||
<div v-if="inputs.length > 0" class="inputs mb-2">
|
||||
<div
|
||||
v-for="(input, index) in inputs"
|
||||
:key="`input-${index}`"
|
||||
class="input-slot flex items-center gap-2 px-2 py-1 hover:bg-gray-50 dark-theme:hover:bg-gray-700"
|
||||
@click="onSlotClick(index, $event, 'input')"
|
||||
>
|
||||
<!-- Input connection point -->
|
||||
<div
|
||||
class="slot-connector w-3 h-3 rounded-full border-2 border-gray-400 bg-white dark-theme:bg-gray-800"
|
||||
:class="getSlotColor(input.type, 'input')"
|
||||
></div>
|
||||
|
||||
<!-- Input label -->
|
||||
<span class="text-sm text-gray-700 dark-theme:text-gray-300 flex-grow">
|
||||
{{ input.name || `Input ${index}` }}
|
||||
</span>
|
||||
|
||||
<!-- Input type badge -->
|
||||
<span
|
||||
v-if="input.type && input.type !== '*'"
|
||||
class="text-xs px-1 py-0.5 bg-gray-200 dark-theme:bg-gray-600 text-gray-600 dark-theme:text-gray-400 rounded"
|
||||
>
|
||||
{{ input.type }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output slots -->
|
||||
<div v-if="outputs.length > 0" class="outputs">
|
||||
<div
|
||||
v-for="(output, index) in outputs"
|
||||
:key="`output-${index}`"
|
||||
class="output-slot flex items-center gap-2 px-2 py-1 hover:bg-gray-50 dark-theme:hover:bg-gray-700"
|
||||
@click="onSlotClick(index, $event, 'output')"
|
||||
>
|
||||
<!-- Output type badge -->
|
||||
<span
|
||||
v-if="output.type && output.type !== '*'"
|
||||
class="text-xs px-1 py-0.5 bg-gray-200 dark-theme:bg-gray-600 text-gray-600 dark-theme:text-gray-400 rounded"
|
||||
>
|
||||
{{ output.type }}
|
||||
</span>
|
||||
|
||||
<!-- Output label -->
|
||||
<span class="text-sm text-gray-700 dark-theme:text-gray-300 flex-grow text-right">
|
||||
{{ output.name || `Output ${index}` }}
|
||||
</span>
|
||||
|
||||
<!-- Output connection point -->
|
||||
<div
|
||||
class="slot-connector w-3 h-3 rounded-full border-2 border-gray-400 bg-white dark-theme:bg-gray-800"
|
||||
:class="getSlotColor(output.type, 'output')"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { INodeInputSlot, INodeOutputSlot } from '@comfyorg/litegraph'
|
||||
|
||||
interface VueNodeSlotsProps {
|
||||
inputs: INodeInputSlot[]
|
||||
outputs: INodeOutputSlot[]
|
||||
}
|
||||
|
||||
const props = defineProps<VueNodeSlotsProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'slot-click': [slotIndex: number, event: MouseEvent]
|
||||
}>()
|
||||
|
||||
// Color mapping for different slot types
|
||||
const getSlotColor = (type: string | number | undefined, _direction: 'input' | 'output') => {
|
||||
if (!type || type === '*') {
|
||||
return 'border-gray-400'
|
||||
}
|
||||
|
||||
// Convert type to string for lookup
|
||||
const typeStr = String(type)
|
||||
|
||||
// Map common ComfyUI types to colors
|
||||
const typeColors: Record<string, string> = {
|
||||
'IMAGE': 'border-green-500 bg-green-100 dark-theme:bg-green-900',
|
||||
'LATENT': 'border-purple-500 bg-purple-100 dark-theme:bg-purple-900',
|
||||
'MODEL': 'border-blue-500 bg-blue-100 dark-theme:bg-blue-900',
|
||||
'CONDITIONING': 'border-yellow-500 bg-yellow-100 dark-theme:bg-yellow-900',
|
||||
'VAE': 'border-red-500 bg-red-100 dark-theme:bg-red-900',
|
||||
'CLIP': 'border-orange-500 bg-orange-100 dark-theme:bg-orange-900',
|
||||
'STRING': 'border-gray-500 bg-gray-100 dark-theme:bg-gray-900',
|
||||
'INT': 'border-indigo-500 bg-indigo-100 dark-theme:bg-indigo-900',
|
||||
'FLOAT': 'border-pink-500 bg-pink-100 dark-theme:bg-pink-900'
|
||||
}
|
||||
|
||||
return typeColors[typeStr.toUpperCase()] || 'border-gray-400'
|
||||
}
|
||||
|
||||
const onSlotClick = (index: number, event: MouseEvent, slotType: 'input' | 'output') => {
|
||||
event.stopPropagation()
|
||||
|
||||
// Calculate the actual slot index based on type
|
||||
// For outputs, we need to add the input count to get the correct index
|
||||
const slotIndex = slotType === 'output' ? props.inputs.length + index : index
|
||||
|
||||
emit('slot-click', slotIndex, event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vue-node-slots {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.slot-connector {
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slot-connector:hover {
|
||||
transform: scale(1.2);
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.input-slot {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.output-slot {
|
||||
border-right: 3px solid transparent;
|
||||
}
|
||||
|
||||
.input-slot:hover {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.output-slot:hover {
|
||||
border-right-color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
204
src/components/graph/widgets/BadgedNumberInput.vue
Normal file
204
src/components/graph/widgets/BadgedNumberInput.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="badged-number-input relative w-full">
|
||||
<InputGroup class="w-full rounded-lg border-none px-0.5">
|
||||
<!-- State badge prefix -->
|
||||
<InputGroupAddon
|
||||
v-if="badgeState !== 'normal'"
|
||||
class="rounded-l-lg bg-[#222222] border-[#222222] shadow-none border-r-[#A0A1A2] rounded-r-none"
|
||||
>
|
||||
<i
|
||||
:class="badgeIcon + ' text-xs'"
|
||||
:title="badgeTooltip"
|
||||
:style="{ color: badgeColor }"
|
||||
></i>
|
||||
</InputGroupAddon>
|
||||
|
||||
<!-- Number input for non-slider mode -->
|
||||
<InputNumber
|
||||
v-if="!isSliderMode"
|
||||
v-model="numericValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
size="small"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
class: 'bg-[#222222] text-xs shadow-none rounded-none !border-0'
|
||||
}
|
||||
},
|
||||
incrementButton: {
|
||||
class: 'text-xs shadow-none bg-[#222222] rounded-l-none !border-0'
|
||||
},
|
||||
decrementButton: {
|
||||
class: {
|
||||
'text-xs shadow-none bg-[#222222] rounded-r-none !border-0':
|
||||
badgeState === 'normal',
|
||||
'text-xs shadow-none bg-[#222222] rounded-none !border-0':
|
||||
badgeState !== 'normal'
|
||||
}
|
||||
}
|
||||
}"
|
||||
class="flex-1 rounded-none"
|
||||
show-buttons
|
||||
button-layout="horizontal"
|
||||
:increment-button-icon="'pi pi-plus'"
|
||||
:decrement-button-icon="'pi pi-minus'"
|
||||
/>
|
||||
|
||||
<!-- Slider mode -->
|
||||
<div
|
||||
v-else
|
||||
:class="{
|
||||
'rounded-r-lg': badgeState !== 'normal',
|
||||
'rounded-lg': badgeState === 'normal'
|
||||
}"
|
||||
class="flex-1 flex items-center gap-2 px-1 bg-surface-0 border border-surface-300"
|
||||
>
|
||||
<Slider
|
||||
v-model="numericValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
class="flex-1"
|
||||
/>
|
||||
<InputNumber
|
||||
v-model="numericValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
class="w-16 rounded-md"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
class: 'bg-[#222222] text-xs shadow-none border-[#222222]'
|
||||
}
|
||||
}
|
||||
}"
|
||||
:show-buttons="false"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputGroup from 'primevue/inputgroup'
|
||||
import InputGroupAddon from 'primevue/inputgroupaddon'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
type BadgeState = 'normal' | 'random' | 'lock' | 'increment' | 'decrement'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const {
|
||||
widget,
|
||||
badgeState = 'normal',
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
widget: ComponentWidget<string>
|
||||
badgeState?: BadgeState
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
// Convert string model value to/from number for the InputNumber component
|
||||
const numericValue = computed({
|
||||
get: () => parseFloat(modelValue.value) || 0,
|
||||
set: (value: number) => {
|
||||
modelValue.value = value.toString()
|
||||
}
|
||||
})
|
||||
|
||||
// Extract options from input spec
|
||||
const inputSpec = widget.inputSpec
|
||||
const min = (inputSpec as any).min ?? 0
|
||||
const max = (inputSpec as any).max ?? 100
|
||||
const step = (inputSpec as any).step ?? 1
|
||||
const placeholder = (inputSpec as any).placeholder ?? 'Enter number'
|
||||
|
||||
// Check if slider mode should be enabled
|
||||
const isSliderMode = computed(() => {
|
||||
console.log('inputSpec', inputSpec)
|
||||
return (inputSpec as any).slider === true
|
||||
})
|
||||
|
||||
// Badge configuration
|
||||
const badgeIcon = computed(() => {
|
||||
switch (badgeState) {
|
||||
case 'random':
|
||||
return 'pi pi-refresh'
|
||||
case 'lock':
|
||||
return 'pi pi-lock'
|
||||
case 'increment':
|
||||
return 'pi pi-arrow-up'
|
||||
case 'decrement':
|
||||
return 'pi pi-arrow-down'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const badgeColor = computed(() => {
|
||||
switch (badgeState) {
|
||||
case 'random':
|
||||
return 'var(--p-primary-color)'
|
||||
case 'lock':
|
||||
return 'var(--p-orange-500)'
|
||||
case 'increment':
|
||||
return 'var(--p-green-500)'
|
||||
case 'decrement':
|
||||
return 'var(--p-red-500)'
|
||||
default:
|
||||
return 'var(--p-text-color)'
|
||||
}
|
||||
})
|
||||
|
||||
const badgeTooltip = computed(() => {
|
||||
switch (badgeState) {
|
||||
case 'random':
|
||||
return 'Random mode: Value randomizes after each run'
|
||||
case 'lock':
|
||||
return 'Locked: Value never changes'
|
||||
case 'increment':
|
||||
return 'Auto-increment: Value increases after each run'
|
||||
case 'decrement':
|
||||
return 'Auto-decrement: Value decreases after each run'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.badged-number-input {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Ensure proper styling for the input group */
|
||||
:deep(.p-inputgroup) {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
:deep(.p-inputnumber) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.p-inputnumber-input) {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* Badge styling */
|
||||
:deep(.p-badge) {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
</style>
|
||||
545
src/components/graph/widgets/ColorPickerWidget.vue
Normal file
545
src/components/graph/widgets/ColorPickerWidget.vue
Normal file
@@ -0,0 +1,545 @@
|
||||
<template>
|
||||
<div class="color-picker-widget">
|
||||
<div
|
||||
:style="{ width: widgetWidth }"
|
||||
class="flex items-center gap-2 p-2 rounded-lg border border-surface-300 bg-surface-0 w-full"
|
||||
>
|
||||
<!-- Color picker preview and popup trigger -->
|
||||
<div class="relative">
|
||||
<div
|
||||
:style="{ backgroundColor: parsedColor.hex }"
|
||||
class="w-4 h-4 rounded border-2 border-surface-400 cursor-pointer hover:border-surface-500 transition-colors"
|
||||
title="Click to edit color"
|
||||
@click="toggleColorPicker"
|
||||
/>
|
||||
|
||||
<!-- Color picker popover -->
|
||||
<Popover ref="colorPickerPopover" class="!p-0">
|
||||
<ColorPicker
|
||||
v-model="colorValue"
|
||||
format="hex"
|
||||
class="border-none"
|
||||
@update:model-value="updateColorFromPicker"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Color component inputs -->
|
||||
<div class="flex gap-5">
|
||||
<InputNumber
|
||||
v-for="component in colorComponents"
|
||||
:key="component.name"
|
||||
v-model="component.value"
|
||||
:min="component.min"
|
||||
:max="component.max"
|
||||
:step="component.step"
|
||||
:placeholder="component.name"
|
||||
class="flex-1 text-xs max-w-8"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
class:
|
||||
'max-w-12 bg-[#222222] text-xs shadow-none border-[#222222]'
|
||||
}
|
||||
}
|
||||
}"
|
||||
:show-buttons="false"
|
||||
size="small"
|
||||
@update:model-value="updateColorFromComponents"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Format dropdown -->
|
||||
<Select
|
||||
v-model="currentFormat"
|
||||
:options="colorFormats"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-24 ml-3 bg-[#222222] text-xs shadow-none border-none p-0"
|
||||
size="small"
|
||||
@update:model-value="handleFormatChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Popover from 'primevue/popover'
|
||||
import Select from 'primevue/select'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
interface ColorComponent {
|
||||
name: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
}
|
||||
|
||||
interface ParsedColor {
|
||||
hex: string
|
||||
rgb: { r: number; g: number; b: number; a: number }
|
||||
hsl: { h: number; s: number; l: number; a: number }
|
||||
hsv: { h: number; s: number; v: number; a: number }
|
||||
}
|
||||
|
||||
type ColorFormat = 'rgba' | 'hsla' | 'hsva' | 'hex'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: ComponentWidget<string>
|
||||
}>()
|
||||
|
||||
// Color format options
|
||||
const colorFormats = [
|
||||
{ label: 'RGBA', value: 'rgba' },
|
||||
{ label: 'HSLA', value: 'hsla' },
|
||||
{ label: 'HSVA', value: 'hsva' },
|
||||
{ label: 'HEX', value: 'hex' }
|
||||
]
|
||||
|
||||
// Current format state
|
||||
const currentFormat = ref<ColorFormat>('rgba')
|
||||
|
||||
// Color picker popover reference
|
||||
const colorPickerPopover = ref()
|
||||
|
||||
// Internal color value for the PrimeVue ColorPicker
|
||||
const colorValue = ref<string>('#ff0000')
|
||||
|
||||
// Calculate widget width based on node size with padding
|
||||
const widgetWidth = computed(() => {
|
||||
if (!widget?.node?.size) return 'auto'
|
||||
|
||||
const nodeWidth = widget.node.size[0]
|
||||
const WIDGET_PADDING = 16 // Account for padding around the widget
|
||||
const maxWidth = Math.max(200, nodeWidth - WIDGET_PADDING) // Minimum 200px, but scale with node
|
||||
|
||||
return `${maxWidth}px`
|
||||
})
|
||||
|
||||
// Parse color string to various formats
|
||||
const parsedColor = computed<ParsedColor>(() => {
|
||||
const value = modelValue.value || '#ff0000'
|
||||
|
||||
// Handle different input formats
|
||||
if (value.startsWith('#')) {
|
||||
return parseHexColor(value)
|
||||
} else if (value.startsWith('rgb')) {
|
||||
return parseRgbaColor(value)
|
||||
} else if (value.startsWith('hsl')) {
|
||||
return parseHslaColor(value)
|
||||
} else if (value.startsWith('hsv')) {
|
||||
return parseHsvaColor(value)
|
||||
}
|
||||
|
||||
return parseHexColor('#ff0000') // Default fallback
|
||||
})
|
||||
|
||||
// Get color components based on current format
|
||||
const colorComponents = computed<ColorComponent[]>(() => {
|
||||
const { rgb, hsl, hsv } = parsedColor.value
|
||||
|
||||
switch (currentFormat.value) {
|
||||
case 'rgba':
|
||||
return [
|
||||
{ name: 'R', value: rgb.r, min: 0, max: 255, step: 1 },
|
||||
{ name: 'G', value: rgb.g, min: 0, max: 255, step: 1 },
|
||||
{ name: 'B', value: rgb.b, min: 0, max: 255, step: 1 },
|
||||
{ name: 'A', value: rgb.a, min: 0, max: 1, step: 0.01 }
|
||||
]
|
||||
case 'hsla':
|
||||
return [
|
||||
{ name: 'H', value: hsl.h, min: 0, max: 360, step: 1 },
|
||||
{ name: 'S', value: hsl.s, min: 0, max: 100, step: 1 },
|
||||
{ name: 'L', value: hsl.l, min: 0, max: 100, step: 1 },
|
||||
{ name: 'A', value: hsl.a, min: 0, max: 1, step: 0.01 }
|
||||
]
|
||||
case 'hsva':
|
||||
return [
|
||||
{ name: 'H', value: hsv.h, min: 0, max: 360, step: 1 },
|
||||
{ name: 'S', value: hsv.s, min: 0, max: 100, step: 1 },
|
||||
{ name: 'V', value: hsv.v, min: 0, max: 100, step: 1 },
|
||||
{ name: 'A', value: hsv.a, min: 0, max: 1, step: 0.01 }
|
||||
]
|
||||
case 'hex':
|
||||
return [] // No components for hex format
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for changes in modelValue to update colorValue
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
(newValue) => {
|
||||
if (newValue && newValue !== colorValue.value) {
|
||||
colorValue.value = parsedColor.value.hex
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Toggle color picker popover
|
||||
function toggleColorPicker(event: Event) {
|
||||
colorPickerPopover.value.toggle(event)
|
||||
}
|
||||
|
||||
// Update color from picker
|
||||
function updateColorFromPicker(value: string) {
|
||||
colorValue.value = value
|
||||
updateModelValue(parseHexColor(value))
|
||||
}
|
||||
|
||||
// Update color from component inputs
|
||||
function updateColorFromComponents() {
|
||||
const components = colorComponents.value
|
||||
if (components.length === 0) return
|
||||
|
||||
let newColor: ParsedColor
|
||||
const rgbFromHsl = hslToRgb(
|
||||
components[0].value,
|
||||
components[1].value,
|
||||
components[2].value,
|
||||
components[3].value
|
||||
)
|
||||
const rgbFromHsv = hsvToRgb(
|
||||
components[0].value,
|
||||
components[1].value,
|
||||
components[2].value,
|
||||
components[3].value
|
||||
)
|
||||
|
||||
switch (currentFormat.value) {
|
||||
case 'rgba':
|
||||
newColor = {
|
||||
hex: rgbToHex(
|
||||
components[0].value,
|
||||
components[1].value,
|
||||
components[2].value
|
||||
),
|
||||
rgb: {
|
||||
r: components[0].value,
|
||||
g: components[1].value,
|
||||
b: components[2].value,
|
||||
a: components[3].value
|
||||
},
|
||||
hsl: rgbToHsl(
|
||||
components[0].value,
|
||||
components[1].value,
|
||||
components[2].value,
|
||||
components[3].value
|
||||
),
|
||||
hsv: rgbToHsv(
|
||||
components[0].value,
|
||||
components[1].value,
|
||||
components[2].value,
|
||||
components[3].value
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'hsla':
|
||||
newColor = {
|
||||
hex: rgbToHex(rgbFromHsl.r, rgbFromHsl.g, rgbFromHsl.b),
|
||||
rgb: rgbFromHsl,
|
||||
hsl: {
|
||||
h: components[0].value,
|
||||
s: components[1].value,
|
||||
l: components[2].value,
|
||||
a: components[3].value
|
||||
},
|
||||
hsv: rgbToHsv(rgbFromHsl.r, rgbFromHsl.g, rgbFromHsl.b, rgbFromHsl.a)
|
||||
}
|
||||
break
|
||||
case 'hsva':
|
||||
newColor = {
|
||||
hex: rgbToHex(rgbFromHsv.r, rgbFromHsv.g, rgbFromHsv.b),
|
||||
rgb: rgbFromHsv,
|
||||
hsl: rgbToHsl(rgbFromHsv.r, rgbFromHsv.g, rgbFromHsv.b, rgbFromHsv.a),
|
||||
hsv: {
|
||||
h: components[0].value,
|
||||
s: components[1].value,
|
||||
v: components[2].value,
|
||||
a: components[3].value
|
||||
}
|
||||
}
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
updateModelValue(newColor)
|
||||
}
|
||||
|
||||
// Handle format change
|
||||
function handleFormatChange() {
|
||||
updateModelValue(parsedColor.value)
|
||||
}
|
||||
|
||||
// Update the model value based on current format
|
||||
function updateModelValue(color: ParsedColor) {
|
||||
switch (currentFormat.value) {
|
||||
case 'rgba':
|
||||
modelValue.value = `rgba(${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b}, ${color.rgb.a})`
|
||||
break
|
||||
case 'hsla':
|
||||
modelValue.value = `hsla(${color.hsl.h}, ${color.hsl.s}%, ${color.hsl.l}%, ${color.hsl.a})`
|
||||
break
|
||||
case 'hsva':
|
||||
modelValue.value = `hsva(${color.hsv.h}, ${color.hsv.s}%, ${color.hsv.v}%, ${color.hsv.a})`
|
||||
break
|
||||
case 'hex':
|
||||
modelValue.value = color.hex
|
||||
break
|
||||
}
|
||||
|
||||
colorValue.value = color.hex
|
||||
}
|
||||
|
||||
// Color parsing functions
|
||||
function parseHexColor(hex: string): ParsedColor {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
const a = hex.length === 9 ? parseInt(hex.slice(7, 9), 16) / 255 : 1
|
||||
|
||||
return {
|
||||
hex,
|
||||
rgb: { r, g, b, a },
|
||||
hsl: rgbToHsl(r, g, b, a),
|
||||
hsv: rgbToHsv(r, g, b, a)
|
||||
}
|
||||
}
|
||||
|
||||
function parseRgbaColor(rgba: string): ParsedColor {
|
||||
const match = rgba.match(/rgba?\(([^)]+)\)/)
|
||||
if (!match) return parseHexColor('#ff0000')
|
||||
|
||||
const [r, g, b, a = 1] = match[1].split(',').map((v) => parseFloat(v.trim()))
|
||||
|
||||
return {
|
||||
hex: rgbToHex(r, g, b),
|
||||
rgb: { r, g, b, a },
|
||||
hsl: rgbToHsl(r, g, b, a),
|
||||
hsv: rgbToHsv(r, g, b, a)
|
||||
}
|
||||
}
|
||||
|
||||
function parseHslaColor(hsla: string): ParsedColor {
|
||||
const match = hsla.match(/hsla?\(([^)]+)\)/)
|
||||
if (!match) return parseHexColor('#ff0000')
|
||||
|
||||
const [h, s, l, a = 1] = match[1]
|
||||
.split(',')
|
||||
.map((v) => parseFloat(v.trim().replace('%', '')))
|
||||
const rgb = hslToRgb(h, s, l, a)
|
||||
|
||||
return {
|
||||
hex: rgbToHex(rgb.r, rgb.g, rgb.b),
|
||||
rgb,
|
||||
hsl: { h, s, l, a },
|
||||
hsv: rgbToHsv(rgb.r, rgb.g, rgb.b, rgb.a)
|
||||
}
|
||||
}
|
||||
|
||||
function parseHsvaColor(hsva: string): ParsedColor {
|
||||
const match = hsva.match(/hsva?\(([^)]+)\)/)
|
||||
if (!match) return parseHexColor('#ff0000')
|
||||
|
||||
const [h, s, v, a = 1] = match[1]
|
||||
.split(',')
|
||||
.map((val) => parseFloat(val.trim().replace('%', '')))
|
||||
const rgb = hsvToRgb(h, s, v, a)
|
||||
|
||||
return {
|
||||
hex: rgbToHex(rgb.r, rgb.g, rgb.b),
|
||||
rgb,
|
||||
hsl: rgbToHsl(rgb.r, rgb.g, rgb.b, rgb.a),
|
||||
hsv: { h, s, v, a }
|
||||
}
|
||||
}
|
||||
|
||||
// Color conversion utility functions
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return (
|
||||
'#' +
|
||||
[r, g, b].map((x) => Math.round(x).toString(16).padStart(2, '0')).join('')
|
||||
)
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number, a: number) {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h: number, s: number
|
||||
const l = (max + min) / 2
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0
|
||||
} else {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0)
|
||||
break
|
||||
case g:
|
||||
h = (b - r) / d + 2
|
||||
break
|
||||
case b:
|
||||
h = (r - g) / d + 4
|
||||
break
|
||||
default:
|
||||
h = 0
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(s * 100),
|
||||
l: Math.round(l * 100),
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
function rgbToHsv(r: number, g: number, b: number, a: number) {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h: number
|
||||
const v = max
|
||||
const s = max === 0 ? 0 : (max - min) / max
|
||||
|
||||
if (max === min) {
|
||||
h = 0
|
||||
} else {
|
||||
const d = max - min
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0)
|
||||
break
|
||||
case g:
|
||||
h = (b - r) / d + 2
|
||||
break
|
||||
case b:
|
||||
h = (r - g) / d + 4
|
||||
break
|
||||
default:
|
||||
h = 0
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(s * 100),
|
||||
v: Math.round(v * 100),
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number, a: number) {
|
||||
h /= 360
|
||||
s /= 100
|
||||
l /= 100
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t
|
||||
if (t < 1 / 2) return q
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
|
||||
return p
|
||||
}
|
||||
|
||||
let r: number, g: number, b: number
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
r = hue2rgb(p, q, h + 1 / 3)
|
||||
g = hue2rgb(p, q, h)
|
||||
b = hue2rgb(p, q, h - 1 / 3)
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.round(r * 255),
|
||||
g: Math.round(g * 255),
|
||||
b: Math.round(b * 255),
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
function hsvToRgb(h: number, s: number, v: number, a: number) {
|
||||
h /= 360
|
||||
s /= 100
|
||||
v /= 100
|
||||
|
||||
const c = v * s
|
||||
const x = c * (1 - Math.abs(((h * 6) % 2) - 1))
|
||||
const m = v - c
|
||||
|
||||
let r: number, g: number, b: number
|
||||
|
||||
if (h < 1 / 6) {
|
||||
;[r, g, b] = [c, x, 0]
|
||||
} else if (h < 2 / 6) {
|
||||
;[r, g, b] = [x, c, 0]
|
||||
} else if (h < 3 / 6) {
|
||||
;[r, g, b] = [0, c, x]
|
||||
} else if (h < 4 / 6) {
|
||||
;[r, g, b] = [0, x, c]
|
||||
} else if (h < 5 / 6) {
|
||||
;[r, g, b] = [x, 0, c]
|
||||
} else {
|
||||
;[r, g, b] = [c, 0, x]
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.round((r + m) * 255),
|
||||
g: Math.round((g + m) * 255),
|
||||
b: Math.round((b + m) * 255),
|
||||
a
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.color-picker-widget {
|
||||
min-height: 40px;
|
||||
overflow: hidden; /* Prevent overflow outside node bounds */
|
||||
}
|
||||
|
||||
/* Ensure proper styling for small inputs */
|
||||
:deep(.p-inputnumber-input) {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
:deep(.p-select) {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
:deep(.p-select .p-select-label) {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
:deep(.p-colorpicker) {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
40
src/components/graph/widgets/DropdownComboWidget.vue
Normal file
40
src/components/graph/widgets/DropdownComboWidget.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="px-2">
|
||||
<Select
|
||||
v-model="selectedValue"
|
||||
:options="computedOptions"
|
||||
:placeholder="placeholder"
|
||||
class="w-full rounded-lg bg-[#222222] text-xs border-[#222222] shadow-none"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
const selectedValue = defineModel<string>()
|
||||
const { widget } = defineProps<{
|
||||
widget?: ComponentWidget<string>
|
||||
}>()
|
||||
|
||||
const inputSpec = (widget?.inputSpec ?? {}) as ComboInputSpec
|
||||
const placeholder = 'Select option'
|
||||
const isLoading = computed(() => selectedValue.value === 'Loading...')
|
||||
|
||||
// For remote widgets, we need to dynamically get options
|
||||
const computedOptions = computed(() => {
|
||||
if (inputSpec.remote) {
|
||||
// For remote widgets, the options may be dynamically updated
|
||||
// The useRemoteWidget will update the inputSpec.options
|
||||
return inputSpec.options ?? []
|
||||
}
|
||||
return inputSpec.options ?? []
|
||||
})
|
||||
|
||||
// Tooltip support is available via inputSpec.tooltip if needed in the future
|
||||
</script>
|
||||
210
src/components/graph/widgets/ImagePreviewWidget.vue
Normal file
210
src/components/graph/widgets/ImagePreviewWidget.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div class="image-preview-widget relative w-full">
|
||||
<!-- Single image or grid view -->
|
||||
<div
|
||||
v-if="images.length > 0"
|
||||
class="relative rounded-lg overflow-hidden bg-gray-100 dark-theme:bg-gray-800"
|
||||
:style="{ minHeight: `${minHeight}px` }"
|
||||
>
|
||||
<!-- Single image view -->
|
||||
<div
|
||||
v-if="selectedImageIndex !== null && images[selectedImageIndex]"
|
||||
class="relative flex items-center justify-center w-full h-full"
|
||||
>
|
||||
<img
|
||||
:src="images[selectedImageIndex].src"
|
||||
:alt="`Preview ${selectedImageIndex + 1}`"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Action buttons overlay -->
|
||||
<div class="absolute top-2 right-2 flex gap-1">
|
||||
<Button
|
||||
v-if="images.length > 1"
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||
@click="showGrid"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||
@click="handleEdit"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-sun"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||
@click="handleBrightness"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-download"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Navigation for multiple images -->
|
||||
<div
|
||||
v-if="images.length > 1"
|
||||
class="absolute bottom-2 right-2 bg-black/60 text-white px-2 py-1 rounded text-sm cursor-pointer hover:bg-black/80"
|
||||
@click="nextImage"
|
||||
>
|
||||
{{ selectedImageIndex + 1 }}/{{ images.length }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid view for multiple images -->
|
||||
<div
|
||||
v-else-if="allowBatch && images.length > 1"
|
||||
class="grid gap-1 p-2"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(image, index) in images"
|
||||
:key="index"
|
||||
class="relative aspect-square bg-gray-200 dark-theme:bg-gray-700 rounded cursor-pointer overflow-hidden hover:ring-2 hover:ring-blue-500"
|
||||
@click="selectImage(index)"
|
||||
>
|
||||
<img
|
||||
:src="image.src"
|
||||
:alt="`Thumbnail ${index + 1}`"
|
||||
class="w-full h-full object-cover"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single image in grid mode -->
|
||||
<div v-else-if="images.length === 1" class="p-2">
|
||||
<div
|
||||
class="relative bg-gray-200 dark-theme:bg-gray-700 rounded cursor-pointer overflow-hidden"
|
||||
@click="selectImage(0)"
|
||||
>
|
||||
<img
|
||||
:src="images[0].src"
|
||||
:alt="'Preview'"
|
||||
class="w-full h-auto object-contain"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center w-full bg-gray-100 dark-theme:bg-gray-800 rounded-lg"
|
||||
:style="{ minHeight: `${minHeight}px` }"
|
||||
>
|
||||
<div class="text-gray-500 text-sm">No images to preview</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
interface ImageData {
|
||||
src: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
const modelValue = defineModel<string | string[]>({ required: true })
|
||||
const { widget } = defineProps<{
|
||||
widget: ComponentWidget<string | string[]>
|
||||
}>()
|
||||
|
||||
// Widget configuration
|
||||
const inputSpec = widget.inputSpec
|
||||
const allowBatch = computed(() => Boolean(inputSpec.allow_batch))
|
||||
const imageFolder = computed(() => inputSpec.image_folder || 'input')
|
||||
|
||||
// State
|
||||
const selectedImageIndex = ref<number | null>(null)
|
||||
const minHeight = 320
|
||||
|
||||
// Convert model value to image data
|
||||
const images = computed<ImageData[]>(() => {
|
||||
const value = modelValue.value
|
||||
if (!value) return []
|
||||
|
||||
const paths = Array.isArray(value) ? value : [value]
|
||||
return paths.map((path) => ({
|
||||
src: path.startsWith('http')
|
||||
? path
|
||||
: `api/view?filename=${encodeURIComponent(path)}&type=${imageFolder.value}`, // TODO: add subfolder
|
||||
width: undefined,
|
||||
height: undefined
|
||||
}))
|
||||
})
|
||||
|
||||
// Grid layout for batch images
|
||||
const gridStyle = computed(() => {
|
||||
const count = images.value.length
|
||||
if (count <= 1) return {}
|
||||
|
||||
const cols = Math.ceil(Math.sqrt(count))
|
||||
return {
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`
|
||||
}
|
||||
})
|
||||
|
||||
// Methods
|
||||
const selectImage = (index: number) => {
|
||||
selectedImageIndex.value = index
|
||||
}
|
||||
|
||||
const showGrid = () => {
|
||||
selectedImageIndex.value = null
|
||||
}
|
||||
|
||||
const nextImage = () => {
|
||||
if (images.value.length === 0) return
|
||||
|
||||
const current = selectedImageIndex.value ?? -1
|
||||
const next = (current + 1) % images.value.length
|
||||
selectedImageIndex.value = next
|
||||
}
|
||||
|
||||
const handleImageError = (event: Event) => {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
console.warn('Failed to load image:', img.src)
|
||||
}
|
||||
|
||||
// Stub button handlers for now
|
||||
const handleEdit = () => {
|
||||
console.log('Edit button clicked - functionality to be implemented')
|
||||
}
|
||||
|
||||
const handleBrightness = () => {
|
||||
console.log('Brightness button clicked - functionality to be implemented')
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
console.log('Save button clicked - functionality to be implemented')
|
||||
}
|
||||
|
||||
// Initialize to show first image if available
|
||||
if (images.value.length === 1) {
|
||||
selectedImageIndex.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-preview-widget {
|
||||
/* Ensure proper dark theme styling */
|
||||
}
|
||||
</style>
|
||||
150
src/components/graph/widgets/MediaLoaderWidget.vue
Normal file
150
src/components/graph/widgets/MediaLoaderWidget.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="media-loader-widget w-full px-2 max-h-44">
|
||||
<div
|
||||
class="upload-area border-2 border-dashed border-surface-300 dark-theme:border-surface-600 rounded-lg p-6 text-center bg-surface-50 dark-theme:bg-surface-800 hover:bg-surface-100 dark-theme:hover:bg-surface-700 transition-colors cursor-pointer"
|
||||
:class="{
|
||||
'border-primary-500 bg-primary-50 dark-theme:bg-primary-950': isDragOver
|
||||
}"
|
||||
@click="triggerFileUpload"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<i
|
||||
class="pi pi-cloud-upload text-2xl text-surface-500 dark-theme:text-surface-400"
|
||||
></i>
|
||||
<div class="text-sm text-surface-600 dark-theme:text-surface-300">
|
||||
<span>Drop your file here or </span>
|
||||
<span
|
||||
class="text-primary-600 dark-theme:text-primary-400 hover:text-primary-700 dark-theme:hover:text-primary-300 underline cursor-pointer"
|
||||
@click.stop="triggerFileUpload"
|
||||
>
|
||||
browse files
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="accept"
|
||||
class="text-xs text-surface-500 dark-theme:text-surface-400"
|
||||
>
|
||||
Accepted formats: {{ formatAcceptTypes }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
// Props and model
|
||||
const modelValue = defineModel<string[]>({ required: true, default: () => [] })
|
||||
const { widget, accept } = defineProps<{
|
||||
widget: ComponentWidget<string[]>
|
||||
accept?: string
|
||||
}>()
|
||||
|
||||
// Reactive state
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const isDragOver = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const formatAcceptTypes = computed(() => {
|
||||
if (!accept) return ''
|
||||
return accept
|
||||
.split(',')
|
||||
.map((type) =>
|
||||
type
|
||||
.trim()
|
||||
.replace('image/', '')
|
||||
.replace('video/', '')
|
||||
.replace('audio/', '')
|
||||
)
|
||||
.join(', ')
|
||||
})
|
||||
|
||||
// Methods
|
||||
const triggerFileUpload = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const onFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files) {
|
||||
handleFiles(Array.from(target.files))
|
||||
}
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
const onDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
const onDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
isDragOver.value = false
|
||||
|
||||
if (event.dataTransfer?.files) {
|
||||
handleFiles(Array.from(event.dataTransfer.files))
|
||||
}
|
||||
}
|
||||
|
||||
const handleFiles = (files: File[]) => {
|
||||
// Filter files based on accept prop if provided
|
||||
let validFiles = files
|
||||
if (accept) {
|
||||
const acceptTypes = accept
|
||||
.split(',')
|
||||
.map((type) => type.trim().toLowerCase())
|
||||
validFiles = files.filter((file) => {
|
||||
return acceptTypes.some((acceptType) => {
|
||||
if (acceptType.includes('*')) {
|
||||
// Handle wildcard types like "image/*"
|
||||
const baseType = acceptType.split('/')[0]
|
||||
return file.type.startsWith(baseType + '/')
|
||||
}
|
||||
return file.type.toLowerCase() === acceptType
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
// Emit files to parent component for handling upload
|
||||
const fileNames = validFiles.map((file) => file.name)
|
||||
modelValue.value = fileNames
|
||||
|
||||
// Trigger the widget's upload handler if available
|
||||
if ((widget.options as any)?.onFilesSelected) {
|
||||
;(widget.options as any).onFilesSelected(validFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-area {
|
||||
min-height: 80px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: var(--p-primary-500);
|
||||
}
|
||||
</style>
|
||||
45
src/components/graph/widgets/StringWidget.vue
Normal file
45
src/components/graph/widgets/StringWidget.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="w-full px-2">
|
||||
<!-- Single line text input -->
|
||||
<InputText
|
||||
v-if="!isMultiline"
|
||||
v-model="modelValue"
|
||||
:placeholder="placeholder"
|
||||
class="w-full rounded-lg px-3 py-2 text-sm bg-[#222222] text-xs mt-0.5 border-[#222222] shadow-none"
|
||||
/>
|
||||
|
||||
<!-- Multi-line textarea -->
|
||||
<Textarea
|
||||
v-else
|
||||
v-model="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:auto-resize="true"
|
||||
:rows="3"
|
||||
class="w-full rounded-lg px-3 py-2 text-sm resize-none bg-[#222222] text-xs mt-0.5 border-[#222222] shadow-none"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { StringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
const { widget } = defineProps<{
|
||||
widget: ComponentWidget<string>
|
||||
}>()
|
||||
|
||||
const inputSpec = widget.inputSpec as StringInputSpec
|
||||
const isMultiline = computed(() => inputSpec.multiline === true)
|
||||
const placeholder = computed(
|
||||
() =>
|
||||
inputSpec.placeholder ??
|
||||
inputSpec.default ??
|
||||
inputSpec.defaultVal ??
|
||||
inputSpec.name
|
||||
)
|
||||
</script>
|
||||
77
src/composables/node/useNodeImagePreview.ts
Normal file
77
src/composables/node/useNodeImagePreview.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
const IMAGE_PREVIEW_WIDGET_NAME = '$$node-image-preview'
|
||||
|
||||
/**
|
||||
* Composable for handling node-level operations for ImagePreview widget
|
||||
*/
|
||||
export function useNodeImagePreview() {
|
||||
const imagePreviewWidget = useImagePreviewWidget()
|
||||
|
||||
const findImagePreviewWidget = (node: LGraphNode) =>
|
||||
node.widgets?.find((w) => w.name === IMAGE_PREVIEW_WIDGET_NAME)
|
||||
|
||||
const addImagePreviewWidget = (
|
||||
node: LGraphNode,
|
||||
inputSpec?: Partial<InputSpec>
|
||||
) =>
|
||||
imagePreviewWidget(node, {
|
||||
name: IMAGE_PREVIEW_WIDGET_NAME,
|
||||
type: 'IMAGEPREVIEW',
|
||||
allow_batch: true,
|
||||
image_folder: 'input',
|
||||
...inputSpec
|
||||
} as InputSpec)
|
||||
|
||||
/**
|
||||
* Shows image preview widget for a node
|
||||
* @param node The graph node to show the widget for
|
||||
* @param images The images to display (can be single image or array)
|
||||
* @param options Configuration options
|
||||
*/
|
||||
function showImagePreview(
|
||||
node: LGraphNode,
|
||||
images: string | string[],
|
||||
options: {
|
||||
allow_batch?: boolean
|
||||
image_folder?: string
|
||||
imageInputName?: string
|
||||
} = {}
|
||||
) {
|
||||
const widget =
|
||||
findImagePreviewWidget(node) ??
|
||||
addImagePreviewWidget(node, {
|
||||
allow_batch: options.allow_batch,
|
||||
image_folder: options.image_folder || 'input'
|
||||
})
|
||||
|
||||
// Set the widget value
|
||||
widget.value = images
|
||||
node.setDirtyCanvas?.(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes image preview widget from a node
|
||||
* @param node The graph node to remove the widget from
|
||||
*/
|
||||
function removeImagePreview(node: LGraphNode) {
|
||||
if (!node.widgets) return
|
||||
|
||||
const widgetIdx = node.widgets.findIndex(
|
||||
(w) => w.name === IMAGE_PREVIEW_WIDGET_NAME
|
||||
)
|
||||
|
||||
if (widgetIdx > -1) {
|
||||
node.widgets[widgetIdx].onRemove?.()
|
||||
node.widgets.splice(widgetIdx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showImagePreview,
|
||||
removeImagePreview
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,10 @@ export const useNodeImageUpload = (
|
||||
return validPaths
|
||||
}
|
||||
|
||||
// Handle drag & drop
|
||||
// Note: MediaLoader widget functionality is handled directly by
|
||||
// useImageUploadMediaWidget.ts to avoid circular dependencies
|
||||
|
||||
// Traditional approach: Handle drag & drop
|
||||
useNodeDragAndDrop(node, {
|
||||
fileFilter,
|
||||
onDrop: handleUploadBatch
|
||||
|
||||
122
src/composables/node/useNodeMediaUpload.ts
Normal file
122
src/composables/node/useNodeMediaUpload.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
const MEDIA_LOADER_WIDGET_NAME = '$$node-media-loader'
|
||||
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||
|
||||
const uploadFile = async (file: File, isPasted: boolean) => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
interface MediaUploadOptions {
|
||||
fileFilter?: (file: File) => boolean
|
||||
onUploadComplete: (paths: string[]) => void
|
||||
allow_batch?: boolean
|
||||
accept?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for handling media upload with Vue MediaLoader widget
|
||||
*/
|
||||
export function useNodeMediaUpload() {
|
||||
const mediaLoaderWidget = useMediaLoaderWidget()
|
||||
|
||||
const findMediaLoaderWidget = (node: LGraphNode) =>
|
||||
node.widgets?.find((w) => w.name === MEDIA_LOADER_WIDGET_NAME)
|
||||
|
||||
const addMediaLoaderWidget = (
|
||||
node: LGraphNode,
|
||||
options: MediaUploadOptions
|
||||
) => {
|
||||
const isPastedFile = (file: File): boolean =>
|
||||
file.name === 'image.png' &&
|
||||
file.lastModified - Date.now() < PASTED_IMAGE_EXPIRY_MS
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
const path = await uploadFile(file, isPastedFile(file))
|
||||
if (!path) return
|
||||
return path
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
// Create the MediaLoader widget
|
||||
const widget = mediaLoaderWidget(node, {
|
||||
name: MEDIA_LOADER_WIDGET_NAME,
|
||||
type: 'MEDIA_LOADER'
|
||||
} as InputSpec)
|
||||
|
||||
// Connect the widget to the upload handler
|
||||
if (widget.options) {
|
||||
;(widget.options as any).onFilesSelected = async (files: File[]) => {
|
||||
const filteredFiles = options.fileFilter
|
||||
? files.filter(options.fileFilter)
|
||||
: files
|
||||
|
||||
const paths = await Promise.all(filteredFiles.map(handleUpload))
|
||||
const validPaths = paths.filter((p): p is string => !!p)
|
||||
if (validPaths.length) {
|
||||
options.onUploadComplete(validPaths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows media loader widget for a node
|
||||
* @param node The graph node to show the widget for
|
||||
* @param options Upload configuration options
|
||||
*/
|
||||
function showMediaLoader(node: LGraphNode, options: MediaUploadOptions) {
|
||||
const widget =
|
||||
findMediaLoaderWidget(node) ?? addMediaLoaderWidget(node, options)
|
||||
node.setDirtyCanvas?.(true)
|
||||
return widget
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes media loader widget from a node
|
||||
* @param node The graph node to remove the widget from
|
||||
*/
|
||||
function removeMediaLoader(node: LGraphNode) {
|
||||
if (!node.widgets) return
|
||||
|
||||
const widgetIdx = node.widgets.findIndex(
|
||||
(w) => w.name === MEDIA_LOADER_WIDGET_NAME
|
||||
)
|
||||
|
||||
if (widgetIdx > -1) {
|
||||
node.widgets[widgetIdx].onRemove?.()
|
||||
node.widgets.splice(widgetIdx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showMediaLoader,
|
||||
removeMediaLoader,
|
||||
addMediaLoaderWidget
|
||||
}
|
||||
}
|
||||
71
src/composables/nodeRendering/useNodeInteractionProxy.ts
Normal file
71
src/composables/nodeRendering/useNodeInteractionProxy.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { computed } from 'vue'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
export interface NodeInteractionEvent {
|
||||
type: 'mousedown' | 'contextmenu' | 'slot-click'
|
||||
nodeId: string
|
||||
originalEvent: MouseEvent
|
||||
slotIndex?: number
|
||||
}
|
||||
|
||||
export function useNodeInteractionProxy() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
// Get canvas reference
|
||||
const canvas = computed(() => canvasStore.canvas)
|
||||
|
||||
const handleNodeInteraction = (event: NodeInteractionEvent) => {
|
||||
const { type, nodeId, originalEvent } = event
|
||||
|
||||
if (!canvas.value?.graph) return
|
||||
|
||||
const node = canvas.value.graph.getNodeById(Number(nodeId))
|
||||
if (!node) return
|
||||
|
||||
switch (type) {
|
||||
case 'mousedown':
|
||||
// Convert Vue event coordinates back to canvas coordinates
|
||||
const rect = canvas.value.canvas.getBoundingClientRect()
|
||||
const canvasX = originalEvent.clientX - rect.left
|
||||
const canvasY = originalEvent.clientY - rect.top
|
||||
|
||||
// Transform to graph coordinates
|
||||
const graphPos = canvas.value.convertOffsetToCanvas([canvasX, canvasY])
|
||||
|
||||
// Note: simulatedEvent not currently used but kept for future expansion
|
||||
|
||||
// Trigger node selection and dragging
|
||||
canvas.value.selectNode(node, originalEvent.ctrlKey || originalEvent.metaKey)
|
||||
canvas.value.node_dragged = node
|
||||
|
||||
// Start drag operation if not holding modifier keys
|
||||
if (!originalEvent.ctrlKey && !originalEvent.metaKey && !originalEvent.shiftKey) {
|
||||
canvas.value.dragging_canvas = false
|
||||
canvas.value.node_dragged = node
|
||||
canvas.value.drag_start = [originalEvent.clientX, originalEvent.clientY]
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'contextmenu':
|
||||
// Show context menu for the node
|
||||
originalEvent.preventDefault()
|
||||
canvas.value.showContextMenu(originalEvent, node)
|
||||
break
|
||||
|
||||
case 'slot-click':
|
||||
// Handle slot connection interactions
|
||||
if (event.slotIndex !== undefined) {
|
||||
const slot = node.inputs?.[event.slotIndex] || node.outputs?.[event.slotIndex]
|
||||
if (slot) {
|
||||
canvas.value.processSlotClick(node, event.slotIndex, originalEvent)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleNodeInteraction
|
||||
}
|
||||
}
|
||||
124
src/composables/nodeRendering/useNodePositionSync.ts
Normal file
124
src/composables/nodeRendering/useNodePositionSync.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { ref, computed, readonly, watchEffect } from 'vue'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
// Note: useEventListener imported but not currently used - may be used for future enhancements
|
||||
|
||||
export interface NodePosition {
|
||||
id: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function useNodePositionSync() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const nodePositions = ref<Record<string, NodePosition>>({})
|
||||
const canvasScale = ref(1)
|
||||
const canvasOffset = ref({ x: 0, y: 0 })
|
||||
|
||||
// Get canvas reference
|
||||
const canvas = computed(() => canvasStore.canvas)
|
||||
|
||||
// Sync canvas transform (scale and offset)
|
||||
watchEffect(() => {
|
||||
if (!canvas.value) return
|
||||
|
||||
const updateTransform = () => {
|
||||
if (!canvas.value?.ds) return
|
||||
|
||||
canvasScale.value = canvas.value.ds.scale
|
||||
canvasOffset.value = {
|
||||
x: canvas.value.ds.offset[0],
|
||||
y: canvas.value.ds.offset[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Hook into the canvas draw cycle to update transform
|
||||
canvas.value.onDrawForeground = useChainCallback(
|
||||
canvas.value.onDrawForeground,
|
||||
updateTransform
|
||||
)
|
||||
|
||||
// Initial transform update
|
||||
updateTransform()
|
||||
})
|
||||
|
||||
// Sync node positions
|
||||
const syncNodePositions = () => {
|
||||
if (!canvas.value?.graph) return
|
||||
|
||||
const positions: Record<string, NodePosition> = {}
|
||||
for (const node of canvas.value.graph._nodes) {
|
||||
positions[node.id] = {
|
||||
id: String(node.id),
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
}
|
||||
}
|
||||
nodePositions.value = positions
|
||||
}
|
||||
|
||||
// Listen for node position changes
|
||||
watchEffect(() => {
|
||||
if (!canvas.value) return
|
||||
|
||||
// Hook into various node update events
|
||||
const originalOnNodeMoved = canvas.value.onNodeMoved
|
||||
canvas.value.onNodeMoved = useChainCallback(
|
||||
originalOnNodeMoved,
|
||||
syncNodePositions
|
||||
)
|
||||
|
||||
// Hook into general graph changes
|
||||
const originalOnGraphChanged = canvas.value.onGraphChanged
|
||||
canvas.value.onGraphChanged = useChainCallback(
|
||||
originalOnGraphChanged,
|
||||
syncNodePositions
|
||||
)
|
||||
|
||||
// Initial sync
|
||||
syncNodePositions()
|
||||
})
|
||||
|
||||
// Get visible nodes (within viewport bounds)
|
||||
const visibleNodes = computed(() => {
|
||||
if (!canvas.value?.graph) {
|
||||
console.log('🚫 useNodePositionSync: No canvas or graph available')
|
||||
return []
|
||||
}
|
||||
|
||||
const allNodes = canvas.value.graph._nodes
|
||||
console.log('🔍 useNodePositionSync: Checking', allNodes.length, 'total nodes')
|
||||
|
||||
const phantomNodes = allNodes.filter((node: LGraphNode) => {
|
||||
const isPhantom = node.phantom_mode === true
|
||||
if (isPhantom) {
|
||||
console.log('👻 Found phantom node:', { id: node.id, title: node.title, phantom_mode: node.phantom_mode })
|
||||
}
|
||||
return isPhantom
|
||||
})
|
||||
|
||||
console.log('📊 useNodePositionSync: Found', phantomNodes.length, 'phantom nodes out of', allNodes.length, 'total')
|
||||
|
||||
// TODO: Add viewport culling for performance
|
||||
// For now, return all phantom nodes
|
||||
return phantomNodes
|
||||
})
|
||||
|
||||
// Manual sync function for external triggers
|
||||
const forceSync = () => {
|
||||
syncNodePositions()
|
||||
}
|
||||
|
||||
return {
|
||||
nodePositions: readonly(nodePositions),
|
||||
canvasScale: readonly(canvasScale),
|
||||
canvasOffset: readonly(canvasOffset),
|
||||
visibleNodes: readonly(visibleNodes),
|
||||
forceSync
|
||||
}
|
||||
}
|
||||
142
src/composables/nodeRendering/usePhantomNodes.ts
Normal file
142
src/composables/nodeRendering/usePhantomNodes.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { computed } from 'vue'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
export function usePhantomNodes() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// Get canvas reference
|
||||
const canvas = computed(() => canvasStore.canvas)
|
||||
|
||||
// Check if Vue node rendering is enabled
|
||||
const vueRenderingEnabled = computed(() => true) // Temporarily enabled for testing
|
||||
|
||||
/**
|
||||
* Enable phantom mode for a specific node
|
||||
* @param nodeId The ID of the node to make phantom
|
||||
*/
|
||||
const enablePhantomMode = (nodeId: string | number) => {
|
||||
if (!canvas.value?.graph) return false
|
||||
|
||||
const node = canvas.value.graph.getNodeById(Number(nodeId))
|
||||
if (!node) return false
|
||||
|
||||
node.phantom_mode = true
|
||||
// Trigger canvas redraw to hide the node visually
|
||||
canvas.value.setDirty(true, true)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable phantom mode for a specific node
|
||||
* @param nodeId The ID of the node to make visible again
|
||||
*/
|
||||
const disablePhantomMode = (nodeId: string | number) => {
|
||||
if (!canvas.value?.graph) return false
|
||||
|
||||
const node = canvas.value.graph.getNodeById(Number(nodeId))
|
||||
if (!node) return false
|
||||
|
||||
node.phantom_mode = false
|
||||
// Trigger canvas redraw to show the node visually
|
||||
canvas.value.setDirty(true, true)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle phantom mode for a specific node
|
||||
* @param nodeId The ID of the node to toggle
|
||||
*/
|
||||
const togglePhantomMode = (nodeId: string | number) => {
|
||||
if (!canvas.value?.graph) return false
|
||||
|
||||
const node = canvas.value.graph.getNodeById(Number(nodeId))
|
||||
if (!node) return false
|
||||
|
||||
const newMode = !node.phantom_mode
|
||||
node.phantom_mode = newMode
|
||||
// Trigger canvas redraw
|
||||
canvas.value.setDirty(true, true)
|
||||
|
||||
return newMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable phantom mode for all nodes (global Vue rendering)
|
||||
*/
|
||||
const enableAllPhantomMode = () => {
|
||||
if (!canvas.value?.graph) return 0
|
||||
|
||||
let count = 0
|
||||
for (const node of canvas.value.graph._nodes) {
|
||||
if (!node.phantom_mode) {
|
||||
node.phantom_mode = true
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
canvas.value.setDirty(true, true)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable phantom mode for all nodes (back to canvas rendering)
|
||||
*/
|
||||
const disableAllPhantomMode = () => {
|
||||
if (!canvas.value?.graph) return 0
|
||||
|
||||
let count = 0
|
||||
for (const node of canvas.value.graph._nodes) {
|
||||
if (node.phantom_mode) {
|
||||
node.phantom_mode = false
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
canvas.value.setDirty(true, true)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all phantom nodes
|
||||
*/
|
||||
const getPhantomNodes = (): LGraphNode[] => {
|
||||
if (!canvas.value?.graph) return []
|
||||
|
||||
return canvas.value.graph._nodes.filter((node: LGraphNode) =>
|
||||
node.phantom_mode === true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is in phantom mode
|
||||
* @param nodeId The ID of the node to check
|
||||
*/
|
||||
const isPhantomNode = (nodeId: string | number): boolean => {
|
||||
if (!canvas.value?.graph) return false
|
||||
|
||||
const node = canvas.value.graph.getNodeById(Number(nodeId))
|
||||
return node?.phantom_mode === true
|
||||
}
|
||||
|
||||
return {
|
||||
vueRenderingEnabled,
|
||||
enablePhantomMode,
|
||||
disablePhantomMode,
|
||||
togglePhantomMode,
|
||||
enableAllPhantomMode,
|
||||
disableAllPhantomMode,
|
||||
getPhantomNodes,
|
||||
isPhantomNode
|
||||
}
|
||||
}
|
||||
59
src/composables/nodeRendering/useTestPhantomNodes.ts
Normal file
59
src/composables/nodeRendering/useTestPhantomNodes.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { usePhantomNodes } from './usePhantomNodes'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
/**
|
||||
* Development helper to automatically enable phantom mode for testing
|
||||
*/
|
||||
export function useTestPhantomNodes() {
|
||||
const { enableAllPhantomMode, getPhantomNodes } = usePhantomNodes()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
let graphChangeHandler: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
// Function to enable phantom mode for all nodes
|
||||
const enablePhantomModeForAllNodes = () => {
|
||||
if (canvasStore.canvas?.graph) {
|
||||
const count = enableAllPhantomMode()
|
||||
if (count > 0) {
|
||||
console.log(`✅ Enabled phantom mode for ${count} nodes`)
|
||||
}
|
||||
return count
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Listen for graph changes to immediately enable phantom mode for new nodes
|
||||
graphChangeHandler = () => {
|
||||
enablePhantomModeForAllNodes()
|
||||
}
|
||||
|
||||
api.addEventListener('graphChanged', graphChangeHandler)
|
||||
|
||||
// Initial attempt when mounted
|
||||
setTimeout(() => {
|
||||
enablePhantomModeForAllNodes()
|
||||
}, 100) // Much shorter timeout just to ensure canvas is ready
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (graphChangeHandler) {
|
||||
api.removeEventListener('graphChanged', graphChangeHandler)
|
||||
}
|
||||
})
|
||||
|
||||
// Expose helper functions to global scope for manual testing
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).testPhantomNodes = {
|
||||
enableAll: enableAllPhantomMode,
|
||||
getPhantom: getPhantomNodes,
|
||||
enableSingle: (nodeId: string) => {
|
||||
const { enablePhantomMode } = usePhantomNodes()
|
||||
return enablePhantomMode(nodeId)
|
||||
}
|
||||
}
|
||||
console.log('🚀 Phantom node testing helpers available on window.testPhantomNodes')
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
return
|
||||
}
|
||||
app.canvas.fitViewToSelectionAnimated()
|
||||
// Trigger re-render of Vue nodes after view change
|
||||
api.dispatchCustomEvent('graphChanged')
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
166
src/composables/widgets/useBadgedNumberInput.ts
Normal file
166
src/composables/widgets/useBadgedNumberInput.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import BadgedNumberInput from '@/components/graph/widgets/BadgedNumberInput.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
|
||||
type BadgeState = 'normal' | 'random' | 'lock' | 'increment' | 'decrement'
|
||||
|
||||
type NumberWidgetMode = 'int' | 'float'
|
||||
|
||||
interface BadgedNumberInputOptions {
|
||||
defaultValue?: number
|
||||
badgeState?: BadgeState
|
||||
disabled?: boolean
|
||||
serialize?: boolean
|
||||
mode?: NumberWidgetMode
|
||||
}
|
||||
|
||||
// Helper function to map control widget values to badge states
|
||||
const mapControlValueToBadgeState = (controlValue: string): BadgeState => {
|
||||
switch (controlValue) {
|
||||
case 'fixed':
|
||||
return 'lock'
|
||||
case 'increment':
|
||||
return 'increment'
|
||||
case 'decrement':
|
||||
return 'decrement'
|
||||
case 'randomize':
|
||||
return 'random'
|
||||
default:
|
||||
return 'normal'
|
||||
}
|
||||
}
|
||||
|
||||
export const useBadgedNumberInput = (
|
||||
options: BadgedNumberInputOptions = {}
|
||||
) => {
|
||||
const {
|
||||
defaultValue = 0,
|
||||
disabled = false,
|
||||
serialize = true,
|
||||
mode = 'int'
|
||||
} = options
|
||||
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
// Initialize widget value as string to conform to ComponentWidgetImpl requirements
|
||||
const widgetValue = ref<string>(defaultValue.toString())
|
||||
|
||||
// Determine if we should show control widget and badge
|
||||
const shouldShowControlWidget =
|
||||
inputSpec.control_after_generate ??
|
||||
// Legacy compatibility: seed inputs get control widgets
|
||||
['seed', 'noise_seed'].includes(inputSpec.name)
|
||||
|
||||
// Create reactive props object for the component
|
||||
const componentProps = reactive({
|
||||
badgeState:
|
||||
options.badgeState ??
|
||||
(shouldShowControlWidget ? 'random' : ('normal' as BadgeState)),
|
||||
disabled
|
||||
})
|
||||
|
||||
const controlWidget: any = null
|
||||
|
||||
// Create the main widget instance
|
||||
const widget = new ComponentWidgetImpl<
|
||||
string | object,
|
||||
Omit<
|
||||
InstanceType<typeof BadgedNumberInput>['$props'],
|
||||
'widget' | 'modelValue'
|
||||
>
|
||||
>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: BadgedNumberInput,
|
||||
inputSpec,
|
||||
props: componentProps,
|
||||
options: {
|
||||
// Required: getter for widget value - return as string
|
||||
getValue: () => widgetValue.value as string | object,
|
||||
|
||||
// Required: setter for widget value - accept number, string or object
|
||||
setValue: (value: string | object | number) => {
|
||||
let numValue: number
|
||||
if (typeof value === 'object') {
|
||||
numValue = parseFloat(JSON.stringify(value))
|
||||
} else {
|
||||
numValue =
|
||||
typeof value === 'number' ? value : parseFloat(String(value))
|
||||
}
|
||||
|
||||
if (!isNaN(numValue)) {
|
||||
// Apply int/float specific value processing
|
||||
if (mode === 'int') {
|
||||
const step = (inputSpec as any).step ?? 1
|
||||
if (step === 1) {
|
||||
numValue = Math.round(numValue)
|
||||
} else {
|
||||
const min = (inputSpec as any).min ?? 0
|
||||
const offset = min % step
|
||||
numValue =
|
||||
Math.round((numValue - offset) / step) * step + offset
|
||||
}
|
||||
}
|
||||
widgetValue.value = numValue.toString()
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize
|
||||
}
|
||||
})
|
||||
|
||||
// Add control widget if needed - temporarily disabled to fix circular dependency
|
||||
if (shouldShowControlWidget) {
|
||||
// TODO: Re-implement control widget functionality without circular dependency
|
||||
console.warn(
|
||||
'Control widget functionality temporarily disabled due to circular dependency'
|
||||
)
|
||||
// controlWidget = addValueControlWidget(
|
||||
// node,
|
||||
// widget as any, // Cast to satisfy the interface
|
||||
// 'randomize',
|
||||
// undefined,
|
||||
// undefined,
|
||||
// transformInputSpecV2ToV1(inputSpec)
|
||||
// )
|
||||
|
||||
// Set up reactivity to update badge state when control widget changes
|
||||
if (controlWidget) {
|
||||
const originalCallback = controlWidget.callback
|
||||
controlWidget.callback = function (value: string) {
|
||||
componentProps.badgeState = mapControlValueToBadgeState(value)
|
||||
if (originalCallback) {
|
||||
originalCallback.call(this, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize badge state
|
||||
componentProps.badgeState = mapControlValueToBadgeState(
|
||||
controlWidget.value || 'randomize'
|
||||
)
|
||||
|
||||
// Link the widgets
|
||||
;(widget as any).linkedWidgets = [controlWidget]
|
||||
}
|
||||
}
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
|
||||
// Export types for use in other modules
|
||||
export type { BadgeState, BadgedNumberInputOptions, NumberWidgetMode }
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type InputSpec,
|
||||
isBooleanInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
export const useBooleanWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
|
||||
@@ -4,9 +4,8 @@ import { ref } from 'vue'
|
||||
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 16
|
||||
|
||||
export const useChatHistoryWidget = (
|
||||
options: {
|
||||
@@ -32,7 +31,6 @@ export const useChatHistoryWidget = (
|
||||
setValue: (value: string | object) => {
|
||||
widgetValue.value = typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
getMinHeight: () => 400 + PADDING
|
||||
}
|
||||
})
|
||||
addWidget(node, widget)
|
||||
|
||||
200
src/composables/widgets/useColorPickerWidget.ts
Normal file
200
src/composables/widgets/useColorPickerWidget.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ColorPickerWidget from '@/components/graph/widgets/ColorPickerWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
interface ColorPickerWidgetOptions {
|
||||
defaultValue?: string
|
||||
defaultFormat?: 'rgba' | 'hsla' | 'hsva' | 'hex'
|
||||
serialize?: boolean
|
||||
}
|
||||
|
||||
export const useColorPickerWidget = (
|
||||
options: ColorPickerWidgetOptions = {}
|
||||
) => {
|
||||
const {
|
||||
defaultValue = 'rgba(255, 0, 0, 1)',
|
||||
serialize = true
|
||||
} = options
|
||||
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
// Initialize widget value as string
|
||||
const widgetValue = ref<string>(defaultValue)
|
||||
|
||||
// Create the main widget instance
|
||||
const widget = new ComponentWidgetImpl<string>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: ColorPickerWidget,
|
||||
inputSpec,
|
||||
options: {
|
||||
// Required: getter for widget value
|
||||
getValue: () => widgetValue.value,
|
||||
|
||||
// Required: setter for widget value
|
||||
setValue: (value: string | any) => {
|
||||
// Handle different input types
|
||||
if (typeof value === 'string') {
|
||||
// Validate and normalize color string
|
||||
const normalizedValue = normalizeColorString(value)
|
||||
if (normalizedValue) {
|
||||
widgetValue.value = normalizedValue
|
||||
}
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// Handle object input (e.g., from PrimeVue ColorPicker)
|
||||
if (value.hex) {
|
||||
widgetValue.value = value.hex
|
||||
} else {
|
||||
// Try to convert object to string
|
||||
widgetValue.value = String(value)
|
||||
}
|
||||
} else {
|
||||
// Fallback to string conversion
|
||||
widgetValue.value = String(value)
|
||||
}
|
||||
},
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize
|
||||
}
|
||||
})
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget as any)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes color string inputs to ensure consistent format
|
||||
* @param colorString - The input color string
|
||||
* @returns Normalized color string or null if invalid
|
||||
*/
|
||||
function normalizeColorString(colorString: string): string | null {
|
||||
if (!colorString || typeof colorString !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = colorString.trim()
|
||||
|
||||
// Handle hex colors
|
||||
if (trimmed.startsWith('#')) {
|
||||
if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(trimmed)) {
|
||||
// Convert 3-digit hex to 6-digit
|
||||
if (trimmed.length === 4) {
|
||||
return (
|
||||
'#' +
|
||||
trimmed[1] +
|
||||
trimmed[1] +
|
||||
trimmed[2] +
|
||||
trimmed[2] +
|
||||
trimmed[3] +
|
||||
trimmed[3]
|
||||
)
|
||||
}
|
||||
return trimmed.toLowerCase()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle rgb/rgba colors
|
||||
if (trimmed.startsWith('rgb')) {
|
||||
const rgbaMatch = trimmed.match(
|
||||
/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/
|
||||
)
|
||||
if (rgbaMatch) {
|
||||
const [, r, g, b, a] = rgbaMatch
|
||||
const red = Math.max(0, Math.min(255, parseInt(r)))
|
||||
const green = Math.max(0, Math.min(255, parseInt(g)))
|
||||
const blue = Math.max(0, Math.min(255, parseInt(b)))
|
||||
const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1
|
||||
|
||||
if (alpha === 1) {
|
||||
return `rgb(${red}, ${green}, ${blue})`
|
||||
} else {
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle hsl/hsla colors
|
||||
if (trimmed.startsWith('hsl')) {
|
||||
const hslaMatch = trimmed.match(
|
||||
/hsla?\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*(?:,\s*([\d.]+))?\s*\)/
|
||||
)
|
||||
if (hslaMatch) {
|
||||
const [, h, s, l, a] = hslaMatch
|
||||
const hue = Math.max(0, Math.min(360, parseInt(h)))
|
||||
const saturation = Math.max(0, Math.min(100, parseInt(s)))
|
||||
const lightness = Math.max(0, Math.min(100, parseInt(l)))
|
||||
const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1
|
||||
|
||||
if (alpha === 1) {
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`
|
||||
} else {
|
||||
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle hsv/hsva colors (custom format)
|
||||
if (trimmed.startsWith('hsv')) {
|
||||
const hsvaMatch = trimmed.match(
|
||||
/hsva?\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*(?:,\s*([\d.]+))?\s*\)/
|
||||
)
|
||||
if (hsvaMatch) {
|
||||
const [, h, s, v, a] = hsvaMatch
|
||||
const hue = Math.max(0, Math.min(360, parseInt(h)))
|
||||
const saturation = Math.max(0, Math.min(100, parseInt(s)))
|
||||
const value = Math.max(0, Math.min(100, parseInt(v)))
|
||||
const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1
|
||||
|
||||
if (alpha === 1) {
|
||||
return `hsv(${hue}, ${saturation}%, ${value}%)`
|
||||
} else {
|
||||
return `hsva(${hue}, ${saturation}%, ${value}%, ${alpha})`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle named colors by converting to hex (basic set)
|
||||
const namedColors: Record<string, string> = {
|
||||
red: '#ff0000',
|
||||
green: '#008000',
|
||||
blue: '#0000ff',
|
||||
white: '#ffffff',
|
||||
black: '#000000',
|
||||
yellow: '#ffff00',
|
||||
cyan: '#00ffff',
|
||||
magenta: '#ff00ff',
|
||||
orange: '#ffa500',
|
||||
purple: '#800080',
|
||||
pink: '#ffc0cb',
|
||||
brown: '#a52a2a',
|
||||
gray: '#808080',
|
||||
grey: '#808080'
|
||||
}
|
||||
|
||||
const lowerTrimmed = trimmed.toLowerCase()
|
||||
if (namedColors[lowerTrimmed]) {
|
||||
return namedColors[lowerTrimmed]
|
||||
}
|
||||
|
||||
// If we can't parse it, return null
|
||||
return null
|
||||
}
|
||||
|
||||
// Export types for use in other modules
|
||||
export type { ColorPickerWidgetOptions }
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import {
|
||||
ComboInputSpec,
|
||||
type InputSpec,
|
||||
@@ -14,19 +12,11 @@ import {
|
||||
ComponentWidgetImpl,
|
||||
addWidget
|
||||
} from '@/scripts/domWidget'
|
||||
import {
|
||||
type ComfyWidgetConstructorV2,
|
||||
addValueControlWidgets
|
||||
} from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
import { useRemoteWidget } from './useRemoteWidget'
|
||||
import { useDropdownComboWidget } from './useDropdownComboWidget'
|
||||
|
||||
const getDefaultValue = (inputSpec: ComboInputSpec) => {
|
||||
if (inputSpec.default) return inputSpec.default
|
||||
if (inputSpec.options?.length) return inputSpec.options[0]
|
||||
if (inputSpec.remote) return 'Loading...'
|
||||
return undefined
|
||||
}
|
||||
// Default value logic is now handled in useDropdownComboWidget
|
||||
|
||||
const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
const widgetValue = ref<string[]>([])
|
||||
@@ -39,7 +29,9 @@ const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
getValue: () => widgetValue.value,
|
||||
setValue: (value: string[]) => {
|
||||
widgetValue.value = value
|
||||
}
|
||||
},
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true
|
||||
}
|
||||
})
|
||||
addWidget(node, widget as BaseDOMWidget<object | string>)
|
||||
@@ -49,49 +41,9 @@ const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
}
|
||||
|
||||
const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
const defaultValue = getDefaultValue(inputSpec)
|
||||
const comboOptions = inputSpec.options ?? []
|
||||
const widget = node.addWidget(
|
||||
'combo',
|
||||
inputSpec.name,
|
||||
defaultValue,
|
||||
() => {},
|
||||
{
|
||||
values: comboOptions
|
||||
}
|
||||
) as IComboWidget
|
||||
|
||||
if (inputSpec.remote) {
|
||||
const remoteWidget = useRemoteWidget({
|
||||
remoteConfig: inputSpec.remote,
|
||||
defaultValue,
|
||||
node,
|
||||
widget
|
||||
})
|
||||
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
|
||||
|
||||
const origOptions = widget.options
|
||||
widget.options = new Proxy(origOptions, {
|
||||
get(target, prop) {
|
||||
// Assertion: Proxy handler passthrough
|
||||
return prop !== 'values'
|
||||
? target[prop as keyof typeof target]
|
||||
: remoteWidget.getValue()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (inputSpec.control_after_generate) {
|
||||
widget.linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
widget,
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
}
|
||||
|
||||
return widget
|
||||
// Use the new dropdown combo widget for single-selection combo widgets
|
||||
const dropdownWidget = useDropdownComboWidget()
|
||||
return dropdownWidget(node, inputSpec)
|
||||
}
|
||||
|
||||
export const useComboWidget = () => {
|
||||
|
||||
94
src/composables/widgets/useDropdownComboWidget.ts
Normal file
94
src/composables/widgets/useDropdownComboWidget.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import DropdownComboWidget from '@/components/graph/widgets/DropdownComboWidget.vue'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import type {
|
||||
ComboInputSpec,
|
||||
InputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
import { addValueControlWidgets } from '@/scripts/widgets'
|
||||
|
||||
import { useRemoteWidget } from './useRemoteWidget'
|
||||
|
||||
const getDefaultValue = (inputSpec: ComboInputSpec) => {
|
||||
if (inputSpec.default) return inputSpec.default
|
||||
if (inputSpec.options?.length) return inputSpec.options[0]
|
||||
if (inputSpec.remote) return 'Loading...'
|
||||
return ''
|
||||
}
|
||||
|
||||
export const useDropdownComboWidget = (
|
||||
options: { defaultValue?: string } = {}
|
||||
) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
// Type assertion to ComboInputSpec since this is specifically for combo widgets
|
||||
const comboInputSpec = inputSpec as ComboInputSpec
|
||||
|
||||
// Initialize widget value
|
||||
const defaultValue = options.defaultValue ?? getDefaultValue(comboInputSpec)
|
||||
const widgetValue = ref<string>(defaultValue)
|
||||
|
||||
// Create the widget instance
|
||||
const widget = new ComponentWidgetImpl<string>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: DropdownComboWidget,
|
||||
inputSpec,
|
||||
options: {
|
||||
// Required: getter for widget value
|
||||
getValue: () => widgetValue.value,
|
||||
|
||||
// Required: setter for widget value
|
||||
setValue: (value: string) => {
|
||||
widgetValue.value = value
|
||||
},
|
||||
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true
|
||||
}
|
||||
})
|
||||
|
||||
// Handle remote widget functionality
|
||||
if (comboInputSpec.remote) {
|
||||
const remoteWidget = useRemoteWidget({
|
||||
remoteConfig: comboInputSpec.remote,
|
||||
defaultValue,
|
||||
node,
|
||||
widget: widget as any // Cast to be compatible with the remote widget interface
|
||||
})
|
||||
if (comboInputSpec.remote.refresh_button) {
|
||||
remoteWidget.addRefreshButton()
|
||||
}
|
||||
|
||||
// Update the widget to use remote data
|
||||
// Note: The remote widget will handle updating the options through the inputSpec
|
||||
}
|
||||
|
||||
// Handle control_after_generate widgets
|
||||
if (comboInputSpec.control_after_generate) {
|
||||
const linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
widget as any, // Cast to be compatible with legacy widget interface
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(comboInputSpec)
|
||||
)
|
||||
// Store reference to linked widgets (mimicking original behavior)
|
||||
;(widget as any).linkedWidgets = linkedWidgets
|
||||
}
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget as any)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type InputSpec,
|
||||
isFloatInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
function onFloatValueChange(this: INumericWidget, v: number) {
|
||||
|
||||
@@ -1,317 +1,49 @@
|
||||
import {
|
||||
BaseWidget,
|
||||
type CanvasPointer,
|
||||
type LGraphNode,
|
||||
LiteGraph
|
||||
} from '@comfyorg/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
} from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ImagePreviewWidget from '@/components/graph/widgets/ImagePreviewWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
|
||||
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const renderPreview = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: LGraphNode,
|
||||
shiftY: number
|
||||
// Removed PADDING constant as it's no longer needed for CSS flexbox layout
|
||||
|
||||
export const useImagePreviewWidget = (
|
||||
options: { defaultValue?: string | string[] } = {}
|
||||
) => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
const mouse = canvas.graph_mouse
|
||||
|
||||
if (!canvas.pointer_is_down && node.pointerDown) {
|
||||
if (
|
||||
mouse[0] === node.pointerDown.pos[0] &&
|
||||
mouse[1] === node.pointerDown.pos[1]
|
||||
) {
|
||||
node.imageIndex = node.pointerDown.index
|
||||
}
|
||||
node.pointerDown = null
|
||||
}
|
||||
|
||||
const imgs = node.imgs ?? []
|
||||
let { imageIndex } = node
|
||||
const numImages = imgs.length
|
||||
if (numImages === 1 && !imageIndex) {
|
||||
// This skips the thumbnail render section below
|
||||
node.imageIndex = imageIndex = 0
|
||||
}
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
|
||||
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
|
||||
const dw = node.size[0]
|
||||
const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT
|
||||
|
||||
if (imageIndex == null) {
|
||||
// No image selected; draw thumbnails of all
|
||||
let cellWidth: number
|
||||
let cellHeight: number
|
||||
let shiftX: number
|
||||
let cell_padding: number
|
||||
let cols: number
|
||||
|
||||
const compact_mode = is_all_same_aspect_ratio(imgs)
|
||||
if (!compact_mode) {
|
||||
// use rectangle cell style and border line
|
||||
cell_padding = 2
|
||||
// Prevent infinite canvas2d scale-up
|
||||
const largestDimension = imgs.reduce(
|
||||
(acc, current) =>
|
||||
Math.max(acc, current.naturalWidth, current.naturalHeight),
|
||||
0
|
||||
)
|
||||
const fakeImgs = []
|
||||
fakeImgs.length = imgs.length
|
||||
fakeImgs[0] = {
|
||||
naturalWidth: largestDimension,
|
||||
naturalHeight: largestDimension
|
||||
}
|
||||
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
|
||||
fakeImgs,
|
||||
dw,
|
||||
dh
|
||||
))
|
||||
} else {
|
||||
cell_padding = 0
|
||||
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
|
||||
imgs,
|
||||
dw,
|
||||
dh
|
||||
))
|
||||
}
|
||||
|
||||
let anyHovered = false
|
||||
node.imageRects = []
|
||||
for (let i = 0; i < numImages; i++) {
|
||||
const img = imgs[i]
|
||||
const row = Math.floor(i / cols)
|
||||
const col = i % cols
|
||||
const x = col * cellWidth + shiftX
|
||||
const y = row * cellHeight + shiftY
|
||||
if (!anyHovered) {
|
||||
anyHovered = LiteGraph.isInsideRectangle(
|
||||
mouse[0],
|
||||
mouse[1],
|
||||
x + node.pos[0],
|
||||
y + node.pos[1],
|
||||
cellWidth,
|
||||
cellHeight
|
||||
)
|
||||
if (anyHovered) {
|
||||
node.overIndex = i
|
||||
let value = 110
|
||||
if (canvas.pointer_is_down) {
|
||||
if (!node.pointerDown || node.pointerDown.index !== i) {
|
||||
node.pointerDown = { index: i, pos: [...mouse] }
|
||||
}
|
||||
value = 125
|
||||
}
|
||||
ctx.filter = `contrast(${value}%) brightness(${value}%)`
|
||||
canvas.canvas.style.cursor = 'pointer'
|
||||
}
|
||||
}
|
||||
node.imageRects.push([x, y, cellWidth, cellHeight])
|
||||
|
||||
const wratio = cellWidth / img.width
|
||||
const hratio = cellHeight / img.height
|
||||
const ratio = Math.min(wratio, hratio)
|
||||
|
||||
const imgHeight = ratio * img.height
|
||||
const imgY = row * cellHeight + shiftY + (cellHeight - imgHeight) / 2
|
||||
const imgWidth = ratio * img.width
|
||||
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
|
||||
|
||||
ctx.drawImage(
|
||||
img,
|
||||
imgX + cell_padding,
|
||||
imgY + cell_padding,
|
||||
imgWidth - cell_padding * 2,
|
||||
imgHeight - cell_padding * 2
|
||||
)
|
||||
if (!compact_mode) {
|
||||
// rectangle cell and border line style
|
||||
ctx.strokeStyle = '#8F8F8F'
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(
|
||||
x + cell_padding,
|
||||
y + cell_padding,
|
||||
cellWidth - cell_padding * 2,
|
||||
cellHeight - cell_padding * 2
|
||||
)
|
||||
}
|
||||
|
||||
ctx.filter = 'none'
|
||||
}
|
||||
|
||||
if (!anyHovered) {
|
||||
node.pointerDown = null
|
||||
node.overIndex = null
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
// Draw individual
|
||||
const img = imgs[imageIndex]
|
||||
let w = img.naturalWidth
|
||||
let h = img.naturalHeight
|
||||
|
||||
const scaleX = dw / w
|
||||
const scaleY = dh / h
|
||||
const scale = Math.min(scaleX, scaleY, 1)
|
||||
|
||||
w *= scale
|
||||
h *= scale
|
||||
|
||||
const x = (dw - w) / 2
|
||||
const y = (dh - h) / 2 + shiftY
|
||||
ctx.drawImage(img, x, y, w, h)
|
||||
|
||||
// Draw image size text below the image
|
||||
if (allowImageSizeDraw) {
|
||||
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
|
||||
ctx.textAlign = 'center'
|
||||
ctx.font = '10px sans-serif'
|
||||
const sizeText = `${Math.round(img.naturalWidth)} × ${Math.round(img.naturalHeight)}`
|
||||
const textY = y + h + 10
|
||||
ctx.fillText(sizeText, x + w / 2, textY)
|
||||
}
|
||||
|
||||
const drawButton = (
|
||||
x: number,
|
||||
y: number,
|
||||
sz: number,
|
||||
text: string
|
||||
): boolean => {
|
||||
const hovered = LiteGraph.isInsideRectangle(
|
||||
mouse[0],
|
||||
mouse[1],
|
||||
x + node.pos[0],
|
||||
y + node.pos[1],
|
||||
sz,
|
||||
sz
|
||||
)
|
||||
let fill = '#333'
|
||||
let textFill = '#fff'
|
||||
let isClicking = false
|
||||
if (hovered) {
|
||||
canvas.canvas.style.cursor = 'pointer'
|
||||
if (canvas.pointer_is_down) {
|
||||
fill = '#1e90ff'
|
||||
isClicking = true
|
||||
} else {
|
||||
fill = '#eee'
|
||||
textFill = '#000'
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = fill
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, sz, sz, [4])
|
||||
ctx.fill()
|
||||
ctx.fillStyle = textFill
|
||||
ctx.font = '12px Arial'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(text, x + 15, y + 20)
|
||||
|
||||
return isClicking
|
||||
}
|
||||
|
||||
if (!(numImages > 1)) return
|
||||
|
||||
const imageNum = (node.imageIndex ?? 0) + 1
|
||||
if (drawButton(dw - 40, dh + shiftY - 40, 30, `${imageNum}/${numImages}`)) {
|
||||
const i = imageNum >= numImages ? 0 : imageNum
|
||||
if (!node.pointerDown || node.pointerDown.index !== i) {
|
||||
node.pointerDown = { index: i, pos: [...mouse] }
|
||||
}
|
||||
}
|
||||
|
||||
if (drawButton(dw - 40, shiftY + 10, 30, `x`)) {
|
||||
if (!node.pointerDown || node.pointerDown.index !== null) {
|
||||
node.pointerDown = { index: null, pos: [...mouse] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePreviewWidget extends BaseWidget {
|
||||
constructor(
|
||||
node: LGraphNode,
|
||||
name: string,
|
||||
options: IWidgetOptions<string | object>
|
||||
) {
|
||||
const widget: IBaseWidget = {
|
||||
name,
|
||||
options,
|
||||
type: 'custom',
|
||||
/** Dummy value to satisfy type requirements. */
|
||||
value: '',
|
||||
y: 0
|
||||
}
|
||||
super(widget, node)
|
||||
|
||||
// Don't serialize the widget value
|
||||
this.serialize = false
|
||||
}
|
||||
|
||||
override drawWidget(ctx: CanvasRenderingContext2D): void {
|
||||
renderPreview(ctx, this.node, this.y)
|
||||
}
|
||||
|
||||
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
|
||||
pointer.onDragStart = () => {
|
||||
const { canvas } = app
|
||||
const { graph } = canvas
|
||||
canvas.emitBeforeChange()
|
||||
graph?.beforeChange()
|
||||
// Ensure that dragging is properly cleaned up, on success or failure.
|
||||
pointer.finally = () => {
|
||||
canvas.isDragging = false
|
||||
graph?.afterChange()
|
||||
canvas.emitAfterChange()
|
||||
}
|
||||
|
||||
canvas.processSelect(node, pointer.eDown)
|
||||
canvas.isDragging = true
|
||||
}
|
||||
|
||||
pointer.onDragEnd = (e) => {
|
||||
const { canvas } = app
|
||||
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
|
||||
canvas.graph?.snapToGrid(canvas.selectedItems)
|
||||
|
||||
canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override onClick(): void {}
|
||||
|
||||
override computeLayoutSize() {
|
||||
return {
|
||||
minHeight: 220,
|
||||
minWidth: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useImagePreviewWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
return node.addCustomWidget(
|
||||
new ImagePreviewWidget(node, inputSpec.name, {
|
||||
serialize: false
|
||||
})
|
||||
// Initialize widget value
|
||||
const widgetValue = ref<string | string[]>(
|
||||
options.defaultValue ?? (inputSpec.allow_batch ? [] : '')
|
||||
)
|
||||
|
||||
// Create the Vue-based widget instance
|
||||
const widget = new ComponentWidgetImpl<string | string[]>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: ImagePreviewWidget,
|
||||
inputSpec,
|
||||
options: {
|
||||
// Required: getter for widget value
|
||||
getValue: () => widgetValue.value,
|
||||
|
||||
// Required: setter for widget value
|
||||
setValue: (value: string | string[]) => {
|
||||
widgetValue.value = value
|
||||
},
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: false
|
||||
}
|
||||
})
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget as any)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
|
||||
208
src/composables/widgets/useImageUploadMediaWidget.ts
Normal file
208
src/composables/widgets/useImageUploadMediaWidget.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MediaLoaderWidget from '@/components/graph/widgets/MediaLoaderWidget.vue'
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeImagePreview } from '@/composables/node/useNodeImagePreview'
|
||||
import { useValueTransform } from '@/composables/useValueTransform'
|
||||
import type { ResultItem } from '@/schemas/apiSchema'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { createAnnotatedPath } from '@/utils/formatUtil'
|
||||
import { addToComboValues } from '@/utils/litegraphUtil'
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
|
||||
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
|
||||
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||
|
||||
const uploadFile = async (file: File, isPasted: boolean) => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
type InternalFile = string | ResultItem
|
||||
type InternalValue = InternalFile | InternalFile[]
|
||||
type ExposedValue = string | string[]
|
||||
|
||||
const isImageFile = (file: File) => file.type.startsWith('image/')
|
||||
const isVideoFile = (file: File) => file.type.startsWith('video/')
|
||||
|
||||
const findFileComboWidget = (node: LGraphNode, inputName: string) =>
|
||||
node.widgets!.find((w) => w.name === inputName) as IComboWidget & {
|
||||
value: ExposedValue
|
||||
}
|
||||
|
||||
export const useImageUploadMediaWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
inputData: InputSpec
|
||||
) => {
|
||||
const inputOptions = inputData[1] ?? {}
|
||||
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const isAnimated = !!inputOptions.animated_image_upload
|
||||
const isVideo = !!inputOptions.video_upload
|
||||
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
|
||||
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
|
||||
const { showImagePreview } = useNodeImagePreview()
|
||||
|
||||
const fileFilter = isVideo ? isVideoFile : isImageFile
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
const fileComboWidget = findFileComboWidget(node, imageInputName)
|
||||
const initialFile = `${fileComboWidget.value}`
|
||||
const formatPath = (value: InternalFile) =>
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
createAnnotatedPath(value, { rootFolder: image_folder })
|
||||
|
||||
const transform = (internalValue: InternalValue): ExposedValue => {
|
||||
if (!internalValue) return initialFile
|
||||
if (Array.isArray(internalValue))
|
||||
return allow_batch
|
||||
? internalValue.map(formatPath)
|
||||
: formatPath(internalValue[0])
|
||||
return formatPath(internalValue)
|
||||
}
|
||||
|
||||
Object.defineProperty(
|
||||
fileComboWidget,
|
||||
'value',
|
||||
useValueTransform(transform, initialFile)
|
||||
)
|
||||
|
||||
// Convert the V1 input spec to V2 format for the MediaLoader widget
|
||||
const inputSpecV2 = transformInputSpecV1ToV2(inputData, { name: inputName })
|
||||
|
||||
|
||||
// State for MediaLoader widget
|
||||
const uploadedFiles = ref<string[]>([])
|
||||
|
||||
// Create the MediaLoader widget directly
|
||||
const uploadWidget = new ComponentWidgetImpl<string[], { accept?: string }>(
|
||||
{
|
||||
node,
|
||||
name: inputName,
|
||||
component: MediaLoaderWidget,
|
||||
inputSpec: inputSpecV2,
|
||||
props: {
|
||||
accept
|
||||
},
|
||||
options: {
|
||||
getValue: () => uploadedFiles.value,
|
||||
setValue: (value: string[]) => {
|
||||
uploadedFiles.value = value
|
||||
},
|
||||
serialize: false,
|
||||
onFilesSelected: async (files: File[]) => {
|
||||
const isPastedFile = (file: File): boolean =>
|
||||
file.name === 'image.png' &&
|
||||
file.lastModified - Date.now() < PASTED_IMAGE_EXPIRY_MS
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
const path = await uploadFile(file, isPastedFile(file))
|
||||
if (!path) return
|
||||
return path
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
// Filter and upload files
|
||||
const filteredFiles = files.filter(fileFilter)
|
||||
const paths = await Promise.all(filteredFiles.map(handleUpload))
|
||||
const validPaths = paths.filter((p): p is string => !!p)
|
||||
|
||||
if (validPaths.length) {
|
||||
validPaths.forEach((path) =>
|
||||
addToComboValues(fileComboWidget, path)
|
||||
)
|
||||
|
||||
const output = allow_batch ? validPaths : validPaths[0]
|
||||
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||
fileComboWidget.value = output
|
||||
|
||||
// Update widget value to show file names
|
||||
uploadedFiles.value = Array.isArray(output) ? output : [output]
|
||||
|
||||
// Trigger the combo widget callback to update all dependent widgets
|
||||
fileComboWidget.callback?.(output)
|
||||
}
|
||||
}
|
||||
} as any
|
||||
}
|
||||
)
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, uploadWidget as any)
|
||||
|
||||
// Store the original callback if it exists
|
||||
const originalCallback = fileComboWidget.callback
|
||||
|
||||
// Add our own callback to the combo widget to render an image when it changes
|
||||
fileComboWidget.callback = function (value?: any) {
|
||||
// Call original callback first if it exists
|
||||
originalCallback?.call(this, value)
|
||||
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
isAnimated
|
||||
})
|
||||
|
||||
// Use Vue widget for image preview, fallback to DOM widget for video
|
||||
if (!isVideo) {
|
||||
showImagePreview(node, fileComboWidget.value, {
|
||||
allow_batch: allow_batch as boolean,
|
||||
image_folder: image_folder as string,
|
||||
imageInputName: imageInputName as string
|
||||
})
|
||||
}
|
||||
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
// On load if we have a value then render the image
|
||||
// The value isnt set immediately so we need to wait a moment
|
||||
// No change callbacks seem to be fired on initial setting of the value
|
||||
requestAnimationFrame(() => {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
isAnimated
|
||||
})
|
||||
|
||||
// Use appropriate preview method
|
||||
if (isVideo) {
|
||||
showPreview({ block: false })
|
||||
} else {
|
||||
showImagePreview(node, fileComboWidget.value, {
|
||||
allow_batch: allow_batch as boolean,
|
||||
image_folder: image_folder as string,
|
||||
imageInputName: imageInputName as string
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { widget: uploadWidget }
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeImagePreview } from '@/composables/node/useNodeImagePreview'
|
||||
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
||||
import { useValueTransform } from '@/composables/useValueTransform'
|
||||
import { t } from '@/i18n'
|
||||
@@ -41,6 +42,7 @@ export const useImageUploadWidget = () => {
|
||||
const isVideo = !!inputOptions.video_upload
|
||||
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
|
||||
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
|
||||
const { showImagePreview } = useNodeImagePreview()
|
||||
|
||||
const fileFilter = isVideo ? isVideoFile : isImageFile
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
@@ -96,6 +98,16 @@ export const useImageUploadWidget = () => {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
isAnimated
|
||||
})
|
||||
|
||||
// Use Vue widget for image preview, fallback to DOM widget for video
|
||||
if (!isVideo) {
|
||||
showImagePreview(node, fileComboWidget.value, {
|
||||
allow_batch: allow_batch as boolean,
|
||||
image_folder: image_folder as string,
|
||||
imageInputName: imageInputName as string
|
||||
})
|
||||
}
|
||||
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
@@ -106,7 +118,17 @@ export const useImageUploadWidget = () => {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
isAnimated
|
||||
})
|
||||
showPreview({ block: false })
|
||||
|
||||
// Use appropriate preview method
|
||||
if (isVideo) {
|
||||
showPreview({ block: false })
|
||||
} else {
|
||||
showImagePreview(node, fileComboWidget.value, {
|
||||
allow_batch: allow_batch as boolean,
|
||||
image_folder: image_folder as string,
|
||||
imageInputName: imageInputName as string
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { widget: uploadWidget }
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { INumericWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import {
|
||||
type InputSpec,
|
||||
isIntInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
type ComfyWidgetConstructorV2,
|
||||
addValueControlWidget
|
||||
} from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
function onValueChange(this: INumericWidget, v: number) {
|
||||
@@ -81,15 +77,19 @@ export const useIntWidget = () => {
|
||||
['seed', 'noise_seed'].includes(inputSpec.name)
|
||||
|
||||
if (controlAfterGenerate) {
|
||||
const seedControl = addValueControlWidget(
|
||||
node,
|
||||
widget,
|
||||
'randomize',
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
// TODO: Re-implement control widget functionality without circular dependency
|
||||
console.warn(
|
||||
'Control widget functionality temporarily disabled for int widgets due to circular dependency'
|
||||
)
|
||||
widget.linkedWidgets = [seedControl]
|
||||
// const seedControl = addValueControlWidget(
|
||||
// node,
|
||||
// widget,
|
||||
// 'randomize',
|
||||
// undefined,
|
||||
// undefined,
|
||||
// transformInputSpecV2ToV1(inputSpec)
|
||||
// )
|
||||
// widget.linkedWidgets = [seedControl]
|
||||
}
|
||||
|
||||
return widget
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
|
||||
|
||||
import { type InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
function addMarkdownWidget(
|
||||
node: LGraphNode,
|
||||
|
||||
66
src/composables/widgets/useMediaLoaderWidget.ts
Normal file
66
src/composables/widgets/useMediaLoaderWidget.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MediaLoaderWidget from '@/components/graph/widgets/MediaLoaderWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
ComponentWidgetImpl,
|
||||
type DOMWidgetOptions,
|
||||
addWidget
|
||||
} from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
|
||||
interface MediaLoaderOptions {
|
||||
defaultValue?: string[]
|
||||
accept?: string
|
||||
onFilesSelected?: (files: File[]) => void
|
||||
}
|
||||
|
||||
interface MediaLoaderWidgetOptions extends DOMWidgetOptions<string[]> {
|
||||
onFilesSelected?: (files: File[]) => void
|
||||
}
|
||||
|
||||
export const useMediaLoaderWidget = (options: MediaLoaderOptions = {}) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
// Initialize widget value
|
||||
const widgetValue = ref<string[]>(options.defaultValue ?? [])
|
||||
|
||||
// Create the widget instance
|
||||
const widget = new ComponentWidgetImpl<string[], { accept?: string }>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: MediaLoaderWidget,
|
||||
inputSpec,
|
||||
props: {
|
||||
accept: options.accept
|
||||
},
|
||||
options: {
|
||||
// Required: getter for widget value
|
||||
getValue: () => widgetValue.value,
|
||||
|
||||
// Required: setter for widget value
|
||||
setValue: (value: string[]) => {
|
||||
widgetValue.value = Array.isArray(value) ? value : []
|
||||
},
|
||||
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true,
|
||||
|
||||
// Custom option for file selection callback
|
||||
onFilesSelected: options.onFilesSelected
|
||||
} as MediaLoaderWidgetOptions
|
||||
})
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget as any)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -4,15 +4,10 @@ import { ref } from 'vue'
|
||||
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 16
|
||||
|
||||
export const useTextPreviewWidget = (
|
||||
options: {
|
||||
minHeight?: number
|
||||
} = {}
|
||||
) => {
|
||||
export const useTextPreviewWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
@@ -28,7 +23,6 @@ export const useTextPreviewWidget = (
|
||||
setValue: (value: string | object) => {
|
||||
widgetValue.value = typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
getMinHeight: () => options.minHeight ?? 42 + PADDING,
|
||||
serialize: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isStringInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
function addMultilineWidget(
|
||||
@@ -91,6 +91,55 @@ function addMultilineWidget(
|
||||
return widget
|
||||
}
|
||||
|
||||
function addSingleLineWidget(
|
||||
node: LGraphNode,
|
||||
name: string,
|
||||
opts: { defaultVal: string; placeholder?: string }
|
||||
) {
|
||||
const inputEl = document.createElement('input')
|
||||
inputEl.className = 'comfy-text-input'
|
||||
inputEl.type = 'text'
|
||||
inputEl.value = opts.defaultVal
|
||||
inputEl.placeholder = opts.placeholder || name
|
||||
|
||||
const widget = node.addDOMWidget(name, 'text', inputEl, {
|
||||
getValue(): string {
|
||||
return inputEl.value
|
||||
},
|
||||
setValue(v: string) {
|
||||
inputEl.value = v
|
||||
}
|
||||
})
|
||||
|
||||
widget.inputEl = inputEl
|
||||
widget.options.minNodeSize = [200, 40]
|
||||
|
||||
inputEl.addEventListener('input', () => {
|
||||
widget.callback?.(widget.value)
|
||||
})
|
||||
|
||||
// Allow middle mouse button panning
|
||||
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseDown(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
|
||||
if ((event.buttons & 4) === 4) {
|
||||
app.canvas.processMouseMove(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseUp(event)
|
||||
}
|
||||
})
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
export const useStringWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
@@ -108,7 +157,10 @@ export const useStringWidget = () => {
|
||||
defaultVal,
|
||||
placeholder: inputSpec.placeholder
|
||||
})
|
||||
: node.addWidget('text', inputSpec.name, defaultVal, () => {}, {})
|
||||
: addSingleLineWidget(node, inputSpec.name, {
|
||||
defaultVal,
|
||||
placeholder: inputSpec.placeholder
|
||||
})
|
||||
|
||||
if (typeof inputSpec.dynamicPrompts === 'boolean') {
|
||||
widget.dynamicPrompts = inputSpec.dynamicPrompts
|
||||
|
||||
60
src/composables/widgets/useStringWidgetVue.ts
Normal file
60
src/composables/widgets/useStringWidgetVue.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import StringWidget from '@/components/graph/widgets/StringWidget.vue'
|
||||
import {
|
||||
type InputSpec,
|
||||
isStringInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
// Removed PADDING constant as it's no longer needed for CSS flexbox layout
|
||||
|
||||
export const useStringWidgetVue = (options: { defaultValue?: string } = {}) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
if (!isStringInputSpec(inputSpec)) {
|
||||
throw new Error(`Invalid input data: ${inputSpec}`)
|
||||
}
|
||||
|
||||
// Initialize widget value
|
||||
const widgetValue = ref<string>(
|
||||
inputSpec.default ?? options.defaultValue ?? ''
|
||||
)
|
||||
|
||||
// Create the Vue-based widget instance
|
||||
const widget = new ComponentWidgetImpl<string>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: StringWidget,
|
||||
inputSpec,
|
||||
options: {
|
||||
// Required: getter for widget value
|
||||
getValue: () => widgetValue.value,
|
||||
|
||||
// Required: setter for widget value
|
||||
setValue: (value: string) => {
|
||||
widgetValue.value = value
|
||||
},
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true
|
||||
}
|
||||
})
|
||||
|
||||
// Add dynamic prompts support if specified
|
||||
if (typeof inputSpec.dynamicPrompts === 'boolean') {
|
||||
widget.dynamicPrompts = inputSpec.dynamicPrompts
|
||||
}
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget as any)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -230,6 +230,16 @@
|
||||
"black": "Black",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"widgets": {
|
||||
"colorPicker": {
|
||||
"clickToEdit": "Click to edit color",
|
||||
"selectColor": "Select a color",
|
||||
"formatRGBA": "RGBA",
|
||||
"formatHSLA": "HSLA",
|
||||
"formatHSVA": "HSVA",
|
||||
"formatHEX": "HEX"
|
||||
}
|
||||
},
|
||||
"contextMenu": {
|
||||
"Inputs": "Inputs",
|
||||
"Outputs": "Outputs",
|
||||
|
||||
@@ -1429,6 +1429,16 @@
|
||||
"getStarted": "Empezar",
|
||||
"title": "Bienvenido a ComfyUI"
|
||||
},
|
||||
"widgets": {
|
||||
"colorPicker": {
|
||||
"clickToEdit": "Haz clic para editar el color",
|
||||
"formatHEX": "HEX",
|
||||
"formatHSLA": "HSLA",
|
||||
"formatHSVA": "HSVA",
|
||||
"formatRGBA": "RGBA",
|
||||
"selectColor": "Selecciona un color"
|
||||
}
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "Introduzca el nombre del archivo",
|
||||
"exportWorkflow": "Exportar flujo de trabajo",
|
||||
|
||||
@@ -1429,6 +1429,16 @@
|
||||
"getStarted": "Commencer",
|
||||
"title": "Bienvenue sur ComfyUI"
|
||||
},
|
||||
"widgets": {
|
||||
"colorPicker": {
|
||||
"clickToEdit": "Cliquez pour modifier la couleur",
|
||||
"formatHEX": "HEX",
|
||||
"formatHSLA": "HSLA",
|
||||
"formatHSVA": "HSVA",
|
||||
"formatRGBA": "RGBA",
|
||||
"selectColor": "Sélectionnez une couleur"
|
||||
}
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "Entrez le nom du fichier",
|
||||
"exportWorkflow": "Exporter le flux de travail",
|
||||
|
||||
@@ -1429,6 +1429,16 @@
|
||||
"getStarted": "はじめる",
|
||||
"title": "ComfyUIへようこそ"
|
||||
},
|
||||
"widgets": {
|
||||
"colorPicker": {
|
||||
"clickToEdit": "色を編集するにはクリックしてください",
|
||||
"formatHEX": "HEX",
|
||||
"formatHSLA": "HSLA",
|
||||
"formatHSVA": "HSVA",
|
||||
"formatRGBA": "RGBA",
|
||||
"selectColor": "色を選択"
|
||||
}
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "ファイル名を入力",
|
||||
"exportWorkflow": "ワークフローをエクスポート",
|
||||
|
||||
@@ -1429,6 +1429,16 @@
|
||||
"getStarted": "시작하기",
|
||||
"title": "ComfyUI에 오신 것을 환영합니다"
|
||||
},
|
||||
"widgets": {
|
||||
"colorPicker": {
|
||||
"clickToEdit": "색상 편집하려면 클릭하세요",
|
||||
"formatHEX": "HEX",
|
||||
"formatHSLA": "HSLA",
|
||||
"formatHSVA": "HSVA",
|
||||
"formatRGBA": "RGBA",
|
||||
"selectColor": "색상 선택"
|
||||
}
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "파일 이름 입력",
|
||||
"exportWorkflow": "워크플로 내보내기",
|
||||
|
||||
@@ -1429,6 +1429,16 @@
|
||||
"getStarted": "Начать",
|
||||
"title": "Добро пожаловать в ComfyUI"
|
||||
},
|
||||
"widgets": {
|
||||
"colorPicker": {
|
||||
"clickToEdit": "Нажмите, чтобы изменить цвет",
|
||||
"formatHEX": "HEX",
|
||||
"formatHSLA": "HSLA",
|
||||
"formatHSVA": "HSVA",
|
||||
"formatRGBA": "RGBA",
|
||||
"selectColor": "Выберите цвет"
|
||||
}
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "Введите название файла",
|
||||
"exportWorkflow": "Экспорт рабочего процесса",
|
||||
|
||||
@@ -1429,6 +1429,16 @@
|
||||
"getStarted": "开始使用",
|
||||
"title": "欢迎使用 ComfyUI"
|
||||
},
|
||||
"widgets": {
|
||||
"colorPicker": {
|
||||
"clickToEdit": "点击编辑颜色",
|
||||
"formatHEX": "HEX",
|
||||
"formatHSLA": "HSLA",
|
||||
"formatHSVA": "HSVA",
|
||||
"formatRGBA": "RGBA",
|
||||
"selectColor": "选择颜色"
|
||||
}
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "输入文件名",
|
||||
"exportWorkflow": "导出工作流",
|
||||
|
||||
@@ -1150,6 +1150,7 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
useExtensionService().invokeExtensions('loadedGraphNode', node)
|
||||
api.dispatchCustomEvent('graphChanged', { workflow: this.graph.serialize() as unknown as ComfyWorkflowJSON })
|
||||
}
|
||||
|
||||
if (missingNodeTypes.length && showMissingNodesDialog) {
|
||||
@@ -1169,6 +1170,7 @@ export class ComfyApp {
|
||||
)
|
||||
requestAnimationFrame(() => {
|
||||
this.graph.setDirtyCanvas(true, true)
|
||||
api.dispatchCustomEvent('graphChanged', { workflow: this.graph.serialize() as unknown as ComfyWorkflowJSON })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -271,6 +271,27 @@ export class ChangeTracker {
|
||||
return v
|
||||
}
|
||||
|
||||
// Handle wheel events (zoom/pan with mouse wheel)
|
||||
const processMouseWheel = LGraphCanvas.prototype.processMouseWheel
|
||||
LGraphCanvas.prototype.processMouseWheel = function (e) {
|
||||
const v = processMouseWheel.apply(this, [e])
|
||||
logger.debug('checkState on processMouseWheel')
|
||||
checkState()
|
||||
return v
|
||||
}
|
||||
|
||||
// Handle drag events (panning)
|
||||
const processMouseMove = LGraphCanvas.prototype.processMouseMove
|
||||
LGraphCanvas.prototype.processMouseMove = function (e) {
|
||||
const v = processMouseMove.apply(this, [e])
|
||||
// Only check state if we're dragging the canvas (not a node)
|
||||
if (this.dragging_canvas) {
|
||||
logger.debug('checkState on processMouseMove (canvas drag)')
|
||||
checkState()
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Handle litegraph dialog popup for number/string widgets
|
||||
const prompt = LGraphCanvas.prototype.prompt
|
||||
LGraphCanvas.prototype.prompt = function (
|
||||
@@ -369,10 +390,8 @@ export class ChangeTracker {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare extra properties ignoring ds
|
||||
if (
|
||||
!_.isEqual(_.omit(a.extra ?? {}, ['ds']), _.omit(b.extra ?? {}, ['ds']))
|
||||
)
|
||||
// Compare extra properties including ds for Vue node position updates
|
||||
if (!_.isEqual(a.extra ?? {}, b.extra ?? {}))
|
||||
return false
|
||||
|
||||
// Compare other properties normally
|
||||
|
||||
@@ -241,7 +241,8 @@ export class ComponentWidgetImpl<
|
||||
}) {
|
||||
super({
|
||||
...obj,
|
||||
type: 'custom'
|
||||
type: 'custom',
|
||||
options: { hideOnZoom: true, ...obj.options }
|
||||
})
|
||||
this.component = obj.component
|
||||
this.inputSpec = obj.inputSpec
|
||||
|
||||
12
src/scripts/widgetTypes.ts
Normal file
12
src/scripts/widgetTypes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
/**
|
||||
* Constructor function type for ComfyUI widgets using V2 input specification
|
||||
*/
|
||||
export type ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpecV2
|
||||
) => IBaseWidget
|
||||
@@ -5,27 +5,25 @@ import type {
|
||||
IStringWidget
|
||||
} from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import { useBadgedNumberInput } from '@/composables/widgets/useBadgedNumberInput'
|
||||
import { useBooleanWidget } from '@/composables/widgets/useBooleanWidget'
|
||||
import { useColorPickerWidget } from '@/composables/widgets/useColorPickerWidget'
|
||||
import { useComboWidget } from '@/composables/widgets/useComboWidget'
|
||||
import { useFloatWidget } from '@/composables/widgets/useFloatWidget'
|
||||
import { useImageUploadWidget } from '@/composables/widgets/useImageUploadWidget'
|
||||
import { useIntWidget } from '@/composables/widgets/useIntWidget'
|
||||
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
|
||||
import { useImageUploadMediaWidget } from '@/composables/widgets/useImageUploadMediaWidget'
|
||||
import { useMarkdownWidget } from '@/composables/widgets/useMarkdownWidget'
|
||||
import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
|
||||
import { useStringWidget } from '@/composables/widgets/useStringWidget'
|
||||
import { useStringWidgetVue } from '@/composables/widgets/useStringWidgetVue'
|
||||
import { t } from '@/i18n'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
import type { ComfyApp } from './app'
|
||||
import './domWidget'
|
||||
import './errorNodeWidgets'
|
||||
|
||||
export type ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpecV2
|
||||
) => IBaseWidget
|
||||
import type { ComfyWidgetConstructorV2 } from './widgetTypes'
|
||||
|
||||
export type ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
@@ -283,11 +281,18 @@ export function addValueControlWidgets(
|
||||
}
|
||||
|
||||
export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
|
||||
INT: transformWidgetConstructorV2ToV1(useIntWidget()),
|
||||
FLOAT: transformWidgetConstructorV2ToV1(useFloatWidget()),
|
||||
INT: transformWidgetConstructorV2ToV1(useBadgedNumberInput({ mode: 'int' })),
|
||||
FLOAT: transformWidgetConstructorV2ToV1(
|
||||
useBadgedNumberInput({ mode: 'float' })
|
||||
),
|
||||
BOOLEAN: transformWidgetConstructorV2ToV1(useBooleanWidget()),
|
||||
STRING: transformWidgetConstructorV2ToV1(useStringWidget()),
|
||||
STRING: transformWidgetConstructorV2ToV1(useStringWidgetVue()),
|
||||
STRING_DOM: transformWidgetConstructorV2ToV1(useStringWidget()), // Fallback to DOM-based implementation
|
||||
MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()),
|
||||
COMBO: transformWidgetConstructorV2ToV1(useComboWidget()),
|
||||
IMAGEUPLOAD: useImageUploadWidget()
|
||||
COLOR: transformWidgetConstructorV2ToV1(useColorPickerWidget()),
|
||||
IMAGEUPLOAD: useImageUploadMediaWidget(),
|
||||
MEDIA_LOADER: transformWidgetConstructorV2ToV1(useMediaLoaderWidget()),
|
||||
IMAGEPREVIEW: transformWidgetConstructorV2ToV1(useImagePreviewWidget()),
|
||||
BADGED_NUMBER: transformWidgetConstructorV2ToV1(useBadgedNumberInput())
|
||||
}
|
||||
|
||||
@@ -552,7 +552,13 @@ export const useLitegraphService = () => {
|
||||
showAnimatedPreview(this)
|
||||
} else {
|
||||
removeAnimatedPreview(this)
|
||||
showCanvasImagePreview(this)
|
||||
// Only show canvas image preview if we don't already have a Vue image preview widget
|
||||
const hasVueImagePreview = this.widgets?.some(
|
||||
(w) => w.name === '$$node-image-preview' || w.type === 'IMAGEPREVIEW'
|
||||
)
|
||||
if (!hasVueImagePreview) {
|
||||
showCanvasImagePreview(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
7
src/types/phantomNode.d.ts
vendored
Normal file
7
src/types/phantomNode.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// Type extensions for phantom node functionality
|
||||
|
||||
declare module '@comfyorg/litegraph' {
|
||||
interface LGraphNode {
|
||||
phantom_mode?: boolean
|
||||
}
|
||||
}
|
||||
55
tests-ui/composables/useImagePreviewWidget.test.ts
Normal file
55
tests-ui/composables/useImagePreviewWidget.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
ComponentWidgetImpl: vi.fn().mockImplementation(() => ({
|
||||
name: 'test-widget',
|
||||
value: ''
|
||||
})),
|
||||
addWidget: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useImagePreviewWidget', () => {
|
||||
const mockNode = {
|
||||
id: 'test-node',
|
||||
widgets: []
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const mockInputSpec: InputSpec = {
|
||||
name: 'image_preview',
|
||||
type: 'IMAGEPREVIEW',
|
||||
allow_batch: true,
|
||||
image_folder: 'input'
|
||||
}
|
||||
|
||||
it('creates widget constructor with default options', () => {
|
||||
const constructor = useImagePreviewWidget()
|
||||
expect(constructor).toBeDefined()
|
||||
expect(typeof constructor).toBe('function')
|
||||
})
|
||||
|
||||
it('creates widget with custom default value', () => {
|
||||
const constructor = useImagePreviewWidget({
|
||||
defaultValue: 'test-image.png'
|
||||
})
|
||||
expect(constructor).toBeDefined()
|
||||
})
|
||||
|
||||
it('creates widget with array default value for batch mode', () => {
|
||||
const constructor = useImagePreviewWidget({
|
||||
defaultValue: ['image1.png', 'image2.png']
|
||||
})
|
||||
expect(constructor).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls constructor with node and inputSpec', () => {
|
||||
const constructor = useImagePreviewWidget()
|
||||
const widget = constructor(mockNode, mockInputSpec)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
})
|
||||
})
|
||||
108
tests-ui/composables/useMediaLoaderWidget.test.ts
Normal file
108
tests-ui/composables/useMediaLoaderWidget.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
ComponentWidgetImpl: class MockComponentWidgetImpl {
|
||||
node: any
|
||||
name: string
|
||||
component: any
|
||||
inputSpec: any
|
||||
props: any
|
||||
options: any
|
||||
|
||||
constructor(config: any) {
|
||||
this.node = config.node
|
||||
this.name = config.name
|
||||
this.component = config.component
|
||||
this.inputSpec = config.inputSpec
|
||||
this.props = config.props
|
||||
this.options = config.options
|
||||
}
|
||||
},
|
||||
addWidget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/graph/widgets/MediaLoaderWidget.vue', () => ({
|
||||
default: {}
|
||||
}))
|
||||
|
||||
describe('useMediaLoaderWidget', () => {
|
||||
let mockNode: LGraphNode
|
||||
let mockInputSpec: InputSpec
|
||||
|
||||
beforeEach(() => {
|
||||
mockNode = {
|
||||
id: 1,
|
||||
widgets: []
|
||||
} as unknown as LGraphNode
|
||||
|
||||
mockInputSpec = {
|
||||
name: 'test_media_loader',
|
||||
type: 'MEDIA_LOADER'
|
||||
}
|
||||
})
|
||||
|
||||
it('creates widget constructor with default options', () => {
|
||||
const constructor = useMediaLoaderWidget()
|
||||
expect(constructor).toBeInstanceOf(Function)
|
||||
})
|
||||
|
||||
it('creates widget with custom options', () => {
|
||||
const onFilesSelected = vi.fn()
|
||||
const constructor = useMediaLoaderWidget({
|
||||
defaultValue: ['test.jpg'],
|
||||
minHeight: 120,
|
||||
accept: 'image/*',
|
||||
onFilesSelected
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, mockInputSpec)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget.name).toBe('test_media_loader')
|
||||
expect((widget.options as any)?.getValue()).toEqual(['test.jpg'])
|
||||
expect((widget.options as any)?.getMinHeight()).toBe(128) // 120 + 8 padding
|
||||
expect((widget.options as any)?.onFilesSelected).toBe(onFilesSelected)
|
||||
})
|
||||
|
||||
it('handles value setting with validation', () => {
|
||||
const constructor = useMediaLoaderWidget()
|
||||
const widget = constructor(mockNode, mockInputSpec)
|
||||
|
||||
// Test valid array
|
||||
;(widget.options as any)?.setValue(['file1.jpg', 'file2.png'])
|
||||
expect((widget.options as any)?.getValue()).toEqual([
|
||||
'file1.jpg',
|
||||
'file2.png'
|
||||
])
|
||||
|
||||
// Test invalid value conversion
|
||||
;(widget.options as any)?.setValue('invalid' as any)
|
||||
expect((widget.options as any)?.getValue()).toEqual([])
|
||||
})
|
||||
|
||||
it('sets correct minimum height with padding', () => {
|
||||
const constructor = useMediaLoaderWidget({ minHeight: 150 })
|
||||
const widget = constructor(mockNode, mockInputSpec)
|
||||
|
||||
expect((widget.options as any)?.getMinHeight()).toBe(158) // 150 + 8 padding
|
||||
})
|
||||
|
||||
it('uses default minimum height when not specified', () => {
|
||||
const constructor = useMediaLoaderWidget()
|
||||
const widget = constructor(mockNode, mockInputSpec)
|
||||
|
||||
expect((widget.options as any)?.getMinHeight()).toBe(108) // 100 + 8 padding
|
||||
})
|
||||
|
||||
it('passes accept prop to widget', () => {
|
||||
const constructor = useMediaLoaderWidget({ accept: 'video/*' })
|
||||
const widget = constructor(mockNode, mockInputSpec)
|
||||
|
||||
expect((widget as any).props?.accept).toBe('video/*')
|
||||
})
|
||||
})
|
||||
69
tests-ui/composables/useNodeImagePreview.test.ts
Normal file
69
tests-ui/composables/useNodeImagePreview.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useNodeImagePreview } from '@/composables/node/useNodeImagePreview'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/composables/widgets/useImagePreviewWidget', () => ({
|
||||
useImagePreviewWidget: vi.fn(() =>
|
||||
vi.fn(() => ({
|
||||
name: '$$node-image-preview',
|
||||
value: '',
|
||||
onRemove: vi.fn()
|
||||
}))
|
||||
)
|
||||
}))
|
||||
|
||||
describe('useNodeImagePreview', () => {
|
||||
const mockNode = {
|
||||
id: 'test-node',
|
||||
widgets: [],
|
||||
setDirtyCanvas: vi.fn()
|
||||
} as unknown as LGraphNode
|
||||
|
||||
it('provides showImagePreview and removeImagePreview functions', () => {
|
||||
const { showImagePreview, removeImagePreview } = useNodeImagePreview()
|
||||
|
||||
expect(showImagePreview).toBeDefined()
|
||||
expect(removeImagePreview).toBeDefined()
|
||||
expect(typeof showImagePreview).toBe('function')
|
||||
expect(typeof removeImagePreview).toBe('function')
|
||||
})
|
||||
|
||||
it('shows image preview for single image', () => {
|
||||
const { showImagePreview } = useNodeImagePreview()
|
||||
|
||||
showImagePreview(mockNode, 'test-image.png')
|
||||
|
||||
expect(mockNode.setDirtyCanvas).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('shows image preview for multiple images', () => {
|
||||
const { showImagePreview } = useNodeImagePreview()
|
||||
|
||||
showImagePreview(mockNode, ['image1.png', 'image2.png'], {
|
||||
allow_batch: true
|
||||
})
|
||||
|
||||
expect(mockNode.setDirtyCanvas).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('removes image preview widget', () => {
|
||||
const mockWidget = {
|
||||
name: '$$node-image-preview',
|
||||
onRemove: vi.fn()
|
||||
}
|
||||
|
||||
const nodeWithWidget = {
|
||||
...mockNode,
|
||||
widgets: [mockWidget]
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const { removeImagePreview } = useNodeImagePreview()
|
||||
|
||||
removeImagePreview(nodeWithWidget)
|
||||
|
||||
expect(mockWidget.onRemove).toHaveBeenCalled()
|
||||
expect(nodeWithWidget.widgets).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
114
tests-ui/composables/useNodeMediaUpload.test.ts
Normal file
114
tests-ui/composables/useNodeMediaUpload.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useNodeMediaUpload } from '@/composables/node/useNodeMediaUpload'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/composables/widgets/useMediaLoaderWidget', () => ({
|
||||
useMediaLoaderWidget: vi.fn(() =>
|
||||
vi.fn(() => ({
|
||||
name: '$$node-media-loader',
|
||||
options: {
|
||||
onFilesSelected: null
|
||||
}
|
||||
}))
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeImageUpload', () => ({
|
||||
useNodeImageUpload: vi.fn(() => ({
|
||||
handleUpload: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('useNodeMediaUpload', () => {
|
||||
let mockNode: LGraphNode
|
||||
|
||||
beforeEach(() => {
|
||||
mockNode = {
|
||||
id: 1,
|
||||
widgets: [],
|
||||
setDirtyCanvas: vi.fn()
|
||||
} as unknown as LGraphNode
|
||||
})
|
||||
|
||||
it('creates composable with required methods', () => {
|
||||
const { showMediaLoader, removeMediaLoader, addMediaLoaderWidget } =
|
||||
useNodeMediaUpload()
|
||||
|
||||
expect(showMediaLoader).toBeInstanceOf(Function)
|
||||
expect(removeMediaLoader).toBeInstanceOf(Function)
|
||||
expect(addMediaLoaderWidget).toBeInstanceOf(Function)
|
||||
})
|
||||
|
||||
it('shows media loader widget with options', () => {
|
||||
const { showMediaLoader } = useNodeMediaUpload()
|
||||
const options = {
|
||||
fileFilter: (file: File) => file.type.startsWith('image/'),
|
||||
onUploadComplete: vi.fn(),
|
||||
allow_batch: true,
|
||||
accept: 'image/*'
|
||||
}
|
||||
|
||||
const widget = showMediaLoader(mockNode, options)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget.name).toBe('$$node-media-loader')
|
||||
expect(mockNode.setDirtyCanvas).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('removes media loader widget from node', () => {
|
||||
const { showMediaLoader, removeMediaLoader } = useNodeMediaUpload()
|
||||
const options = {
|
||||
fileFilter: () => true,
|
||||
onUploadComplete: vi.fn()
|
||||
}
|
||||
|
||||
// Add widget
|
||||
showMediaLoader(mockNode, options)
|
||||
mockNode.widgets = [
|
||||
{
|
||||
name: '$$node-media-loader',
|
||||
onRemove: vi.fn()
|
||||
}
|
||||
] as any
|
||||
|
||||
// Remove widget
|
||||
removeMediaLoader(mockNode)
|
||||
|
||||
expect(mockNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles node without widgets gracefully', () => {
|
||||
const { removeMediaLoader } = useNodeMediaUpload()
|
||||
const nodeWithoutWidgets = { id: 1 } as LGraphNode
|
||||
|
||||
expect(() => removeMediaLoader(nodeWithoutWidgets)).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not remove non-matching widgets', () => {
|
||||
const { removeMediaLoader } = useNodeMediaUpload()
|
||||
const otherWidget = { name: 'other-widget' }
|
||||
mockNode.widgets! = [otherWidget] as any
|
||||
|
||||
removeMediaLoader(mockNode)
|
||||
|
||||
expect(mockNode.widgets).toHaveLength(1)
|
||||
expect(mockNode.widgets![0]).toBe(otherWidget)
|
||||
})
|
||||
|
||||
it('calls widget onRemove when removing', () => {
|
||||
const { removeMediaLoader } = useNodeMediaUpload()
|
||||
const onRemove = vi.fn()
|
||||
mockNode.widgets! = [
|
||||
{
|
||||
name: '$$node-media-loader',
|
||||
onRemove
|
||||
}
|
||||
] as any
|
||||
|
||||
removeMediaLoader(mockNode)
|
||||
|
||||
expect(onRemove).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
93
tests-ui/composables/useStringWidgetVue.test.ts
Normal file
93
tests-ui/composables/useStringWidgetVue.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useStringWidgetVue } from '@/composables/widgets/useStringWidgetVue'
|
||||
import type { StringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
describe('useStringWidgetVue', () => {
|
||||
it('creates widget constructor with correct default value', () => {
|
||||
const constructor = useStringWidgetVue({ defaultValue: 'test' })
|
||||
expect(constructor).toBeDefined()
|
||||
expect(typeof constructor).toBe('function')
|
||||
})
|
||||
|
||||
it('creates widget for single-line string input', () => {
|
||||
const constructor = useStringWidgetVue()
|
||||
const node = new LGraphNode('test')
|
||||
const inputSpec: StringInputSpec = {
|
||||
type: 'STRING',
|
||||
name: 'test_input',
|
||||
default: 'default_value',
|
||||
placeholder: 'Enter text...'
|
||||
}
|
||||
|
||||
const widget = constructor(node, inputSpec)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget.name).toBe('test_input')
|
||||
expect(widget.value).toBe('default_value')
|
||||
})
|
||||
|
||||
it('creates widget for multiline string input', () => {
|
||||
const constructor = useStringWidgetVue()
|
||||
const node = new LGraphNode('test')
|
||||
const inputSpec: StringInputSpec = {
|
||||
type: 'STRING',
|
||||
name: 'multiline_input',
|
||||
default: 'default\nvalue',
|
||||
placeholder: 'Enter multiline text...',
|
||||
multiline: true
|
||||
}
|
||||
|
||||
const widget = constructor(node, inputSpec)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget.name).toBe('multiline_input')
|
||||
expect(widget.value).toBe('default\nvalue')
|
||||
})
|
||||
|
||||
it('handles placeholder fallback correctly', () => {
|
||||
const constructor = useStringWidgetVue()
|
||||
const node = new LGraphNode('test')
|
||||
const inputSpec: StringInputSpec = {
|
||||
type: 'STRING',
|
||||
name: 'no_placeholder_input',
|
||||
default: 'default_value'
|
||||
}
|
||||
|
||||
const widget = constructor(node, inputSpec)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget.name).toBe('no_placeholder_input')
|
||||
expect(widget.value).toBe('default_value')
|
||||
})
|
||||
|
||||
it('supports dynamic prompts configuration', () => {
|
||||
const constructor = useStringWidgetVue()
|
||||
const node = new LGraphNode('test')
|
||||
const inputSpec: StringInputSpec = {
|
||||
type: 'STRING',
|
||||
name: 'dynamic_input',
|
||||
default: 'value',
|
||||
dynamicPrompts: true
|
||||
}
|
||||
|
||||
const widget = constructor(node, inputSpec)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget.dynamicPrompts).toBe(true)
|
||||
})
|
||||
|
||||
it('throws error for invalid input spec', () => {
|
||||
const constructor = useStringWidgetVue()
|
||||
const node = new LGraphNode('test')
|
||||
const invalidInputSpec = {
|
||||
type: 'INT',
|
||||
name: 'invalid_input'
|
||||
} as any
|
||||
|
||||
expect(() => constructor(node, invalidInputSpec)).toThrow(
|
||||
'Invalid input data'
|
||||
)
|
||||
})
|
||||
})
|
||||
73
tests-ui/tests/composables/useBadgedNumberInput.test.ts
Normal file
73
tests-ui/tests/composables/useBadgedNumberInput.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useBadgedNumberInput } from '@/composables/widgets/useBadgedNumberInput'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
ComponentWidgetImpl: vi.fn().mockImplementation((config) => ({
|
||||
...config,
|
||||
value: config.options.getValue(),
|
||||
setValue: config.options.setValue,
|
||||
options: config.options,
|
||||
props: config.props
|
||||
})),
|
||||
addWidget: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useBadgedNumberInput', () => {
|
||||
const createMockNode = (): LGraphNode =>
|
||||
({
|
||||
id: 1,
|
||||
title: 'Test Node',
|
||||
widgets: [],
|
||||
addWidget: vi.fn()
|
||||
}) as any
|
||||
|
||||
const createInputSpec = (overrides: Partial<InputSpec> = {}): InputSpec => ({
|
||||
name: 'test_input',
|
||||
type: 'number',
|
||||
...overrides
|
||||
})
|
||||
|
||||
it('creates widget constructor with default options', () => {
|
||||
const constructor = useBadgedNumberInput()
|
||||
expect(constructor).toBeDefined()
|
||||
expect(typeof constructor).toBe('function')
|
||||
})
|
||||
|
||||
it('creates widget with default options', () => {
|
||||
const constructor = useBadgedNumberInput()
|
||||
const node = createMockNode()
|
||||
const inputSpec = createInputSpec()
|
||||
|
||||
const widget = constructor(node, inputSpec)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget.name).toBe(inputSpec.name)
|
||||
})
|
||||
|
||||
it('creates widget with custom badge state', () => {
|
||||
const constructor = useBadgedNumberInput({ badgeState: 'random' })
|
||||
const node = createMockNode()
|
||||
const inputSpec = createInputSpec()
|
||||
|
||||
const widget = constructor(node, inputSpec)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
// Widget is created with the props, but accessing them requires the mock structure
|
||||
expect((widget as any).props.badgeState).toBe('random')
|
||||
})
|
||||
|
||||
it('creates widget with disabled state', () => {
|
||||
const constructor = useBadgedNumberInput({ disabled: true })
|
||||
const node = createMockNode()
|
||||
const inputSpec = createInputSpec()
|
||||
|
||||
const widget = constructor(node, inputSpec)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect((widget as any).props.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
80
tests-ui/tests/composables/useColorPickerWidget.test.ts
Normal file
80
tests-ui/tests/composables/useColorPickerWidget.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useColorPickerWidget } from '@/composables/widgets/useColorPickerWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
ComponentWidgetImpl: vi.fn().mockImplementation((config) => ({
|
||||
...config,
|
||||
name: config.name,
|
||||
options: {
|
||||
...config.options,
|
||||
getValue: config.options.getValue,
|
||||
setValue: config.options.setValue,
|
||||
getMinHeight: config.options.getMinHeight
|
||||
}
|
||||
})),
|
||||
addWidget: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useColorPickerWidget', () => {
|
||||
const createMockNode = (): LGraphNode =>
|
||||
({
|
||||
id: 1,
|
||||
title: 'Test Node',
|
||||
widgets: [],
|
||||
addWidget: vi.fn()
|
||||
}) as any
|
||||
|
||||
const createInputSpec = (overrides: Partial<InputSpec> = {}): InputSpec => ({
|
||||
name: 'color',
|
||||
type: 'COLOR',
|
||||
...overrides
|
||||
})
|
||||
|
||||
it('creates widget constructor with default options', () => {
|
||||
const constructor = useColorPickerWidget()
|
||||
expect(constructor).toBeDefined()
|
||||
expect(typeof constructor).toBe('function')
|
||||
})
|
||||
|
||||
it('creates widget with default options', () => {
|
||||
const constructor = useColorPickerWidget()
|
||||
const node = createMockNode()
|
||||
const inputSpec = createInputSpec()
|
||||
|
||||
const widget = constructor(node, inputSpec)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget.name).toBe(inputSpec.name)
|
||||
})
|
||||
|
||||
it('creates widget with custom default value', () => {
|
||||
const constructor = useColorPickerWidget({
|
||||
defaultValue: '#00ff00'
|
||||
})
|
||||
const node = createMockNode()
|
||||
const inputSpec = createInputSpec()
|
||||
|
||||
const widget = constructor(node, inputSpec)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget.name).toBe(inputSpec.name)
|
||||
})
|
||||
|
||||
it('creates widget with custom options', () => {
|
||||
const constructor = useColorPickerWidget({
|
||||
minHeight: 60,
|
||||
serialize: false
|
||||
})
|
||||
const node = createMockNode()
|
||||
const inputSpec = createInputSpec()
|
||||
|
||||
const widget = constructor(node, inputSpec)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget.name).toBe(inputSpec.name)
|
||||
})
|
||||
})
|
||||
109
tests-ui/tests/composables/useDropdownComboWidget.test.ts
Normal file
109
tests-ui/tests/composables/useDropdownComboWidget.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useDropdownComboWidget } from '@/composables/widgets/useDropdownComboWidget'
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
// Mock the domWidget store and related dependencies
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
ComponentWidgetImpl: vi.fn().mockImplementation((config) => ({
|
||||
name: config.name,
|
||||
value: '',
|
||||
options: config.options
|
||||
})),
|
||||
addWidget: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock the scripts/widgets for control widgets
|
||||
vi.mock('@/scripts/widgets', () => ({
|
||||
addValueControlWidgets: vi.fn().mockReturnValue([])
|
||||
}))
|
||||
|
||||
// Mock the remote widget functionality
|
||||
vi.mock('@/composables/widgets/useRemoteWidget', () => ({
|
||||
useRemoteWidget: vi.fn(() => ({
|
||||
addRefreshButton: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
const createMockNode = () => {
|
||||
return {
|
||||
widgets: [],
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
},
|
||||
addWidget: vi.fn(),
|
||||
addCustomWidget: vi.fn(),
|
||||
setDirtyCanvas: vi.fn()
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('useDropdownComboWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
it('creates widget constructor successfully', () => {
|
||||
const constructor = useDropdownComboWidget()
|
||||
expect(constructor).toBeDefined()
|
||||
expect(typeof constructor).toBe('function')
|
||||
})
|
||||
|
||||
it('widget constructor handles input spec correctly', () => {
|
||||
const constructor = useDropdownComboWidget()
|
||||
const mockNode = createMockNode()
|
||||
|
||||
const inputSpec: ComboInputSpec = {
|
||||
name: 'test_dropdown',
|
||||
type: 'COMBO',
|
||||
options: ['option1', 'option2', 'option3']
|
||||
}
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget.name).toBe('test_dropdown')
|
||||
})
|
||||
|
||||
it('widget constructor accepts default value option', () => {
|
||||
const constructor = useDropdownComboWidget({ defaultValue: 'custom' })
|
||||
expect(constructor).toBeDefined()
|
||||
expect(typeof constructor).toBe('function')
|
||||
})
|
||||
|
||||
it('handles remote widgets correctly', () => {
|
||||
const constructor = useDropdownComboWidget()
|
||||
const mockNode = createMockNode()
|
||||
|
||||
const inputSpec: ComboInputSpec = {
|
||||
name: 'remote_dropdown',
|
||||
type: 'COMBO',
|
||||
options: [],
|
||||
remote: {
|
||||
route: '/api/options',
|
||||
refresh_button: true
|
||||
}
|
||||
}
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget.name).toBe('remote_dropdown')
|
||||
})
|
||||
|
||||
it('handles control_after_generate widgets correctly', () => {
|
||||
const constructor = useDropdownComboWidget()
|
||||
const mockNode = createMockNode()
|
||||
|
||||
const inputSpec: ComboInputSpec = {
|
||||
name: 'control_dropdown',
|
||||
type: 'COMBO',
|
||||
options: ['option1', 'option2'],
|
||||
control_after_generate: true
|
||||
}
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget.name).toBe('control_dropdown')
|
||||
})
|
||||
})
|
||||
@@ -1,39 +1,89 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useComboWidget } from '@/composables/widgets/useComboWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
vi.mock('@/scripts/widgets', () => ({
|
||||
addValueControlWidgets: vi.fn()
|
||||
// Mock the dropdown combo widget since COMBO now uses it
|
||||
vi.mock('@/composables/widgets/useDropdownComboWidget', () => ({
|
||||
useDropdownComboWidget: vi.fn(() =>
|
||||
vi.fn().mockReturnValue({ name: 'mockWidget' })
|
||||
)
|
||||
}))
|
||||
|
||||
// Mock the domWidget store and related dependencies
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
ComponentWidgetImpl: vi.fn().mockImplementation((config) => ({
|
||||
name: config.name,
|
||||
value: [],
|
||||
options: config.options
|
||||
})),
|
||||
addWidget: vi.fn()
|
||||
}))
|
||||
|
||||
const createMockNode = () => {
|
||||
return {
|
||||
widgets: [],
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
},
|
||||
addWidget: vi.fn(),
|
||||
addCustomWidget: vi.fn(),
|
||||
setDirtyCanvas: vi.fn()
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('useComboWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should handle undefined spec', () => {
|
||||
it('should delegate single-selection combo to dropdown widget', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue({ options: {} } as any)
|
||||
}
|
||||
const mockNode = createMockNode()
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
const inputSpec: ComboInputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'inputName'
|
||||
name: 'inputName',
|
||||
options: ['option1', 'option2']
|
||||
}
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec)
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'combo',
|
||||
'inputName',
|
||||
undefined, // default value
|
||||
expect.any(Function), // callback
|
||||
expect.objectContaining({
|
||||
values: []
|
||||
})
|
||||
// Should create a widget (delegated to dropdown widget)
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget.name).toBe('mockWidget')
|
||||
})
|
||||
|
||||
it('should use multi-select widget for multi_select combo', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockNode = createMockNode()
|
||||
|
||||
const inputSpec: ComboInputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'multiSelectInput',
|
||||
options: ['option1', 'option2'],
|
||||
multi_select: { placeholder: 'Select multiple' }
|
||||
}
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
// Should create a multi-select widget
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget.name).toBe('multiSelectInput')
|
||||
})
|
||||
|
||||
it('should handle invalid input spec', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockNode = createMockNode()
|
||||
|
||||
const invalidSpec = {
|
||||
type: 'NOT_COMBO',
|
||||
name: 'invalidInput'
|
||||
} as any
|
||||
|
||||
expect(() => constructor(mockNode, invalidSpec)).toThrow(
|
||||
'Invalid input data'
|
||||
)
|
||||
expect(widget).toEqual({ options: {} })
|
||||
})
|
||||
})
|
||||
|
||||
919
vue-widget-conversion/primevue-components.json
Normal file
919
vue-widget-conversion/primevue-components.json
Normal file
@@ -0,0 +1,919 @@
|
||||
{
|
||||
"version": "4.2.5",
|
||||
"generatedAt": "2025-06-09T04:50:20.566Z",
|
||||
"totalComponents": 147,
|
||||
"categories": {
|
||||
"Panel": [
|
||||
{
|
||||
"name": "accordion",
|
||||
"description": "Groups a collection of contents in tabs",
|
||||
"documentationUrl": "https://primevue.org/accordion/",
|
||||
"pascalCase": "Accordion"
|
||||
},
|
||||
{
|
||||
"name": "accordioncontent",
|
||||
"description": "Content container for accordion panels",
|
||||
"documentationUrl": "https://primevue.org/accordion/",
|
||||
"pascalCase": "Accordioncontent"
|
||||
},
|
||||
{
|
||||
"name": "accordionheader",
|
||||
"description": "Header section for accordion panels",
|
||||
"documentationUrl": "https://primevue.org/accordion/",
|
||||
"pascalCase": "Accordionheader"
|
||||
},
|
||||
{
|
||||
"name": "accordionpanel",
|
||||
"description": "Individual panel in an accordion",
|
||||
"documentationUrl": "https://primevue.org/accordion/",
|
||||
"pascalCase": "Accordionpanel"
|
||||
},
|
||||
{
|
||||
"name": "accordiontab",
|
||||
"description": "Legacy accordion tab component",
|
||||
"documentationUrl": "https://primevue.org/accordion/",
|
||||
"pascalCase": "Accordiontab"
|
||||
},
|
||||
{
|
||||
"name": "card",
|
||||
"description": "Flexible content container",
|
||||
"documentationUrl": "https://primevue.org/card/",
|
||||
"pascalCase": "Card"
|
||||
},
|
||||
{
|
||||
"name": "deferredcontent",
|
||||
"description": "Loads content on demand",
|
||||
"documentationUrl": "https://primevue.org/deferredcontent/",
|
||||
"pascalCase": "Deferredcontent"
|
||||
},
|
||||
{
|
||||
"name": "divider",
|
||||
"description": "Separator component",
|
||||
"documentationUrl": "https://primevue.org/divider/",
|
||||
"pascalCase": "Divider"
|
||||
},
|
||||
{
|
||||
"name": "fieldset",
|
||||
"description": "Groups related form elements",
|
||||
"documentationUrl": "https://primevue.org/fieldset/",
|
||||
"pascalCase": "Fieldset"
|
||||
},
|
||||
{
|
||||
"name": "panel",
|
||||
"description": "Collapsible content container",
|
||||
"documentationUrl": "https://primevue.org/panel/",
|
||||
"pascalCase": "Panel"
|
||||
},
|
||||
{
|
||||
"name": "scrollpanel",
|
||||
"description": "Scrollable content container",
|
||||
"documentationUrl": "https://primevue.org/scrollpanel/",
|
||||
"pascalCase": "Scrollpanel"
|
||||
},
|
||||
{
|
||||
"name": "splitter",
|
||||
"description": "Resizable split panels",
|
||||
"documentationUrl": "https://primevue.org/splitter/",
|
||||
"pascalCase": "Splitter"
|
||||
},
|
||||
{
|
||||
"name": "splitterpanel",
|
||||
"description": "Panel within splitter",
|
||||
"documentationUrl": "https://primevue.org/splitter/",
|
||||
"pascalCase": "Splitterpanel"
|
||||
},
|
||||
{
|
||||
"name": "tab",
|
||||
"description": "Individual tab component",
|
||||
"documentationUrl": "https://primevue.org/tabs/",
|
||||
"pascalCase": "Tab"
|
||||
},
|
||||
{
|
||||
"name": "tablist",
|
||||
"description": "Container for tabs",
|
||||
"documentationUrl": "https://primevue.org/tabs/",
|
||||
"pascalCase": "Tablist"
|
||||
},
|
||||
{
|
||||
"name": "tabpanel",
|
||||
"description": "Content panel for tabs",
|
||||
"documentationUrl": "https://primevue.org/tabs/",
|
||||
"pascalCase": "Tabpanel"
|
||||
},
|
||||
{
|
||||
"name": "tabpanels",
|
||||
"description": "Container for tab panels",
|
||||
"documentationUrl": "https://primevue.org/tabs/",
|
||||
"pascalCase": "Tabpanels"
|
||||
},
|
||||
{
|
||||
"name": "tabs",
|
||||
"description": "Modern tab container",
|
||||
"documentationUrl": "https://primevue.org/tabs/",
|
||||
"pascalCase": "Tabs"
|
||||
},
|
||||
{
|
||||
"name": "tabview",
|
||||
"description": "Legacy tabbed interface",
|
||||
"documentationUrl": "https://primevue.org/tabview/",
|
||||
"pascalCase": "Tabview"
|
||||
}
|
||||
],
|
||||
"Directives": [
|
||||
{
|
||||
"name": "animateonscroll",
|
||||
"description": "Directive to apply animations when element becomes visible",
|
||||
"documentationUrl": "https://primevue.org/animateonscroll/",
|
||||
"pascalCase": "Animateonscroll"
|
||||
},
|
||||
{
|
||||
"name": "badgedirective",
|
||||
"description": "Directive to add badges to any element",
|
||||
"documentationUrl": "https://primevue.org/badge/",
|
||||
"pascalCase": "Badgedirective"
|
||||
},
|
||||
{
|
||||
"name": "focustrap",
|
||||
"description": "Directive to trap focus within element",
|
||||
"documentationUrl": "https://primevue.org/focustrap/",
|
||||
"pascalCase": "Focustrap"
|
||||
},
|
||||
{
|
||||
"name": "keyfilter",
|
||||
"description": "Directive to filter keyboard input",
|
||||
"documentationUrl": "https://primevue.org/keyfilter/",
|
||||
"pascalCase": "Keyfilter"
|
||||
},
|
||||
{
|
||||
"name": "ripple",
|
||||
"description": "Directive for material design ripple effect",
|
||||
"documentationUrl": "https://primevue.org/ripple/",
|
||||
"pascalCase": "Ripple"
|
||||
},
|
||||
{
|
||||
"name": "styleclass",
|
||||
"description": "Directive for dynamic styling",
|
||||
"documentationUrl": "https://primevue.org/styleclass/",
|
||||
"pascalCase": "Styleclass"
|
||||
}
|
||||
],
|
||||
"Form": [
|
||||
{
|
||||
"name": "autocomplete",
|
||||
"description": "Provides filtered suggestions while typing input, supports multiple selection and custom item templates",
|
||||
"documentationUrl": "https://primevue.org/autocomplete/",
|
||||
"pascalCase": "Autocomplete"
|
||||
},
|
||||
{
|
||||
"name": "calendar",
|
||||
"description": "Input component for date selection (legacy)",
|
||||
"documentationUrl": "https://primevue.org/calendar/",
|
||||
"pascalCase": "Calendar"
|
||||
},
|
||||
{
|
||||
"name": "cascadeselect",
|
||||
"description": "Nested dropdown selection component",
|
||||
"documentationUrl": "https://primevue.org/cascadeselect/",
|
||||
"pascalCase": "Cascadeselect"
|
||||
},
|
||||
{
|
||||
"name": "checkbox",
|
||||
"description": "Binary selection component",
|
||||
"documentationUrl": "https://primevue.org/checkbox/",
|
||||
"pascalCase": "Checkbox"
|
||||
},
|
||||
{
|
||||
"name": "checkboxgroup",
|
||||
"description": "Groups multiple checkboxes",
|
||||
"documentationUrl": "https://primevue.org/checkbox/",
|
||||
"pascalCase": "Checkboxgroup"
|
||||
},
|
||||
{
|
||||
"name": "chips",
|
||||
"description": "Input component for entering multiple values",
|
||||
"documentationUrl": "https://primevue.org/chips/",
|
||||
"pascalCase": "Chips"
|
||||
},
|
||||
{
|
||||
"name": "colorpicker",
|
||||
"description": "Input component for color selection",
|
||||
"documentationUrl": "https://primevue.org/colorpicker/",
|
||||
"pascalCase": "Colorpicker"
|
||||
},
|
||||
{
|
||||
"name": "datepicker",
|
||||
"description": "Input component for date and time selection with calendar popup, supports date ranges and custom formatting",
|
||||
"documentationUrl": "https://primevue.org/datepicker/",
|
||||
"pascalCase": "Datepicker"
|
||||
},
|
||||
{
|
||||
"name": "dropdown",
|
||||
"description": "Single selection dropdown",
|
||||
"documentationUrl": "https://primevue.org/dropdown/",
|
||||
"pascalCase": "Dropdown"
|
||||
},
|
||||
{
|
||||
"name": "editor",
|
||||
"description": "Rich text editor component",
|
||||
"documentationUrl": "https://primevue.org/editor/",
|
||||
"pascalCase": "Editor"
|
||||
},
|
||||
{
|
||||
"name": "floatlabel",
|
||||
"description": "Floating label for input components",
|
||||
"documentationUrl": "https://primevue.org/inputtext/",
|
||||
"pascalCase": "Floatlabel"
|
||||
},
|
||||
{
|
||||
"name": "iconfield",
|
||||
"description": "Input field with icon",
|
||||
"documentationUrl": "https://primevue.org/inputtext/",
|
||||
"pascalCase": "Iconfield"
|
||||
},
|
||||
{
|
||||
"name": "iftalabel",
|
||||
"description": "Input field with top-aligned label",
|
||||
"documentationUrl": "https://primevue.org/inputtext/",
|
||||
"pascalCase": "Iftalabel"
|
||||
},
|
||||
{
|
||||
"name": "inputchips",
|
||||
"description": "Multiple input values as chips",
|
||||
"documentationUrl": "https://primevue.org/chips/",
|
||||
"pascalCase": "Inputchips"
|
||||
},
|
||||
{
|
||||
"name": "inputgroup",
|
||||
"description": "Groups input elements",
|
||||
"documentationUrl": "https://primevue.org/inputtext/",
|
||||
"pascalCase": "Inputgroup"
|
||||
},
|
||||
{
|
||||
"name": "inputgroupaddon",
|
||||
"description": "Addon for input groups",
|
||||
"documentationUrl": "https://primevue.org/inputtext/",
|
||||
"pascalCase": "Inputgroupaddon"
|
||||
},
|
||||
{
|
||||
"name": "inputicon",
|
||||
"description": "Icon for input components",
|
||||
"documentationUrl": "https://primevue.org/inputtext/",
|
||||
"pascalCase": "Inputicon"
|
||||
},
|
||||
{
|
||||
"name": "inputmask",
|
||||
"description": "Input with format masking",
|
||||
"documentationUrl": "https://primevue.org/inputtext/",
|
||||
"pascalCase": "Inputmask"
|
||||
},
|
||||
{
|
||||
"name": "inputnumber",
|
||||
"description": "Numeric input with spinner",
|
||||
"documentationUrl": "https://primevue.org/inputtext/",
|
||||
"pascalCase": "Inputnumber"
|
||||
},
|
||||
{
|
||||
"name": "inputotp",
|
||||
"description": "One-time password input",
|
||||
"documentationUrl": "https://primevue.org/inputtext/",
|
||||
"pascalCase": "Inputotp"
|
||||
},
|
||||
{
|
||||
"name": "inputswitch",
|
||||
"description": "Binary switch component",
|
||||
"documentationUrl": "https://primevue.org/toggleswitch/",
|
||||
"pascalCase": "Inputswitch"
|
||||
},
|
||||
{
|
||||
"name": "inputtext",
|
||||
"description": "Text input component",
|
||||
"documentationUrl": "https://primevue.org/inputtext/",
|
||||
"pascalCase": "Inputtext"
|
||||
},
|
||||
{
|
||||
"name": "knob",
|
||||
"description": "Circular input component",
|
||||
"documentationUrl": "https://primevue.org/knob/",
|
||||
"pascalCase": "Knob"
|
||||
},
|
||||
{
|
||||
"name": "listbox",
|
||||
"description": "Selection component with list interface",
|
||||
"documentationUrl": "https://primevue.org/listbox/",
|
||||
"pascalCase": "Listbox"
|
||||
},
|
||||
{
|
||||
"name": "multiselect",
|
||||
"description": "Multiple selection dropdown",
|
||||
"documentationUrl": "https://primevue.org/multiselect/",
|
||||
"pascalCase": "Multiselect"
|
||||
},
|
||||
{
|
||||
"name": "password",
|
||||
"description": "Password input with strength meter",
|
||||
"documentationUrl": "https://primevue.org/password/",
|
||||
"pascalCase": "Password"
|
||||
},
|
||||
{
|
||||
"name": "radiobutton",
|
||||
"description": "Single selection from group",
|
||||
"documentationUrl": "https://primevue.org/radiobutton/",
|
||||
"pascalCase": "Radiobutton"
|
||||
},
|
||||
{
|
||||
"name": "radiobuttongroup",
|
||||
"description": "Groups radio buttons",
|
||||
"documentationUrl": "https://primevue.org/radiobutton/",
|
||||
"pascalCase": "Radiobuttongroup"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"description": "Star rating input component",
|
||||
"documentationUrl": "https://primevue.org/rating/",
|
||||
"pascalCase": "Rating"
|
||||
},
|
||||
{
|
||||
"name": "select",
|
||||
"description": "Modern dropdown selection",
|
||||
"documentationUrl": "https://primevue.org/select/",
|
||||
"pascalCase": "Select"
|
||||
},
|
||||
{
|
||||
"name": "selectbutton",
|
||||
"description": "Button-style selection component",
|
||||
"documentationUrl": "https://primevue.org/selectbutton/",
|
||||
"pascalCase": "Selectbutton"
|
||||
},
|
||||
{
|
||||
"name": "slider",
|
||||
"description": "Range selection component",
|
||||
"documentationUrl": "https://primevue.org/slider/",
|
||||
"pascalCase": "Slider"
|
||||
},
|
||||
{
|
||||
"name": "textarea",
|
||||
"description": "Multi-line text input",
|
||||
"documentationUrl": "https://primevue.org/textarea/",
|
||||
"pascalCase": "Textarea"
|
||||
},
|
||||
{
|
||||
"name": "togglebutton",
|
||||
"description": "Two-state button component",
|
||||
"documentationUrl": "https://primevue.org/togglebutton/",
|
||||
"pascalCase": "Togglebutton"
|
||||
},
|
||||
{
|
||||
"name": "toggleswitch",
|
||||
"description": "Switch component for binary state",
|
||||
"documentationUrl": "https://primevue.org/toggleswitch/",
|
||||
"pascalCase": "Toggleswitch"
|
||||
},
|
||||
{
|
||||
"name": "treeselect",
|
||||
"description": "Tree-structured selection",
|
||||
"documentationUrl": "https://primevue.org/treeselect/",
|
||||
"pascalCase": "Treeselect"
|
||||
}
|
||||
],
|
||||
"Misc": [
|
||||
{
|
||||
"name": "avatar",
|
||||
"description": "Represents people using icons, labels and images",
|
||||
"documentationUrl": "https://primevue.org/avatar/",
|
||||
"pascalCase": "Avatar"
|
||||
},
|
||||
{
|
||||
"name": "avatargroup",
|
||||
"description": "Groups multiple avatars together",
|
||||
"documentationUrl": "https://primevue.org/avatar/",
|
||||
"pascalCase": "Avatargroup"
|
||||
},
|
||||
{
|
||||
"name": "badge",
|
||||
"description": "Small numeric indicator for other components",
|
||||
"documentationUrl": "https://primevue.org/badge/",
|
||||
"pascalCase": "Badge"
|
||||
},
|
||||
{
|
||||
"name": "blockui",
|
||||
"description": "Blocks user interaction with page elements",
|
||||
"documentationUrl": "https://primevue.org/blockui/",
|
||||
"pascalCase": "Blockui"
|
||||
},
|
||||
{
|
||||
"name": "chip",
|
||||
"description": "Compact element representing input, attribute or action",
|
||||
"documentationUrl": "https://primevue.org/chip/",
|
||||
"pascalCase": "Chip"
|
||||
},
|
||||
{
|
||||
"name": "inplace",
|
||||
"description": "Editable content in place",
|
||||
"documentationUrl": "https://primevue.org/inplace/",
|
||||
"pascalCase": "Inplace"
|
||||
},
|
||||
{
|
||||
"name": "metergroup",
|
||||
"description": "Displays multiple meter values",
|
||||
"documentationUrl": "https://primevue.org/metergroup/",
|
||||
"pascalCase": "Metergroup"
|
||||
},
|
||||
{
|
||||
"name": "overlaybadge",
|
||||
"description": "Badge overlay for components",
|
||||
"documentationUrl": "https://primevue.org/badge/",
|
||||
"pascalCase": "Overlaybadge"
|
||||
},
|
||||
{
|
||||
"name": "progressbar",
|
||||
"description": "Progress indication component",
|
||||
"documentationUrl": "https://primevue.org/progressbar/",
|
||||
"pascalCase": "Progressbar"
|
||||
},
|
||||
{
|
||||
"name": "progressspinner",
|
||||
"description": "Loading spinner component",
|
||||
"documentationUrl": "https://primevue.org/progressspinner/",
|
||||
"pascalCase": "Progressspinner"
|
||||
},
|
||||
{
|
||||
"name": "skeleton",
|
||||
"description": "Placeholder for loading content",
|
||||
"documentationUrl": "https://primevue.org/skeleton/",
|
||||
"pascalCase": "Skeleton"
|
||||
},
|
||||
{
|
||||
"name": "tag",
|
||||
"description": "Label component for categorization",
|
||||
"documentationUrl": "https://primevue.org/tag/",
|
||||
"pascalCase": "Tag"
|
||||
},
|
||||
{
|
||||
"name": "terminal",
|
||||
"description": "Command line interface",
|
||||
"documentationUrl": "https://primevue.org/terminal/",
|
||||
"pascalCase": "Terminal"
|
||||
}
|
||||
],
|
||||
"Menu": [
|
||||
{
|
||||
"name": "breadcrumb",
|
||||
"description": "Navigation component showing current page location",
|
||||
"documentationUrl": "https://primevue.org/breadcrumb/",
|
||||
"pascalCase": "Breadcrumb"
|
||||
},
|
||||
{
|
||||
"name": "contextmenu",
|
||||
"description": "Right-click context menu",
|
||||
"documentationUrl": "https://primevue.org/contextmenu/",
|
||||
"pascalCase": "Contextmenu"
|
||||
},
|
||||
{
|
||||
"name": "dock",
|
||||
"description": "Dock layout with expandable items",
|
||||
"documentationUrl": "https://primevue.org/dock/",
|
||||
"pascalCase": "Dock"
|
||||
},
|
||||
{
|
||||
"name": "megamenu",
|
||||
"description": "Navigation with grouped menu items",
|
||||
"documentationUrl": "https://primevue.org/megamenu/",
|
||||
"pascalCase": "Megamenu"
|
||||
},
|
||||
{
|
||||
"name": "menu",
|
||||
"description": "Navigation menu component",
|
||||
"documentationUrl": "https://primevue.org/menu/",
|
||||
"pascalCase": "Menu"
|
||||
},
|
||||
{
|
||||
"name": "menubar",
|
||||
"description": "Horizontal navigation menu",
|
||||
"documentationUrl": "https://primevue.org/menubar/",
|
||||
"pascalCase": "Menubar"
|
||||
},
|
||||
{
|
||||
"name": "panelmenu",
|
||||
"description": "Vertical navigation menu",
|
||||
"documentationUrl": "https://primevue.org/panelmenu/",
|
||||
"pascalCase": "Panelmenu"
|
||||
},
|
||||
{
|
||||
"name": "steps",
|
||||
"description": "Step-by-step navigation",
|
||||
"documentationUrl": "https://primevue.org/steps/",
|
||||
"pascalCase": "Steps"
|
||||
},
|
||||
{
|
||||
"name": "tabmenu",
|
||||
"description": "Menu styled as tabs",
|
||||
"documentationUrl": "https://primevue.org/tabmenu/",
|
||||
"pascalCase": "Tabmenu"
|
||||
},
|
||||
{
|
||||
"name": "tieredmenu",
|
||||
"description": "Hierarchical menu component",
|
||||
"documentationUrl": "https://primevue.org/tieredmenu/",
|
||||
"pascalCase": "Tieredmenu"
|
||||
}
|
||||
],
|
||||
"Button": [
|
||||
{
|
||||
"name": "button",
|
||||
"description": "Standard button component with various styles and severity levels, supports icons and loading states",
|
||||
"documentationUrl": "https://primevue.org/button/",
|
||||
"pascalCase": "Button"
|
||||
},
|
||||
{
|
||||
"name": "buttongroup",
|
||||
"description": "Groups multiple buttons together as a cohesive unit with shared styling",
|
||||
"documentationUrl": "https://primevue.org/button/",
|
||||
"pascalCase": "Buttongroup"
|
||||
},
|
||||
{
|
||||
"name": "speeddial",
|
||||
"description": "Floating action button with expandable menu items, supports radial and linear layouts",
|
||||
"documentationUrl": "https://primevue.org/speeddial/",
|
||||
"pascalCase": "Speeddial"
|
||||
},
|
||||
{
|
||||
"name": "splitbutton",
|
||||
"description": "Button with attached dropdown menu for additional actions",
|
||||
"documentationUrl": "https://primevue.org/splitbutton/",
|
||||
"pascalCase": "Splitbutton"
|
||||
}
|
||||
],
|
||||
"Data": [
|
||||
{
|
||||
"name": "carousel",
|
||||
"description": "Displays content in a rotating slideshow",
|
||||
"documentationUrl": "https://primevue.org/carousel/",
|
||||
"pascalCase": "Carousel"
|
||||
},
|
||||
{
|
||||
"name": "datatable",
|
||||
"description": "Advanced data table with sorting, filtering, pagination, row selection, and column resizing",
|
||||
"documentationUrl": "https://primevue.org/datatable/",
|
||||
"pascalCase": "Datatable"
|
||||
},
|
||||
{
|
||||
"name": "dataview",
|
||||
"description": "Displays data in list layout",
|
||||
"documentationUrl": "https://primevue.org/dataview/",
|
||||
"pascalCase": "Dataview"
|
||||
},
|
||||
{
|
||||
"name": "orderlist",
|
||||
"description": "Reorderable list component",
|
||||
"documentationUrl": "https://primevue.org/orderlist/",
|
||||
"pascalCase": "Orderlist"
|
||||
},
|
||||
{
|
||||
"name": "organizationchart",
|
||||
"description": "Hierarchical organization display",
|
||||
"documentationUrl": "https://primevue.org/organizationchart/",
|
||||
"pascalCase": "Organizationchart"
|
||||
},
|
||||
{
|
||||
"name": "paginator",
|
||||
"description": "Navigation for paged data",
|
||||
"documentationUrl": "https://primevue.org/paginator/",
|
||||
"pascalCase": "Paginator"
|
||||
},
|
||||
{
|
||||
"name": "picklist",
|
||||
"description": "Dual list for item transfer",
|
||||
"documentationUrl": "https://primevue.org/picklist/",
|
||||
"pascalCase": "Picklist"
|
||||
},
|
||||
{
|
||||
"name": "timeline",
|
||||
"description": "Chronological event display",
|
||||
"documentationUrl": "https://primevue.org/timeline/",
|
||||
"pascalCase": "Timeline"
|
||||
},
|
||||
{
|
||||
"name": "tree",
|
||||
"description": "Hierarchical tree structure",
|
||||
"documentationUrl": "https://primevue.org/tree/",
|
||||
"pascalCase": "Tree"
|
||||
},
|
||||
{
|
||||
"name": "treetable",
|
||||
"description": "Table with tree structure",
|
||||
"documentationUrl": "https://primevue.org/treetable/",
|
||||
"pascalCase": "Treetable"
|
||||
},
|
||||
{
|
||||
"name": "virtualscroller",
|
||||
"description": "Virtual scrolling for large datasets",
|
||||
"documentationUrl": "https://primevue.org/virtualscroller/",
|
||||
"pascalCase": "Virtualscroller"
|
||||
}
|
||||
],
|
||||
"Chart": [
|
||||
{
|
||||
"name": "chart",
|
||||
"description": "Charts and graphs using Chart.js",
|
||||
"documentationUrl": "https://primevue.org/chart/",
|
||||
"pascalCase": "Chart"
|
||||
}
|
||||
],
|
||||
"Utilities": [
|
||||
{
|
||||
"name": "column",
|
||||
"description": "Table column component for DataTable",
|
||||
"documentationUrl": "https://primevue.org/datatable/",
|
||||
"pascalCase": "Column"
|
||||
},
|
||||
{
|
||||
"name": "columngroup",
|
||||
"description": "Groups table columns",
|
||||
"documentationUrl": "https://primevue.org/datatable/",
|
||||
"pascalCase": "Columngroup"
|
||||
},
|
||||
{
|
||||
"name": "confirmationservice",
|
||||
"description": "Service for programmatically displaying and managing confirmation dialogs",
|
||||
"documentationUrl": "https://primevue.org/confirmdialog/",
|
||||
"pascalCase": "Confirmationservice"
|
||||
},
|
||||
{
|
||||
"name": "dialogservice",
|
||||
"description": "Service for dynamic dialog creation",
|
||||
"documentationUrl": "https://primevue.org/dialogservice/",
|
||||
"pascalCase": "Dialogservice"
|
||||
},
|
||||
{
|
||||
"name": "fluid",
|
||||
"description": "Container with fluid width",
|
||||
"documentationUrl": "https://primevue.org/fluid/",
|
||||
"pascalCase": "Fluid"
|
||||
},
|
||||
{
|
||||
"name": "portal",
|
||||
"description": "Renders content in different DOM location",
|
||||
"documentationUrl": "https://primevue.org/portal/",
|
||||
"pascalCase": "Portal"
|
||||
},
|
||||
{
|
||||
"name": "row",
|
||||
"description": "Table row component",
|
||||
"documentationUrl": "https://primevue.org/datatable/",
|
||||
"pascalCase": "Row"
|
||||
},
|
||||
{
|
||||
"name": "scrolltop",
|
||||
"description": "Button to scroll to top",
|
||||
"documentationUrl": "https://primevue.org/scrolltop/",
|
||||
"pascalCase": "Scrolltop"
|
||||
},
|
||||
{
|
||||
"name": "terminalservice",
|
||||
"description": "Service for terminal component",
|
||||
"documentationUrl": "https://primevue.org/terminal/",
|
||||
"pascalCase": "Terminalservice"
|
||||
},
|
||||
{
|
||||
"name": "useconfirm",
|
||||
"description": "Composable for confirmation dialogs",
|
||||
"documentationUrl": "https://primevue.org/confirmdialog/",
|
||||
"pascalCase": "Useconfirm"
|
||||
},
|
||||
{
|
||||
"name": "usedialog",
|
||||
"description": "Composable for dynamic dialogs",
|
||||
"documentationUrl": "https://primevue.org/dynamicdialog/",
|
||||
"pascalCase": "Usedialog"
|
||||
},
|
||||
{
|
||||
"name": "usestyle",
|
||||
"description": "Composable for dynamic styling",
|
||||
"documentationUrl": "https://primevue.org/usestyle/",
|
||||
"pascalCase": "Usestyle"
|
||||
},
|
||||
{
|
||||
"name": "usetoast",
|
||||
"description": "Composable for toast notifications",
|
||||
"documentationUrl": "https://primevue.org/toast/",
|
||||
"pascalCase": "Usetoast"
|
||||
}
|
||||
],
|
||||
"Uncategorized": [
|
||||
{
|
||||
"name": "config",
|
||||
"description": "Configuration utility for global PrimeVue settings including theming, locale, and component options",
|
||||
"documentationUrl": "https://primevue.org/config/",
|
||||
"pascalCase": "Config"
|
||||
},
|
||||
{
|
||||
"name": "confirmationeventbus",
|
||||
"description": "Internal event bus system for managing confirmation dialog events and communication",
|
||||
"documentationUrl": "https://primevue.org/confirmdialog/",
|
||||
"pascalCase": "Confirmationeventbus"
|
||||
},
|
||||
{
|
||||
"name": "confirmationoptions",
|
||||
"description": "TypeScript interface definitions for confirmation dialog configuration options",
|
||||
"documentationUrl": "https://primevue.org/confirmdialog/",
|
||||
"pascalCase": "Confirmationoptions"
|
||||
},
|
||||
{
|
||||
"name": "dynamicdialogeventbus",
|
||||
"description": "Internal event bus system for managing dynamic dialog creation and communication",
|
||||
"documentationUrl": "https://primevue.org/dynamicdialog/",
|
||||
"pascalCase": "Dynamicdialogeventbus"
|
||||
},
|
||||
{
|
||||
"name": "dynamicdialogoptions",
|
||||
"description": "TypeScript interface definitions for dynamic dialog configuration options",
|
||||
"documentationUrl": "https://primevue.org/dynamicdialog/",
|
||||
"pascalCase": "Dynamicdialogoptions"
|
||||
},
|
||||
{
|
||||
"name": "menuitem",
|
||||
"description": "TypeScript interface definitions for menu item configuration shared across menu components",
|
||||
"documentationUrl": "https://primevue.org/menuitem/",
|
||||
"pascalCase": "Menuitem"
|
||||
},
|
||||
{
|
||||
"name": "overlayeventbus",
|
||||
"description": "Internal event bus system for managing overlay component events and communication",
|
||||
"documentationUrl": "https://primevue.org/overlaypanel/",
|
||||
"pascalCase": "Overlayeventbus"
|
||||
},
|
||||
{
|
||||
"name": "passthrough",
|
||||
"description": "Utility for customizing component styling and attributes through pass-through properties",
|
||||
"documentationUrl": "https://primevue.org/passthrough/",
|
||||
"pascalCase": "Passthrough"
|
||||
},
|
||||
{
|
||||
"name": "toasteventbus",
|
||||
"description": "Internal event bus system for managing toast notification events and communication",
|
||||
"documentationUrl": "https://primevue.org/toast/",
|
||||
"pascalCase": "Toasteventbus"
|
||||
},
|
||||
{
|
||||
"name": "toastservice",
|
||||
"description": "Service for programmatically displaying and managing toast notifications",
|
||||
"documentationUrl": "https://primevue.org/toast/",
|
||||
"pascalCase": "Toastservice"
|
||||
},
|
||||
{
|
||||
"name": "toolbar",
|
||||
"description": "Container for action buttons",
|
||||
"documentationUrl": "https://primevue.org/toolbar/",
|
||||
"pascalCase": "Toolbar"
|
||||
},
|
||||
{
|
||||
"name": "treenode",
|
||||
"description": "Individual node in tree",
|
||||
"documentationUrl": "https://primevue.org/tree/",
|
||||
"pascalCase": "Treenode"
|
||||
}
|
||||
],
|
||||
"Overlay": [
|
||||
{
|
||||
"name": "confirmdialog",
|
||||
"description": "Modal dialog for user confirmation",
|
||||
"documentationUrl": "https://primevue.org/confirmdialog/",
|
||||
"pascalCase": "Confirmdialog"
|
||||
},
|
||||
{
|
||||
"name": "confirmpopup",
|
||||
"description": "Popup for user confirmation",
|
||||
"documentationUrl": "https://primevue.org/confirmdialog/",
|
||||
"pascalCase": "Confirmpopup"
|
||||
},
|
||||
{
|
||||
"name": "dialog",
|
||||
"description": "Modal dialog component",
|
||||
"documentationUrl": "https://primevue.org/dialog/",
|
||||
"pascalCase": "Dialog"
|
||||
},
|
||||
{
|
||||
"name": "drawer",
|
||||
"description": "Sliding panel overlay",
|
||||
"documentationUrl": "https://primevue.org/drawer/",
|
||||
"pascalCase": "Drawer"
|
||||
},
|
||||
{
|
||||
"name": "dynamicdialog",
|
||||
"description": "Programmatically created dialogs",
|
||||
"documentationUrl": "https://primevue.org/dynamicdialog/",
|
||||
"pascalCase": "Dynamicdialog"
|
||||
},
|
||||
{
|
||||
"name": "overlaypanel",
|
||||
"description": "Overlay panel component",
|
||||
"documentationUrl": "https://primevue.org/overlaypanel/",
|
||||
"pascalCase": "Overlaypanel"
|
||||
},
|
||||
{
|
||||
"name": "popover",
|
||||
"description": "Overlay component triggered by user interaction",
|
||||
"documentationUrl": "https://primevue.org/popover/",
|
||||
"pascalCase": "Popover"
|
||||
},
|
||||
{
|
||||
"name": "sidebar",
|
||||
"description": "Side panel overlay",
|
||||
"documentationUrl": "https://primevue.org/sidebar/",
|
||||
"pascalCase": "Sidebar"
|
||||
},
|
||||
{
|
||||
"name": "tooltip",
|
||||
"description": "Informational popup on hover",
|
||||
"documentationUrl": "https://primevue.org/tooltip/",
|
||||
"pascalCase": "Tooltip"
|
||||
}
|
||||
],
|
||||
"File": [
|
||||
{
|
||||
"name": "fileupload",
|
||||
"description": "File upload component with drag-drop",
|
||||
"documentationUrl": "https://primevue.org/fileupload/",
|
||||
"pascalCase": "Fileupload"
|
||||
}
|
||||
],
|
||||
"Media": [
|
||||
{
|
||||
"name": "galleria",
|
||||
"description": "Image gallery with thumbnails",
|
||||
"documentationUrl": "https://primevue.org/galleria/",
|
||||
"pascalCase": "Galleria"
|
||||
},
|
||||
{
|
||||
"name": "image",
|
||||
"description": "Enhanced image component with preview",
|
||||
"documentationUrl": "https://primevue.org/image/",
|
||||
"pascalCase": "Image"
|
||||
},
|
||||
{
|
||||
"name": "imagecompare",
|
||||
"description": "Before/after image comparison slider",
|
||||
"documentationUrl": "https://primevue.org/imagecompare/",
|
||||
"pascalCase": "Imagecompare"
|
||||
}
|
||||
],
|
||||
"Messages": [
|
||||
{
|
||||
"name": "inlinemessage",
|
||||
"description": "Inline message display",
|
||||
"documentationUrl": "https://primevue.org/inlinemessage/",
|
||||
"pascalCase": "Inlinemessage"
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"description": "Message component for notifications",
|
||||
"documentationUrl": "https://primevue.org/message/",
|
||||
"pascalCase": "Message"
|
||||
},
|
||||
{
|
||||
"name": "toast",
|
||||
"description": "Temporary message notifications",
|
||||
"documentationUrl": "https://primevue.org/toast/",
|
||||
"pascalCase": "Toast"
|
||||
}
|
||||
],
|
||||
"Stepper": [
|
||||
{
|
||||
"name": "step",
|
||||
"description": "Individual step in stepper",
|
||||
"documentationUrl": "https://primevue.org/stepper/",
|
||||
"pascalCase": "Step"
|
||||
},
|
||||
{
|
||||
"name": "stepitem",
|
||||
"description": "Item within step",
|
||||
"documentationUrl": "https://primevue.org/stepper/",
|
||||
"pascalCase": "Stepitem"
|
||||
},
|
||||
{
|
||||
"name": "steplist",
|
||||
"description": "List of steps",
|
||||
"documentationUrl": "https://primevue.org/stepper/",
|
||||
"pascalCase": "Steplist"
|
||||
},
|
||||
{
|
||||
"name": "steppanel",
|
||||
"description": "Content panel for stepper",
|
||||
"documentationUrl": "https://primevue.org/stepper/",
|
||||
"pascalCase": "Steppanel"
|
||||
},
|
||||
{
|
||||
"name": "steppanels",
|
||||
"description": "Container for step panels",
|
||||
"documentationUrl": "https://primevue.org/stepper/",
|
||||
"pascalCase": "Steppanels"
|
||||
},
|
||||
{
|
||||
"name": "stepper",
|
||||
"description": "Multi-step process navigation",
|
||||
"documentationUrl": "https://primevue.org/stepper/",
|
||||
"pascalCase": "Stepper"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
2261
vue-widget-conversion/primevue-components.md
Normal file
2261
vue-widget-conversion/primevue-components.md
Normal file
File diff suppressed because it is too large
Load Diff
552
vue-widget-conversion/vue-widget-guide.md
Normal file
552
vue-widget-conversion/vue-widget-guide.md
Normal file
@@ -0,0 +1,552 @@
|
||||
# Step-by-Step Guide: Converting Widgets to Vue Components in ComfyUI
|
||||
|
||||
## Overview
|
||||
This guide explains how to convert existing DOM widgets or create new widgets using Vue components in ComfyUI. The Vue widget system provides better reactivity, type safety, and maintainability compared to traditional DOM manipulation.
|
||||
|
||||
## Prerequisites
|
||||
- Understanding of Vue 3 Composition API
|
||||
- Basic knowledge of TypeScript
|
||||
- Familiarity with ComfyUI widget system
|
||||
|
||||
## Step 1: Create the Vue Component
|
||||
|
||||
Create a new Vue component in `src/components/graph/widgets/`:
|
||||
|
||||
```vue
|
||||
<!-- src/components/graph/widgets/YourWidget.vue -->
|
||||
<template>
|
||||
<div class="your-widget-container">
|
||||
<!-- Your widget UI here -->
|
||||
<input
|
||||
v-model="modelValue"
|
||||
type="text"
|
||||
class="w-full px-2 py-1 rounded"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineModel, defineProps } from 'vue'
|
||||
import type { ComponentWidget } from '@/types'
|
||||
|
||||
// Define two-way binding for the widget value
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
// Receive widget configuration
|
||||
const { widget } = defineProps<{
|
||||
widget: ComponentWidget<string>
|
||||
}>()
|
||||
|
||||
// Access widget properties
|
||||
const inputSpec = widget.inputSpec
|
||||
const options = inputSpec.options || {}
|
||||
|
||||
// Add any logic necessary here to make a functional, feature-rich widget.
|
||||
// You can use the vueuse library for helper functions.
|
||||
// You can take liberty in things to add, as this is just a prototype.
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Use Tailwind classes in template, custom CSS here if needed */
|
||||
</style>
|
||||
```
|
||||
|
||||
## Step 2: Create the Widget Composables (Dual Pattern)
|
||||
|
||||
The Vue widget system uses a **dual composable pattern** for separation of concerns:
|
||||
|
||||
### 2a. Create the Widget Constructor Composable
|
||||
|
||||
Create the core widget constructor in `src/composables/widgets/`:
|
||||
|
||||
```typescript
|
||||
// src/composables/widgets/useYourWidget.ts
|
||||
import { ref } from 'vue'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import YourWidget from '@/components/graph/widgets/YourWidget.vue'
|
||||
|
||||
const PADDING = 8
|
||||
|
||||
export const useYourWidget = (options: { defaultValue?: string } = {}) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
// Initialize widget value
|
||||
const widgetValue = ref<string>(options.defaultValue ?? '')
|
||||
|
||||
// Create the widget instance
|
||||
const widget = new ComponentWidgetImpl<string>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: YourWidget,
|
||||
inputSpec,
|
||||
options: {
|
||||
// Required: getter for widget value
|
||||
getValue: () => widgetValue.value,
|
||||
|
||||
// Required: setter for widget value
|
||||
setValue: (value: string) => {
|
||||
widgetValue.value = value
|
||||
},
|
||||
|
||||
// Optional: minimum height for the widget
|
||||
getMinHeight: () => options.minHeight ?? 40 + PADDING,
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true,
|
||||
|
||||
// Optional: custom serialization
|
||||
serializeValue: (value: string) => {
|
||||
return { yourWidget: value }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
```
|
||||
|
||||
### 2b. Create the Node-Level Logic Composable (When Needed)
|
||||
|
||||
**Only create this if your widget needs dynamic management** (showing/hiding widgets based on events, execution state, etc.). Most standard widgets only need the widget constructor composable.
|
||||
|
||||
For widgets that need node-level operations (like showing/hiding widgets dynamically), create a separate composable in `src/composables/node/`:
|
||||
|
||||
```typescript
|
||||
// src/composables/node/useNodeYourWidget.ts
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useYourWidget } from '@/composables/widgets/useYourWidget'
|
||||
|
||||
const YOUR_WIDGET_NAME = '$$node-your-widget'
|
||||
|
||||
/**
|
||||
* Composable for handling node-level operations for YourWidget
|
||||
*/
|
||||
export function useNodeYourWidget() {
|
||||
const yourWidget = useYourWidget()
|
||||
|
||||
const findYourWidget = (node: LGraphNode) =>
|
||||
node.widgets?.find((w) => w.name === YOUR_WIDGET_NAME)
|
||||
|
||||
const addYourWidget = (node: LGraphNode) =>
|
||||
yourWidget(node, {
|
||||
name: YOUR_WIDGET_NAME,
|
||||
type: 'yourWidgetType'
|
||||
})
|
||||
|
||||
/**
|
||||
* Shows your widget for a node
|
||||
* @param node The graph node to show the widget for
|
||||
* @param value The value to set
|
||||
*/
|
||||
function showYourWidget(node: LGraphNode, value: string) {
|
||||
const widget = findYourWidget(node) ?? addYourWidget(node)
|
||||
widget.value = value
|
||||
node.setDirtyCanvas?.(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes your widget from a node
|
||||
* @param node The graph node to remove the widget from
|
||||
*/
|
||||
function removeYourWidget(node: LGraphNode) {
|
||||
if (!node.widgets) return
|
||||
|
||||
const widgetIdx = node.widgets.findIndex(
|
||||
(w) => w.name === YOUR_WIDGET_NAME
|
||||
)
|
||||
|
||||
if (widgetIdx > -1) {
|
||||
node.widgets[widgetIdx].onRemove?.()
|
||||
node.widgets.splice(widgetIdx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showYourWidget,
|
||||
removeYourWidget
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Register the Widget
|
||||
|
||||
Add your widget to the global widget registry in `src/scripts/widgets.ts`:
|
||||
|
||||
```typescript
|
||||
// src/scripts/widgets.ts
|
||||
import { useYourWidget } from '@/composables/widgets/useYourWidget'
|
||||
import { transformWidgetConstructorV2ToV1 } from '@/scripts/utils'
|
||||
|
||||
export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
|
||||
// ... existing widgets ...
|
||||
YOUR_WIDGET: transformWidgetConstructorV2ToV1(useYourWidget()),
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Handle Widget-Specific Logic
|
||||
|
||||
For widgets that need special handling (e.g., listening to execution events):
|
||||
|
||||
```typescript
|
||||
// In your composable or a separate composable
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { watchEffect, onUnmounted } from 'vue'
|
||||
|
||||
export const useYourWidgetLogic = (nodeId: string) => {
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
// Watch for execution state changes
|
||||
const stopWatcher = watchEffect(() => {
|
||||
if (executionStore.isNodeExecuting(nodeId)) {
|
||||
// Handle execution start
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
onUnmounted(() => {
|
||||
stopWatcher()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Handle Complex Widget Types
|
||||
|
||||
For widgets with complex data types or special requirements:
|
||||
|
||||
```typescript
|
||||
// Multi-value widget example
|
||||
const widget = new ComponentWidgetImpl<string[]>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: MultiSelectWidget,
|
||||
inputSpec,
|
||||
options: {
|
||||
getValue: () => widgetValue.value,
|
||||
setValue: (value: string[]) => {
|
||||
widgetValue.value = Array.isArray(value) ? value : []
|
||||
},
|
||||
getMinHeight: () => 40 + PADDING,
|
||||
|
||||
// Custom validation
|
||||
isValid: (value: string[]) => {
|
||||
return Array.isArray(value) && value.length > 0
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Step 6: Add Widget Props (Optional)
|
||||
|
||||
Pass additional props to your Vue component:
|
||||
|
||||
```typescript
|
||||
const widget = new ComponentWidgetImpl<string, { placeholder: string }>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: YourWidget,
|
||||
inputSpec,
|
||||
props: {
|
||||
placeholder: 'Enter value...'
|
||||
},
|
||||
options: {
|
||||
// ... options
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Step 7: Handle Widget Lifecycle
|
||||
|
||||
For widgets that need cleanup or special lifecycle handling:
|
||||
|
||||
```typescript
|
||||
// In your widget component
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
onMounted(() => {
|
||||
// Initialize resources
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Cleanup resources
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Step 8: Test Your Widget
|
||||
|
||||
1. Create a test node that uses your widget:
|
||||
```python
|
||||
class TestYourWidget:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"value": ("YOUR_WIDGET", {"default": "test"})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Write unit tests for your composable:
|
||||
```typescript
|
||||
// tests-ui/composables/useYourWidget.test.ts
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { useYourWidget } from '@/composables/widgets/useYourWidget'
|
||||
|
||||
describe('useYourWidget', () => {
|
||||
it('creates widget with correct default value', () => {
|
||||
const constructor = useYourWidget({ defaultValue: 'test' })
|
||||
// ... test implementation
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Common Patterns and Best Practices
|
||||
|
||||
### 1. Use PrimeVue Components (REQUIRED)
|
||||
|
||||
**Always use PrimeVue components** for UI elements to maintain consistency across the application. ComfyUI includes PrimeVue 4.2.5 with 147 available components.
|
||||
|
||||
**Reference Documentation**:
|
||||
- See `primevue-components.md` in the project root directory for a complete list of all available PrimeVue components with descriptions and documentation links
|
||||
- Alternative location: `vue-widget-conversion/primevue-components.md` (if working in a conversion branch)
|
||||
- This reference includes all 147 components organized by category (Form, Button, Data, Panel, etc.) with enhanced descriptions
|
||||
|
||||
**Important**: When deciding how to create a widget, always consult the PrimeVue components reference first to find the most appropriate component for your use case.
|
||||
|
||||
Common widget components include:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Text input -->
|
||||
<InputText v-model="modelValue" class="w-full" />
|
||||
|
||||
<!-- Number input -->
|
||||
<InputNumber v-model="modelValue" class="w-full" />
|
||||
|
||||
<!-- Dropdown selection -->
|
||||
<Dropdown v-model="modelValue" :options="options" class="w-full" />
|
||||
|
||||
<!-- Multi-selection -->
|
||||
<MultiSelect v-model="modelValue" :options="options" class="w-full" />
|
||||
|
||||
<!-- Toggle switch -->
|
||||
<ToggleSwitch v-model="modelValue" />
|
||||
|
||||
<!-- Slider -->
|
||||
<Slider v-model="modelValue" class="w-full" />
|
||||
|
||||
<!-- Text area -->
|
||||
<Textarea v-model="modelValue" class="w-full" />
|
||||
|
||||
<!-- File upload -->
|
||||
<FileUpload mode="basic" />
|
||||
|
||||
<!-- Color picker -->
|
||||
<ColorPicker v-model="modelValue" />
|
||||
|
||||
<!-- Rating -->
|
||||
<Rating v-model="modelValue" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import Slider from 'primevue/slider'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import FileUpload from 'primevue/fileupload'
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import Rating from 'primevue/rating'
|
||||
</script>
|
||||
```
|
||||
|
||||
**Important**: Always import PrimeVue components individually as shown above, not from the main primevue package.
|
||||
|
||||
### 2. Handle Type Conversions
|
||||
Ensure proper type handling:
|
||||
```typescript
|
||||
setValue: (value: string | number) => {
|
||||
widgetValue.value = String(value)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Responsive Design
|
||||
Use Tailwind classes for responsive widgets:
|
||||
```vue
|
||||
<div class="w-full min-h-[40px] max-h-[200px] overflow-y-auto">
|
||||
```
|
||||
|
||||
### 4. Error Handling
|
||||
Add validation and error states:
|
||||
```vue
|
||||
<template>
|
||||
<div :class="{ 'border-red-500': hasError }">
|
||||
<!-- widget content -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 5. Performance
|
||||
Use `v-show` instead of `v-if` for frequently toggled content:
|
||||
```vue
|
||||
<div v-show="isExpanded">...</div>
|
||||
```
|
||||
|
||||
## File References
|
||||
|
||||
- **Widget components**: `src/components/graph/widgets/`
|
||||
- **Widget composables**: `src/composables/widgets/`
|
||||
- **Widget registration**: `src/scripts/widgets.ts`
|
||||
- **DOM widget implementation**: `src/scripts/domWidget.ts`
|
||||
- **Widget store**: `src/stores/domWidgetStore.ts`
|
||||
- **Widget container**: `src/components/graph/DomWidgets.vue`
|
||||
- **Widget wrapper**: `src/components/graph/widgets/DomWidget.vue`
|
||||
|
||||
## Real Examples from PRs
|
||||
|
||||
### Example 1: Text Progress Widget (PR #3824)
|
||||
|
||||
**Component** (`src/components/graph/widgets/TextPreviewWidget.vue`):
|
||||
```vue
|
||||
<template>
|
||||
<div class="relative w-full text-xs min-h-[28px] max-h-[200px] rounded-lg px-4 py-2 overflow-y-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 break-all flex items-center gap-2">
|
||||
<span v-html="formattedText"></span>
|
||||
<Skeleton v-if="isParentNodeExecuting" class="!flex-1 !h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
|
||||
import { formatMarkdownValue } from '@/utils/formatUtil'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
const { widget } = defineProps<{ widget?: object }>()
|
||||
|
||||
const { isParentNodeExecuting } = useNodeProgressText(widget?.node)
|
||||
const formattedText = computed(() => formatMarkdownValue(modelValue.value || ''))
|
||||
</script>
|
||||
```
|
||||
|
||||
### Example 2: Multi-Select Widget (PR #2987)
|
||||
|
||||
**Component** (`src/components/graph/widgets/MultiSelectWidget.vue`):
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<MultiSelect
|
||||
v-model="selectedItems"
|
||||
:options="options"
|
||||
filter
|
||||
:placeholder="placeholder"
|
||||
:max-selected-labels="3"
|
||||
:display="display"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import type { ComponentWidget } from '@/types'
|
||||
import type { ComboInputSpec } from '@/types/apiTypes'
|
||||
|
||||
const selectedItems = defineModel<string[]>({ required: true })
|
||||
const { widget } = defineProps<{ widget: ComponentWidget<string[]> }>()
|
||||
|
||||
const inputSpec = widget.inputSpec as ComboInputSpec
|
||||
const options = inputSpec.options ?? []
|
||||
const placeholder = inputSpec.multi_select?.placeholder ?? 'Select items'
|
||||
const display = inputSpec.multi_select?.chip ? 'chip' : 'comma'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
When converting an existing widget:
|
||||
|
||||
- [ ] Identify the widget type and its current implementation
|
||||
- [ ] Create Vue component with proper v-model binding using PrimeVue components
|
||||
- [ ] Create widget constructor composable in `src/composables/widgets/`
|
||||
- [ ] Create node-level composable in `src/composables/node/` (only if widget needs dynamic management)
|
||||
- [ ] Implement getValue/setValue logic with Vue reactivity
|
||||
- [ ] Handle any special widget behavior (events, validation, execution state)
|
||||
- [ ] Register widget in ComfyWidgets registry
|
||||
- [ ] Test with actual nodes that use the widget type
|
||||
- [ ] Add unit tests for both composables
|
||||
- [ ] Update documentation if needed
|
||||
|
||||
## Key Implementation Patterns
|
||||
|
||||
### 1. Vue Component Definition
|
||||
- Use Composition API with `<script setup>`
|
||||
- Use `defineModel` for two-way binding
|
||||
- Accept `widget` prop to access configuration
|
||||
- Use Tailwind CSS for styling
|
||||
|
||||
### 2. Dual Composable Pattern
|
||||
- **Widget Composable** (`src/composables/widgets/`): Always required - creates widget constructor, handles component instantiation and value management
|
||||
- **Node Composable** (`src/composables/node/`): Only needed for dynamic widget management (showing/hiding based on events/state)
|
||||
- Return `ComfyWidgetConstructorV2` from widget composable
|
||||
- Use `ComponentWidgetImpl` class as bridge between Vue and LiteGraph
|
||||
- Handle value initialization and updates with Vue reactivity
|
||||
|
||||
### 3. Widget Registration
|
||||
- Use `ComponentWidgetImpl` as bridge between Vue and LiteGraph
|
||||
- Register in `domWidgetStore` for state management
|
||||
- Add to `ComfyWidgets` registry
|
||||
|
||||
### 4. Integration Points
|
||||
- **DomWidgets.vue**: Main container for all widgets
|
||||
- **DomWidget.vue**: Wrapper handling positioning and rendering
|
||||
- **domWidgetStore**: Centralized widget state management
|
||||
- **executionStore**: For widgets reacting to execution state
|
||||
|
||||
This guide provides a complete pathway for creating Vue-based widgets in ComfyUI, following the patterns established in PRs #3824 and #2987.
|
||||
|
||||
The system uses:
|
||||
- Cloud Scheduler → HTTP POST → Cloud Run API endpoints
|
||||
- Pub/Sub is only for event-driven tasks (file uploads, deletions)
|
||||
- Direct HTTP approach for scheduled tasks with OIDC authentication
|
||||
|
||||
Existing Scheduled Tasks
|
||||
|
||||
1. Node Reindexing - Daily at midnight
|
||||
2. Security Scanning - Hourly
|
||||
3. Comfy Node Pack Backfill - Every 6 hours
|
||||
|
||||
Standard Approach for GitHub Stars Update
|
||||
|
||||
Following the established pattern, you would:
|
||||
|
||||
1. Add API endpoint to openapi.yml
|
||||
2. Implement handler in server/implementation/registry.go
|
||||
3. Add Cloud Scheduler job in infrastructure/modules/compute/cloud_scheduler.tf
|
||||
4. Update authentication rules
|
||||
|
||||
The scheduler would be configured like:
|
||||
schedule = "0 2 */2 * *" # Every 2 days at 2 AM PST
|
||||
uri = "${var.registry_backend_url}/packs/update-github-stars?max_packs=100"
|
||||
|
||||
This follows the exact same HTTP-based pattern as the existing reindex-nodes, security-scan, and
|
||||
comfy-node-pack-backfill jobs. The infrastructure is designed around direct HTTP calls rather than
|
||||
pub/sub orchestration for scheduled tasks.
|
||||
Reference in New Issue
Block a user