@selenite/graph-editor
Version:
A graph editor for visual programming, based on rete and svelte.
375 lines (374 loc) • 17.7 kB
JavaScript
import {} from '../setup/Setup';
import { _ } from '../../global/todo.svelte';
import { NodeFactory } from '../editor';
import { Node, nodeRegistry } from '../nodes';
import { clientToSurfacePos } from '../../utils/html';
// Ensure all nodes are registered
import '../nodes';
import { VariableNode, XmlNode } from '../nodes/XML';
import { localId, XmlSchema, capitalizeWords, posFromClient, PointerDownWatcher, distance, ContextMenuState } from '@selenite/commons';
import { areTypesCompatible } from './typed-sockets';
export function nodeItem(item) {
return item;
}
export function xmlItem({ label, xmlConfig }) {
return nodeItem({
label,
nodeClass: XmlNode,
params: { xmlConfig },
path: ['XML'],
tags: ['xml'],
description: '',
inputTypes: {},
outputTypes: {}
});
}
export function xmlNodeItems({ schema, basePath = ['XML'], priorities }) {
const res = new Map();
function parseTree(tree, path) {
for (const [name, children] of Object.entries(tree)) {
const itemInRes = res.get(name);
// Ensure node item has the shortest path possible
if (itemInRes && itemInRes.path.length <= path.length) {
continue;
}
const complexType = schema.complexTypes.get(name);
if (complexType === undefined) {
console.error('failed to access complex type', complexType);
continue;
}
const xmlConfig = {
complex: complexType,
priorities
};
const xmlNode = new XmlNode({ xmlConfig });
xmlNode.addAllOptionalAttributes();
res.set(name, {
label: name,
nodeClass: XmlNode,
params: {
schema,
xmlConfig
},
description: '',
inputTypes: xmlNode.inputTypes,
outputTypes: xmlNode.outputTypes,
path,
tags: []
});
if (children === 'recursive') {
continue;
}
parseTree(children, [...path, name]);
}
}
parseTree(schema.tree, basePath);
// console.log([...res.values()])
return [...res.values()];
}
export let baseNodeMenuItems = [];
const cleanup = $effect.root(() => {
$effect(() => {
const res = [];
console.debug('Setting up base node menu items', nodeRegistry.size, nodeRegistry);
for (const [id, nodeClass] of nodeRegistry.entries()) {
if (nodeClass.visible !== undefined && !nodeClass.visible)
continue;
const pieces = id.split('.').map(capitalizeWords);
/** Name of node in id. */
const idName = pieces.at(-1);
// Autogenerate menu path from id if unspecified
if (nodeClass.path === undefined) {
const path = pieces.slice(0, -1);
nodeClass.path = path;
}
const node = new nodeClass();
res.push({
label: node.label === undefined || node.label.trim() === '' ? idName : node.label,
nodeClass: nodeClass,
inputTypes: node.inputTypes,
outputTypes: node.outputTypes,
path: nodeClass.path,
tags: nodeClass.tags ?? [],
description: nodeClass.description ?? ''
});
}
baseNodeMenuItems = res;
});
});
if (import.meta.hot) {
import.meta.hot.on('vite:beforeUpdate', cleanup);
}
export function getMenuItemsFromNodeItems({ factory, pos, nodeItems, action }) {
console.log('getMenuItemsFromNodeItems', nodeItems);
const editor = factory.getEditor();
const area = factory.getArea();
const res = [];
for (const { description, label, path, tags, nodeClass, params } of nodeItems) {
res.push({
id: localId(nodeClass.id.replaceAll('.', '-')),
description,
label,
path,
tags,
action: async () => {
const node = new nodeClass({ factory, ...params });
await editor.addNode(node);
const localPos = clientToSurfacePos({ pos, factory });
await area?.translate(node.id, localPos);
if (action)
action(node);
}
});
}
return res;
}
export function createNodeMenuItem(params) {
return {
tags: [],
path: [],
label: 'Node Item',
description: '',
inputTypes: {},
outputTypes: {},
...params
};
}
export function contextMenuSetup({ showContextMenu, additionalNodeItems }) {
return {
name: 'Context Menu',
type: 'area',
setup: ({ area, factory, editor }) => {
let lastSelectedNodes = [];
const connPlugin = factory.connectionPlugin;
if (!connPlugin) {
console.warn('Connection plugin not found');
}
// Connection drop context menu
else {
connPlugin.addPipe((context) => {
if (context.type === 'pointermove' && ContextMenuState.instance.visible) {
return;
}
if (context.type !== 'connectiondrop')
return context;
// Type conversion is needed to work around the lack of
// extensibility of ConnectionPlugin
const { socket, initial, event, created } = context.data;
if (created || !event)
return;
// Check if the pointer is over a socket
// context.
if (!socket) {
const pos = { x: event.clientX, y: event.clientY };
console.log('pos', pos);
const items = [];
const anyItems = [];
const side = initial.side;
const sourceSocket = initial.payload;
for (const item of [...baseNodeMenuItems, ...(additionalNodeItems || [])]) {
const types = side === 'output' ? item.inputTypes : item.outputTypes;
for (const target of Object.values(types)) {
if (item.nodeClass === XmlNode &&
!sourceSocket.type.startsWith('xmlElement') &&
sourceSocket.type !== 'groupNameRef')
continue;
if (side === 'output'
? areTypesCompatible(sourceSocket, target)
: areTypesCompatible(target, sourceSocket)) {
(target.type === 'any' ? anyItems : items).push(item);
break;
}
}
}
showContextMenu({
expand: true,
pos,
searchbar: true,
items: getMenuItemsFromNodeItems({
factory,
pos,
nodeItems: [...items, ...anyItems],
action: (n) => {
const initialSide = initial.side;
// If connection comes from an input, move the node to the left of the dropped
// connection position
if (n instanceof XmlNode && initialSide === 'output') {
n.addAllOptionalAttributes();
}
const matchingSocket = Object.entries(initialSide === 'output' ? n.inputTypes : n.outputTypes).find(([k, target]) => {
return initialSide === 'output'
? areTypesCompatible(sourceSocket, target)
: areTypesCompatible(target, sourceSocket);
});
if (!matchingSocket) {
console.error("Can't find a valid key for the new node");
return;
}
const newNodeKey = matchingSocket[0];
const source = initialSide === 'output' ? initial.payload.node : n;
const sourceOutput = initialSide === 'output' ? initial.key : newNodeKey;
const target = initialSide === 'output' ? n : initial.payload.node;
const targetInput = initialSide === 'output' ? newNodeKey : initial.key;
if (n instanceof XmlNode && initialSide === 'output') {
n.removeAllOptionalAttributes();
if (n.optionalXmlAttributes.has(targetInput)) {
n.addOptionalAttribute(targetInput);
}
}
editor.addNewConnection(source, sourceOutput, target, targetInput);
const view = area.nodeViews.get(n.id);
// factory.connectionPlugin?.lastPickedSockedData
if (!view) {
throw new Error('Node view not found');
}
let { x, y } = view.position;
// Base action already moves the node to the right of the dropped connection
// position, so we need to move it back by width
// We use setTimeout to wait for the node to get its width
const initialRect = initial.payload.element?.getBoundingClientRect();
if (!initialRect)
throw new Error('Initial rect not found');
setTimeout(() => {
if (initialRect.top > event.clientY)
y = y - n.height / 1.5;
else {
y = y - n.height / 3.5;
}
area.translate(n.id, { x: x - (initialSide === 'input' ? n.width : 0), y });
});
}
}),
onHide: () => {
console.debug('Dropping connection from context menu');
connPlugin.drop();
}
});
return;
}
return context;
});
}
area.addPipe((context) => {
// React to pointerdown and contextmenu events only
if (!['contextmenu', 'pointerdown'].includes(context.type)) {
return context;
}
// Something about node selection, TODO: check what it actually does
// if (context.type === 'pointerdown') {
// const event = context.data.event;
// const nodeDiv =
// event.target instanceof HTMLElement
// ? event.target.classList.contains('node')
// ? event.target
// : event.target.closest('.node')
// : null;
// if (
// event.target instanceof HTMLElement &&
// event.button === 2 &&
// (event.target.classList.contains('node') || event.target.closest('.node')) !== null
// ) {
// const entries = Array(...area.nodeViews.entries());
// const nodeId = entries.find((t) => t[1].element === nodeDiv?.parentElement)?.[0];
// if (!nodeId) return context;
// // factory.selectableNodes?.select(nodeId, true);
// // const selectedNodes = wu(selector.entities.values())
// // .filter((t) => editor.getNode(t.id) !== undefined)
// // .toArray();
// // console.log('remember selected', selectedNodes);
// // if (selectedNodes.length > 0) {
// // lastSelectedNodes = selectedNodes;
// // }
// }
// }
// Handle context menu events
if (context.type === 'contextmenu') {
const e = context.data.event;
e.preventDefault();
const pos = posFromClient(e);
const pDownE = PointerDownWatcher.instance.lastEvent;
if (pDownE) {
const pDownPos = posFromClient(pDownE);
if (distance(pDownPos, pos) > 10) {
return context;
}
}
// Context menu on node
if (context.data.context !== 'root') {
if (!(context.data.context instanceof Node))
return context;
console.debug('Context menu on node');
const node = context.data.context;
// (factory as NodeFactory).select(context.data.context, {
// accumulate: (factory as NodeFactory).selector.entities.size > 1
// });
showContextMenu({
items: [
{
id: 'preview',
get label() {
return node.previewed ? 'Stop Preview' : 'Preview';
},
action() {
node.previewed = !node.previewed;
}
},
{
id: 'delete',
label: 'Delete',
description: 'Delete a node from the editor, removing its connections.',
async action() {
if (node.selected) {
await factory.deleteSelectedElements();
}
else {
await factory.removeNode(context.data.context);
}
},
path: [],
tags: ['delete', 'deletion']
}
],
pos,
searchbar: false
});
context.data.event.preventDefault();
context.data.event.stopImmediatePropagation();
return;
}
// Context menu on editor
context.data.event.preventDefault();
console.debug('Context menu on editor');
const variables = [];
for (const v of Object.values(editor.variables)) {
variables.push(createNodeMenuItem({
nodeClass: VariableNode,
label: v.name,
description: `Get the variable: '${v.name}'.`,
params: { variableId: v.id },
path: ['Variables'],
tags: ['get']
}));
}
const items = getMenuItemsFromNodeItems({
factory,
pos,
nodeItems: [
...variables,
...[...baseNodeMenuItems, ...(additionalNodeItems || [])].sort((a, b) => (a.path.join('') + a.label).localeCompare(b.path.join('') + b.label))
]
});
console.debug('settin hey', baseNodeMenuItems);
// Spawn context menu
showContextMenu({
items,
pos,
sort: true,
searchbar: true
});
}
return context;
});
}
};
}