@selenite/graph-editor
Version:
A graph editor for visual programming, based on rete and svelte.
296 lines (295 loc) • 10.9 kB
JavaScript
import { BaseComponent } from '../components';
import { Connection, Node } from '../nodes';
import { SvelteSet } from 'svelte/reactivity';
import { Comment } from 'rete-comment-plugin';
import { boxSelection, Rect } from '@selenite/commons';
const entityTypesMap = {
node: Node,
connection: Connection,
comment: Comment
};
export class NodeSelection extends BaseComponent {
entities = new SvelteSet();
accumulating = $state(false);
ranging = $state(false);
modifierActive = $derived(this.accumulating || this.ranging);
typedEntities = $derived(this.entities.values()
.map((e) => {
let type;
if (e instanceof Node) {
type = 'node';
}
else if (e instanceof Connection) {
type = 'connection';
}
else if (e instanceof Comment) {
type = 'comment';
}
else {
throw new Error('Unsupported entity.');
}
return { type, entity: e };
})
.toArray());
constructor(params) {
super(params);
this.cleanup = $effect.root(() => {
$effect(() => {
if (!this.owner.area)
return;
console.debug('Adding selection accumulation.');
const onkeydown = (e) => {
this.accumulating = e.ctrlKey;
this.ranging = e.shiftKey;
};
const onkeyup = (e) => {
this.accumulating = e.ctrlKey;
this.ranging = e.shiftKey;
};
window.addEventListener('keydown', onkeydown);
window.addEventListener('keyup', onkeyup);
return () => {
console.debug('Removing selection accumulation.');
window.removeEventListener('keydown', onkeydown);
window.removeEventListener('keyup', onkeyup);
};
});
$effect(() => {
if (!this.owner.area)
return;
const factory = this.owner;
let twitch = null;
this.owner.area.addPipe((ctx) => {
const selector = factory.selector;
if (ctx.type === 'nodepicked' && this.modifierActive) {
const node = factory.getNode(ctx.data.id);
if (node && this.isSelected(node) && !this.isPicked(node)) {
this.pick(node);
}
}
if (ctx.type === 'nodetranslated') {
const { id, position, previous } = ctx.data;
const node = factory.getNode(id);
if (!node) {
// console.error("Couldn't find node.");
return ctx;
}
const dx = position.x - previous.x;
const dy = position.y - previous.y;
if (this.accumulating && selector.isPicked(node)) {
selector.translate(dx, dy);
}
}
if (this.accumulating || this.ranging)
return ctx;
if (ctx.type === 'pointerdown' || ctx.type === 'pointerup') {
if (ctx.data.event.button !== 0)
return ctx;
const target = ctx.data.event.target;
if (target instanceof Element && !this.owner.area?.container.contains(target)) {
// console.debug('Not contained');
return ctx;
}
}
if (twitch === null) {
if (ctx.type === 'pointerdown')
twitch = 0;
}
else {
if (ctx.type === 'pointermove')
twitch++;
else if (ctx.type === 'pointerup') {
if (ctx.data.event.button !== 0)
return ctx;
if (twitch < 4) {
factory.unselectAll();
}
twitch = null;
}
}
return ctx;
});
});
});
}
nodes = $derived(this.entities.values()
.filter((e) => e instanceof Node)
.toArray());
connections = $derived(this.entities.values()
.filter((e) => e instanceof Connection)
.toArray());
picked = $state();
pickedNode = $derived(this.picked instanceof Node ? this.picked : undefined);
pickedConnection = $derived(this.picked instanceof Connection ? this.picked : undefined);
entityElement(entity) {
const area = this.owner.area;
if (entity instanceof Node) {
return area?.nodeViews.get(entity.id)?.element;
}
if (entity instanceof Connection) {
return (area?.connectionViews.get(entity.id)?.element.querySelector('.visible-path') ?? undefined);
}
if (entity instanceof Comment)
return entity.element;
console.error("Can't get element, unknown entity");
return;
}
isSelected(entity) {
return this.entities.has(entity);
}
invertSelection() {
const nodes = new Set(this.owner.nodes);
const selectedNodes = new Set(this.nodes);
const toSelect = nodes.difference(selectedNodes);
this.unselectAll();
this.selectMultiple(toSelect);
}
/**
* Selects all entities between two entities.
*
* Selects only entities with 50% of their bounding box inside
* the bouding box formed by the two entities.
*/
selectRange(a, b) {
const rectA = this.entityElement(a)?.getBoundingClientRect();
const rectB = this.entityElement(b)?.getBoundingClientRect();
if (!rectA || !rectB)
return;
const minX = Math.min(rectA.left, rectB.left);
const minY = Math.min(rectA.top, rectB.top);
const maxX = Math.max(rectA.right, rectB.right);
const maxY = Math.max(rectA.bottom, rectB.bottom);
const boudingRect = new Rect(minX, minY, maxX - minX, maxY - minY);
for (const e of this.owner.nodes.concat(this.owner.connections)) {
const rect = this.entityElement(e)?.getBoundingClientRect();
if (!rect)
continue;
const rectArea = Rect.area(rect);
if (rectArea === 0)
continue;
const intersection = Rect.intersection(boudingRect, rect);
const proportion = Rect.area(intersection) / rectArea;
if (proportion >= 0.5) {
this.entities.add(e);
}
}
}
selectMultiple(entities) {
if (!this.accumulating && !this.ranging) {
this.unselectAll();
}
for (const e of entities) {
this.entities.add(e);
}
}
select(entity, options = {}) {
if ((options.range === undefined && this.ranging) || options.range) {
if (this.picked) {
this.selectRange(this.picked, entity);
}
}
else if (!this.ranging &&
(options.accumulate === undefined ? !this.accumulating : !options.accumulate)) {
this.unselectAll();
}
if ((options.accumulate === undefined ? this.accumulating : options.accumulate) &&
this.isSelected(entity)) {
this.unselect(entity);
return;
}
this.entities.add(entity);
if (options.pick ?? true) {
this.pick(entity);
}
}
remove(entity) {
this.entities.delete(entity);
}
unselect(entity) {
this.entities.delete(entity);
if (this.picked === entity) {
this.releasePicked();
}
}
unselectAll() {
console.debug('Unselect all');
this.picked = undefined;
this.entities.clear();
}
translate(dx, dy) {
for (const { type, entity } of this.typedEntities) {
if (entity === this.picked)
continue;
switch (type) {
case 'node':
if (!this.owner.area)
return;
const view = this.owner.area.nodeViews.get(entity.id);
const current = view?.position;
if (current) {
view.translate(current.x + dx, current.y + dy);
}
break;
}
}
}
pick(entity) {
this.entities.add(entity);
this.picked = entity;
}
releasePicked() {
this.picked = undefined;
}
isPicked(entity) {
return entity === this.picked;
}
selectAll() {
const editor = this.owner.editor;
this.selectMultiple(this.owner.nodes);
// wu.chain<SelectorEntity>(editor.nodesArray, editor.connectionsArray).forEach((e) => this.entities.add(e));
// this.comment?.comments.forEach((comment) => {
// this.comment?.select(comment.id);
// });
}
//
// Box selection
//
#boxSelectionEnabled = $state(false);
get boxSelectionEnabled() {
return this.#boxSelectionEnabled;
}
boxSelectionAction;
set boxSelectionEnabled(value) {
this.#boxSelectionEnabled = value;
const holder = this.owner.area?.area.content.holder;
const params = {
holder,
threshold: 0.5,
enabled: value,
onselection: (elements) => {
const set = new Set(elements);
const nodeViews = this.owner.area?.nodeViews;
if (!nodeViews)
return;
const toSelect = [];
for (const [nodeId, view] of nodeViews) {
if (set.has(view.element)) {
const node = this.owner.getNode(nodeId);
if (node)
toSelect.push(node);
}
}
this.selectMultiple(toSelect);
}
};
if (!this.boxSelectionAction) {
const container = this.owner.area?.container;
if (!container || !holder)
return;
container.classList.toggle('heybro');
this.boxSelectionAction = boxSelection(container, params);
return;
}
this.boxSelectionAction.update?.(params);
}
}