diff --git a/README.md b/README.md index 579dc4f..7373c09 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,18 @@ var defaults = { handleOutlineColor: '#000000', // the colour of the handle outline handleOutlineWidth: 0, // the width of the handle outline in pixels handleNodes: 'node', // selector/filter function for whether edges can be made from a given node - handlePosition: 'middle top', // sets the position of the handle in the format of "X-AXIS Y-AXIS" such as "left top", "middle top" + handlePosition: 'middle top', // sets the position of the handle in the csv format of "X-AXIS Y-AXIS" such as "left top,middle top" OR + // angle format "0,180" (angle: 0,1,...,359,360 where 0 is at right, 180 is at left of node or -1 for middle of the node). + handleHighlightColor: '#ff0000', // the colour to highlight handle on hover + handleHighlightPercentOffset: '1.0', // percent offset respective to handle size hoverDelay: 150, // time spend over a target node before it is considered a target selection cxt: false, // whether cxt events trigger edgehandles (useful on touch) enabled: true, // whether to start the plugin in the enabled state toggleOffOnLeave: false, // whether an edge is cancelled by leaving a node (true), or whether you need to go over again to cancel (false; allows multiple edges in one pass) + addEdgeOnHitHandle: false, // whether an edge is created on drag end only on edge handle (true), or anywhere on node (false) + // addEdgeOnHitHandle true only works with toggleOffOnLeave true + highlightHandleOnHover: false, // whether a handle should be highlighted on hover (true), or not (false) + // highlightHandleOnHover true only works with toggleOffOnLeave true edgeType: function( sourceNode, targetNode ) { // can return 'flat' for flat edges between nodes or 'node' for intermediate node between them // returning null/undefined means an edge can't be added between the two nodes @@ -92,11 +99,19 @@ var defaults = { // NB: i indicates edge index in case of edgeType: 'node' return {}; }, + nodeHandlePosition: function( node ) { + // for handle positions for a specific node. + // set value in handlePosition for default handle positions + // and return a specific positions for node in this function. + // return null to use the default value from handlePosition + return null; + }, start: function( sourceNode ) { // fired when edgehandles interaction starts (drag on handle) }, - complete: function( sourceNode, targetNodes, addedEntities ) { + complete: function( sourceNode, targetNodes, addedEntities, sourceHandleAngle, targetHandleAngle ) { // fired when edgehandles is done and entities are added + // sourceHandleAngle, targetHandleAngle are only returned when toggleOffOnLeave is true }, stop: function( sourceNode ) { // fired when edgehandles interaction is stopped (either complete with added edges or incomplete) @@ -163,7 +178,7 @@ All function can be called via `cy.edgehandles('function-name')`: * `cy.edgehandles('start', 'some-node-id')` : start the handle drag state on node with specified id (e.g. `'some-node-id'`) * `cy.edgehandles('drawon')` : enable draw mode * `cy.edgehandles('drawoff')` : disable draw mode - + ## Publishing instructions diff --git a/cytoscape-edgehandles.js b/cytoscape-edgehandles.js index 1373bce..f53d007 100644 --- a/cytoscape-edgehandles.js +++ b/cytoscape-edgehandles.js @@ -178,10 +178,16 @@ SOFTWARE. handleOutlineWidth: 0, // the width of the handle outline in pixels handleNodes: 'node', // selector/filter function for whether edges can be made from a given node handlePosition: 'middle top', // sets the position of the handle in the format of "X-AXIS Y-AXIS" such as "left top", "middle top" + handleHighlightColor: '#ff0000', // the colour to highlight handle on hover + handleHighlightPercentOffset: '1.0', // percent offset respective to handle size hoverDelay: 150, // time spend over a target node before it is considered a target selection cxt: false, // whether cxt events trigger edgehandles (useful on touch) enabled: true, // whether to start the plugin in the enabled state toggleOffOnLeave: false, // whether an edge is cancelled by leaving a node (true), or whether you need to go over again to cancel (false; allows multiple edges in one pass) + highlightHandleOnHover: false, // whether a handle should be highlighted on hover (true), or not (false) + // highlightHandleOnHover true only works with toggleOffOnLeave true + addEdgeOnHitHandle: false, // whether an edge is created on drag end only on edge handle (true), or anywhere on node (false) + // addEdgeOnHitHandle true only works with toggleOffOnLeave true edgeType: function( sourceNode, targetNode ) { // can return 'flat' for flat edges between nodes or 'node' for intermediate node between them // returning null/undefined means an edge can't be added between the two nodes @@ -203,11 +209,19 @@ SOFTWARE. // NB: i indicates edge index in case of edgeType: 'node' return {}; }, + nodeHandlePosition: function( node ) { + // for handle positions for a specific node. + // set value in handlePosition for default handle positions + // and return a specific positions for node in this function. + // return null to use the default value from handlePosition + return null; + }, start: function( sourceNode ) { // fired when edgehandles interaction starts (drag on handle) }, - complete: function( sourceNode, targetNodes, addedEntities ) { + complete: function( sourceNode, targetNodes, addedEntities, sourceHandleAngle, targetHandleAngle ) { // fired when edgehandles is done and entities are added + // sourceHandleAngle, targetHandleAngle are only returned when toggleOffOnLeave is true }, stop: function( sourceNode ) { // fired when edgehandles interaction is stopped (either complete with added edges or incomplete) @@ -224,6 +238,51 @@ SOFTWARE. var fn = params; var container = cy.container(); + const positionXYToAngle = { + "right middle": 0, + "right top": 45, + "middle top": 90, + "left top": 135, + "left middle": 180, + "left bottom": 225, + "middle bottom": 270, + "right bottom": 315, + "middle middle": -1 + }; + + function sanitizePositionFormat( pos ) { + const posXY = pos.split( ' ' ); + if( posXY.length !== 2 ) { + return 'middle middle'; + } + + if( posXY[0] !== 'left' && posXY[0] !== 'right' ) { + posXY[0] = 'middle'; + } + + if( posXY[1] !== 'top' && posXY[1] !== 'bottom' ) { + posXY[1] = 'middle'; + } + + return posXY.join( ' ' ); + } + + function parseHandlePosition( positions ) { + return positions.split(',').reduce( function ( prev, item ) { + if(isNaN( parseInt(item)) ) { + const sanitized = sanitizePositionFormat( item ); + const angle = positionXYToAngle[sanitized]; + if(angle !== undefined) { + prev.push( angle ); + } + } else { + prev.push( item ); + } + + return prev; + }, [] ).join( ',' ); + } + var functions = { destroy: function() { var $container = $( this ); @@ -248,6 +307,11 @@ SOFTWARE. } var options = data.options; + options.handlePosition = parseHandlePosition(data.options.handlePosition); + + if( options.addEdgeOnHitHandle || options.highlightHandleOnHover ) { + options.toggleOffOnLeave = true; + } if( value === undefined ) { if( typeof name == typeof {} ) { @@ -297,7 +361,7 @@ SOFTWARE. var mdownOnHandle = false; var grabbingNode = false; var inForceStart = false; - var hx, hy, hr; + var hr; var mx, my; var hoverTimeout; var drawsClear = true; @@ -305,6 +369,15 @@ SOFTWARE. var sourceNode; var drawMode = false; var pxRatio; + var sourceHandleAngle; + var hoveredTarget; + var hoveredTargetHandle = {}; + + opts.handlePosition = parseHandlePosition(opts.handlePosition); + + if( opts.addEdgeOnHitHandle || opts.highlightHandleOnHover ){ + opts.toggleOffOnLeave = true; + } function getDevicePixelRatio(){ return window.devicePixelRatio || 1; @@ -479,7 +552,13 @@ SOFTWARE. } - function drawHandle() { + function drawHandle( node ) { + if( node ) { + drawHandleForAngles( node ); + } + } + + function drawHandleForPosition( hx, hy ) { ctx.fillStyle = options().handleColor; ctx.strokeStyle = options().handleOutlineColor; @@ -488,20 +567,92 @@ SOFTWARE. ctx.closePath(); ctx.fill(); - if(options().handleOutlineWidth) { + if( options().handleOutlineWidth ) { ctx.lineWidth = options().handleLineWidth * cy.zoom(); ctx.stroke(); } - if(options().handleIcon){ + if( options().handleIcon ){ var icon = options().handleIcon; var width = icon.width*cy.zoom(), height = icon.height*cy.zoom(); - ctx.drawImage(icon, hx-(width/2), hy-(height/2), width, height); + ctx.drawImage( icon, hx-(width/2), hy-(height/2), width, height ); } + } + + function drawHandleForAngles( node ) { + const positions = getHandlePositionsForNode( node ); + + positions.forEach( function(position) { + drawHandleForPosition(position.posX, position.posY); + } ); drawsClear = false; } + function getHandlePositionsForNode( node ) { + const strHandles = options().nodeHandlePosition( node ); + // If there are specific handle positions is set for this node, use that + // Otherwise, use default handle positions. + const handles = strHandles ? parseHandlePosition( strHandles ) : options().handlePosition; + + const angles = handles.split(',').reduce( function(prev, strAngle) { + const angle = parseInt(strAngle); + if (!isNaN(angle)) { + prev.push(angle); + } + + return prev; + }, [] ); + + const nodePos = node.renderedPosition(); + const nodeWidth = node.renderedWidth(); + const nodeHeight = node.renderedHeight(); + + return angles.map( function(angle) { + if (node.style().shape === 'ellipse' || !node.style().shape) { + return getHandleCenterForCircle( angle, nodePos, nodeWidth, nodeHeight ); + } else { + return getHandleCenterForRect( angle, nodePos, nodeWidth, nodeHeight ); + } + } ); + } + + function getHandleCenterForCircle( angle, pos, width, height ) { + if (angle < 0) { + return { angle: angle, posX: pos.x, posY: pos.y }; + } + + const rad = toRadian( angle ); + const posX = pos.x + ( width / 2 ) * Math.cos( rad ); + const posY = pos.y - ( height / 2 ) * Math.sin( rad ); + + return { angle: angle, posX: posX, posY: posY }; + } + + function getHandleCenterForRect( angle, pos, width, height ) { + if (angle < 0) { + return { angle: angle, posX: pos.x, posY: pos.y }; + } + + var posX, posY; + const rad = toRadian( angle ); + + const abssin = Math.abs( Math.sin(rad) ); + const abscos = Math.abs( Math.cos(rad) ); + const fracMax = 1 / Math.max( abssin, abscos ); + + const rectRadiusX = width / 2 * fracMax; + const rectRadiusY = height / 2 * fracMax; + posX = pos.x + rectRadiusX * Math.cos( rad ); + posY = pos.y - rectRadiusY * Math.sin( rad ); + + return { angle: angle, posX: posX, posY: posY }; + } + + function toRadian( angle ) { + return angle * ( Math.PI / 180 ); + } + var lineDrawRate = 1000 / 60; var drawLine = throttle( function( x, y ) { @@ -515,14 +666,19 @@ SOFTWARE. ctx.lineWidth = options().handleLineWidth; } + const handles = getHandlePositionsForNode(sourceNode); + const sourceHandle = handles.filter(function( handle ) { + return handle.angle === sourceHandleAngle.angle; + })[0]; + const hx = sourceHandle.posX; + const hy = sourceHandle.posY; + // draw line based on type switch( options().handleLineType ) { case 'ghost': if( !ghostNode || ghostNode.removed() ) { - drawHandle(); - ghostNode = cy.add( { group: 'nodes', classes: 'edgehandles-ghost edgehandles-ghost-node', @@ -580,6 +736,7 @@ SOFTWARE. } ctx.beginPath(); + ctx.moveTo( hx, hy ); for( var i = 0; i < linePoints.length; i++ ) { @@ -618,12 +775,26 @@ SOFTWARE. return; // nothing to do :( } + const hit = hitTest(targets, { x: mx, y: my }); + // just remove preview class if we already have the edges if( !src && !tgt ) { if( !preview && options().preview ) { added = cy.elements( '.edgehandles-preview' ).removeClass( 'edgehandles-preview' ); - options().complete( source, targets, added ); - source.trigger( 'cyedgehandles.complete' ); + + if ( !hit && options().addEdgeOnHitHandle) { + added.remove(); + } else { + const targetHandleAngle = hit; + // TODO: Make it possible to track source/target angle when toggleOffOnLeave is false + if (options().toggleOffOnLeave) { + options().complete( source, targets, added, sourceHandleAngle.angle, targetHandleAngle.angle ); + } else { + options().complete( source, targets, added ); + } + source.trigger( 'cyedgehandles.complete' ); + } + return; } else { // remove old previews @@ -718,14 +889,20 @@ SOFTWARE. } if( !preview ) { - options().complete( source, targets, added ); + const targetHandleAngle = hit; + if (options().toggleOffOnLeave) { + options().complete( source, targets, added, sourceHandleAngle.angle, targetHandleAngle.angle ); + } else { + options().complete( source, targets, added ); + } source.trigger( 'cyedgehandles.complete' ); } } function hoverOver( node ) { var target = node; - + hoveredTarget = target; + clearTimeout( hoverTimeout ); hoverTimeout = setTimeout( function() { var source = cy.nodes( '.edgehandles-source' ); @@ -772,32 +949,57 @@ SOFTWARE. removePreview( source, target ); } - } - function setHandleDimensions( node ){ - var p = node.renderedPosition(); - var h = node.renderedHeight(); - var w = node.renderedWidth(); + if (target.data().id !== sourceNode.data().id) { + deHighlightHandle( target ); + hoveredTarget = null; + hoveredTargetHandle = {}; + } + } + function setHandleDimensions(){ hr = options().handleSize / 2 * cy.zoom(); + } + + function hitTest ( node, touchPos ) { + const handlePositions = getHandlePositionsForNode(node); + const halfHandleSize = (options().handleIcon ? options().handleIcon.width : hr) * cy.zoom() / 2; - // store how much we should move the handle from origin(p.x, p.y) - var moveX = 0; - var moveY = 0; + const hits = handlePositions.filter(function (handle) { + return Math.abs(handle.posX - touchPos.x) <= halfHandleSize && + Math.abs(handle.posY - touchPos.y) <= halfHandleSize; + }); - // grab axis's - var axisX = options().handlePosition.split(' ')[0].toLowerCase(); - var axisY = options().handlePosition.split(' ')[1].toLowerCase(); + return hits[0]; + } - // based on handlePosition move left/right/top/bottom. Middle/middle will just be normal - if(axisX == 'left') moveX = -(w / 2); - else if(axisX == 'right') moveX = w / 2; - if(axisY == 'top') moveY = -(h / 2); - else if(axisY == 'bottom') moveY = h / 2; + function highlightHandle( node, handle ) { + // console.log(node, handle); + if( options().highlightHandleOnHover && options().handleHighlightColor ) { + const percentOffset = options().handleHighlightPercentOffset || 1.0; + const hightlightSize = options().handleIcon ? options().handleIcon.width / 2 : hr; + ctx.beginPath(); + ctx.arc( handle.posX, handle.posY, hightlightSize * percentOffset, 0, 2 * Math.PI ); + ctx.closePath(); + + ctx.strokeStyle = options().handleHighlightColor; + ctx.lineWidth = cy.zoom(); + ctx.stroke(); - // set handle x and y based on adjusted positions - hx = p.x + moveX; - hy = p.y + moveY; + drawsClear = false; + } + } + + function deHighlightHandle(node) { + if( options().highlightHandleOnHover ) { + const pos = node.renderedPosition(); + const x = pos.x; + const y = pos.y; + const width = node.renderedOuterWidth(); + const height = node.renderedOuterHeight(); + + ctx.clearRect(x - width, y - height, width * 2, height * 2); + } } cy.ready( function() { @@ -837,10 +1039,10 @@ SOFTWARE. // remove old handle clearDraws(); - setHandleDimensions( node ); + setHandleDimensions(); // add new handle - drawHandle(); + drawHandle( node ); node.trigger( 'cyedgehandles.showhandle' ); @@ -859,9 +1061,11 @@ SOFTWARE. return; // sorry, no right clicks allowed } - if( Math.abs( x - hx ) > hrTarget || Math.abs( y - hy ) > hrTarget ) { - return; // only consider this a proper mousedown if on the handle + const hit = hitTest(node, { x: x, y: y }); + if ( !hit ) { + return; } + sourceHandleAngle = hit; if( inForceStart ) { return; // we don't want this going off if we have the forced start to consider @@ -876,6 +1080,10 @@ SOFTWARE. sourceNode = node; + hoveredTarget = null; + hoveredTargetHandle = {}; + highlightHandle( sourceNode, sourceHandleAngle ); + node.addClass( 'edgehandles-source' ); node.trigger( 'cyedgehandles.start' ); @@ -918,7 +1126,19 @@ SOFTWARE. if( options().handleLineType !== 'ghost' ) { clearDraws(); - drawHandle(); + drawHandle( sourceNode ); + + if( hoveredTarget ) { + const hit = hitTest( hoveredTarget, {x: mx, y: my} ); + + deHighlightHandle( hoveredTarget ); + drawHandle( hoveredTarget ); + + if( hit ) { + highlightHandle( hoveredTarget, hit ); + hoveredTargetHandle = hit; + } + } } drawLine( x, y ); @@ -962,7 +1182,7 @@ SOFTWARE. hoverOut( node ); } - } ).on( 'drag position', 'node', dragNodeHandler = function() { + } ).on( 'drag position', 'node', dragNodeHandler = function(e) { if( drawMode ) { return; } @@ -973,10 +1193,24 @@ SOFTWARE. setTimeout( clearDraws, 50 ); } + if( hoveredTarget ) { + const hit = hitTest( hoveredTarget, node.renderedPosition() ); + + deHighlightHandle( hoveredTarget ); + drawHandle( hoveredTarget ); + + if( hit ) { + highlightHandle( hoveredTarget, hit ); + hoveredTargetHandle = hit; + } + } + } ).on( 'grab', 'node', grabHandler = function() { //grabbingNode = true; //setTimeout(function(){ + hoveredTarget = null; + hoveredTargetHandle = {}; clearDraws(); //}, 5); @@ -1009,9 +1243,9 @@ SOFTWARE. var h = node.renderedOuterHeight(); var w = node.renderedOuterWidth(); - setHandleDimensions( node ); + setHandleDimensions(); - drawHandle(); + drawHandle( node ); node.trigger( 'cyedgehandles.showhandle' ); @@ -1159,9 +1393,9 @@ SOFTWARE. node.trigger( 'cyedgehandles.start' ); node.addClass( 'edgehandles-source' ); - setHandleDimensions( node ); + setHandleDimensions(); - drawHandle(); + drawHandle( node ); node.trigger( 'cyedgehandles.showhandle' ); @@ -1239,7 +1473,6 @@ SOFTWARE. // node.trigger( 'cyedgehandles.showhandle' ); // }, 16 ); // } - } ); diff --git a/demo-multi-handle.html b/demo-multi-handle.html new file mode 100644 index 0000000..e313431 --- /dev/null +++ b/demo-multi-handle.html @@ -0,0 +1,161 @@ + + + + +
+