diff --git a/app/package.json b/app/package.json index 0aa8ccd64..20a323d50 100644 --- a/app/package.json +++ b/app/package.json @@ -41,6 +41,7 @@ "glob": "^7.1.2", "is-online": "^5.2.0", "jquery": "^3.2.1", + "lodash.debounce": "^4.0.8", "marked": "^0.3.12", "node-emoji": "^1.8.1", "node-lang-info": "^0.2.1", diff --git a/app/scripts/factories/node.js b/app/scripts/factories/node.js index f8aa6e1b7..b18a78a05 100644 --- a/app/scripts/factories/node.js +++ b/app/scripts/factories/node.js @@ -58,6 +58,9 @@ angular.module('icestudio') .factory('nodeTemp', function() { return require('temporary'); }) + .factory('nodeDebounce', function() { + return require('lodash.debounce'); + }) .factory('SVGO', function() { var config = { full: true, diff --git a/app/scripts/graphics/joint.command.js b/app/scripts/graphics/joint.command.js index 55da16a69..077130cdc 100755 --- a/app/scripts/graphics/joint.command.js +++ b/app/scripts/graphics/joint.command.js @@ -178,7 +178,7 @@ joint.dia.CommandManager = Backbone.Model.extend({ initBatchCommand: function() { - //console.log('initBatchCommand', this.batchCommand); + // console.log('initBatchCommand', this.batchCommand); if (!this.batchCommand) { @@ -198,30 +198,30 @@ joint.dia.CommandManager = Backbone.Model.extend({ storeBatchCommand: function() { - //console.log('storeBatchCommand', this.batchCommand, this.batchLevel); + // console.log('storeBatchCommand', this.batchCommand, this.batchLevel); // In order to store batch command it is necesary to run storeBatchCommand as many times as // initBatchCommand was executed if (this.batchCommand && this.batchLevel <= 0) { - // checking if there is any valid command in batch - // for example: calling `initBatchCommand` immediately followed by `storeBatchCommand` - if (this.lastCmdIndex >= 0) { + // checking if there is any valid command in batch + // for example: calling `initBatchCommand` immediately followed by `storeBatchCommand` + if (this.lastCmdIndex >= 0) { - this.redoStack = []; + this.redoStack = []; - this.undoStack.push(this.batchCommand); - if (this.batchCommand && this.batchCommand[0] && this.batchCommand[0].action !== 'lang') { - // Do not store lang in changesStack - this.changesStack.push(this.batchCommand); - this.triggerChange(); + this.undoStack.push(this.batchCommand); + if (this.batchCommand && this.batchCommand[0] && this.batchCommand[0].action !== 'lang') { + // Do not store lang in changesStack + this.changesStack.push(this.batchCommand); + this.triggerChange(); + } + this.trigger('add', this.batchCommand); } - this.trigger('add', this.batchCommand); - } - delete this.batchCommand; - delete this.lastCmdIndex; - delete this.batchLevel; + delete this.batchCommand; + delete this.lastCmdIndex; + delete this.batchLevel; } else if (this.batchCommand && this.batchLevel > 0) { @@ -249,7 +249,9 @@ joint.dia.CommandManager = Backbone.Model.extend({ switch (cmd.action) { case 'add': - cell.remove(); + if (cell) { + cell.remove(); + } break; case 'remove': diff --git a/app/scripts/graphics/joint.selection.js b/app/scripts/graphics/joint.selection.js index a33cb2c49..61d0086d0 100644 --- a/app/scripts/graphics/joint.selection.js +++ b/app/scripts/graphics/joint.selection.js @@ -103,18 +103,16 @@ joint.ui.SelectionView = Backbone.View.extend({ } }, - startTranslatingSelection: function(evt, noBatch) { + startTranslatingSelection: function(evt) { - if (evt.which === 1 || noBatch) { + if (this._action !== 'adding' && evt.which === 1) { // Mouse left button if (!evt.shiftKey) { this._action = 'translating'; - if (!noBatch) { - this.options.graph.trigger('batch:stop'); - this.options.graph.trigger('batch:start'); - } + this.options.graph.trigger('batch:stop'); + this.options.graph.trigger('batch:start'); var snappedClientCoords = this.options.paper.snapToGrid(g.point(evt.clientX, evt.clientY)); this._snappedClientX = snappedClientCoords.x; @@ -125,9 +123,15 @@ joint.ui.SelectionView = Backbone.View.extend({ } }, - isTranslating: function() { + startAddingSelection: function(evt) { + + this._action = 'adding'; + + var snappedClientCoords = this.options.paper.snapToGrid(g.point(evt.clientX, evt.clientY)); + this._snappedClientX = snappedClientCoords.x; + this._snappedClientY = snappedClientCoords.y; - return this._action === 'translating'; + this.trigger('selection-box:pointerdown', evt); }, startSelecting: function(evt/*, x, y*/) { @@ -179,6 +183,7 @@ joint.ui.SelectionView = Backbone.View.extend({ }); break; + case 'adding': case 'translating': var snappedClientCoords = this.options.paper.snapToGrid(g.point(evt.clientX, evt.clientY)); @@ -233,6 +238,8 @@ joint.ui.SelectionView = Backbone.View.extend({ this._snappedClientY = snappedClientY; } + this.trigger('selection-box:pointermove', evt); + break; } }, @@ -281,6 +288,9 @@ joint.ui.SelectionView = Backbone.View.extend({ // Everything else is done during the translation. break; + case 'adding': + break; + case 'cherry-picking': // noop; All is done in the `createSelectionBox()` function. // This is here to avoid removing selection boxes as a reaction on mouseup event and diff --git a/app/scripts/graphics/joint.shapes.js b/app/scripts/graphics/joint.shapes.js index 7455e4e18..d4cc87c98 100644 --- a/app/scripts/graphics/joint.shapes.js +++ b/app/scripts/graphics/joint.shapes.js @@ -190,7 +190,7 @@ joint.shapes.ice.Model = joint.shapes.basic.Generic.extend({ attrs[portLabelSelector]['y'] = -5-offset; attrs[portLabelSelector]['text-anchor'] = 'end'; attrs[portWireSelector]['y'] = position; - attrs[portWireSelector]['d'] = 'M 0 0 L 16 0'; + attrs[portWireSelector]['d'] = 'M 0 0 L 8 0'; break; case 'right': attrs[portSelector]['ref-dx'] = 8; @@ -199,7 +199,7 @@ joint.shapes.ice.Model = joint.shapes.basic.Generic.extend({ attrs[portLabelSelector]['y'] = -5-offset; attrs[portLabelSelector]['text-anchor'] = 'start'; attrs[portWireSelector]['y'] = position; - attrs[portWireSelector]['d'] = 'M 0 0 L -16 0'; + attrs[portWireSelector]['d'] = 'M 0 0 L -8 0'; break; case 'top': attrs[portSelector]['ref-y'] = -8; @@ -208,7 +208,7 @@ joint.shapes.ice.Model = joint.shapes.basic.Generic.extend({ attrs[portLabelSelector]['y'] = 2; attrs[portLabelSelector]['text-anchor'] = 'start'; attrs[portWireSelector]['x'] = position; - attrs[portWireSelector]['d'] = 'M 0 0 L 0 16'; + attrs[portWireSelector]['d'] = 'M 0 0 L 0 8'; break; case 'bottom': attrs[portSelector]['ref-dy'] = 8; @@ -217,7 +217,7 @@ joint.shapes.ice.Model = joint.shapes.basic.Generic.extend({ attrs[portLabelSelector]['y'] = -2; attrs[portLabelSelector]['text-anchor'] = 'start'; attrs[portWireSelector]['x'] = position; - attrs[portWireSelector]['d'] = 'M 0 0 L 0 -16'; + attrs[portWireSelector]['d'] = 'M 0 0 L 0 -8'; break; } @@ -296,10 +296,14 @@ joint.shapes.ice.ModelView = joint.dia.ElementView.extend({ return; } + var type = self.model.get('type'); var size = self.model.get('size'); var state = self.model.get('state'); var gridstep = 8 * 2; - var minSize = { width: 64, height: 32 }; + var minSize = { + width: type === 'ice.Code' ? 96 : 64, + height: type === 'ice.Code' ? 64 : 32 + }; var clientCoords = snapToGrid({ x: event.clientX, y: event.clientY }); var oldClientCoords = snapToGrid({ x: self._clientX, y: self._clientY }); diff --git a/app/scripts/services/graph.js b/app/scripts/services/graph.js index 759e48513..6fa9064ad 100644 --- a/app/scripts/services/graph.js +++ b/app/scripts/services/graph.js @@ -9,6 +9,7 @@ angular.module('icestudio') utils, common, gettextCatalog, + nodeDebounce, window) { // Variables @@ -304,7 +305,7 @@ angular.module('icestudio') selectionView.on('selection-box:pointerdown', function(/*evt*/) { // Move selection to top view - if (selection) { + if (hasSelection()) { selection.each(function(cell) { var cellView = paper.findViewByModel(cell); if (!cellView.model.isLink()) { @@ -318,10 +319,12 @@ angular.module('icestudio') selectionView.on('selection-box:pointerclick', function(evt) { if (self.addingDraggableBlock) { - // Set new block position + // Set new block's position self.addingDraggableBlock = false; + processReplaceBlock(selection.at(0)); disableSelected(); updateWiresOnObstacles(); + graph.trigger('batch:stop'); } else { // Toggle selected cell @@ -330,7 +333,7 @@ angular.module('icestudio') selection.reset(selection.without(cell)); selectionView.destroySelectionBox(cell); } - } + } }); var pointerDown = false; @@ -365,7 +368,6 @@ angular.module('icestudio') // Add cell to selection selection.add(cellView.model); selectionView.createSelectionBox(cellView.model); - //unhighlight(cellView); } } } @@ -446,7 +448,6 @@ angular.module('icestudio') // Move selection to top view if !mousedown if (!utils.hasButtonPressed(evt)) { if (!cellView.model.isLink()) { - //highlight(cellView); if (cellView.$box.css('z-index') < z.index) { cellView.$box.css('z-index', ++z.index); } @@ -454,29 +455,155 @@ angular.module('icestudio') } }); - /*paper.on('cell:mouseout', function(cellView, evt) { - if (!utils.hasButtonPressed(evt)) { - if (!cellView.model.isLink()) { - unhighlight(cellView); + paper.on('cell:pointerup', function(cellView/*, evt*/) { + graph.trigger('batch:start'); + processReplaceBlock(cellView.model); + graph.trigger('batch:stop'); + }); + + paper.on('cell:pointermove', function(cellView/*, evt*/) { + debounceDisableReplacedBlock(cellView.model); + }); + + selectionView.on('selection-box:pointermove', function(/*evt*/) { + if (self.addingDraggableBlock && hasSelection()) { + debounceDisableReplacedBlock(selection.at(0)); + } + }); + + function processReplaceBlock(upperBlock) { + debounceDisableReplacedBlock.flush(); + var lowerBlock = findLowerBlock(upperBlock); + replaceBlock(upperBlock, lowerBlock); + } + + function findLowerBlock(upperBlock) { + if (upperBlock.get('type') !== 'ice.Generic' && + upperBlock.get('type') !== 'ice.Code' && + upperBlock.get('type') !== 'ice.Input') { + return; + } + var blocks = graph.findModelsUnderElement(upperBlock); + // There is at least one model ice.Generic under the upperModel + if (blocks.length <= 0) { + return; + } + // Get the first model found + var lowerBlock = blocks[0]; + if (lowerBlock.get('type') !== 'ice.Generic' && + lowerBlock.get('type') !== 'ice.Code' && + lowerBlock.get('type') !== 'ice.Input') { + return; + } + return lowerBlock; + } + + function replaceBlock(upperBlock, lowerBlock) { + if (lowerBlock) { + // 1. Compute portsMap between the upperBlock and the lowerBlock + var portsMap = computeAllPortsMap(upperBlock, lowerBlock); + // 2. Reconnect the wires from the lowerBlock to the upperBlock + var wires = graph.getConnectedLinks(lowerBlock); + _.each(wires, function(wire) { + // Replace wire's source + replaceWireConnection(wire, 'source'); + // Replace wire's target + replaceWireConnection(wire, 'target'); + }); + // 3. Move the upperModel to the lowerModel's position + upperBlock.set('position', lowerBlock.get('position')); + // 4. Remove the lowerModel + lowerBlock.remove(); + prevLowerBlock = null; + } + + function replaceWireConnection(wire, connectorType) { + var connector = wire.get(connectorType); + if (connector.id === lowerBlock.get('id') && portsMap[connector.port]) { + wire.set(connectorType, { + id: upperBlock.get('id'), + port: portsMap[connector.port] + }); } } - });*/ + } - /*paper.on('cell:pointerdown', function(cellView) { - if (paper.options.enabled) { - if (cellView.model.isLink()) { - // Unhighlight source block of the wire - unhighlight(paper.findViewByModel(cellView.model.get('source').id)); + function computeAllPortsMap(upperBlock, lowerBlock) { + var portsMap = {}; + + // Compute the ports for each side: left, right and top. + // If there are ports with the same name they are ordered + // by position, from 0 to n. + // + // Top ports 0 ·· n + // _____|__|__|_____ + // Left ports 0 --| |-- 0 Right ports + // · --| BLOCK |-- · + // · --| |-- · + // n |_________________| n + // + _.merge(portsMap, computePortsMap(upperBlock, lowerBlock, 'leftPorts')); + _.merge(portsMap, computePortsMap(upperBlock, lowerBlock, 'rightPorts')); + _.merge(portsMap, computePortsMap(upperBlock, lowerBlock, 'topPorts')); + + return portsMap; + } + + function computePortsMap(upperBlock, lowerBlock, portType) { + var portsMap = {}; + var usedUpperPorts = []; + var upperPorts = upperBlock.get(portType); + var lowerPorts = lowerBlock.get(portType); + + _.each(lowerPorts, function(lowerPort) { + var matchedPorts = _.filter(upperPorts, function(upperPort) { + return lowerPort.name === upperPort.name && + lowerPort.size === upperPort.size && + !_.includes(usedUpperPorts, upperPort); + }); + if (matchedPorts && matchedPorts.length > 0) { + portsMap[lowerPort.id] = matchedPorts[0].id; + usedUpperPorts = usedUpperPorts.concat(matchedPorts[0]); + } + }); + + if (_.isEmpty(portsMap)) { + // If there is no match replace the connections if the + // port's size matches ignoring the port's name. + var n = Math.min(upperPorts.length, lowerPorts.length); + for (var i = 0; i < n; i++) { + if (lowerPorts[i].size === upperPorts[i].size) { + portsMap[lowerPorts[i].id] = upperPorts[i].id; + } } } - }); - graph.on('change:position', function(cell) { - if (!selectionView.isTranslating()) { - // Update wires on obstacles motion - updateWiresOnObstacles(); + return portsMap; + } + + var prevLowerBlock = null; + + function disableReplacedBlock(lowerBlock) { + if (prevLowerBlock) { + // Unhighlight previous lower block + var prevLowerBlockView = paper.findViewByModel(prevLowerBlock); + prevLowerBlockView.$box.removeClass('block-disabled'); + prevLowerBlockView.$el.removeClass('block-disabled'); + } + if (lowerBlock) { + // Highlight new lower block + var lowerBlockView = paper.findViewByModel(lowerBlock); + lowerBlockView.$box.addClass('block-disabled'); + lowerBlockView.$el.addClass('block-disabled'); } - });*/ + prevLowerBlock = lowerBlock; + } + + // Debounce `pointermove` handler to improve the performance + var debounceDisableReplacedBlock = nodeDebounce(function (upperBlock) { + var lowerBlock = findLowerBlock(upperBlock); + disableReplacedBlock(lowerBlock); + }, 100); graph.on('add change:source change:target', function(cell) { if (cell.isLink() && cell.get('source').id) { @@ -556,11 +683,13 @@ angular.module('icestudio') this.undo = function() { disableSelected(); commandManager.undo(); + updateWiresOnObstacles(); }; this.redo = function() { disableSelected(); commandManager.redo(); + updateWiresOnObstacles(); }; this.clearAll = function() { @@ -629,10 +758,9 @@ angular.module('icestudio') addCell(cell); disableSelected(); var opt = { transparent: true, initooltip: false }; - var noBatch = true; selection.add(cell); selectionView.createSelectionBox(cell, opt); - selectionView.startTranslatingSelection({ clientX: mousePosition.x, clientY: mousePosition.y }, noBatch); + selectionView.startAddingSelection({ clientX: mousePosition.x, clientY: mousePosition.y }); }; this.addDraggableCells = function(cells) { @@ -655,12 +783,11 @@ angular.module('icestudio') addCells(cells); disableSelected(); var opt = { transparent: true }; - var noBatch = true; _.each(cells, function(cell) { selection.add(cell); selectionView.createSelectionBox(cell, opt); }); - selectionView.startTranslatingSelection({ clientX: mousePosition.x, clientY: mousePosition.y }, noBatch); + selectionView.startAddingSelection({ clientX: mousePosition.x, clientY: mousePosition.y }); } }; @@ -821,77 +948,10 @@ angular.module('icestudio') if (!cell.isLink()) { selection.add(cell); selectionView.createSelectionBox(cell); - //unhighlight(cellView); } }); }; - /*function highlight(cellView) { - if (cellView) { - switch(cellView.model.get('type')) { - case 'ice.Input': - case 'ice.Output': - if (cellView.model.get('data').virtual) { - cellView.$box.addClass('highlight-green'); - } - else { - cellView.$box.addClass('highlight-yellow'); - } - break; - case 'ice.Constant': - cellView.$box.addClass('highlight-orange'); - break; - case 'ice.Code': - cellView.$box.addClass('highlight-blue'); - break; - case 'ice.Generic': - if (cellView.model.get('config')) { - cellView.$box.addClass('highlight-yellow'); - } - else { - cellView.$box.addClass('highlight-blue'); - } - break; - case 'ice.Info': - cellView.$box.addClass('highlight-gray'); - break; - } - } - } - - function unhighlight(cellView) { - if (cellView) { - switch(cellView.model.get('type')) { - case 'ice.Input': - case 'ice.Output': - if (cellView.model.get('data').virtual) { - cellView.$box.removeClass('highlight-green'); - } - else { - cellView.$box.removeClass('highlight-yellow'); - } - break; - case 'ice.Constant': - cellView.$box.removeClass('highlight-orange'); - break; - case 'ice.Code': - cellView.$box.removeClass('highlight-blue'); - break; - case 'ice.Generic': - if (cellView.model.get('config')) { - cellView.$box.removeClass('highlight-yellow'); - } - else { - cellView.$box.removeClass('highlight-blue'); - } - break; - case 'ice.Info': - cellView.$box.removeClass('highlight-gray'); - break; - } - } - }*/ - function hasSelection() { return selection && selection.length > 0; } @@ -1153,7 +1213,6 @@ angular.module('icestudio') } selection.add(cell); selectionView.createSelectionBox(cell); - //unhighlight(cellView); } }); } diff --git a/app/styles/design.css b/app/styles/design.css index 2b79a7581..b075c0cda 100644 --- a/app/styles/design.css +++ b/app/styles/design.css @@ -104,8 +104,13 @@ pointer-events: none; } -.body { +.block-disabled { + opacity: 0.4; +} + +g { fill: transparent; + transition: opacity 0.2s; } .generic-block { @@ -117,6 +122,7 @@ -webkit-user-select: none; user-select: none; z-index: 0; + transition: opacity 0.2s; } .generic-block .clock { @@ -198,6 +204,7 @@ -webkit-user-select: none; user-select: none; z-index: 0; + transition: opacity 0.2s; } .virtual-port p { @@ -229,6 +236,7 @@ -webkit-user-select: none; user-select: none; z-index: 0; + transition: opacity 0.2s; } .fpga-port p { @@ -308,6 +316,7 @@ -webkit-user-select: none; user-select: none; z-index: 0; + transition: opacity 0.2s; } .constant-block p { @@ -357,6 +366,7 @@ -webkit-user-select: none; user-select: none; z-index: 0; + transition: opacity 0.2s; } .code-block .code-editor { @@ -380,6 +390,7 @@ -webkit-user-select: none; user-select: none; z-index: 0; + transition: opacity 0.2s; } .info-block-readonly {