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
+
+ };
+
+}]);