Fixed dragging a connection block into an input when multiple connection blocks. Added filter for tag dropdown while typing

This commit is contained in:
Emir Karabeg
2025-02-03 23:06:19 -08:00
parent a0dacfd906
commit c17272f2da
4 changed files with 160 additions and 73 deletions

View File

@@ -26,7 +26,7 @@ export function ConnectionBlocks({
id: connection.id,
name: connection.name,
outputType: connection.outputType,
sourceBlockId: blockId,
sourceBlockId: connection.id,
},
})
)

View File

@@ -4,7 +4,10 @@ import { cn } from '@/lib/utils'
import { useState, useRef } from 'react'
import { SubBlockConfig } from '@/blocks/types'
import { formatDisplayText } from '@/components/ui/formatted-text'
import { EnvVarDropdown, checkEnvVarTrigger } from '@/components/ui/env-var-dropdown'
import {
EnvVarDropdown,
checkEnvVarTrigger,
} from '@/components/ui/env-var-dropdown'
import { TagDropdown, checkTagTrigger } from '@/components/ui/tag-dropdown'
import { useWorkflowStore } from '@/stores/workflow/store'
@@ -30,6 +33,9 @@ export function LongInput({
const [cursorPosition, setCursorPosition] = useState(0)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const overlayRef = useRef<HTMLDivElement>(null)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(
null
)
// Handle input changes
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
@@ -37,12 +43,12 @@ export function LongInput({
const newCursorPosition = e.target.selectionStart ?? 0
setValue(newValue)
setCursorPosition(newCursorPosition)
// Check for environment variables trigger
const envVarTrigger = checkEnvVarTrigger(newValue, newCursorPosition)
setShowEnvVars(envVarTrigger.show)
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
// Check for tag trigger
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
@@ -71,12 +77,16 @@ export function LongInput({
if (data.type !== 'connectionBlock') return
// Get current cursor position or append to end
const dropPosition = textareaRef.current?.selectionStart ?? value?.toString().length ?? 0
const dropPosition =
textareaRef.current?.selectionStart ?? value?.toString().length ?? 0
// Insert '<' at drop position to trigger the dropdown
const currentValue = value?.toString() ?? ''
const newValue = currentValue.slice(0, dropPosition) + '<' + currentValue.slice(dropPosition)
const newValue =
currentValue.slice(0, dropPosition) +
'<' +
currentValue.slice(dropPosition)
// Focus the textarea first
textareaRef.current?.focus()
@@ -86,6 +96,11 @@ export function LongInput({
setCursorPosition(dropPosition + 1)
setShowTags(true)
// Pass the source block ID from the dropped connection
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
// Set cursor position after state updates
setTimeout(() => {
if (textareaRef.current) {
@@ -152,10 +167,12 @@ export function LongInput({
visible={showTags}
onSelect={setValue}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
setActiveSourceBlockId(null)
}}
/>
</div>

View File

@@ -39,6 +39,9 @@ export function ShortInput({
const [cursorPosition, setCursorPosition] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const overlayRef = useRef<HTMLDivElement>(null)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(
null
)
// Use either controlled or uncontrolled value
const value = propValue !== undefined ? propValue : storeValue
@@ -120,6 +123,11 @@ export function ShortInput({
setCursorPosition(dropPosition + 1)
setShowTags(true)
// Pass the source block ID from the dropped connection
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
// Set cursor position after state updates
setTimeout(() => {
if (inputRef.current) {
@@ -206,10 +214,12 @@ export function ShortInput({
visible={showTags}
onSelect={handleEnvVarSelect}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
setActiveSourceBlockId(null)
}}
/>
</div>

View File

@@ -6,6 +6,7 @@ interface TagDropdownProps {
visible: boolean
onSelect: (newValue: string) => void
blockId: string
activeSourceBlockId: string | null
className?: string
inputValue: string
cursorPosition: number
@@ -16,6 +17,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
visible,
onSelect,
blockId,
activeSourceBlockId,
className,
inputValue,
cursorPosition,
@@ -26,80 +28,127 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
// Get available tags from workflow state
const blocks = useWorkflowStore((state) => state.blocks)
const edges = useWorkflowStore((state) => state.edges)
// Extract search term from input
const searchTerm = useMemo(() => {
const textBeforeCursor = inputValue.slice(0, cursorPosition)
const match = textBeforeCursor.match(/<([^>]*)$/)
return match ? match[1].toLowerCase() : ''
}, [inputValue, cursorPosition])
// Get source block and compute tags
const { tags } = useMemo(() => {
const sourceEdge = edges.find(edge => edge.target === blockId)
const sourceBlock = sourceEdge ? blocks[sourceEdge.source] : null
if (!sourceBlock) {
return { tags: [] }
}
// If we have an active source block ID from a drop, use that specific block only
if (activeSourceBlockId) {
const sourceBlock = blocks[activeSourceBlockId]
if (!sourceBlock) return { tags: [] }
// Get all available output paths recursively
const getOutputPaths = (obj: any, prefix = ''): string[] => {
// If we're at a primitive type or null, return the current path
if (typeof obj !== 'object' || obj === null) {
return prefix ? [prefix] : []
const getOutputPaths = (obj: any, prefix = ''): string[] => {
if (typeof obj !== 'object' || obj === null) {
return prefix ? [prefix] : []
}
if ('type' in obj) {
return getOutputPaths(obj.type, prefix)
}
return Object.entries(obj).flatMap(([key, value]) => {
const newPrefix = prefix ? `${prefix}.${key}` : key
return getOutputPaths(value, newPrefix)
})
}
// If we have a type field, this is a block output definition
if ('type' in obj) {
return getOutputPaths(obj.type, prefix)
const outputPaths = getOutputPaths(sourceBlock.outputs)
const blockName = sourceBlock.name || sourceBlock.type
return {
tags: outputPaths.map(
(path) => `${blockName.replace(/\s+/g, '').toLowerCase()}.${path}`
),
}
}
// Otherwise, show tags from all incoming connections
const sourceEdges = edges.filter((edge) => edge.target === blockId)
const sourceTags = sourceEdges.flatMap((edge) => {
const sourceBlock = blocks[edge.source]
if (!sourceBlock) return []
const getOutputPaths = (obj: any, prefix = ''): string[] => {
if (typeof obj !== 'object' || obj === null) {
return prefix ? [prefix] : []
}
if ('type' in obj) {
return getOutputPaths(obj.type, prefix)
}
return Object.entries(obj).flatMap(([key, value]) => {
const newPrefix = prefix ? `${prefix}.${key}` : key
return getOutputPaths(value, newPrefix)
})
}
// Otherwise, traverse the object's properties
return Object.entries(obj).flatMap(([key, value]) => {
const newPrefix = prefix ? `${prefix}.${key}` : key
return getOutputPaths(value, newPrefix)
})
}
const outputPaths = getOutputPaths(sourceBlock.outputs)
const blockName = sourceBlock.name || sourceBlock.type
// Get all output paths starting from the outputs object
const outputPaths = getOutputPaths(sourceBlock.outputs)
const blockName = sourceBlock.name || sourceBlock.type
return outputPaths.map(
(path) => `${blockName.replace(/\s+/g, '').toLowerCase()}.${path}`
)
})
// Format tags with block name and output paths
return {
tags: outputPaths.map(path => `${blockName.replace(/\s+/g, '').toLowerCase()}.${path}`)
}
}, [blocks, edges, blockId])
return { tags: sourceTags }
}, [blocks, edges, blockId, activeSourceBlockId])
// Filter tags based on search term
const filteredTags = useMemo(() => {
if (!searchTerm) return tags
return tags.filter((tag) => tag.toLowerCase().includes(searchTerm))
}, [tags, searchTerm])
// Reset selection when filtered results change
useEffect(() => {
setSelectedIndex(0)
}, [searchTerm])
// Handle tag selection
const handleTagSelect = (tag: string) => {
const textBeforeCursor = inputValue.slice(0, cursorPosition)
const textAfterCursor = inputValue.slice(cursorPosition)
// Find the position of the last '<' before cursor
const lastOpenBracket = textBeforeCursor.lastIndexOf('<')
if (lastOpenBracket === -1) return
const newValue = textBeforeCursor.slice(0, lastOpenBracket) +
'<' + tag + '>' +
textAfterCursor
const newValue =
textBeforeCursor.slice(0, lastOpenBracket) +
'<' +
tag +
'>' +
textAfterCursor
onSelect(newValue)
onClose?.()
}
// Handle keyboard navigation
const handleKeyDown = (e: KeyboardEvent) => {
if (!visible || tags.length === 0) return
if (!visible || filteredTags.length === 0) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex(prev =>
prev < tags.length - 1 ? prev + 1 : prev
setSelectedIndex((prev) =>
prev < filteredTags.length - 1 ? prev + 1 : prev
)
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex(prev => prev > 0 ? prev - 1 : prev)
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
break
case 'Enter':
e.preventDefault()
handleTagSelect(tags[selectedIndex])
handleTagSelect(filteredTags[selectedIndex])
break
case 'Escape':
e.preventDefault()
@@ -114,7 +163,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}
}, [visible, selectedIndex, tags])
}, [visible, selectedIndex, filteredTags])
// Don't render if not visible or no tags
if (!visible || tags.length === 0) return null
@@ -122,46 +171,57 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
return (
<div
className={cn(
"absolute z-[9999] w-full mt-1 overflow-hidden bg-popover rounded-md border shadow-md",
'absolute z-[9999] w-full mt-1 overflow-hidden bg-popover rounded-md border shadow-md',
className
)}
>
<div className="py-1">
{tags.map((tag, index) => (
<button
key={tag}
className={cn(
"w-full px-3 py-1.5 text-sm text-left",
"hover:bg-accent hover:text-accent-foreground",
"focus:bg-accent focus:text-accent-foreground focus:outline-none",
index === selectedIndex && "bg-accent text-accent-foreground"
)}
onMouseEnter={() => setSelectedIndex(index)}
onMouseDown={(e) => {
e.preventDefault() // Prevent input blur
handleTagSelect(tag)
}}
>
{tag}
</button>
))}
{filteredTags.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
No matching tags found
</div>
) : (
filteredTags.map((tag, index) => (
<button
key={tag}
className={cn(
'w-full px-3 py-1.5 text-sm text-left',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
index === selectedIndex && 'bg-accent text-accent-foreground'
)}
onMouseEnter={() => setSelectedIndex(index)}
onMouseDown={(e) => {
e.preventDefault() // Prevent input blur
handleTagSelect(tag)
}}
>
{tag}
</button>
))
)}
</div>
</div>
)
}
// Helper function to check for '<' trigger
export const checkTagTrigger = (text: string, cursorPosition: number): { show: boolean } => {
export const checkTagTrigger = (
text: string,
cursorPosition: number
): { show: boolean } => {
if (cursorPosition >= 1) {
const textBeforeCursor = text.slice(0, cursorPosition)
const lastOpenBracket = textBeforeCursor.lastIndexOf('<')
const lastCloseBracket = textBeforeCursor.lastIndexOf('>')
// Show if we have an unclosed '<' that's not part of a completed tag
if (lastOpenBracket !== -1 && (lastCloseBracket === -1 || lastCloseBracket < lastOpenBracket)) {
if (
lastOpenBracket !== -1 &&
(lastCloseBracket === -1 || lastCloseBracket < lastOpenBracket)
) {
return { show: true }
}
}
return { show: false }
}
}