426 lines
15 KiB
Svelte
426 lines
15 KiB
Svelte
<!-- packages/tinyflow-ui/src/components/base/mixed-input.svelte -->
|
|
<script lang="ts">
|
|
import FloatingTrigger from './floating-trigger.svelte';
|
|
import type {SelectItem} from '#types';
|
|
import {nodeIcons} from '../../consts';
|
|
|
|
let {
|
|
type = 'fixed',
|
|
textValue = '',
|
|
refValue = '',
|
|
refOptions = [],
|
|
placeholder = '请输入内容',
|
|
onTypeChange,
|
|
onTextChange,
|
|
onRefChange,
|
|
style = ''
|
|
}: {
|
|
type: 'fixed' | 'ref';
|
|
textValue: string;
|
|
refValue: string;
|
|
refOptions: SelectItem[];
|
|
onTypeChange?: (type: 'fixed' | 'ref') => void;
|
|
onTextChange?: (val: string) => void;
|
|
onRefChange?: (val: string) => void;
|
|
placeholder?: string;
|
|
style?: string;
|
|
} = $props();
|
|
|
|
let floatingRef: any = $state();
|
|
let hoveredItem: SelectItem | null = $state(null);
|
|
let isOpen = $state(false);
|
|
|
|
let selectedItem = $derived.by(() => {
|
|
let found: SelectItem | null = null;
|
|
const findItem = (items: SelectItem[]) => {
|
|
for (const it of items) {
|
|
if (it.value === refValue) {
|
|
found = it;
|
|
}
|
|
if (it.children) findItem(it.children);
|
|
}
|
|
};
|
|
findItem(refOptions);
|
|
return found;
|
|
});
|
|
|
|
function closeMenu() {
|
|
floatingRef?.hide();
|
|
isOpen = false;
|
|
hoveredItem = null;
|
|
}
|
|
|
|
function handlerOnSelect(item: SelectItem) {
|
|
if (item.selectable !== false) {
|
|
onTypeChange?.('ref');
|
|
onRefChange?.(item.value as string);
|
|
closeMenu();
|
|
} else {
|
|
hoveredItem = item;
|
|
}
|
|
}
|
|
|
|
function handleMouseEnter(item: SelectItem) {
|
|
if (item.children && item.children.length > 0) {
|
|
hoveredItem = item;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#snippet renderNestedItems(items: SelectItem[], depth = 0)}
|
|
{#each items as item}
|
|
<div class="tf-mixed-item-container" style="padding-left: {depth * 16}px">
|
|
<button class="tf-mixed-item {item.children && item.children.length > 0 ? 'has-children' : ''}"
|
|
onclick={() => handlerOnSelect(item)}>
|
|
<div class="tf-mixed-label">
|
|
<div class="tf-mixed-name-wrapper">
|
|
{#if item.children && item.children.length > 0}
|
|
<span class="tf-mixed-expand-icon">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
|
</span>
|
|
{/if}
|
|
<span class="tf-mixed-name">{item.label}</span>
|
|
</div>
|
|
{#if item.dataType}
|
|
<span class="tf-mixed-type">{item.dataType}</span>
|
|
{/if}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
{#if item.children && item.children.length > 0}
|
|
<div class="tf-mixed-item-children">
|
|
{@render renderNestedItems(item.children, depth + 1)}
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
{/snippet}
|
|
|
|
<div class="tf-mixed-input-root" {style}>
|
|
<FloatingTrigger
|
|
bind:this={floatingRef}
|
|
onShow={() => isOpen = true}
|
|
onHide={() => { isOpen = false; hoveredItem = null; }}
|
|
syncWidth={true}
|
|
syncWidthMode="min"
|
|
placement="bottom"
|
|
>
|
|
<!-- Use identical structure to ensure zero visual jumping in box outline -->
|
|
<div class="tf-mixed-wrapper {isOpen ? 'is-focus' : ''}">
|
|
{#if type === 'ref'}
|
|
<!-- Ref view: Styled identical to text view input box -->
|
|
<div class="tf-mixed-box tf-mixed-ref-box">
|
|
{#if selectedItem}
|
|
<div class="tf-mixed-sel-val">
|
|
{#if selectedItem.nodeType && nodeIcons[selectedItem.nodeType]}
|
|
<span class="tf-mixed-val-icon">
|
|
{@html nodeIcons[selectedItem.nodeType]}
|
|
</span>
|
|
{/if}
|
|
<span class="tf-mixed-val-name">{selectedItem.displayLabel || selectedItem.label}</span>
|
|
</div>
|
|
{:else}
|
|
<div class="tf-mixed-placeholder">{placeholder}</div>
|
|
{/if}
|
|
<!-- Clickable area to trigger dropdown -->
|
|
<button class="tf-mixed-trigger-btn" aria-label="选择变量" tabindex="-1">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
|
|
<path d="M12 2L20.6603 7V17L12 22L3.33975 17V7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
|
<circle cx="12" cy="12" r="2" fill="currentColor"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<!-- Clear Button overlapping -->
|
|
<button class="tf-mixed-clear-btn" onclick={(e) => { e.stopPropagation(); onTypeChange?.('fixed'); onRefChange?.(''); }} title="清空引用">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
</button>
|
|
{:else}
|
|
<div class="tf-mixed-box tf-mixed-text-box">
|
|
<input
|
|
type="text"
|
|
class="tf-mixed-native-input nopan nodrag"
|
|
value={textValue}
|
|
placeholder={placeholder}
|
|
spellcheck="false"
|
|
oninput={(e) => onTextChange?.((e.target as HTMLInputElement).value)}
|
|
onclick={(e) => e.stopPropagation()}
|
|
/>
|
|
<!-- Trigger button embedded magically over the right edge! Bubbles event up to floating trigger! -->
|
|
<button class="tf-mixed-trigger-btn" aria-label="选择变量" tabindex="-1">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
|
|
<path d="M12 2L20.6603 7V17L12 22L3.33975 17V7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
|
<circle cx="12" cy="12" r="2" fill="currentColor"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#snippet floating()}
|
|
<div class="tf-mixed-dropdown nopan nodrag nowheel">
|
|
<div class="tf-mixed-list tf-mixed-primary-list">
|
|
{#each refOptions as item}
|
|
<button class="tf-mixed-item {hoveredItem?.value === item.value ? 'active' : ''}"
|
|
onmouseenter={() => handleMouseEnter(item)}
|
|
onclick={() => handlerOnSelect(item)}>
|
|
<span class="tf-mixed-item-icon">
|
|
{#if item.icon}
|
|
{@html item.icon}
|
|
{:else if item.nodeType && nodeIcons[item.nodeType]}
|
|
{@html nodeIcons[item.nodeType]}
|
|
{:else if item.children && item.children.length > 0}
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h18M3 12h18M3 17h18"></path></svg>
|
|
{/if}
|
|
</span>
|
|
<span class="tf-mixed-item-label">{item.label}</span>
|
|
{#if item.children && item.children.length > 0}
|
|
<span class="tf-mixed-item-arrow">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{#if hoveredItem && hoveredItem.children && hoveredItem.children.length > 0}
|
|
<div class="tf-mixed-list tf-mixed-secondary-list">
|
|
{@render renderNestedItems(hoveredItem.children)}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/snippet}
|
|
</FloatingTrigger>
|
|
</div>
|
|
|
|
<style lang="less">
|
|
.tf-mixed-input-root {
|
|
display: flex;
|
|
flex: 1;
|
|
min-width: 0;
|
|
width: 100%;
|
|
}
|
|
|
|
/* This forces the wrapper generated by floating-trigger to occupy full width */
|
|
:global(.tf-mixed-input-root > div) {
|
|
display: flex;
|
|
flex: 1;
|
|
min-width: 0;
|
|
width: 100%;
|
|
}
|
|
|
|
.tf-mixed-wrapper {
|
|
position: relative;
|
|
display: flex;
|
|
flex: 1;
|
|
min-width: 0;
|
|
width: 100%;
|
|
align-items: center;
|
|
}
|
|
|
|
.tf-mixed-box {
|
|
display: flex;
|
|
flex: 1;
|
|
min-width: 0;
|
|
width: 100%;
|
|
position: relative;
|
|
border-radius: 6px;
|
|
height: 32px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.tf-mixed-ref-box {
|
|
background-color: var(--tf-bg-input);
|
|
border: 1px solid var(--tf-border-color-strong);
|
|
padding: 0 48px 0 12px;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.tf-mixed-text-box {
|
|
background-color: var(--tf-bg-input);
|
|
border: 1px solid var(--tf-border-color-strong);
|
|
padding: 0 48px 0 12px;
|
|
align-items: center;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.tf-mixed-wrapper.is-focus .tf-mixed-ref-box,
|
|
.tf-mixed-wrapper.is-focus .tf-mixed-text-box,
|
|
.tf-mixed-ref-box:hover,
|
|
.tf-mixed-text-box:hover {
|
|
border-color: var(--tf-border-color-strong);
|
|
}
|
|
.tf-mixed-wrapper.is-focus .tf-mixed-ref-box,
|
|
.tf-mixed-wrapper.is-focus .tf-mixed-text-box {
|
|
border-color: var(--tf-primary-color);
|
|
outline: none;
|
|
box-shadow: var(--tf-focus-shadow);
|
|
}
|
|
|
|
.tf-mixed-sel-val {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tf-mixed-val-icon {
|
|
width: 18px;
|
|
height: 18px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
color: var(--tf-icon-color);
|
|
background: var(--tf-icon-bg);
|
|
border-radius: 4px;
|
|
padding: 2px;
|
|
box-sizing: border-box;
|
|
|
|
:global(svg) { width: 12px; height: 12px; }
|
|
}
|
|
|
|
.tf-mixed-val-name { color: var(--tf-text-primary); font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; user-select: none; }
|
|
.tf-mixed-placeholder {
|
|
color: var(--tf-text-muted);
|
|
font-size: 13px;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
line-height: 1;
|
|
}
|
|
|
|
/* Trigger button base styling */
|
|
.tf-mixed-trigger-btn {
|
|
position: absolute;
|
|
width: 26px;
|
|
background: transparent;
|
|
border: none;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
color: var(--tf-text-muted);
|
|
border-radius: 0 5px 5px 0;
|
|
transition: all 0.2s;
|
|
flex-shrink: 0;
|
|
|
|
&:hover { background: var(--tf-bg-tag); color: var(--tf-text-secondary); }
|
|
}
|
|
|
|
/* Keep the trigger in the same visual position in both text/ref modes */
|
|
.tf-mixed-text-box .tf-mixed-trigger-btn {
|
|
right: 1px;
|
|
top: 1px;
|
|
bottom: 1px;
|
|
}
|
|
|
|
/* Match text mode exactly to avoid 1px horizontal jump after selecting ref */
|
|
.tf-mixed-ref-box .tf-mixed-trigger-btn {
|
|
right: 1px;
|
|
top: 1px;
|
|
bottom: 1px;
|
|
}
|
|
|
|
.tf-mixed-clear-btn {
|
|
position: absolute;
|
|
right: 31px;
|
|
width: 20px;
|
|
height: 20px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
background: var(--tf-bg-active);
|
|
border: none;
|
|
border-radius: 50%;
|
|
color: var(--tf-text-secondary);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
opacity: 0;
|
|
transition: all 0.2s;
|
|
padding: 3px;
|
|
z-index: 10;
|
|
}
|
|
.tf-mixed-clear-btn:hover { background: var(--tf-danger-color); color: var(--tf-bg-surface); }
|
|
.tf-mixed-wrapper:hover .tf-mixed-clear-btn { opacity: 1; }
|
|
|
|
.tf-mixed-native-input {
|
|
width: 100%;
|
|
min-width: 0;
|
|
border: none;
|
|
outline: none;
|
|
background: transparent;
|
|
color: var(--tf-text-primary);
|
|
font-size: 13px;
|
|
line-height: 32px;
|
|
height: 32px;
|
|
padding: 0;
|
|
margin: 0;
|
|
box-sizing: border-box;
|
|
appearance: none;
|
|
}
|
|
|
|
.tf-mixed-native-input::placeholder {
|
|
color: var(--tf-text-muted);
|
|
line-height: 32px;
|
|
}
|
|
|
|
/* Dropdown Styles matched perfectly to Select */
|
|
.tf-mixed-dropdown {
|
|
display: flex;
|
|
background: var(--tf-bg-surface);
|
|
border-radius: 12px;
|
|
box-shadow: var(--tf-shadow-medium);
|
|
border: 1px solid var(--tf-border-color);
|
|
overflow: hidden;
|
|
width: max-content;
|
|
box-sizing: border-box;
|
|
max-height: 480px;
|
|
z-index: 99999;
|
|
margin-top: 5px;
|
|
}
|
|
.tf-mixed-list { display: flex; flex-direction: column; padding: 8px; overflow-y: auto; }
|
|
.tf-mixed-primary-list { width: 100%; flex-shrink: 0; background: var(--tf-bg-surface-alt); }
|
|
.tf-mixed-dropdown:has(.tf-mixed-secondary-list) .tf-mixed-primary-list { width: auto; min-width: 180px; }
|
|
.tf-mixed-secondary-list { min-width: 220px; background: var(--tf-bg-surface); padding: 12px; border-left: 1px solid var(--tf-bg-muted); animation: slideIn 0.2s ease-out; box-sizing: border-box; }
|
|
|
|
@keyframes slideIn { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); } }
|
|
|
|
.tf-mixed-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 8px 12px;
|
|
border: none;
|
|
background: transparent;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
font-size: 13px;
|
|
color: var(--tf-text-primary);
|
|
gap: 10px;
|
|
width: 100%;
|
|
transition: all 0.15s;
|
|
margin-bottom: 2px;
|
|
|
|
&:hover { background: var(--tf-bg-muted); }
|
|
&.active { background: var(--tf-primary-soft-bg); color: var(--tf-primary-color); font-weight: 500; }
|
|
&.has-children { background: var(--tf-bg-hover); margin-bottom: 4px; &:hover { background: var(--tf-bg-active); } }
|
|
}
|
|
|
|
.tf-mixed-item-icon {
|
|
width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--tf-icon-color); background: var(--tf-icon-bg); border-radius: 5px; padding: 3px; box-sizing: border-box;
|
|
:global(svg) { width: 16px; height: 16px; }
|
|
}
|
|
.tf-mixed-item-label { flex-grow: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.tf-mixed-item-arrow { width: 14px; height: 14px; color: var(--tf-text-muted); }
|
|
.tf-mixed-item-container { position: relative; width: 100%; }
|
|
.tf-mixed-item-children { position: relative; width: 100%; &::before { content: ''; position: absolute; left: 8px; top: 0; bottom: 0; width: 1px; background: var(--tf-border-color-soft); } }
|
|
.tf-mixed-label { display: flex; justify-content: flex-start; align-items: center; width: 100%; gap: 8px; overflow: hidden;}
|
|
.tf-mixed-name-wrapper { display: flex; align-items: center; gap: 6px; }
|
|
.tf-mixed-expand-icon { width: 12px; height: 12px; color: currentColor; opacity: 0.6; }
|
|
.tf-mixed-name { color: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.tf-mixed-type { background: var(--tf-bg-tag); color: var(--tf-text-secondary); padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; white-space: nowrap; }
|
|
</style>
|