diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacTiledClients.js b/guacamole/src/main/frontend/src/app/client/directives/guacTiledClients.js index b4bd04090..b5071bbc2 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacTiledClients.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacTiledClients.js @@ -52,23 +52,64 @@ angular.module('client').directive('guacTiledClients', [function guacTiledClient directive.controller = ['$scope', '$injector', '$element', function guacTiledClientsController($scope, $injector, $element) { + // Required types + var ManagedClientGroup = $injector.get('ManagedClientGroup'); + /** - * Assigns keyboard focus to the given client, allowing that client to - * receive and handle keyboard events. Multiple clients may have - * keyboard focus simultaneously. + * Returns a callback for guacClick that assigns or updates keyboard + * focus to the given client, allowing that client to receive and + * handle keyboard events. Multiple clients may have keyboard focus + * simultaneously. * * @param {ManagedClient} client * The client that should receive keyboard focus. + * + * @return {guacClick~callback} + * The callback that guacClient should invoke when the given client + * has been clicked. */ - $scope.assignFocus = function assignFocus(client) { + $scope.getFocusAssignmentCallback = function getFocusAssignmentCallback(client) { + return (shift, ctrl) => { - // Clear focus of all other clients - $scope.clientGroup.clients.forEach(client => { - client.clientProperties.focused = false; - }); + // Clear focus of all other clients if not selecting multiple + if (!shift && !ctrl) { + $scope.clientGroup.clients.forEach(client => { + client.clientProperties.focused = false; + }); + } - client.clientProperties.focused = true; + client.clientProperties.focused = true; + // Fill in any gaps if performing rectangular multi-selection + // via shift-click + if (shift) { + + var minRow = $scope.clientGroup.rows - 1; + var minColumn = $scope.clientGroup.columns - 1; + var maxRow = 0; + var maxColumn = 0; + + // Determine extents of selected area + ManagedClientGroup.forEach($scope.clientGroup, (client, row, column) => { + if (client.clientProperties.focused) { + minRow = Math.min(minRow, row); + minColumn = Math.min(minColumn, column); + maxRow = Math.max(maxRow, row); + maxColumn = Math.max(maxColumn, column); + } + }); + + ManagedClientGroup.forEach($scope.clientGroup, (client, row, column) => { + client.clientProperties.focused = + row >= minRow + && row <= maxRow + && column >= minColumn + && column <= maxColumn; + }); + + } + + }; }; /** diff --git a/guacamole/src/main/frontend/src/app/client/templates/guacTiledClients.html b/guacamole/src/main/frontend/src/app/client/templates/guacTiledClients.html index 0c56bf2bd..f12094f8f 100644 --- a/guacamole/src/main/frontend/src/app/client/templates/guacTiledClients.html +++ b/guacamole/src/main/frontend/src/app/client/templates/guacTiledClients.html @@ -4,7 +4,7 @@ ng-repeat="client in clientGroup.clients" ng-style="{ 'width' : getTileWidth(), 'height' : getTileHeight() }" ng-class="{ 'focused' : client.clientProperties.focused }" - ng-click="assignFocus(client)"> + guac-click="getFocusAssignmentCallback(client)">

{{ client.title }}

diff --git a/guacamole/src/main/frontend/src/app/client/types/ManagedClientGroup.js b/guacamole/src/main/frontend/src/app/client/types/ManagedClientGroup.js index aa4763906..eb03e98d8 100644 --- a/guacamole/src/main/frontend/src/app/client/types/ManagedClientGroup.js +++ b/guacamole/src/main/frontend/src/app/client/types/ManagedClientGroup.js @@ -220,6 +220,47 @@ angular.module('client').factory('ManagedClientGroup', ['$injector', function de }; + /** + * A callback that is invoked for a ManagedClient within a ManagedClientGroup. + * + * @callback ManagedClientGroup~clientCallback + * @param {ManagedClient} client + * The relevant ManagedClient. + * + * @param {number} row + * The row number of the client within the tiled grid, where 0 is the + * first row. + * + * @param {number} column + * The column number of the client within the tiled grid, where 0 is + * the first column. + * + * @param {number} index + * The index of the client within the relevant + * {@link ManagedClientGroup#clients} array. + */ + + /** + * Loops through each of the clients associated with the given + * ManagedClientGroup, invoking the given callback for each client. + * + * @param {ManagedClientGroup} group + * The ManagedClientGroup to loop through. + * + * @param {ManagedClientGroup~clientCallback} callback + * The callback to invoke for each of the clients within the given + * ManagedClientGroup. + */ + ManagedClientGroup.forEach = function forEach(group, callback) { + var current = 0; + for (var row = 0; row < group.rows; row++) { + for (var column = 0; column < group.columns; column++) { + callback(group.clients[current], row, column, current); + current++; + } + } + }; + return ManagedClientGroup; }]); \ No newline at end of file diff --git a/guacamole/src/main/frontend/src/app/element/directives/guacClick.js b/guacamole/src/main/frontend/src/app/element/directives/guacClick.js new file mode 100644 index 000000000..ef847e0d1 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/element/directives/guacClick.js @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * A directive which provides handling of click and click-like touch events. + * The state of Shift and Ctrl modifiers is tracked through these click events + * to allow for specific handling of Shift+Click and Ctrl+Click. + */ +angular.module('element').directive('guacClick', [function guacClick() { + + return { + restrict: 'A', + + link: function linkGuacClick($scope, $element, $attrs) { + + /** + * A callback that is invoked by the guacClick directive when a + * click or click-like event is received. + * + * @callback guacClick~callback + * @param {boolean} shift + * Whether Shift was held down at the time the click occurred. + * + * @param {boolean} ctrl + * Whether Ctrl or Meta (the Mac "Command" key) was held down + * at the time the click occurred. + */ + + /** + * The callback to invoke when a click or click-like event is + * received on the assocaited element. + * + * @type guacClick~callback + */ + var guacClick = $scope.$eval($attrs.guacClick); + + /** + * The element which will register the click. + * + * @type Element + */ + var element = $element[0]; + + /** + * Whether either Shift key is currently pressed. + * + * @type boolean + */ + var shift = false; + + /** + * Whether either Ctrl key is currently pressed. To allow the + * Command key to be used on Mac platforms, this flag also + * considers the state of either Meta key. + * + * @type boolean + */ + var ctrl = false; + + /** + * Updates the state of the {@link shift} and {@link ctrl} flags + * based on which keys are currently marked as held down by the + * given Guacamole.Keyboard. + * + * @param {Guacamole.Keyboard} keyboard + * The Guacamole.Keyboard instance to read key states from. + */ + var updateModifiers = function updateModifiers(keyboard) { + + shift = !!( + keyboard.pressed[0xFFE1] // Left shift + || keyboard.pressed[0xFFE2] // Right shift + ); + + ctrl = !!( + keyboard.pressed[0xFFE3] // Left ctrl + || keyboard.pressed[0xFFE4] // Right ctrl + || keyboard.pressed[0xFFE7] // Left meta (command) + || keyboard.pressed[0xFFE8] // Right meta (command) + ); + + }; + + // Update tracking of modifier states for each key press + $scope.$on('guacKeydown', function keydownListener(event, keysym, keyboard) { + updateModifiers(keyboard); + }); + + // Update tracking of modifier states for each key release + $scope.$on('guacKeyup', function keyupListener(event, keysym, keyboard) { + updateModifiers(keyboard); + }); + + // Fire provided callback for each mouse-initiated "click" event ... + element.addEventListener('click', function elementClicked(e) { + if (element.contains(e.target)) + $scope.$apply(() => guacClick(shift, ctrl)); + }); + + // ... and for touch-initiated click-like events + element.addEventListener('touchstart', function elementClicked(e) { + if (element.contains(e.target)) + $scope.$apply(() => guacClick(shift, ctrl)); + }); + + } // end guacClick link function + + }; + +}]);