logic-mesh 0.9.0

Control logic engine using event based and reactive blocks written in Rust.
Documentation
<script lang="ts">
	import { Handle, Position } from '@xyflow/svelte';
	import * as Select from '$lib/components/ui/select';
	import { Input } from '$lib/components/ui/input';
	import { Button } from '$lib/components/ui/button';
	import { Plus } from 'lucide-svelte';
	import BlockCommons from '../BlockCommons.svelte';
	import { useEngine } from '$lib/Engine';
	import type { Block } from '$lib/Block';

	interface Props {
		data: { value: Block };
	}

	let { data }: Props = $props();
	const { command } = useEngine();

	const block = $derived(data.value);
	const inputKey = $derived(Object.keys(block.inputs)[0] ?? 'in');
	const outputKey = $derived(Object.keys(block.outputs)[0] ?? 'out');

	let customItems: string[] = $state([]);
	let customEntry = $state('');
	let showCustomInput = $state(false);

	// Items from the CSV input + any custom-added items
	const items = $derived.by(() => {
		const csv = String(block.inputs.in.value ?? '');
		const fromInput = csv
			.split(',')
			.map((s) => s.trim())
			.filter(Boolean);
		// Merge, deduplicate, keep order
		const all = [...fromInput];
		for (const c of customItems) {
			if (!all.includes(c)) all.push(c);
		}
		return all;
	});

	const selected = $derived(
		block.outputs.out.value != null ? String(block.outputs.out.value) : undefined
	);

	function onSelect(value: string | undefined) {
		if (value != null) {
			block.outputs.out.value = value;
			command.writeBlockOutput(block.id, outputKey, value);
		}
	}

	function addCustomItem() {
		const val = customEntry.trim();
		if (val && !items.includes(val)) {
			customItems = [...customItems, val];
		}
		if (val) {
			onSelect(val);
		}
		customEntry = '';
		showCustomInput = false;
	}

	function onCustomKeydown(e: KeyboardEvent) {
		if (e.key === 'Enter') {
			e.preventDefault();
			addCustomItem();
		} else if (e.key === 'Escape') {
			showCustomInput = false;
			customEntry = '';
		}
	}
</script>

<BlockCommons data={block}>
	<div class="ui-block-body">
		<Handle id={inputKey} type="target" position={Position.Left} class="handle-dot handle-input" />

		<div class="combo-container">
			<Select.Root type="single" value={selected} onValueChange={onSelect}>
				<Select.Trigger class="h-7 w-32 text-xs">
					{selected ?? 'Select...'}
				</Select.Trigger>
				<Select.Content>
					{#each items as item}
						<Select.Item value={item} label={item} />
					{/each}
				</Select.Content>
			</Select.Root>

			{#if showCustomInput}
				<div class="custom-entry">
					<Input
						bind:value={customEntry}
						onkeydown={onCustomKeydown}
						placeholder="New item..."
						class="h-6 w-24 text-xs"
					/>
					<Button variant="ghost" size="icon" class="h-6 w-6" onclick={addCustomItem}>
						<Plus class="h-3 w-3" />
					</Button>
				</div>
			{:else}
				<Button
					variant="ghost"
					size="icon"
					class="h-6 w-6"
					onclick={() => (showCustomInput = true)}
					aria-label="Add custom item"
				>
					<Plus class="h-3 w-3" />
				</Button>
			{/if}
		</div>

		<Handle id={outputKey} type="source" position={Position.Right} class="handle-dot handle-output" />
	</div>
</BlockCommons>

<style>
	.ui-block-body {
		display: flex;
		align-items: center;
		padding: 6px 10px;
		position: relative;
	}

	.combo-container {
		display: flex;
		flex-direction: column;
		gap: 4px;
	}

	.custom-entry {
		display: flex;
		align-items: center;
		gap: 2px;
	}

	:global(.handle-dot) {
		width: 8px !important;
		height: 8px !important;
		border-radius: 50% !important;
		min-width: 0 !important;
		border: 1.5px solid white !important;
	}
	:global(.handle-input) { background: #6b9eff !important; }
	:global(.handle-output) { background: #6bcf7f !important; }
</style>