From f123e50028ce50f37d31f5d7ec592d58add45094 Mon Sep 17 00:00:00 2001 From: Jaon Date: Mon, 31 May 2021 23:54:37 -0300 Subject: [PATCH] Refactor internalRender wires into Connections and Wire component, add basic tests --- src/BaseWire.js | 96 -------- src/BaseWire.ts | 84 +++++++ src/Node.ts | 8 +- src/Port.ts | 4 +- src/{Render.js => Render.ts} | 102 ++++----- src/Sticky.js | 11 +- src/Wire.js | 54 +---- src/domEventHandlers.js | 215 ------------------ src/eventHandlers.ts | 203 +++++++++++++++++ .../{Connections.js => Connections.tsx} | 18 +- src/react/components/Container.tsx | 2 +- src/react/components/Line.js | 3 +- src/react/components/Wire.tsx | 68 ++++++ src/react/components/index.js | 4 +- src/standaloneEntry.tsx | 28 +++ src/types.ts | 11 +- tests/Connections.test.tsx | 47 ++++ tests/leader-line.test.js | 2 +- webpack.config.js | 2 +- 19 files changed, 517 insertions(+), 445 deletions(-) delete mode 100644 src/BaseWire.js create mode 100644 src/BaseWire.ts rename src/{Render.js => Render.ts} (72%) delete mode 100644 src/domEventHandlers.js create mode 100644 src/eventHandlers.ts rename src/react/components/{Connections.js => Connections.tsx} (66%) create mode 100644 src/react/components/Wire.tsx create mode 100644 src/standaloneEntry.tsx create mode 100644 tests/Connections.test.tsx diff --git a/src/BaseWire.js b/src/BaseWire.js deleted file mode 100644 index 6a0dfbd..0000000 --- a/src/BaseWire.js +++ /dev/null @@ -1,96 +0,0 @@ -import { _p } from './utils/points'; -import { spliceByIndex } from './utils/dom' - -export default class BaseWire { - constructor ( config ) { - const { controlPoints, render } = config; - this.controlPoints = controlPoints || []; - this._inverted = false; - this._behavior = undefined; - this.renderInstance = render; - this.custom = false; - - return this; - } - - getControlPoints () { - const [head, ...tail] = this.controlPoints; - return [head, tail.pop()]; - } - - addControlPoints (...controlPoints) { - this.controlPoints = [...this.controlPoints, ...controlPoints]; - } - - setTarget (value) { - const [head, ...tail] = this.controlPoints; - this.controlPoints = [head, value]; - } - - setupInstance (ref) { - this._el = ref; - this._el.type = 'wire'; - this._el.wrapper = this; - } - - getNodes () { - const [sourcePort, targetPort] = this.getControlPoints(); - return [sourcePort.node, targetPort.node] - } - - seal() { - const [sourcePort, targetPort] = this.getControlPoints(); - - if (sourcePort.direction == targetPort.direction) - return false; - - const canAttach = sourcePort.attach(targetPort); - - if (canAttach) { - sourcePort.node.wires.push(this); - targetPort.node.wires.push(this); - } - - if ( !canAttach && this._el && this.renderInstance && this.renderInstance.internalRender) - this.renderInstance.removeElement(this._el); - - return canAttach; - } - - delete () { - const [sourcePort, targetPort] = this.getControlPoints(); - - spliceByIndex( sourcePort.node.wires, this ); - spliceByIndex( targetPort.node.wires, this ); - - sourcePort.dettach(targetPort); - - if (!this.custom) - this.removeFromParent(); - } - - removeFromParent () { - if (this._el) - this._el.parentNode.removeChild(this._el); - } - - renderTranslated (cpA, cpB, offset = { x: 0, y: 0 }, zoom = 1) { - const vOffset = _p.multiply(offset, zoom); - const pointA = _p.add(cpA, vOffset); - const pointB = _p.add(cpB, vOffset); - this.renderPoints(pointA, pointB, this._inverted); - } - - render (offset, zoom) { - if (this.custom) - return null; - - const [sourcePort, targetPort] = this.getControlPoints(); - this.renderTranslated( - sourcePort.getPoint(zoom), - targetPort.getPoint(zoom), - offset, - zoom - ); - } -} diff --git a/src/BaseWire.ts b/src/BaseWire.ts new file mode 100644 index 0000000..c7d264e --- /dev/null +++ b/src/BaseWire.ts @@ -0,0 +1,84 @@ +import { _p } from './utils/points'; +import { spliceByIndex } from './utils/dom' +import type { Position } from './types' +import Port from './Port'; + +type ControlPoint = Port|Position + +export default class BaseWire { + controlPoints: ControlPoint[]; + _inverted: Boolean = false; + custom: Boolean = false; + _el: SVGElement; + + constructor ( config ) { + const { controlPoints, render } = config; + this.controlPoints = controlPoints || []; + this._inverted = false; + // this.renderInstance = render; + this.custom = false; + + return this; + } + + getControlPoints () { + const [head, ...tail] = this.controlPoints; + return [head, tail.pop()]; + } + + getPorts () { + return this.getControlPoints().filter(<(v) => v is Port>(v => v instanceof Port)); + } + + addControlPoints (...controlPoints: ControlPoint[]) { + this.controlPoints = [...this.controlPoints, ...controlPoints]; + } + + setTarget (value) { + const [head, ...tail] = this.controlPoints; + this.controlPoints = [head, value]; + } + + setupInstance (ref: SVGElement) { + this._el = ref; + this._el['type'] = 'wire'; + this._el['wrapper'] = this; + } + + getNodes () { + const [sourcePort, targetPort] = this.getPorts() as Port[]; + return [sourcePort?.node, targetPort?.node] + } + + seal() { + const [sourcePort, targetPort] = this.getPorts(); + + if (sourcePort?.direction == targetPort?.direction) + return false; + + const canAttach = sourcePort.attach(targetPort); + + if (canAttach) { + sourcePort.node.wires.push(this); + targetPort.node.wires.push(this); + } + + return canAttach; + } + + delete () { + const [sourcePort, targetPort] = this.getPorts(); + spliceByIndex( sourcePort.node.wires, this ); + spliceByIndex( targetPort.node.wires, this ); + sourcePort.dettach(targetPort); + } + + getPointTranslated (point, offset = { x: 0, y: 0 }, zoom = 1) { + const vOffset = _p.multiply(offset, zoom); + return _p.add(point, vOffset); + } + + render (offset, zoom) { + return null; + } +} diff --git a/src/Node.ts b/src/Node.ts index f8af521..b56748f 100644 --- a/src/Node.ts +++ b/src/Node.ts @@ -2,7 +2,7 @@ import _isNil from 'lodash/isNil'; import set from 'lodash/set'; import forEach from 'lodash/forEach'; import Port from './Port' -import Wire from './Wire' +import BaseWire from './BaseWire'; import { SVGContainer } from './blockBuilder'; import { getParentSvg } from './utils/dom'; @@ -66,7 +66,7 @@ export default class Node { _states: { dragging: boolean }; x: number; y: number; - wires: Wire[]; + wires: BaseWire[]; behavior: Function; // run cfg: NodeConfig; // config gui: GuiConfig; // inputsConfig @@ -121,12 +121,10 @@ export default class Node { delete () { for (let wire of [...this.wires]) wire.delete(); - - this._el.parentNode.removeChild(this._el); } updateWires (offset, zoom) { - this.wires.forEach( wire => wire.render(offset, zoom) ); + // this.wires.forEach( wire => wire.render(offset, zoom) ); } getValue (getNode, context, id?) { diff --git a/src/Port.ts b/src/Port.ts index bb6936d..5fe0c10 100644 --- a/src/Port.ts +++ b/src/Port.ts @@ -74,8 +74,8 @@ export default class Port { return this._el.getAttribute(key); } - getPoint (zoom = 1) { - return _p.add(_p.multiply(this.node, zoom), this); + getPoint (offset = { x: 0, y: 0 }, zoom = 1) { + return _p.add(_p.multiply(this.node, zoom), _p.multiply(offset, zoom), this); } isCompatible (to: Port) { diff --git a/src/Render.js b/src/Render.ts similarity index 72% rename from src/Render.js rename to src/Render.ts index a2bb4e2..34289e1 100644 --- a/src/Render.js +++ b/src/Render.ts @@ -1,42 +1,57 @@ import React from 'react'; -import ReactDOM from 'react-dom'; +import _throttle from 'lodash/throttle'; +import BaseWire from './BaseWire'; +import Sticky from './Sticky'; import Wire from './Wire'; -import { createElement } from './utils/dom'; -import { registerEvents } from './domEventHandlers' -import { NodeGraph } from './react/components'; +import { registerEvents } from './eventHandlers' import { _p } from './utils/points'; +import { Offset, Position, TargetElement, TargetWrappers, Zoom } from './types'; const defaultConfig = { width: 800, height: 600 }; +export type Config = typeof defaultConfig & { wrapper: Sticky }; + +type MouseDownContext = Position & { offset?: Offset; wrapper?: Position; barePos?: Position; mouse?: Position; }; +type TemporaryContext = { wire?: BaseWire; lastZoomTime?: number; mouseDown?: MouseDownContext; }; // TODO: // change id argument approach, use 'element' from config // move reactDom outside export default class Render { - constructor (id, config) { + _svg: SVGElement & { type?: string }; + zoom: Zoom = 1; + offset: Offset = { x: 0, y: 0 }; + disableZoom = false; + disableDragging = false; + internalRender = true; + gridSize = 20; + gridColor = 'grey'; + backgroundColor = '#CCCCCC75'; + config: Config; + lastSelected?: TargetWrappers; + dragging?: TargetElement; + _state: null | string; + _wires: BaseWire[]; + _aux: TemporaryContext; + react?: React.Component; + + constructor (config) { this.config = { ...defaultConfig, ...config }; this._aux = {}; this._state = null; this._wires = []; - this.offset = { x: 0, y: 0 }; - this.zoom = 1; - this.disableZoom = false; - this.disableDragging = false; - this.internalRender = true; - this.gridSize = 20; - this.gridColor = 'grey'; - this.backgroundColor = '#CCCCCC75'; - - const element = document.getElementById(id); - if (element) { - this.reactDOM(element); - } + this.lastSelected = null; + this.dragging = null; - this._p = _p; + // this._p = _p; return this; } + get selectedWire() { + return this.lastSelected instanceof BaseWire ? this.lastSelected : null; + } + getConnections () { const connections = [...this._wires]; const { wire } = this._aux; @@ -52,29 +67,10 @@ export default class Render { registerEvents.call(this); } - reactDOM (element) { - const { wrapper, Component } = this.config; - const svg = createElement('svg', { class: 'svg-content', preserveAspectRatio: "xMidYMid meet" }); - this.loadContainer(svg); - - element.classList.add('sticky__canvas'); - element.appendChild(this._svg); - - ReactDOM.render( - { this.react = ref }} - getNodes={() => wrapper.nodes} - getOffset={() => this.offset} - getZoom={() => this.zoom} - />, - svg - ); - } - - loadContainer (svg) { + loadContainer (svg: SVGElement) { const { width, height } = this.config; this._svg = svg; - svg.type = 'container'; + this._svg.type = 'container'; this.matchViewBox(); this.setCanvasSize({ width, height }); this.registerEvents(); @@ -104,8 +100,8 @@ export default class Render { startDrag (port) { this.setState('dragging'); - this._aux['wire'] = wire; - this.addElement(wire._el); + // this._aux['wire'] = wire; + // this.addElement(wire._el); } startAttach (port) { @@ -116,7 +112,7 @@ export default class Render { this._aux['wire'] = wire; if (this.internalRender) { - this.addElement(wire._el); + // this.addElement(wire._el); } } @@ -146,25 +142,19 @@ export default class Render { return true; } - removeWire (wire) { + removeWire (wire: BaseWire) { const index = this._wires.indexOf(wire); - if (index == -1) return; - this._wires.splice(index, 1); - } - - renderWires () { - this._wires.forEach(wire => { - wire.render(this.offset, this.zoom); - }); - this.forceUpdate(); + if (index == -1) return null; + const removed = this._wires.splice(index, 1)[0]; + removed.delete(); + return removed; } getGridStyle () { const { offset, zoom, gridSize, gridColor, backgroundColor } = this; const zOffset = _p.multiply(offset, zoom); const zGridSize = gridSize * zoom; - const lineWidth = `${parseInt(_p.clamp(1, 1 * zoom, 10))}px`; - console.info('getGridStyle - lineWidthPx', lineWidth); + const lineWidth = `${parseInt(_p.clamp(1, 1 * zoom, 10).toString())}px`; return { backgroundColor, @@ -225,6 +215,8 @@ export default class Render { this.react.forceUpdate(); } + throttleUpdate = _throttle(this.forceUpdate, 1) + setZoom (value) { const cameraTarget = this.getCenterPoint(); this.zoom = value; diff --git a/src/Sticky.js b/src/Sticky.js index 36e2eb0..d5aa697 100644 --- a/src/Sticky.js +++ b/src/Sticky.js @@ -11,13 +11,13 @@ import { toJSON } from './jsonLoader'; // import "./styles/default.scss"; export default class Sticky { - constructor(id, { width, height } = { width: 800, height: 600 }) { + constructor({ width, height } = { width: 800, height: 600 }) { this._uid = 0; this.nodeRefs = {}; this._objects = []; this._wires = []; - this.render = new Render(id, { width, height, wrapper: this }); + this.render = new Render({ width, height, wrapper: this }); this.clearCanvas(); return this; @@ -50,11 +50,9 @@ export default class Sticky { if (index == -1) return; for (let wire of [...node.wires]) { - wire.delete(); this.render.removeWire(wire); } - // TODO(ja0n): should splice wires too if (update) return this._objects.splice(index, 1); } @@ -135,6 +133,7 @@ export default class Sticky { nodey._ports[from][index], nodey2._ports[to][conn.id], ]; + // TODO: sealOrDiscard should be defined here this.render.sealOrDiscard(...cps) } }); @@ -163,10 +162,6 @@ export default class Sticky { this.loadPorts(instance, node.ports.out, ['out', 'in']); this.loadPorts(instance, node.ports.flow_out, ['flow_out', 'flow_in']); } - - if (this.render) { - this.render.renderWires(); - } } reload () { diff --git a/src/Wire.js b/src/Wire.js index 3cf32ec..635719c 100644 --- a/src/Wire.js +++ b/src/Wire.js @@ -6,59 +6,9 @@ export default class Wire extends BaseWire { constructor(config, ...args) { super(config, ...args); - if (config.render.internalRender) - this.initDom(); + // if (config.render.internalRender) + // this.initDom(); return this; } - - initDom () { - const group = createElement('g'); - this._path = styles.map(style => { - const path = createElement('path', style); - path.type = 'wire'; - path.wrapper = this; - group.appendChild(path); - return path; - }); - this.setupInstance(group); - this.renderInstance.addElement(this._el); - } - - renderPoints (sourcePort, targetPort, invert) { - if (!this._path) - return null; - - const direction = invert ? -1 : 1; - const offset = dt2p(sourcePort.x, sourcePort.y, targetPort.x, targetPort.y)/2; - const d = describeJoint(sourcePort.x, sourcePort.y, targetPort.x, targetPort.y, offset * direction); - - for (let element of this._path) - element.setAttribute('d', d); - } } - -const describeJoint = (x1, y1, x2, y2, offset) => - [ "M", x1, y1, - "C", x1 + offset, y1, x2 - offset, y2, x2, y2 - ].join(" "); - -const dt2p = (x1, y1, x2, y2) => Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); -const styles = [ - { - 'stroke': '#505050', - 'stroke-width': 6, - 'stroke-linejoin': 'round', - 'stroke-linecap': 'round', - 'fill': 'none', - 'opacity': 1 - }, - { - 'stroke': '#F3F375', - 'stroke-width': 2, - 'stroke-linecap': 'round', - 'stroke-dasharray': 6, - 'fill': 'none', - 'opacity': 0.8 - }, -]; diff --git a/src/domEventHandlers.js b/src/domEventHandlers.js deleted file mode 100644 index 80f8158..0000000 --- a/src/domEventHandlers.js +++ /dev/null @@ -1,215 +0,0 @@ -import _throttle from 'lodash/throttle'; - -import Node from './Node'; -import { getParentSvg, inIframe } from './utils/dom'; -import { _p } from './utils/points'; - -const normalizeEvent = e => { - if (e.x == undefined) e.x = e.clientX; - if (e.y == undefined) e.y = e.clientY; -}; - -export function registerEvents () { - const store = {}; - const svg = this._svg; - - // DOM Events - svg.addEventListener('mousedown', event => { - console.debug(`Render - mouseDown - inIframe: ${inIframe()}`); - normalizeEvent(event); - this.lastSelected = null; - - let { target } = event; - const mouse = event; - - if (target.type === 'container') { - this.dragging = target; - - const { x, y } = _p.divide(event, this.zoom); - this._aux.mouseDown = { x, y, offset: { ...this.offset } }; - - // const { x, y } = _p.add(e, 0); - // this._aux.mouseDown = { x, y, offset: _p.divide(this.offset, this.zoom)}; - - return null; - } - - if (target.classList.value.includes('leader-line')) { - target = getParentSvg(target); - } - - if (target.type === 'wire') { - this.lastSelected = target.wrapper; - this.selectedWire = target.wrapper; - - return null; - } - - if (target.type === 'port') { - this.startAttach(target); - - const { wire } = this._aux; - const SVGbox = this._svg.getBoundingClientRect(); - const vMouse = _p.subtract(mouse, [SVGbox.left, SVGbox.top]); - wire.addControlPoints(vMouse); - forceUpdate(); - - return true; - } - - const captureList = ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON']; - const shouldCapture = tagName => !captureList.includes(tagName); - const parentSvg = getParentSvg(target); - const isNode = shouldCapture(target.tagName) && parentSvg && parentSvg.type == 'node'; - - if (isNode) { - if (this.disableDragging) - return true; - - const node = parentSvg; - console.debug('Node selected:', node, 'Triggered by: ', target); - const wrapper = node.wrapper; - this.lastSelected = wrapper; - this.dragging = node; - const SVGbox = wrapper._svg.getBoundingClientRect(); - const mouse = _p.divide(event, this.zoom); - const offset = _p.subtract(mouse, [SVGbox.left, SVGbox.top]); - this._aux.mouseDown = { - wrapper: _p.add(wrapper, 1), - barePos: _p.add(event, 1), - mouse, - x: offset.x - wrapper.x, - y: offset.y - wrapper.y, - }; - this._svg.appendChild(this.dragging); - - wrapper.wires.forEach( - wire => wire._el && this._svg.appendChild(wire._el) - ); - } - - }, false); - - svg.addEventListener('mouseup', event => { - let { target } = event; - - this.dragging = null; - - if (this.isState('attaching')) { - if (target.type === 'port') { - this.endAttach(target); - } else { - if (this.internalRender) { - this.removeElement(this._aux['wire']._el); - } - - this.setState(null) - this._aux['wire'] = null; - } - } - - forceUpdate(); - }); - - const forceUpdate = _throttle(() => { - if (this.react) - this.react.forceUpdate(() => this.renderWires()); - }, 1); - - svg.addEventListener('mousemove', e => { - normalizeEvent(e); - - const { dragging, zoom } = this; - const mouse = e; - - if (this.isState('attaching')) { - const { wire } = this._aux; - const SVGbox = this._svg.getBoundingClientRect(); - - if (this.internalRender) { - // offset the wire away so we can detect the event on port - const padding = wire._inverted ? 4 : -4; - const vMouse = _p.add(_p.subtract(mouse, [SVGbox.left, SVGbox.top]), padding); - const vOffset = _p.multiply(this.offset, this.zoom); - const port = wire.getControlPoints()[0].getPoint(this.zoom); - - wire.renderPoints(_p.add(port, vOffset), vMouse, wire._inverted); - } - else { - const vMouse =_p.subtract(mouse, [SVGbox.left, SVGbox.top]); - - if (wire._el && wire._el.leaderLine) { - wire.setTarget(vMouse); - } - - forceUpdate(); - } - - return true; - } - - if (dragging && dragging.type == 'container') { - const firstState = this._aux.mouseDown; - this.offset = _p.add(firstState.offset, _p.subtract(_p.divide(e, zoom), firstState)); - console.debug('Updating offset', this.offset); - forceUpdate(); - return true; - } - - if (dragging) { - const wrapper = this.dragging.wrapper; - const firstState = this._aux.mouseDown; - const mouse = _p.divide(e, this.zoom); - const dtMouse = _p.subtract(e, firstState.barePos); - const { x, y } = _p.add(firstState.wrapper, _p.divide(dtMouse, zoom)); - wrapper.x = x; - wrapper.y = y; - - forceUpdate(); - return true; - } - }); - - const zoomVelocity = 0.05; - let lastTime = null; - window.addEventListener('wheel', event => { - if (this.disableZoom && this.dragging) - return false; - - if (lastTime === null) - lastTime = Date.now(); - - const delta = Math.sign(event.deltaY); - const zoomDt = (zoomVelocity * delta); - this.setZoom(_p.clamp(0.3, (this.zoom - zoomDt), 3)); - // svg.style.transform = `scale(${this.zoom})`; - console.info('MouseWheel - zoomDt', zoomDt); - console.info('MouseWheel - zoom', this.zoom); - - this.forceUpdate(); - this.renderWires(); - - lastTime = Date.now(); - return false; - }); - - document.addEventListener('keydown', e => { - if (e.keyCode === 46 || e.code === 'Delete') { - console.debug('Keydown - deleting', this.lastSelected); - - // @TODO should remove from state (node, wire, port) - if (this.lastSelected instanceof Node) { - this.config.wrapper.removeNode(this.lastSelected, true); - // avoid forceUpdate and renew wrapper.nodes array instead - this.forceUpdate(); - } - - if (this.lastSelected) { - this.lastSelected.delete(); - } - } - }, false); - - - return store; -} diff --git a/src/eventHandlers.ts b/src/eventHandlers.ts new file mode 100644 index 0000000..5ad6f4e --- /dev/null +++ b/src/eventHandlers.ts @@ -0,0 +1,203 @@ +import BaseWire from './BaseWire'; +import Node from './Node'; +import Render from './Render'; +import { TargetElement } from './types'; +import { getParentSvg, inIframe } from './utils/dom'; +import { _p } from './utils/points'; + +const normalizeEvent = (event: MouseEvent) => { + // @ts-ignore + if (event.x == undefined) event.x = event.clientX; + // @ts-ignore + if (event.y == undefined) event.y = event.clientY; +}; + +export function registerEvents (this: Render) { + const store = {}; + const svg = this._svg; + + // register DOM Events + svg.addEventListener('mousedown', onMouseDown.bind(this), false); + svg.addEventListener('mouseup', onMouseUp.bind(this)); + svg.addEventListener('mousemove', onMouseMove.bind(this)); + window.addEventListener('wheel', onMouseWheel.bind(this)); + document.addEventListener('keydown', onKeyDown.bind(this), false); + + return store; +} + + +function onMouseDown (this: Render, event: MouseEvent) { + console.debug(`Render - mouseDown - inIframe: ${inIframe()}`); + normalizeEvent(event); + this.lastSelected = null; + + let target = event.target as TargetElement; + const mouse = event; + + if (target.type === 'container') { + this.dragging = target; + const { x, y } = _p.divide(mouse, this.zoom); + // save first click event position + this._aux.mouseDown = { x, y, offset: { ...this.offset } }; + return null; + } + + if (target.classList.value.includes('leader-line')) { + target = getParentSvg(target); + } + + if (target.type === 'wire') { + console.debug('Wire selected:', target.wrapper, 'Triggered by: ', target); + this.lastSelected = target.wrapper; + return null; + } + + if (target.type === 'port') { + this.startAttach(target); + + const { wire } = this._aux; + const SVGbox = this._svg.getBoundingClientRect(); + const vMouse = _p.subtract(mouse, [SVGbox.left, SVGbox.top]); + wire.addControlPoints(vMouse); + this.throttleUpdate(); + + return true; + } + + const captureList = ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON']; + const shouldCapture = tagName => !captureList.includes(tagName); + const parentSvg = getParentSvg(target); + const isNode = shouldCapture(target.tagName) && parentSvg && parentSvg.type == 'node'; + + if (isNode) { + if (this.disableDragging) + return true; + + const node = parentSvg; + console.debug('Node selected:', node, 'Triggered by: ', target); + const wrapper = node.wrapper; + this.lastSelected = wrapper; + this.dragging = node; + const SVGbox = wrapper._svg.getBoundingClientRect(); + const mouse = _p.divide(event, this.zoom); + const offset = _p.subtract(mouse, [SVGbox.left, SVGbox.top]); + this._aux.mouseDown = { + wrapper: _p.add(wrapper, 1), + barePos: _p.add(event, 1), + mouse, + x: offset.x - wrapper.x, + y: offset.y - wrapper.y, + }; + this._svg.appendChild(this.dragging); + + wrapper.wires.forEach( + wire => wire._el && this._svg.appendChild(wire._el) + ); + } +} + +function onMouseUp (this: Render, event: MouseEvent) { + let target = event.target as TargetElement; + + this.dragging = null; + + if (this.isState('attaching')) { + if (target.type === 'port') { + this.endAttach(target); + } else { + this.setState(null) + this._aux['wire'] = null; + } + this.throttleUpdate(); + } +} + +function onMouseMove (this: Render, event: MouseEvent) { + normalizeEvent(event); + + const { dragging, zoom } = this; + const mouse = event; + + if (this.isState('attaching')) { + const { wire } = this._aux; + const SVGbox = this._svg.getBoundingClientRect(); + + if (this.internalRender) { + // offset the wire away so we can detect the event on port + // this is due the wire is rendered on top of node + const padding = wire._inverted ? 4 : -4; + const vMouse = _p.add(_p.subtract(mouse, [SVGbox.left, SVGbox.top]), padding); + wire.setTarget(vMouse); + this.throttleUpdate(); + } else { + const vMouse =_p.subtract(mouse, [SVGbox.left, SVGbox.top]); + + if (wire._el && wire._el['leaderLine']) { + wire.setTarget(vMouse); + } + + this.throttleUpdate(); + } + + return true; + } + + if (dragging && dragging.type == 'container') { + const firstState = this._aux.mouseDown; + this.offset = _p.add(firstState.offset, _p.subtract(_p.divide(event, zoom), firstState)); + console.debug('Updating offset', this.offset); + this.throttleUpdate(); + return true; + } + + if (dragging && dragging.wrapper instanceof Node) { + const wrapper = dragging.wrapper; + const firstState = this._aux.mouseDown; + const mouse = _p.divide(event, this.zoom); + const dtMouse = _p.subtract(event, firstState.barePos); + const { x, y } = _p.add(firstState.wrapper, _p.divide(dtMouse, zoom)); + wrapper.x = x; + wrapper.y = y; + + this.throttleUpdate(); + return true; + } +} + +const zoomVelocity = 0.05; +function onMouseWheel (this: Render, event: WheelEvent) { + if (this.disableZoom && this.dragging) + return false; + + if (this._aux.lastZoomTime === null) + this._aux.lastZoomTime = Date.now(); + + const delta = Math.sign(event.deltaY); + const zoomDt = (zoomVelocity * delta); + this.setZoom(_p.clamp(0.3, (this.zoom - zoomDt), 3)); + // svg.style.transform = `scale(${this.zoom})`; + console.info('MouseWheel - zoomDt', zoomDt); + console.info('MouseWheel - zoom', this.zoom); + + this.forceUpdate(); + this._aux.lastZoomTime = Date.now(); + return false; +} + +function onKeyDown (this: Render, event: KeyboardEvent) { + if (event.keyCode === 46 || event.code === 'Delete') { + console.debug('Keydown - deleting', this.lastSelected); + + // @TODO should remove from state (node, wire, port) + if (this.lastSelected instanceof Node) { + this.config.wrapper.removeNode(this.lastSelected, true); + // avoid forceUpdate and renew wrapper.nodes array instead + this.forceUpdate(); + } + if (this.lastSelected instanceof BaseWire) { + this.removeWire(this.lastSelected); + this.forceUpdate(); + } + } +} \ No newline at end of file diff --git a/src/react/components/Connections.js b/src/react/components/Connections.tsx similarity index 66% rename from src/react/components/Connections.js rename to src/react/components/Connections.tsx index 6f6719a..79108df 100644 --- a/src/react/components/Connections.js +++ b/src/react/components/Connections.tsx @@ -1,11 +1,17 @@ import React from 'react'; +import _get from 'lodash/get' import { uid } from 'react-uid'; import Line from './Line'; -import _get from 'lodash/get' +import Wire from './Wire'; + -const Connections = ({ connections, canvas, onLoad }) => { +const Connections = ({ connections, canvas, ...props }) => { if (canvas.render.internalRender) - return null; + return connections.map((wire) => { + return ( + + ); + }); return connections.map((wire) => { const [sourcePort, targetPort] = wire.getControlPoints(); @@ -20,10 +26,10 @@ const Connections = ({ connections, canvas, onLoad }) => { const { svg } = line.getProps(); wire.setupInstance(svg); wire.custom = !canvas.render.internalRender; - if (typeof(onLoad) === 'function') - onLoad(line, svg); + if (typeof(props.onLoad) === 'function') + props.onLoad(line, svg); }} - /> + /> ); return null; }); diff --git a/src/react/components/Container.tsx b/src/react/components/Container.tsx index b8d0542..0214ca9 100644 --- a/src/react/components/Container.tsx +++ b/src/react/components/Container.tsx @@ -34,7 +34,7 @@ export default class Container extends React.Component { return null; const { width, height, gridSize, gridColor, backgroundColor } = this.props; - const canvas = new Sticky(null, { width, height }); + const canvas = new Sticky({ width, height }); canvas.render.loadContainer(ref); canvas.render.react = this; canvas.render.gridColor = gridColor; diff --git a/src/react/components/Line.js b/src/react/components/Line.js index 78987cb..c8f290e 100644 --- a/src/react/components/Line.js +++ b/src/react/components/Line.js @@ -27,8 +27,8 @@ export default class Line extends React.Component { setupInstance (ref) { if (ref) { - this.shouldInitLine(); this.svg = ref; + this.shouldInitLine(); } else { this.componentWillUnmount(); } @@ -59,6 +59,7 @@ export default class Line extends React.Component { initLine ({ start, end, options }) { const startPoint = shimPointAnchor(start); const endPoint = shimPointAnchor(end); + debugger; this.line = new LeaderLine({ path: 'fluid', diff --git a/src/react/components/Wire.tsx b/src/react/components/Wire.tsx new file mode 100644 index 0000000..6066848 --- /dev/null +++ b/src/react/components/Wire.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import _get from 'lodash/get'; +import BaseWire from '../../BaseWire'; +import { Offset, Zoom } from '../../types'; +import Port from '../../Port'; + +type Props = { + wrapper: BaseWire; + zoom: Zoom; + offset: Offset; +} + +export default class Wire extends React.Component { + setupInstance (ref: SVGElement) { + if (ref) { + const { wrapper } = this.props; + wrapper.setupInstance(ref) + } + } + + render() { + const { wrapper, offset, zoom } = this.props; + const [p1, p2] = wrapper.getControlPoints(); + // handle port position or an arbitrary one + const sourcePoint = p1 instanceof Port ? p1.getPoint(offset, zoom) : p1; + const targetPoint = p2 instanceof Port ? p2.getPoint(offset, zoom) : p2; + const direction = wrapper._inverted ? -1 : 1; + const jointOffset = dt2p(sourcePoint.x, sourcePoint.y, targetPoint.x, targetPoint.y)/2; + const d = describeJoint(sourcePoint.x, sourcePoint.y, targetPoint.x, targetPoint.y, jointOffset * direction); + const setupRef = (ref) => { + if (ref) { + ref.wrapper = wrapper; + ref.type = 'wire'; + } + } + return ( + this.setupInstance(ref)}> + {styles.map(style => )} + + ); + } +} + +const describeJoint = (x1: any, y1: any, x2: number, y2: any, offset: number) => + [ "M", x1, y1, + "C", x1 + offset, y1, x2 - offset, y2, x2, y2 + ].join(" "); + +const dt2p = (x1: number, y1: number, x2: number, y2: number) => Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); + +const styles: React.SVGProps[] = [ + { + 'stroke': '#505050', + 'strokeWidth': 6, + 'strokeLinejoin': 'round', + 'strokeLinecap': 'round', + 'fill': 'none', + 'opacity': 1 + }, + { + 'stroke': '#F3F375', + 'strokeWidth': 2, + 'strokeLinecap': 'round', + 'strokeDasharray': 6, + 'fill': 'none', + 'opacity': 0.8 + }, +]; \ No newline at end of file diff --git a/src/react/components/index.js b/src/react/components/index.js index 609a843..31904da 100644 --- a/src/react/components/index.js +++ b/src/react/components/index.js @@ -6,4 +6,6 @@ export { default as NodeContainer } from './NodeContainer' export { default as SVGContainer } from './SVGContainer' export { default as SplitSection } from './SplitSection' export { default as Node } from './Node' -export { default as Connections } from './Connections' \ No newline at end of file +export { default as Connections } from './Connections' +export { default as Line } from './Line' +export { default as Wire } from './Wire' \ No newline at end of file diff --git a/src/standaloneEntry.tsx b/src/standaloneEntry.tsx new file mode 100644 index 0000000..6ae9d3a --- /dev/null +++ b/src/standaloneEntry.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { createElement } from './utils/dom'; +import { NodeGraph } from './react/components'; +import { Config } from './Render'; +import Sticky from './Sticky'; + + +function StickyStandalone (id: string, config: Config) { + // const { wrapper, Component } = this.config; + const manager = new Sticky(config); + const svg = createElement('svg', { class: 'svg-content', preserveAspectRatio: "xMidYMid meet" }); + this.loadContainer(svg); + + const element = document.getElementById(id); + element.classList.add('sticky__canvas'); + element.appendChild(this._svg); + + ReactDOM.render( + { this.react = ref }} + getNodes={() => manager.nodes} + getOffset={() => manager.render.offset} + getZoom={() => manager.render.zoom} + />, + svg + ); +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 97eb708..2fb3349 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,12 @@ +import BaseWire from "./BaseWire"; +import Port from "./Port"; +import Node from "./Node"; + export type Zoom = number; -export type Offset = { x: number; y: number; } \ No newline at end of file +export type Position = { x: number; y: number; }; + +export type Offset = Position; + +export type TargetWrappers = BaseWire | Node | Port +export type TargetElement = Element & { type: string; wrapper: TargetWrappers } \ No newline at end of file diff --git a/tests/Connections.test.tsx b/tests/Connections.test.tsx new file mode 100644 index 0000000..0e865e8 --- /dev/null +++ b/tests/Connections.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import Sticky from '../src/Sticky'; +import { Container, Connections, Wire, Line } from '../src/react/components'; + +describe('Connections Component', () => { + test('last line selected behavior', () => { + }) + test('it is respecting wire managing', () => { + let wrapper, container; + let canvas: Sticky; + + // no offset and no zoom + wrapper = mount( + { + canvas = ref; + }} + /> + ); + + expect(wrapper.find(Wire)).toHaveLength(0); + + const source = canvas.createNode('SourceString', { x: 10, y: 10 }); + const alert1 = canvas.createNode('Alert', { x: 100, y: 100 }); + const alert2 = canvas.createNode('Alert', { x: 200, y: 100 }); + canvas.addNodes([source, alert1, alert2]); // add two nodes + // add wire + let sealed = canvas.render.sealOrDiscard(source._ports.out[0], alert1._ports.in[0]); + wrapper.update(); + expect(sealed).toBeTruthy(); + expect(wrapper.find(Wire)).toHaveLength(1); + + // add another wire + sealed = canvas.render.sealOrDiscard(alert1._ports.flow_out[0], alert2._ports.flow_in[0]); + wrapper.update(); + expect(sealed).toBeTruthy(); + expect(wrapper.find(Wire)).toHaveLength(2); + + // try invalid wire seal + sealed = canvas.render.sealOrDiscard(alert1._ports.flow_out[0], alert2._ports.in[0]); + wrapper.update(); + expect(sealed).toBeFalsy(); + expect(wrapper.find(Wire)).toHaveLength(2); + wrapper.unmount(); + }); +}); diff --git a/tests/leader-line.test.js b/tests/leader-line.test.js index 69f0ab5..ee155f7 100644 --- a/tests/leader-line.test.js +++ b/tests/leader-line.test.js @@ -36,9 +36,9 @@ describe('LeaderLine support', () => { const source = canvas.createNode('SourceString', { x: 10, y: 10 }); const alert = canvas.createNode('Alert', { x: 100, y: 100 }); canvas.addNodes([source, alert]); - debugger; const sealed = canvas.render.sealOrDiscard(source._ports.out[0], alert._ports.in[0]); expect(sealed).toBeTruthy(); + wrapper.update(); // const connections = wrapper.find('.leader-line'); const connections = wrapper.find('.leader-line'); expect(connections).toHaveLength(1); diff --git a/webpack.config.js b/webpack.config.js index 955a52f..8886df9 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -25,7 +25,7 @@ module.exports = { loader: 'skeleton-loader', options: { procedure: content => `${content} export default LeaderLine` } }, - { test: /\.tsx?$/, exclude: /(node_modules)/, loader: "awesome-typescript-loader" }, + { test: /\.tsx?$/, exclude: /(node_modules)/, loader: "ts-loader" }, { test: /\.js?$/, exclude: /(node_modules)/, loader: 'babel-loader' }, { test: /\.js?$/, exclude: /(node_modules)/, loader: 'source-map-loader', enforce: 'pre' }, // { test: /\.css$/, loader: "style-loader!css" },