From a66f707f11ce8ea2da19075f336807a5d61ec1d5 Mon Sep 17 00:00:00 2001 From: moklick Date: Tue, 16 Jul 2019 15:41:31 +0200 Subject: [PATCH] feat(nodes): add selection --- dist/ReactGraph.js | 150 +++++++++++++++++++++++++++++++++++------ example/SimpleGraph.js | 19 ++++-- src/GraphView/index.js | 42 +++++++++--- src/Selection/index.js | 62 +++++++++++++++++ src/hooks.js | 28 ++++++++ src/state/index.js | 5 +- src/style.css | 22 +++++- 7 files changed, 289 insertions(+), 39 deletions(-) create mode 100644 src/Selection/index.js create mode 100644 src/hooks.js diff --git a/dist/ReactGraph.js b/dist/ReactGraph.js index 9e52fee0..2282d0bf 100644 --- a/dist/ReactGraph.js +++ b/dist/ReactGraph.js @@ -29812,8 +29812,8 @@ var boundsCenterX = bounds.x + bounds.width / 2; var boundsCenterY = bounds.y + bounds.height / 2; var translate = [state.width / 2 - boundsCenterX * k, state.height / 2 - boundsCenterY * k]; - var initialTransform = identity$1.translate(translate[0], translate[1]).scale(k); - state.d3Selection.call(state.d3Zoom.transform, initialTransform); + var fittedTransform = identity$1.translate(translate[0], translate[1]).scale(k); + state.d3Selection.call(state.d3Zoom.transform, fittedTransform); return state; } @@ -30075,24 +30075,136 @@ return EdgeRenderer; }(React.PureComponent); - var GraphView = function GraphView(props) { - var zoomNode = React.useRef(null); - var graphContext = React.useContext(GraphContext); - React.useEffect(function () { - var zoom$1 = zoom().scaleExtent([0.5, 2]).on('zoom', function () { - if (event.sourceEvent && event.sourceEvent.target !== zoomNode.current) { - return false; - } + var Selection$2 = (function () { + var selectionPane = React.useRef(null); - graphContext.dispatch(updateTransform(event.transform)); - props.onMove(); - }); - var selection = select(zoomNode.current).call(zoom$1); + var _useState = React.useState({ + x: 0, + y: 0, + width: 0, + height: 0, + draw: false + }), + _useState2 = _slicedToArray(_useState, 2), + rect = _useState2[0], + setRect = _useState2[1]; + + React.useEffect(function () { + function onMouseDown(evt) { + setRect(function (r) { + return _objectSpread2({}, r, { + x: evt.clientX, + y: evt.clientY, + draw: true + }); + }); + } + + function onMouseMove(evt) { + setRect(function (r) { + return _objectSpread2({}, r, { + width: evt.clientX, + height: evt.clientY + }); + }); + } + + function onMouseUp() { + console.log('selection mouse up'); + } + + function removeAll() { + selectionPane.current.removeEventListener('mousedown', onMouseDown); + selectionPane.current.removeEventListener('mousemove', onMouseMove); + selectionPane.current.removeEventListener('mouseup', onMouseUp); + } + + selectionPane.current.addEventListener('mousedown', onMouseDown); + selectionPane.current.addEventListener('mousemove', onMouseMove); + selectionPane.current.addEventListener('mouseup', onMouseUp); + return function () { + removeAll(); + }; + }, []); + return React__default.createElement("div", { + className: "react-graph__selectionpane", + ref: selectionPane + }, rect.draw && React__default.createElement("div", { + className: "react-graph__selection", + style: { + width: rect.width, + height: rect.height, + transform: "translate(".concat(rect.x, "px, ").concat(rect.y, "px)") + } + })); + }); + + function useKeyPress(targetKey) { + var _useState = React.useState(false), + _useState2 = _slicedToArray(_useState, 2), + keyPressed = _useState2[0], + setKeyPressed = _useState2[1]; + + function downHandler(_ref) { + var key = _ref.key; + + if (key === targetKey) { + setKeyPressed(true); + } + } + + var upHandler = function upHandler(_ref2) { + var key = _ref2.key; + + if (key === targetKey) { + setKeyPressed(false); + } + }; + + React.useEffect(function () { + window.addEventListener('keydown', downHandler); + window.addEventListener('keyup', upHandler); + return function () { + window.removeEventListener('keydown', downHandler); + window.removeEventListener('keyup', upHandler); + }; + }, []); + return keyPressed; + } + + var d3ZoomInstance = zoom().scaleExtent([0.5, 2]); + + var GraphView = function GraphView(props) { + var zoomPane = React.useRef(null); + var graphContext = React.useContext(GraphContext); + var shiftPressed = useKeyPress('Shift'); + React.useEffect(function () { + var selection = select(zoomPane.current).call(d3ZoomInstance); graphContext.dispatch(initD3({ - zoom: zoom$1, + zoom: d3ZoomInstance, selection: selection })); }, []); + React.useEffect(function () { + if (shiftPressed) { + d3ZoomInstance.on('zoom', null); + } else { + d3ZoomInstance.on('zoom', function () { + if (event.sourceEvent && event.sourceEvent.target !== zoomPane.current) { + return false; + } + + graphContext.dispatch(updateTransform(event.transform)); + props.onMove(); + }); + + if (graphContext.state.d3Selection) { + // we need to restore the graph transform otherwise d3 zoom transform and graph transform are not synced + var graphTransform = identity$1.translate(graphContext.state.transform[0], graphContext.state.transform[1]).scale(graphContext.state.transform[2]); + graphContext.state.d3Selection.call(graphContext.state.d3Zoom.transform, graphTransform); + } + } + }, [shiftPressed]); React.useEffect(function () { return graphContext.dispatch(updateSize(props.size)); }, [props.size.width, props.size.height]); @@ -30120,9 +30232,9 @@ }), React__default.createElement(EdgeRenderer, { width: graphContext.state.width, height: graphContext.state.height - }), React__default.createElement("div", { - className: "react-graph__zoomnode", - ref: zoomNode + }), shiftPressed && React__default.createElement(Selection$2, null), React__default.createElement("div", { + className: "react-graph__zoompane", + ref: zoomPane })); }; @@ -32518,7 +32630,7 @@ } } - var css = ".react-graph {\n width: 100%;\n height: 100%;\n position: relative;\n overflow: hidden;\n}\n\n.react-graph__renderer {\n width: 100%;\n height: 100%;\n position: absolute;\n}\n\n.react-graph__zoomnode {\n width: 100%;\n height: 100%;\n position: absolute;\n top: 0;\n left: 0;\n}\n\n.react-graph__edges {\n pointer-events: none;\n}\n\n.react-graph__edge {\n fill: none;\n stroke: #333;\n stroke-width: 2;\n}\n\n.react-graph__nodes {\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 2;\n pointer-events: none;\n transform-origin: 0 0;\n}\n\n.react-graph__nodewrap {\n position: absolute;\n width: 150px;\n color: #222;\n font-family: sans-serif;\n font-size: 12px;\n text-align: center;\n cursor: -webkit-grab;\n cursor: grab;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n pointer-events: all;\n transform-origin: 0 0;\n}\n\n.react-graph__nodewrap:hover {\n box-shadow: 0 1px 5px 2px rgba(0, 0, 0, 0.08);\n}\n\n.react-graph__handle {\n position: absolute;\n width: 12px;\n height: 12px;\n transform: translate(-50%, -50%);\n background: #222;\n left: 50%;\n border-radius: 50%;\n}"; + var css = ".react-graph {\n width: 100%;\n height: 100%;\n position: relative;\n overflow: hidden;\n}\n\n.react-graph__renderer {\n width: 100%;\n height: 100%;\n position: absolute;\n}\n\n.react-graph__zoompane {\n width: 100%;\n height: 100%;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 2;\n}\n\n.react-graph__selectionpane {\n width: 100%;\n height: 100%;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 3;\n}\n\n.react-graph__selection {\n position: absolute;\n top: 0;\n left: 0;\n background: rgba(0,0,0,0.1);\n border: 1px solid #222;\n}\n\n.react-graph__edges {\n pointer-events: none;\n}\n\n.react-graph__edge {\n fill: none;\n stroke: #333;\n stroke-width: 2;\n}\n\n.react-graph__nodes {\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 2;\n pointer-events: none;\n transform-origin: 0 0;\n}\n\n.react-graph__nodewrap {\n position: absolute;\n width: 150px;\n color: #222;\n font-family: sans-serif;\n font-size: 12px;\n text-align: center;\n cursor: -webkit-grab;\n cursor: grab;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n pointer-events: all;\n transform-origin: 0 0;\n}\n\n.react-graph__nodewrap:hover > * {\n box-shadow: 0 1px 5px 2px rgba(0, 0, 0, 0.08);\n}\n\n.react-graph__handle {\n position: absolute;\n width: 12px;\n height: 12px;\n transform: translate(-50%, -50%);\n background: #222;\n left: 50%;\n border-radius: 50%;\n}"; styleInject(css); var ReactGraph = diff --git a/example/SimpleGraph.js b/example/SimpleGraph.js index 9c464137..605783f3 100644 --- a/example/SimpleGraph.js +++ b/example/SimpleGraph.js @@ -3,9 +3,13 @@ import React, { PureComponent } from 'react'; import Graph from '../src'; // import Graph from '../dist/ReactGraph'; -const SpecialNode = ({ data, styles }) => -
I am Special!
{data.label}
-; +const SpecialNode = ({ data, styles }) => ( +
+ I am special!
{data.label} +
+); class App extends PureComponent { constructor() { @@ -18,7 +22,7 @@ class App extends PureComponent { { data: { id: '3', label: 'This is a node' }, position: { x: 100, y: 200 }, style: { background: '#222', color: '#fff' } }, { data: { id: '4', label: 'nody nodes', type: 'output' }, position: { x: 50, y: 300 } }, { data: { id: '5', label: 'Another node', type: 'default' }, position: { x: 400, y: 300 } }, - { data: { id: '6', label: 'A label', type: 'special' }, position: { x: 400, y: 400 } }, + { data: { id: '6', label: 'I am label', type: 'special' }, position: { x: 400, y: 400 } }, { data: { source: '1', target: '2' } }, { data: { source: '2', target: '3' } }, { data: { source: '3', target: '4' } }, @@ -51,8 +55,11 @@ class App extends PureComponent { onAdd() { this.setState(prevState => ({ ...prevState, - elements: prevState.elements.concat({ data: { id: prevState.elements.length + 1, label: 'Added node' }, position: { x: 50, y: 50 } }) - })) + elements: prevState.elements.concat({ + data: { id: prevState.elements.length + 1, label: 'Added node' }, + position: { x: 50, y: 50 } + }) + })); } render() { diff --git a/src/GraphView/index.js b/src/GraphView/index.js index 7a0cff33..32a8edc2 100644 --- a/src/GraphView/index.js +++ b/src/GraphView/index.js @@ -6,17 +6,28 @@ import ReactSizeMe from 'react-sizeme'; import { GraphContext } from '../GraphContext'; import NodeRenderer from '../NodeRenderer'; import EdgeRenderer from '../EdgeRenderer'; +import Selection from '../Selection'; import { updateTransform, updateSize, initD3, fitView } from '../state/actions'; +import { useKeyPress } from '../hooks'; + +const d3ZoomInstance = d3Zoom.zoom().scaleExtent([0.5, 2]) const GraphView = (props) => { - const zoomNode = useRef(null); + const zoomPane = useRef(null); const graphContext = useContext(GraphContext); + const shiftPressed = useKeyPress('Shift'); useEffect(() => { - const zoom = d3Zoom.zoom() - .scaleExtent([0.5, 2]) - .on('zoom', () => { - if (event.sourceEvent && event.sourceEvent.target !== zoomNode.current) { + const selection = select(zoomPane.current).call(d3ZoomInstance); + graphContext.dispatch(initD3({ zoom: d3ZoomInstance, selection })); + }, []); + + useEffect(() => { + if (shiftPressed) { + d3ZoomInstance.on('zoom', null); + } else { + d3ZoomInstance.on('zoom', () => { + if (event.sourceEvent && event.sourceEvent.target !== zoomPane.current) { return false; } @@ -25,10 +36,17 @@ const GraphView = (props) => { props.onMove(); }); - const selection = select(zoomNode.current).call(zoom); + if (graphContext.state.d3Selection) { + // we need to restore the graph transform otherwise d3 zoom transform and graph transform are not synced + const graphTransform = d3Zoom.zoomIdentity + .translate(graphContext.state.transform[0], graphContext.state.transform[1]) + .scale(graphContext.state.transform[2]); - graphContext.dispatch(initD3({ zoom, selection })); - }, []); + graphContext.state.d3Selection.call(graphContext.state.d3Zoom.transform, graphTransform); + } + } + + }, [shiftPressed]); useEffect( () => graphContext.dispatch(updateSize(props.size)), @@ -50,13 +68,17 @@ const GraphView = (props) => { nodes: graphContext.state.nodes, edges: graphContext.state.edges, }); - }) + }); return (
-
+ {shiftPressed && } +
); }; diff --git a/src/Selection/index.js b/src/Selection/index.js new file mode 100644 index 00000000..6d3100ed --- /dev/null +++ b/src/Selection/index.js @@ -0,0 +1,62 @@ +import React, { useEffect, useRef, useState } from 'react'; + +export default () => { + const selectionPane = useRef(null); + const [rect, setRect] = useState({ x: 0, y: 0, width: 0, height: 0, draw: false }); + + + useEffect(() => { + function onMouseDown(evt) { + setRect((r) => ({ + ...r, + x: evt.clientX, + y: evt.clientY, + draw: true + })); + } + + function onMouseMove(evt) { + setRect((r) => ({ + ...r, + width: evt.clientX, + height: evt.clientY + })); + } + + function onMouseUp() { + console.log('selection mouse up'); + } + + function removeAll() { + selectionPane.current.removeEventListener('mousedown', onMouseDown); + selectionPane.current.removeEventListener('mousemove', onMouseMove); + selectionPane.current.removeEventListener('mouseup', onMouseUp); + } + + selectionPane.current.addEventListener('mousedown', onMouseDown); + selectionPane.current.addEventListener('mousemove', onMouseMove); + selectionPane.current.addEventListener('mouseup', onMouseUp); + + return () => { + removeAll(); + }; + }, []); + + return ( +
+ {rect.draw && ( +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/hooks.js b/src/hooks.js new file mode 100644 index 00000000..48b1dbc8 --- /dev/null +++ b/src/hooks.js @@ -0,0 +1,28 @@ +import { useState, useEffect } from 'react'; + +export function useKeyPress(targetKey) { + const [keyPressed, setKeyPressed] = useState(false); + + function downHandler({ key }) { + if (key === targetKey) { + setKeyPressed(true); + } + } + + const upHandler = ({ key }) => { + if (key === targetKey) { + setKeyPressed(false); + } + }; + + useEffect(() => { + window.addEventListener('keydown', downHandler); + window.addEventListener('keyup', upHandler); + return () => { + window.removeEventListener('keydown', downHandler); + window.removeEventListener('keyup', upHandler); + }; + }, []); + + return keyPressed; +} \ No newline at end of file diff --git a/src/state/index.js b/src/state/index.js index 660b777f..8a19427a 100644 --- a/src/state/index.js +++ b/src/state/index.js @@ -1,4 +1,5 @@ import { zoomIdentity } from 'd3-zoom'; + import { getBoundingBox } from '../graph-utils'; export const SET_EDGES = 'SET_EDGES'; @@ -56,9 +57,9 @@ export const reducer = (state, action) => { const boundsCenterX = bounds.x + (bounds.width / 2); const boundsCenterY = bounds.y + (bounds.height / 2); const translate = [(state.width / 2) - (boundsCenterX * k), (state.height / 2) - (boundsCenterY * k)]; - const initialTransform = zoomIdentity.translate(translate[0], translate[1]).scale(k); + const fittedTransform = zoomIdentity.translate(translate[0], translate[1]).scale(k); - state.d3Selection.call(state.d3Zoom.transform, initialTransform); + state.d3Selection.call(state.d3Zoom.transform, fittedTransform); return state; } diff --git a/src/style.css b/src/style.css index abfdd83c..541c3fc5 100644 --- a/src/style.css +++ b/src/style.css @@ -11,12 +11,30 @@ position: absolute; } -.react-graph__zoomnode { +.react-graph__zoompane { width: 100%; height: 100%; position: absolute; top: 0; left: 0; + z-index: 2; +} + +.react-graph__selectionpane { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 3; +} + +.react-graph__selection { + position: absolute; + top: 0; + left: 0; + background: rgba(0,0,0,0.1); + border: 1px solid #222; } .react-graph__edges { @@ -51,7 +69,7 @@ transform-origin: 0 0; } -.react-graph__nodewrap:hover { +.react-graph__nodewrap:hover > * { box-shadow: 0 1px 5px 2px rgba(0, 0, 0, 0.08); }