lynxkite / web /src /LynxKiteNode.svelte
darabos's picture
Correct handle placement when there are inputs/outputs on different sides.
321edbc
raw
history blame
5.27 kB
<script lang="ts">
import { Handle, useSvelteFlow, useUpdateNodeInternals, type NodeProps, NodeResizeControl } from '@xyflow/svelte';
import ChevronDownRight from 'virtual:icons/tabler/chevron-down-right';
const { updateNodeData } = useSvelteFlow();
const updateNodeInternals = useUpdateNodeInternals();
type $$Props = NodeProps;
export let nodeStyle = '';
export let containerStyle = '';
export let id: $$Props['id']; id;
export let data: $$Props['data'];
export let dragHandle: $$Props['dragHandle'] = undefined; dragHandle;
export let type: $$Props['type'] = undefined; type;
export let selected: $$Props['selected'] = undefined; selected;
export let isConnectable: $$Props['isConnectable'] = undefined; isConnectable;
export let zIndex: $$Props['zIndex'] = undefined; zIndex;
export let width: $$Props['width'] = undefined; width;
export let height: $$Props['height'] = undefined; height;
export let dragging: $$Props['dragging']; dragging;
export let targetPosition: $$Props['targetPosition'] = undefined; targetPosition;
export let sourcePosition: $$Props['sourcePosition'] = undefined; sourcePosition;
export let positionAbsoluteX: $$Props['positionAbsoluteX'] = undefined; positionAbsoluteX;
export let positionAbsoluteY: $$Props['positionAbsoluteY'] = undefined; positionAbsoluteY;
export let onToggle = () => {};
$: expanded = !data.collapsed;
function titleClicked() {
updateNodeData(id, { collapsed: expanded });
data = data;
onToggle({ expanded });
updateNodeInternals();
}
function asPx(n: number | undefined) {
return n ? n + 'px' : undefined;
}
function getHandles(inputs, outputs) {
const handles: {
position: 'top' | 'bottom' | 'left' | 'right',
name: string,
index: number,
offsetPercentage: number,
showLabel: boolean,
}[] = [];
for (const e of Object.values(inputs)) {
handles.push({ ...e, type: 'target' });
}
for (const e of Object.values(outputs)) {
handles.push({ ...e, type: 'source' });
}
const counts = { top: 0, bottom: 0, left: 0, right: 0 };
for (const e of handles) {
e.index = counts[e.position];
counts[e.position]++;
}
for (const e of handles) {
e.offsetPercentage = 100 * (e.index + 1) / (counts[e.position] + 1);
const simpleHorizontal = counts.top === 0 && counts.bottom === 0 && handles.length <= 2;
const simpleVertical = counts.left === 0 && counts.right === 0 && handles.length <= 2;
e.showLabel = !simpleHorizontal && !simpleVertical;
}
return handles;
}
$: handles = getHandles(data.meta?.inputs || {}, data.meta?.outputs || {});
const handleOffsetDirection = { top: 'left', bottom: 'left', left: 'top', right: 'top' };
</script>
<div class="node-container" class:expanded={expanded}
style:width={asPx(width)} style:height={asPx(expanded ? height : undefined)} style={containerStyle}>
<div class="lynxkite-node" style={nodeStyle}>
<div class="title" on:click={titleClicked}>
{data.title}
{#if data.error}<span class="title-icon">⚠️</span>{/if}
{#if !expanded}<span class="title-icon">⋯</span>{/if}
</div>
{#if expanded}
{#if data.error}
<div class="error">{data.error}</div>
{/if}
<slot />
{/if}
{#each handles as handle}
<Handle
id={handle.name} type={handle.type} position={handle.position}
style="{handleOffsetDirection[handle.position]}: {handle.offsetPercentage}%">
{#if handle.showLabel}<span class="handle-name">{handle.name.replace(/_/g, " ")}</span>{/if}
</Handle>
{/each}
</div>
{#if expanded}
<NodeResizeControl
minWidth={100}
minHeight={50}
style="background: transparent; border: none;"
onResizeStart={() => updateNodeData(id, { beingResized: true })}
onResizeEnd={() => updateNodeData(id, { beingResized: false })}
>
<ChevronDownRight class="node-resizer" />
</NodeResizeControl>
{/if}
</div>
<style>
.error {
background: #ffdddd;
padding: 8px;
font-size: 12px;
}
.title-icon {
margin-left: 5px;
float: right;
}
.node-container {
padding: 8px;
position: relative;
}
.lynxkite-node {
box-shadow: 0px 5px 50px 0px rgba(0, 0, 0, 0.3);
border-radius: 4px;
background: white;
}
.expanded .lynxkite-node {
overflow-y: auto;
height: 100%;
}
.title {
background: oklch(75% 0.2 55);
font-weight: bold;
padding: 8px;
}
.handle-name {
font-size: 10px;
color: black;
letter-spacing: 0.05em;
text-align: right;
white-space: nowrap;
position: absolute;
top: -5px;
backdrop-filter: blur(10px);
padding: 2px 8px;
border-radius: 4px;
visibility: hidden;
}
:global(.left) .handle-name {
right: 20px;
}
:global(.right) .handle-name {
left: 20px;
}
:global(.top) .handle-name,
:global(.bottom) .handle-name {
top: -5px;
left: 5px;
backdrop-filter: none;
}
.node-container:hover .handle-name {
visibility: visible;
}
:global(.node-resizer) {
position: absolute;
bottom: 8px;
right: 8px;
cursor: nwse-resize;
color: var(--bs-border-color);
}
</style>