lynxkite / web /src /LynxKiteFlow.svelte
darabos's picture
"Image" node type to display an image.
4d72daa
raw
history blame
7.41 kB
<script lang="ts">
import { diff } from 'deep-object-diff';
import { writable, derived } from 'svelte/store';
import {
SvelteFlow,
Controls,
MiniMap,
MarkerType,
useSvelteFlow,
type XYPosition,
type Node,
type Edge,
type Connection,
type NodeTypes,
} from '@xyflow/svelte';
import ArrowBack from 'virtual:icons/tabler/arrow-back'
import Backspace from 'virtual:icons/tabler/backspace'
import Atom from 'virtual:icons/tabler/Atom'
import { useQuery, useMutation, useQueryClient } from '@sveltestack/svelte-query';
import NodeWithParams from './NodeWithParams.svelte';
import NodeWithVisualization from './NodeWithVisualization.svelte';
import NodeWithImage from './NodeWithImage.svelte';
import NodeWithTableView from './NodeWithTableView.svelte';
import NodeWithSubFlow from './NodeWithSubFlow.svelte';
import NodeWithArea from './NodeWithArea.svelte';
import NodeSearch from './NodeSearch.svelte';
import EnvironmentSelector from './EnvironmentSelector.svelte';
import '@xyflow/svelte/dist/style.css';
export let path = '';
const { screenToFlowPosition } = useSvelteFlow();
const queryClient = useQueryClient();
const backendWorkspace = useQuery(['workspace', path], async () => {
const res = await fetch(`/api/load?path=${path}`);
return res.json();
}, {staleTime: 10000, retry: false});
const mutation = useMutation(async(update) => {
const res = await fetch('/api/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(update),
});
return await res.json();
}, {
onSuccess: data => queryClient.setQueryData(['workspace', path], data),
});
const nodeTypes: NodeTypes = {
basic: NodeWithParams,
visualization: NodeWithVisualization,
image: NodeWithImage,
table_view: NodeWithTableView,
sub_flow: NodeWithSubFlow,
area: NodeWithArea,
};
const nodes = writable<Node[]>([]);
const edges = writable<Edge[]>([]);
let doNotSave = true;
$: if ($backendWorkspace.isSuccess) {
doNotSave = true; // Change is coming from the backend.
nodes.set(JSON.parse(JSON.stringify($backendWorkspace.data?.nodes || [])));
edges.set(JSON.parse(JSON.stringify($backendWorkspace.data?.edges || [])));
doNotSave = false;
}
function closeNodeSearch() {
nodeSearchSettings = undefined;
}
function toggleNodeSearch({ detail: { event } }) {
if (nodeSearchSettings) {
closeNodeSearch();
return;
}
event.preventDefault();
nodeSearchSettings = {
pos: { x: event.clientX, y: event.clientY },
boxes: $catalog.data[$backendWorkspace.data?.env],
};
}
function addNode(e) {
const meta = {...e.detail};
nodes.update((n) => {
const node = {
type: meta.type,
data: {
meta: meta,
title: meta.name,
params: Object.fromEntries(
Object.values(meta.params).map((p) => [p.name, p.default])),
},
};
node.position = screenToFlowPosition({x: nodeSearchSettings.pos.x, y: nodeSearchSettings.pos.y});
const title = node.data.title;
let i = 1;
node.id = `${title} ${i}`;
while (n.find((x) => x.id === node.id)) {
i += 1;
node.id = `${title} ${i}`;
}
node.parentId = nodeSearchSettings.parentId;
if (node.parentId) {
node.extent = 'parent';
const parent = n.find((x) => x.id === node.parentId);
node.position = { x: node.position.x - parent.position.x, y: node.position.y - parent.position.y };
}
return [...n, node]
});
closeNodeSearch();
}
const catalog = useQuery(['catalog'], async () => {
const res = await fetch('/api/catalog');
return res.json();
}, {staleTime: 60000, retry: false});
let nodeSearchSettings: {
pos: XYPosition,
boxes: any[],
parentId: string,
};
const graph = derived([nodes, edges], ([nodes, edges]) => ({ nodes, edges }));
// Like JSON.stringify, but with keys sorted.
function orderedJSON(obj: any) {
const allKeys = new Set();
JSON.stringify(obj, (key, value) => (allKeys.add(key), value));
return JSON.stringify(obj, Array.from(allKeys).sort());
}
graph.subscribe(async (g) => {
if (doNotSave) return;
const dragging = g.nodes.find((n) => n.dragging);
if (dragging) return;
const resizing = g.nodes.find((n) => n.data?.beingResized);
if (resizing) return;
scheduleSave(g);
});
let saveTimeout;
function scheduleSave(g) {
// A slight delay, so we don't send a million requests when a node is resized, for example.
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => save(g), 500);
}
function save(g) {
g = JSON.parse(JSON.stringify(g));
for (const node of g.nodes) {
delete node.measured;
delete node.selected;
delete node.dragging;
delete node.beingResized;
}
for (const node of g.edges) {
delete node.markerEnd;
delete node.selected;
}
g.env = $backendWorkspace.data?.env;
const ws = orderedJSON(g);
const bd = orderedJSON($backendWorkspace.data);
if (ws === bd) return;
console.log('changed', JSON.stringify(diff(g, $backendWorkspace.data), null, 2));
$mutation.mutate({ path, ws: g });
}
function nodeClick(e) {
const node = e.detail.node;
const meta = node.data.meta;
if (!meta) return;
const sub_nodes = meta.sub_nodes;
if (!sub_nodes) return;
const event = e.detail.event;
if (event.target.classList.contains('title')) return;
nodeSearchSettings = {
pos: { x: event.clientX, y: event.clientY },
boxes: sub_nodes,
parentId: node.id,
};
}
$: parentDir = path.split('/').slice(0, -1).join('/');
</script>
<div class="page">
<div class="top-bar">
<div class="ws-name">
<a href><img src="/favicon.ico"></a>
{path}
</div>
<div class="tools">
<EnvironmentSelector
options={Object.keys($catalog.data || {})}
value={$backendWorkspace.data?.env}
onChange={(env) => $mutation.mutate({ path, ws: { ...$backendWorkspace.data, env } })}
/>
<a href><Atom /></a>
<a href><Backspace /></a>
<a href="#dir?path={parentDir}"><ArrowBack /></a>
</div>
</div>
<div style:height="100%">
<SvelteFlow {nodes} {edges} {nodeTypes} fitView
on:paneclick={toggleNodeSearch}
on:nodeclick={nodeClick}
proOptions={{ hideAttribution: true }}
maxZoom={3}
minZoom={0.3}
defaultEdgeOptions={{ markerEnd: { type: MarkerType.Arrow } }}
>
<Controls />
<MiniMap />
{#if nodeSearchSettings}
<NodeSearch pos={nodeSearchSettings.pos} boxes={nodeSearchSettings.boxes} on:cancel={closeNodeSearch} on:add={addNode} />
{/if}
</SvelteFlow>
</div>
</div>
<style>
.top-bar {
display: flex;
justify-content: space-between;
background: oklch(30% 0.13 230);
color: white;
}
.ws-name {
font-size: 1.5em;
}
.ws-name img {
height: 1.5em;
vertical-align: middle;
margin: 4px;
}
.page {
display: flex;
flex-direction: column;
height: 100vh;
}
.tools {
display: flex;
align-items: center;
}
.tools a {
color: oklch(75% 0.13 230);
font-size: 1.5em;
padding: 0 10px;
}
</style>