@scidian/osui
Version:
Lightweight JavaScript UI library.
297 lines (259 loc) • 11.8 kB
JavaScript
import { Css } from '../utils/Css.js';
import { Div } from '../core/Div.js';
import { Interaction } from '../utils/Interaction.js';
import { Iris } from '../utils/Iris.js';
import { Span } from '../core/Span.js';
import { VectorBox } from '../layout/VectorBox.js';
import { GRID_SIZE, NODE_TYPES, RESIZERS } from '../constants.js';
const MIN_W = 200;
const MIN_H = 100;
const _color1 = new Iris();
const _color2 = new Iris();
class Node extends Div {
#color = new Iris();
#style = {};
#needsUpdate = true;
#startPosition = { x: 0, y: 0 };
constructor({
width = 200,
height = 150,
x = 0,
y = 0,
color = 0x888888,
resizers = [ RESIZERS.TOP, RESIZERS.BOTTOM, RESIZERS.LEFT, RESIZERS.RIGHT ],
} = {}) {
super();
const self = this;
this.addClass('osui-node');
// Enable mouse focus, needs >= 0 for keyboard focus
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#using_tabindex
this.dom.setAttribute('tabindex', '-1');
// Prototype
this.isNode = true;
// Properties
this.graph = undefined;
this.#color.set(color);
// Children
const panel = new Div().addClass('osui-node-panel');
const border = new Div().addClass('osui-node-border');
const sizers = new Div().addClass('osui-node-resizers');
this.addToSelf(sizers, panel, border);
this.contents = function() { return panel; };
// Interior
this.header = new Div().setClass('osui-node-header-title').setStyle('display', 'none');
const lists = new Div().setClass('osui-node-interior');
this.inputList = new Div().setClass('osui-node-item-list');
this.outputList = new Div().setClass('osui-node-item-list');
lists.add(this.inputList, this.outputList);
this.add(this.header, lists);
// Stacking
this.dom.addEventListener('focusout', () => self.removeClass('osui-active-node'));
this.dom.addEventListener('focusin', () => self.activeNode());
this.dom.addEventListener('displayed', () => self.activeNode());
this.dom.addEventListener('pointerdown', () => self.activeNode());
// Disable context menu
function onContextMenu(event) { event.preventDefault(); }
this.onContextMenu(onContextMenu);
// Resizers
let rect = {};
function resizerDown() {
rect.left = self.left;
rect.top = self.top;
rect.width = self.width;
rect.height = self.height;
nodePointerDown();
}
function resizerMove(resizer, diffX, diffY) {
const scale = self.getScale();
diffX *= (1 / scale);
diffY *= (1 / scale);
if (resizer.hasClassWithString('left')) {
const newWidth = Math.max(self.roundNearest(rect.width - diffX), MIN_W);
const newLeft = rect.left + (rect.width - newWidth);
self.setStyle('left', `${newLeft}px`, 'width', `${newWidth}px`);
}
if (resizer.hasClassWithString('top')) {
const newHeight = Math.max(self.roundNearest(rect.height - diffY), MIN_H);
const newTop = rect.top + (rect.height - newHeight);
self.setStyle('top', `${newTop}px`, 'height', `${newHeight}px`);
}
if (resizer.hasClassWithString('right')) {
const newWidth = Math.max(self.roundNearest(rect.width + diffX), MIN_W);
self.setStyle('width', `${newWidth}px`);
}
if (resizer.hasClassWithString('bottom')) {
const newHeight = Math.max(self.roundNearest(rect.height + diffY), MIN_H);
self.setStyle('height', `${newHeight}px`);
}
}
Interaction.makeResizeable(this, sizers, resizers, resizerDown, resizerMove);
// Style Observer
let styleTimeout = undefined;
const observer = new MutationObserver(() => {
self.needsUpdate = true;
clearTimeout(styleTimeout);
styleTimeout = setTimeout(() => self.#updateSizes(), 4);
});
observer.observe(this.dom, { attributes: true, attributeFilter: [ 'style', 'class' ] });
// Initial Size
this.setStyle(
'left', `${this.roundNearest(parseFloat(x))}px`,
'top', `${this.roundNearest(parseFloat(y))}px`,
'width', `${parseFloat(width)}px`,
'height', `${parseFloat(height)}px`,
);
// Dragging (Handle Multiple)
let clickCount = 0;
let watchForSingleClick = false;
let singleClickTimer;
function dragDown() {
if (!self.graph) return;
self.graph.getNodes().forEach((node) => node.setStartPosition(node.left, node.top));
}
function dragMove(diffX, diffY) {
watchForSingleClick = false;
clickCount = 0;
if (!self.graph) return;
self.graph.getNodes().forEach((node) => {
if (node.hasClass('osui-node-selected')) {
node.setStyle('left', `${self.roundNearest(node.getStartPosition().x + diffX)}px`);
node.setStyle('top', `${self.roundNearest(node.getStartPosition().y + diffY)}px`);
}
});
}
function dragUp() {
if (!self.graph) return;
self.dom.dispatchEvent(new Event('dragged'));
}
Interaction.makeDraggable(self, self, false /* limitToWindow */, dragDown, dragMove, dragUp);
// Selectable
function nodePointerDown(event) {
clickCount++;
// Forward right click to Graph
if (event && event.button !== 0) {
if (self.graph) setTimeout(() => self.graph.input.dom.dispatchEvent(event), 0);
return;
}
// Bring to top
if (self.graph) {
const nodes = self.graph.getNodes();
if (self.zIndex !== nodes.length) {
nodes.forEach(node => node.setStyle('zIndex', `${node.zIndex - 1}`));
self.setStyle('zIndex', nodes.length);
}
}
// Select
const selected = document.querySelectorAll(`.osui-node-selected`);
if (!self.hasClass('osui-node-selected')) {
selected.forEach(el => { if (el !== self.dom) el.classList.remove('osui-node-selected'); });
self.addClass('osui-node-selected');
if (self.graph) self.graph.dom.dispatchEvent(new Event('selected'));
watchForSingleClick = false;
} else if (selected.length > 1) {
watchForSingleClick = true;
}
self.dom.ownerDocument.addEventListener('pointerup', nodePointerUp);
}
function nodePointerUp() {
clearTimeout(singleClickTimer);
singleClickTimer = setTimeout(() => {
if (watchForSingleClick && clickCount === 1) {
const selected = document.querySelectorAll(`.osui-node-selected`);
selected.forEach(el => { if (el !== self.dom) el.classList.remove('osui-node-selected'); });
self.addClass('osui-node-selected');
if (self.graph) self.graph.dom.dispatchEvent(new Event('selected'));
}
clickCount = 0;
watchForSingleClick = false;
}, 250);
self.dom.ownerDocument.removeEventListener('pointerup', nodePointerUp);
}
this.onPointerDown(nodePointerDown);
// Double Click (Focus)
function nodeDoubleClick() {
if (!self.graph) return;
self.graph.centerView(false /* resetZoom */, true /* animate */);
}
this.onDblClick(nodeDoubleClick);
// Destroy
this.dom.addEventListener('destroy', function() {
if (observer) observer.disconnect();
}, { once: true });
} // end ctor
/******************** RECT */
get needsUpdate() { return this.#needsUpdate; }
set needsUpdate(update) { this.#needsUpdate = update; }
#updateSizes() {
if (!this.#needsUpdate) return;
const computed = getComputedStyle(this.dom);
const style = this.#style;
style.left = parseFloat(computed.left);
style.top = parseFloat(computed.top);
style.width = parseFloat(computed.width);
style.height = parseFloat(computed.height);
style.right = style.left + style.width;
style.bottom = style.top + style.height;
style.zIndex = parseInt(computed.zIndex);
this.#needsUpdate = false;
const self = this;
if (this.graph) setTimeout(() => {
self.graph.drawMiniMap();
self.graph.drawLines();
}, 20);
}
get left() { this.#updateSizes(); return this.#style.left; }
get top() { this.#updateSizes(); return this.#style.top; }
get width() { this.#updateSizes(); return this.#style.width; }
get height() { this.#updateSizes(); return this.#style.height; }
get right() { this.#updateSizes(); return this.#style.right; }
get bottom() { this.#updateSizes(); return this.#style.bottom; }
get zIndex() { this.#updateSizes(); return this.#style.zIndex; }
// Snapping
roundNearest(decimal, increment = GRID_SIZE) {
if (!this.graph || !this.graph.snapToGrid) return decimal;
return Math.round(decimal / increment) * increment;
}
/******************** SCALE / SNAP / RESIZE */
getScale() {
return (this.graph ? this.graph.getScale() : 1.0);
}
getStartPosition() { return this.#startPosition; }
setStartPosition(x = 0, y = 0) { this.#startPosition.x = x; this.#startPosition.y = y; }
/******************** NODE BUILDING */
addItem(item) {
item.node = this;
switch (item.type) {
case NODE_TYPES.INPUT: this.inputList.add(item); break;
case NODE_TYPES.OUTPUT: this.outputList.add(item); break;
}
}
createHeader(text = '', iconUrl) {
if (this.header.children.length > 0) return; /* already created header */
const icon = new VectorBox(iconUrl);
const iconHolder = new Span().setClass('osui-node-header-icon').add(icon);
const textHolder = new Span().setClass('osui-node-header-text').setTextContent(text);
this.header.add(iconHolder, textHolder);
this.header.setStyle('display', '');
this.applyColor();
this.name = text;
}
/******************** STYLING */
/** Applies 'osui-active-node', ensures this is the only element with this special class */
activeNode(withClass = 'osui-node') {
const activeNode = this.dom;
const panels = document.querySelectorAll(`.${withClass}`);
panels.forEach(el => { if (el !== activeNode) el.classList.remove('osui-active-node'); });
activeNode.classList.add('osui-active-node');
}
applyColor(color) {
if (color !== undefined) this.#color.set(color);
const colorLight = _color2.set(this.#color).darken(1.3).rgbString();
const colorDark = _color1.set(this.#color).darken(0.7).rgbString();
if (this.header) this.header.setStyle('background-image', `linear-gradient(to bottom, rgba(${colorLight}, 0.75), rgba(${colorDark}, 0.75))`);
Css.setVariable('--node-color', _color1.set(this.#color).rgbString(), this.dom);
}
colorString() {
return this.#color.cssString();
}
}
export { Node };