UNPKG

@selenite/graph-editor

Version:

A graph editor for visual programming, based on rete and svelte.

296 lines (295 loc) 10.9 kB
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); } }