diff --git a/guacamole-common-js/src/main/webapp/modules/StringWriter.js b/guacamole-common-js/src/main/webapp/modules/StringWriter.js index 5a865e17d..abf1684ba 100644 --- a/guacamole-common-js/src/main/webapp/modules/StringWriter.js +++ b/guacamole-common-js/src/main/webapp/modules/StringWriter.js @@ -170,7 +170,8 @@ Guacamole.StringWriter = function(stream) { * @param {String} text The text to send. */ this.sendText = function(text) { - array_writer.sendData(__encode_utf8(text)); + if (text.length) + array_writer.sendData(__encode_utf8(text)); }; /** diff --git a/guacamole/src/main/webapp/app/client/clientModule.js b/guacamole/src/main/webapp/app/client/clientModule.js index 49131a328..73d322021 100644 --- a/guacamole/src/main/webapp/app/client/clientModule.js +++ b/guacamole/src/main/webapp/app/client/clientModule.js @@ -22,6 +22,7 @@ */ angular.module('client', [ 'auth', + 'clipboard', 'element', 'history', 'navigation', diff --git a/guacamole/src/main/webapp/app/client/controllers/clientController.js b/guacamole/src/main/webapp/app/client/controllers/clientController.js index daa782229..74aea6220 100644 --- a/guacamole/src/main/webapp/app/client/controllers/clientController.js +++ b/guacamole/src/main/webapp/app/client/controllers/clientController.js @@ -250,7 +250,7 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams * received from the remote desktop while those keys were pressed. All keys * not currently pressed will not have entries within this map. * - * @type Object. + * @type Object. */ var clipboardDataFromKey = {}; @@ -386,7 +386,7 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams // Send clipboard data if menu is hidden if (!menuShown && menuShownPreviousState) - $scope.$broadcast('guacClipboard', 'text/plain', $scope.client.clipboardData); + $scope.$broadcast('guacClipboard', $scope.client.clipboardData); // Disable client keyboard if the menu is shown $scope.client.clientProperties.keyboardEnabled = !menuShown; @@ -396,6 +396,10 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams // Watch clipboard for new data, associating it with any pressed keys $scope.$watch('client.clipboardData', function clipboardChanged(data) { + // Sync local clipboard as long as the menu is not open + if (!$scope.menu.shown) + clipboardService.setLocalClipboard(data); + // Associate new clipboard data with any currently-pressed key for (var keysym in keysCurrentlyPressed) clipboardDataFromKey[keysym] = data; @@ -442,14 +446,13 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams $scope.$on('guacKeyup', function keyupListener(event, keysym, keyboard) { // Sync local clipboard with any clipboard data received while this - // key was pressed (if any) + // key was pressed (if any) as long as the menu is not open var clipboardData = clipboardDataFromKey[keysym]; - if (clipboardData) { + if (clipboardData && !$scope.menu.shown) clipboardService.setLocalClipboard(clipboardData); - delete clipboardDataFromKey[keysym]; - } // Mark key as released + delete clipboardDataFromKey[keysym]; delete keysCurrentlyPressed[keysym]; }); @@ -566,7 +569,7 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams // Sync with local clipboard clipboardService.getLocalClipboard().then(function clipboardRead(data) { - $scope.$broadcast('guacClipboard', 'text/plain', data); + $scope.$broadcast('guacClipboard', data); }); // Hide status notification diff --git a/guacamole/src/main/webapp/app/client/directives/guacClient.js b/guacamole/src/main/webapp/app/client/directives/guacClient.js index c3382d7fb..769edd703 100644 --- a/guacamole/src/main/webapp/app/client/directives/guacClient.js +++ b/guacamole/src/main/webapp/app/client/directives/guacClient.js @@ -410,9 +410,9 @@ angular.module('client').directive('guacClient', [function guacClient() { }; // Update remote clipboard if local clipboard changes - $scope.$on('guacClipboard', function onClipboard(event, mimetype, data) { + $scope.$on('guacClipboard', function onClipboard(event, data) { if (client) { - client.setClipboard(data); + ManagedClient.setClipboard($scope.client, data); $scope.client.clipboardData = data; } }); diff --git a/guacamole/src/main/webapp/app/client/services/clipboardService.js b/guacamole/src/main/webapp/app/client/services/clipboardService.js deleted file mode 100644 index 7a5950476..000000000 --- a/guacamole/src/main/webapp/app/client/services/clipboardService.js +++ /dev/null @@ -1,162 +0,0 @@ -/* - * 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 service for accessing local clipboard data. - */ -angular.module('client').factory('clipboardService', ['$injector', - function clipboardService($injector) { - - // Get required services - var $q = $injector.get('$q'); - var $rootScope = $injector.get('$rootScope'); - - var service = {}; - - /** - * A div which is used to hide the clipboard textarea and remove it from - * document flow. - * - * @type Element - */ - var clipElement = document.createElement('div'); - - /** - * The textarea that will be used to hold the local clipboard contents. - * - * @type Element - */ - var clipboardContent = document.createElement('textarea'); - - /** - * The contents of the last clipboard event broadcast by this service when - * the clipboard contents changed. - * - * @type String - */ - var lastClipboardEvent = ''; - - // Ensure textarea is selectable but not visible - clipElement.appendChild(clipboardContent); - clipElement.style.position = 'absolute'; - clipElement.style.width = '1px'; - clipElement.style.height = '1px'; - clipElement.style.left = '-1px'; - clipElement.style.top = '-1px'; - clipElement.style.overflow = 'hidden'; - - // Add textarea to DOM - document.body.appendChild(clipElement); - - /** - * Sets the local clipboard, if possible, to the given text. - * - * @param {String} text - * The text to which the local clipboard should be set. - * - * @return {Promise} - * A promise that will resolve if setting the clipboard was successful, - * and will reject if it failed. - */ - service.setLocalClipboard = function setLocalClipboard(text) { - - var deferred = $q.defer(); - - // Copy the given value into the clipboard DOM element - clipboardContent.value = text; - clipboardContent.select(); - - // Attempt to copy data from clipboard element into local clipboard - if (document.execCommand('copy')) - deferred.resolve(); - else - deferred.reject(); - - // Unfocus the clipboard DOM event to avoid mobile keyboard opening - clipboardContent.blur(); - - return deferred.promise; - }; - - /** - * Get the current value of the local clipboard. - * - * @return {Promise} - * A promise that will resolve with the contents of the local clipboard - * if getting the clipboard was successful, and will reject if it - * failed. - */ - service.getLocalClipboard = function getLocalClipboard() { - - var deferred = $q.defer(); - - // Wait for the next event queue run before attempting to read - // clipboard data (in case the copy/cut has not yet completed) - window.setTimeout(function deferredClipboardRead() { - - // Clear and select the clipboard DOM element - clipboardContent.value = ''; - clipboardContent.focus(); - clipboardContent.select(); - - // Attempt paste local clipboard into clipboard DOM element - if (document.activeElement === clipboardContent && document.execCommand('paste')) - deferred.resolve(clipboardContent.value); - else - deferred.reject(); - - // Unfocus the clipboard DOM event to avoid mobile keyboard opening - clipboardContent.blur(); - - }, 100); - - return deferred.promise; - }; - - /** - * Checks whether the clipboard data has changed, firing a new - * "guacClipboard" event if it has. - */ - var checkClipboard = function checkClipboard() { - service.getLocalClipboard().then(function clipboardRead(data) { - - // Fire clipboard event if the data has changed - if (data !== lastClipboardEvent) { - $rootScope.$broadcast('guacClipboard', 'text/plain', data); - lastClipboardEvent = data; - } - - }); - }; - - // Attempt to read the clipboard if it may have changed - window.addEventListener('load', checkClipboard, true); - window.addEventListener('copy', checkClipboard, true); - window.addEventListener('cut', checkClipboard, true); - window.addEventListener('focus', function focusGained(e) { - - // Only recheck clipboard if it's the window itself that gained focus - if (e.target === window) - checkClipboard(); - - }, true); - - return service; - -}]); diff --git a/guacamole/src/main/webapp/app/client/styles/guac-menu.css b/guacamole/src/main/webapp/app/client/styles/guac-menu.css index 3f5460e93..bc96eb922 100644 --- a/guacamole/src/main/webapp/app/client/styles/guac-menu.css +++ b/guacamole/src/main/webapp/app/client/styles/guac-menu.css @@ -65,18 +65,6 @@ margin-top: 1em; } -#guac-menu #clipboard-settings textarea { - width: 100%; - border: 1px solid #AAA; - -moz-border-radius: 0.25em; - -webkit-border-radius: 0.25em; - -khtml-border-radius: 0.25em; - border-radius: 0.25em; - white-space: pre; - display: block; - font-size: 1em; -} - #guac-menu #mouse-settings .choice { text-align: center; } diff --git a/guacamole/src/main/webapp/app/client/templates/client.html b/guacamole/src/main/webapp/app/client/templates/client.html index f56b50632..8a4e7fea2 100644 --- a/guacamole/src/main/webapp/app/client/templates/client.html +++ b/guacamole/src/main/webapp/app/client/templates/client.html @@ -54,7 +54,7 @@

{{'CLIENT.SECTION_HEADER_CLIPBOARD' | translate}}

{{'CLIENT.HELP_CLIPBOARD' | translate}}

- +
diff --git a/guacamole/src/main/webapp/app/client/types/ManagedClient.js b/guacamole/src/main/webapp/app/client/types/ManagedClient.js index 9a2870687..7b1d58800 100644 --- a/guacamole/src/main/webapp/app/client/types/ManagedClient.js +++ b/guacamole/src/main/webapp/app/client/types/ManagedClient.js @@ -26,6 +26,7 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector', // Required types var ClientProperties = $injector.get('ClientProperties'); var ClientIdentifier = $injector.get('ClientIdentifier'); + var ClipboardData = $injector.get('ClipboardData'); var ManagedClientState = $injector.get('ManagedClientState'); var ManagedDisplay = $injector.get('ManagedDisplay'); var ManagedFilesystem = $injector.get('ManagedFilesystem'); @@ -37,7 +38,6 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector', var $rootScope = $injector.get('$rootScope'); var $window = $injector.get('$window'); var authenticationService = $injector.get('authenticationService'); - var clipboardService = $injector.get('clipboardService'); var connectionGroupService = $injector.get('connectionGroupService'); var connectionService = $injector.get('connectionService'); var tunnelService = $injector.get('tunnelService'); @@ -100,9 +100,12 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector', /** * The current clipboard contents. * - * @type String + * @type ClipboardData */ - this.clipboardData = template.clipboardData || ''; + this.clipboardData = template.clipboardData || new ClipboardData({ + type : 'text/plain', + data : '' + }); /** * All uploaded files. As files are uploaded, their progress can be @@ -383,6 +386,10 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector', ManagedClientState.setConnectionState(managedClient.clientState, ManagedClientState.ConnectionState.CONNECTED); + // Send any clipboard data already provided + if (managedClient.clipboardData) + ManagedClient.setClipboard(managedClient, managedClient.clipboardData); + // Begin streaming audio input if possible requestAudioStream(client); @@ -417,28 +424,43 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector', // Handle any received clipboard data client.onclipboard = function clientClipboardReceived(stream, mimetype) { - // Only text/plain is supported for now - if (mimetype !== "text/plain") { - stream.sendAck("Only text/plain supported", Guacamole.Status.Code.UNSUPPORTED); - return; + var reader; + + // If the received data is text, read it as a simple string + if (/^text\//.exec(mimetype)) { + + reader = new Guacamole.StringReader(stream); + + // Assemble received data into a single string + var data = ''; + reader.ontext = function textReceived(text) { + data += text; + }; + + // Set clipboard contents once stream is finished + reader.onend = function textComplete() { + $rootScope.$apply(function updateClipboard() { + managedClient.clipboardData = new ClipboardData({ + type : mimetype, + data : data + }); + }); + }; + } - var reader = new Guacamole.StringReader(stream); - var data = ""; - - // Append any received data to buffer - reader.ontext = function clipboard_text_received(text) { - data += text; - stream.sendAck("Received", Guacamole.Status.Code.SUCCESS); - }; - - // Update state when done - reader.onend = function clipboard_text_end() { - $rootScope.$apply(function updateClipboard() { - managedClient.clipboardData = data; - clipboardService.setLocalClipboard(data); - }); - }; + // Otherwise read the clipboard data as a Blob + else { + reader = new Guacamole.BlobReader(stream, mimetype); + reader.onend = function blobComplete() { + $rootScope.$apply(function updateClipboard() { + managedClient.clipboardData = new ClipboardData({ + type : mimetype, + data : reader.getBlob() + }); + }); + }; + } }; @@ -523,6 +545,46 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector', }; + /** + * Sends the given clipboard data over the given Guacamole client, setting + * the contents of the remote clipboard to the data provided. + * + * @param {ManagedClient} managedClient + * The ManagedClient over which the given clipboard data is to be sent. + * + * @param {ClipboardData} data + * The clipboard data to send. + */ + ManagedClient.setClipboard = function setClipboard(managedClient, data) { + + var writer; + + // Create stream with proper mimetype + var stream = managedClient.client.createClipboardStream(data.type); + + // Send data as a string if it is stored as a string + if (typeof data.data === 'string') { + writer = new Guacamole.StringWriter(stream); + writer.sendText(data.data); + writer.sendEnd(); + } + + // Otherwise, assume the data is a File/Blob + else { + + // Write File/Blob asynchronously + writer = new Guacamole.BlobWriter(stream); + writer.oncomplete = function clipboardSent() { + writer.sendEnd(); + }; + + // Begin sending data + writer.sendBlob(data.data); + + } + + }; + return ManagedClient; }]); \ No newline at end of file diff --git a/guacamole/src/main/webapp/app/clipboard/clipboardModule.js b/guacamole/src/main/webapp/app/clipboard/clipboardModule.js new file mode 100644 index 000000000..b7528a4d3 --- /dev/null +++ b/guacamole/src/main/webapp/app/clipboard/clipboardModule.js @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * The module for code used to manipulate/observe the clipboard. + */ +angular.module('clipboard', []); diff --git a/guacamole/src/main/webapp/app/clipboard/directives/guacClipboard.js b/guacamole/src/main/webapp/app/clipboard/directives/guacClipboard.js new file mode 100644 index 000000000..97fc6e026 --- /dev/null +++ b/guacamole/src/main/webapp/app/clipboard/directives/guacClipboard.js @@ -0,0 +1,241 @@ +/* + * 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 provides an editor whose contents are exposed via a + * ClipboardData object via the "data" attribute. If this data should also be + * synced to the local clipboard, or sent via a connected Guacamole client + * using a "guacClipboard" event, it is up to external code to do so. + */ +angular.module('clipboard').directive('guacClipboard', ['$injector', + function guacClipboard($injector) { + + // Required types + var ClipboardData = $injector.get('ClipboardData'); + + /** + * Configuration object for the guacClipboard directive. + * + * @type Object. + */ + var config = { + restrict : 'E', + replace : true, + templateUrl : 'app/clipboard/templates/guacClipboard.html' + }; + + // Scope properties exposed by the guacClipboard directive + config.scope = { + + /** + * The data to display within the field provided by this directive. This + * data will modified or replaced when the user manually alters the + * contents of the field. + * + * @type ClipboardData + */ + data : '=' + + }; + + // guacClipboard directive controller + config.controller = ['$scope', '$injector', '$element', + function guacClipboardController($scope, $injector, $element) { + + // Required services + var $window = $injector.get('$window'); + var clipboardService = $injector.get('clipboardService'); + + /** + * The FileReader to use to read File or Blob data received from the + * clipboard. + * + * @type FileReader + */ + var reader = new FileReader(); + + /** + * The content-editable DOM element which will contain the clipboard + * contents within the user interface provided by this directive. + * + * @type Element + */ + var element = $element[0]; + + /** + * Returns all files currently contained within the local clipboard, + * given a ClipboardEvent which should contain the current clipboard + * data. If no files are contained within the local clipboard, null + * is returned. + * + * @param {ClipboardEvent} e + * The ClipboardEvent which should contain the current clipboard + * data. + * + * @returns {File[]} + * An array of all files currently contained with the clipboard, as + * provided by the given ClipboardEvent, or null if no files are + * present. + */ + var getClipboardFiles = function getClipboardFiles(e) { + + // Pull the clipboard data object + var clipboardData = e.clipboardData || $window.clipboardData; + + // Read from the standard clipboard API items collection first + var items = clipboardData.items; + if (items) { + + var files = []; + + // Produce array of all files from clipboard data + for (var i = 0; i < items.length; i++) { + if (items[i].kind === 'file') + files.push(items[i].getAsFile()); + } + + return files; + + } + + // Failing that, try the files collection + if (clipboardData.files) + return clipboardData.files; + + // No files accessible within given data + return null; + + }; + + // Intercept paste events, handling image data specifically + element.addEventListener('paste', function dataPasted(e) { + + // Read all files from the clipboard data within the event + var files = getClipboardFiles(e); + if (!files) + return; + + // For each item within the clipboard + for (var i = 0; i < files.length; i++) { + + var file = files[i]; + + // If the file is an image, attempt to read that image + if (/^image\//.exec(file.type)) { + + // Set clipboard data to contents + $scope.$apply(function setClipboardData() { + $scope.data = new ClipboardData({ + type : file.type, + data : file + }); + }); + + // Do not paste + e.preventDefault(); + return; + + } + + } // end for each item + + }); + + /** + * Rereads the contents of the clipboard field, updating the + * ClipboardData object on the scope as necessary. The type of data + * stored within the ClipboardData object will be heuristically + * determined from the HTML contents of the clipboard field. + */ + var updateClipboardData = function updateClipboardData() { + + // If the clipboard contains a single image, parse and assign the + // image data to the internal clipboard + var currentImage = clipboardService.getImageContent(element); + if (currentImage) { + + // Convert the image's data URL into a blob + var blob = clipboardService.parseDataURL(currentImage); + if (blob) { + + // Complete the assignment if conversion was successful + $scope.$evalAsync(function assignClipboardData() { + $scope.data = new ClipboardData({ + type : blob.type, + data : blob + }); + }); + + return; + + } + + } // end if clipboard is an image + + // If data does not appear to be an image, or image decoding fails, + // assume clipboard contents are text + $scope.$evalAsync(function assignClipboardText() { + $scope.data = new ClipboardData({ + type : 'text/plain', + data : clipboardService.getTextContent(element) + }); + }); + + }; + + // Update the internally-stored clipboard data when events are fired + // that indicate the clipboard field may have been changed + element.addEventListener('input', updateClipboardData); + element.addEventListener('DOMCharacterDataModified', updateClipboardData); + element.addEventListener('DOMNodeInserted', updateClipboardData); + element.addEventListener('DOMNodeRemoved', updateClipboardData); + + // Watch clipboard for new data, associating it with any pressed keys + $scope.$watch('data', function clipboardDataChanged(data) { + + // Stop any current read process + if (reader.readyState === 1) + reader.abort(); + + // If the clipboard data is a string, render it as text + if (typeof data.data === 'string') + clipboardService.setTextContent(element, data.data); + + // Render Blob/File contents based on mimetype + else if (data.data instanceof Blob) { + + // If the copied data was an image, display it as such + if (/^image\//.exec(data.type)) { + reader.onload = function updateImageURL() { + clipboardService.setImageContent(element, reader.result); + }; + reader.readAsDataURL(data.data); + } + + // Ignore other data types + + } + + }); // end $scope.data watch + + }]; + + return config; + +}]); diff --git a/guacamole/src/main/webapp/app/clipboard/services/clipboardService.js b/guacamole/src/main/webapp/app/clipboard/services/clipboardService.js new file mode 100644 index 000000000..f0529d8b8 --- /dev/null +++ b/guacamole/src/main/webapp/app/clipboard/services/clipboardService.js @@ -0,0 +1,456 @@ +/* + * 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 service for accessing local clipboard data. + */ +angular.module('clipboard').factory('clipboardService', ['$injector', + function clipboardService($injector) { + + // Get required services + var $q = $injector.get('$q'); + var $window = $injector.get('$window'); + + // Required types + var ClipboardData = $injector.get('ClipboardData'); + + var service = {}; + + /** + * The amount of time to wait before actually serving a request to read + * clipboard data, in milliseconds. Providing a reasonable delay between + * request and read attempt allows the cut/copy operation to settle, in + * case the data we are anticipating to be present is not actually present + * in the clipboard yet. + * + * @constant + * @type Number + */ + var CLIPBOARD_READ_DELAY = 100; + + /** + * Reference to the window.document object. + * + * @private + * @type HTMLDocument + */ + var document = $window.document; + + /** + * The textarea that will be used to hold the local clipboard contents. + * + * @type Element + */ + var clipboardContent = document.createElement('div'); + + // Ensure clipboard target is selectable but not visible + clipboardContent.setAttribute('contenteditable', 'true'); + clipboardContent.className = 'clipboard-service-target'; + + // Add clipboard target to DOM + document.body.appendChild(clipboardContent); + + /** + * Stops the propogation of the given event through the DOM tree. This is + * identical to invoking stopPropogation() on the event directly, except + * that this function is usable as an event handler itself. + * + * @param {Event} e + * The event whose propogation through the DOM tree should be stopped. + */ + var stopEventPropagation = function stopEventPropagation(e) { + e.stopPropagation(); + }; + + // Prevent events generated due to execCommand() from disturbing external things + clipboardContent.addEventListener('copy', stopEventPropagation); + clipboardContent.addEventListener('paste', stopEventPropagation); + + /** + * A stack of past node selection ranges. A range convering the nodes + * currently selected within the document can be pushed onto this stack + * with pushSelection(), and the most recently pushed selection can be + * popped off the stack (and thus re-selected) with popSelection(). + * + * @type Range[] + */ + var selectionStack = []; + + /** + * Pushes the current selection range to the selection stack such that it + * can later be restored with popSelection(). + */ + var pushSelection = function pushSelection() { + + // Add a range representing the current selection to the stack + var selection = $window.getSelection(); + if (selection.getRangeAt && selection.rangeCount) + selectionStack.push(selection.getRangeAt(0)); + + }; + + /** + * Pops a selection range off the selection stack restoring the document's + * previous selection state. The selection range will be the most recent + * selection range pushed by pushSelection(). If there are no selection + * ranges currently on the stack, this function has no effect. + */ + var popSelection = function popSelection() { + + // Pull one selection range from the stack + var range = selectionStack.pop(); + if (!range) + return; + + // Replace any current selection with the retrieved selection + var selection = $window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + }; + + /** + * Selects all nodes within the given element. This will replace the + * current selection with a new selection range that covers the element's + * contents. If the original selection should be preserved, use + * pushSelection() and popSelection(). + * + * @param {Element} element + * The element whose contents should be selected. + */ + var selectAll = function selectAll(element) { + + // Generate a range which selects all nodes within the given element + var range = document.createRange(); + range.selectNodeContents(element); + + // Replace any current selection with the generated range + var selection = $window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + }; + + /** + * Sets the local clipboard, if possible, to the given text. + * + * @param {ClipboardData} data + * The data to assign to the local clipboard should be set. + * + * @return {Promise} + * A promise that will resolve if setting the clipboard was successful, + * and will reject if it failed. + */ + service.setLocalClipboard = function setLocalClipboard(data) { + + var deferred = $q.defer(); + + // Track the originally-focused element prior to changing focus + var originalElement = document.activeElement; + pushSelection(); + + // Copy the given value into the clipboard DOM element + if (typeof data.data === 'string') + clipboardContent.textContent = data.data; + else { + clipboardContent.innerHTML = ''; + var img = document.createElement('img'); + img.src = URL.createObjectURL(data.data); + clipboardContent.appendChild(img); + } + + // Select all data within the clipboard target + selectAll(clipboardContent); + + // Attempt to copy data from clipboard element into local clipboard + if (document.execCommand('copy')) + deferred.resolve(); + else + deferred.reject(); + + // Unfocus the clipboard DOM event to avoid mobile keyboard opening, + // restoring whichever element was originally focused + clipboardContent.blur(); + originalElement.focus(); + popSelection(); + + return deferred.promise; + }; + + /** + * Parses the given data URL, returning its decoded contents as a new Blob. + * If the URL is not a valid data URL, null will be returned instead. + * + * @param {String} url + * The data URL to parse. + * + * @returns {Blob} + * A new Blob containing the decoded contents of the data URL, or null + * if the URL is not a valid data URL. + */ + service.parseDataURL = function parseDataURL(url) { + + // Parse given string as a data URL + var result = /^data:([^;]*);base64,([a-zA-Z0-9+/]*[=]*)$/.exec(url); + if (!result) + return null; + + // Pull the mimetype and base64 contents of the data URL + var type = result[1]; + var data = $window.atob(result[2]); + + // Convert the decoded binary string into a typed array + var buffer = new Uint8Array(data.length); + for (var i = 0; i < data.length; i++) + buffer[i] = data.charCodeAt(i); + + // Produce a proper blob containing the data and type provided in + // the data URL + return new Blob([buffer], { type : type }); + + }; + + /** + * Returns the content of the given element as plain, unformatted text, + * preserving only individual characters and newlines. Formatting, images, + * etc. are not taken into account. + * + * @param {Element} element + * The element whose text content should be returned. + * + * @returns {String} + * The plain text contents of the given element, including newlines and + * spacing but otherwise without any formatting. + */ + service.getTextContent = function getTextContent(element) { + + var blocks = []; + var currentBlock = ''; + + // For each child of the given element + var current = element.firstChild; + while (current) { + + // Simply append the content of any text nodes + if (current.nodeType === Node.TEXT_NODE) + currentBlock += current.nodeValue; + + // Render
as a newline character + else if (current.nodeName === 'BR') + currentBlock += '\n'; + + // Render as alt text, if available + else if (current.nodeName === 'IMG') + currentBlock += current.getAttribute('alt') || ''; + + // For all other nodes, handling depends on whether they are + // block-level elements + else { + + // If we are entering a new block context, start a new block if + // the current block is non-empty + if (currentBlock.length && $window.getComputedStyle(current).display === 'block') { + + // Trim trailing newline (would otherwise inflate the line count by 1) + if (currentBlock.substring(currentBlock.length - 1) === '\n') + currentBlock = currentBlock.substring(0, currentBlock.length - 1); + + // Finish current block and start a new block + blocks.push(currentBlock); + currentBlock = ''; + + } + + // Append the content of the current element to the current block + currentBlock += service.getTextContent(current); + + } + + current = current.nextSibling; + + } + + // Add any in-progress block + if (currentBlock.length) + blocks.push(currentBlock); + + // Combine all non-empty blocks, separated by newlines + return blocks.join('\n'); + + }; + + /** + * Replaces the current text content of the given element with the given + * text. To avoid affecting the position of the cursor within an editable + * element, or firing unnecessary DOM modification events, the underlying + * textContent property of the element is only touched if + * doing so would actually change the text. + * + * @param {Element} element + * The element whose text content should be changed. + * + * @param {String} text + * The text content to assign to the given element. + */ + service.setTextContent = function setTextContent(element, text) { + + // Strip out any images + $(element).find('img').remove(); + + // Reset text content only if doing so will actually change the content + if (service.getTextContent(element) !== text) + element.textContent = text; + + }; + + /** + * Returns the URL of the single image within the given element, if the + * element truly contains only one child and that child is an image. If the + * content of the element is mixed or not an image, null is returned. + * + * @param {Element} element + * The element whose image content should be retrieved. + * + * @returns {String} + * The URL of the image contained within the given element, if that + * element contains only a single child element which happens to be an + * image, or null if the content of the element is not purely an image. + */ + service.getImageContent = function getImageContent(element) { + + // Return the source of the single child element, if it is an image + var firstChild = element.firstChild; + if (firstChild && firstChild.nodeName === 'IMG' && !firstChild.nextSibling) + return firstChild.getAttribute('src'); + + // Otherwise, the content of this element is not simply an image + return null; + + }; + + /** + * Replaces the current contents of the given element with a single image + * having the given URL. To avoid affecting the position of the cursor + * within an editable element, or firing unnecessary DOM modification + * events, the content of the element is only touched if doing so would + * actually change content. + * + * @param {Element} element + * The element whose image content should be changed. + * + * @param {String} url + * The URL of the image which should be assigned as the contents of the + * given element. + */ + service.setImageContent = function setImageContent(element, url) { + + // Retrieve the URL of the current image contents, if any + var currentImage = service.getImageContent(element); + + // If the current contents are not the given image (or not an image + // at all), reassign the contents + if (currentImage !== url) { + + // Clear current contents + element.innerHTML = ''; + + // Add a new image as the sole contents of the element + var img = document.createElement('img'); + img.src = url; + element.appendChild(img); + + } + + }; + + /** + * Get the current value of the local clipboard. + * + * @return {Promise.} + * A promise that will resolve with the contents of the local clipboard + * if getting the clipboard was successful, and will reject if it + * failed. + */ + service.getLocalClipboard = function getLocalClipboard() { + + var deferred = $q.defer(); + + // Wait for the next event queue run before attempting to read + // clipboard data (in case the copy/cut has not yet completed) + $window.setTimeout(function deferredClipboardRead() { + + // Track the originally-focused element prior to changing focus + var originalElement = document.activeElement; + pushSelection(); + + // Clear and select the clipboard DOM element + clipboardContent.innerHTML = ''; + clipboardContent.focus(); + selectAll(clipboardContent); + + // Attempt paste local clipboard into clipboard DOM element + if (document.activeElement === clipboardContent && document.execCommand('paste')) { + + // If the pasted data is a single image, resolve with a blob + // containing that image + var currentImage = service.getImageContent(clipboardContent); + if (currentImage) { + + // Convert the image's data URL into a blob + var blob = service.parseDataURL(currentImage); + if (blob) { + deferred.resolve(new ClipboardData({ + type : blob.type, + data : blob + })); + } + + // Reject if conversion fails + else + deferred.reject(); + + } // end if clipboard is an image + + // Otherwise, assume the clipboard contains plain text + else + deferred.resolve(new ClipboardData({ + type : 'text/plain', + data : clipboardContent.textContent + })); + + } + + // Otherwise, reading from the clipboard has failed + else + deferred.reject(); + + // Unfocus the clipboard DOM event to avoid mobile keyboard opening, + // restoring whichever element was originally focused + clipboardContent.blur(); + originalElement.focus(); + popSelection(); + + }, CLIPBOARD_READ_DELAY); + + return deferred.promise; + }; + + return service; + +}]); diff --git a/guacamole/src/main/webapp/app/clipboard/styles/clipboard.css b/guacamole/src/main/webapp/app/clipboard/styles/clipboard.css new file mode 100644 index 000000000..8f4df25b1 --- /dev/null +++ b/guacamole/src/main/webapp/app/clipboard/styles/clipboard.css @@ -0,0 +1,60 @@ +/* + * 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. + */ + +.clipboard, .clipboard-service-target { + background: white; +} + +.clipboard { + position: relative; + border: 1px solid #AAA; + -moz-border-radius: 0.25em; + -webkit-border-radius: 0.25em; + -khtml-border-radius: 0.25em; + border-radius: 0.25em; + width: 100%; + height: 2in; + white-space: pre; + overflow: auto; + padding: 0.25em; +} + +.clipboard p, +.clipboard div { + margin: 0; +} + +.clipboard img { + max-width: 100%; + max-height: 100%; + display: block; + margin: 0 auto; + border: 1px solid black; + background: url('images/checker.png'); +} + +.clipboard-service-target { + position: fixed; + left: -1px; + right: -1px; + width: 1px; + height: 1px; + white-space: pre; + overflow: hidden; +} diff --git a/guacamole/src/main/webapp/app/clipboard/templates/guacClipboard.html b/guacamole/src/main/webapp/app/clipboard/templates/guacClipboard.html new file mode 100644 index 000000000..b3b604d4c --- /dev/null +++ b/guacamole/src/main/webapp/app/clipboard/templates/guacClipboard.html @@ -0,0 +1 @@ +
diff --git a/guacamole/src/main/webapp/app/clipboard/types/ClipboardData.js b/guacamole/src/main/webapp/app/clipboard/types/ClipboardData.js new file mode 100644 index 000000000..f9f573a56 --- /dev/null +++ b/guacamole/src/main/webapp/app/clipboard/types/ClipboardData.js @@ -0,0 +1,59 @@ +/* + * 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. + */ + +/** + * Provides the ClipboardData class used for interchange between the + * guacClipboard directive, clipboardService service, etc. + */ +angular.module('clipboard').factory('ClipboardData', [function defineClipboardData() { + + /** + * Arbitrary data which can be contained by the clipboard. + * + * @constructor + * @param {ClipboardData|Object} [template={}] + * The object whose properties should be copied within the new + * ClipboardData. + */ + var ClipboardData = function ClipboardData(template) { + + // Use empty object by default + template = template || {}; + + /** + * The mimetype of the data currently stored within the clipboard. + * + * @type String + */ + this.type = template.type || 'text/plain'; + + /** + * The data currently stored within the clipboard. Depending on the + * nature of the stored data, this may be either a String, a Blob, or a + * File. + * + * @type String|Blob|File + */ + this.data = template.data || ''; + + }; + + return ClipboardData; + +}]); diff --git a/guacamole/src/main/webapp/app/index/controllers/indexController.js b/guacamole/src/main/webapp/app/index/controllers/indexController.js index 4d951fc46..25e6b6190 100644 --- a/guacamole/src/main/webapp/app/index/controllers/indexController.js +++ b/guacamole/src/main/webapp/app/index/controllers/indexController.js @@ -26,6 +26,7 @@ angular.module('index').controller('indexController', ['$scope', '$injector', // Required services var $document = $injector.get('$document'); var $window = $injector.get('$window'); + var clipboardService = $injector.get('clipboardService'); var guacNotification = $injector.get('guacNotification'); /** @@ -124,6 +125,28 @@ angular.module('index').controller('indexController', ['$scope', '$injector', keyboard.reset(); }; + /** + * Checks whether the clipboard data has changed, firing a new + * "guacClipboard" event if it has. + */ + var checkClipboard = function checkClipboard() { + clipboardService.getLocalClipboard().then(function clipboardRead(data) { + $scope.$broadcast('guacClipboard', data); + }); + }; + + // Attempt to read the clipboard if it may have changed + $window.addEventListener('load', checkClipboard, true); + $window.addEventListener('copy', checkClipboard, true); + $window.addEventListener('cut', checkClipboard, true); + $window.addEventListener('focus', function focusGained(e) { + + // Only recheck clipboard if it's the window itself that gained focus + if (e.target === $window) + checkClipboard(); + + }, true); + // Display login screen if a whole new set of credentials is needed $scope.$on('guacInvalidCredentials', function loginInvalid(event, parameters, error) { $scope.page.title = 'APP.NAME'; diff --git a/guacamole/src/main/webapp/app/index/indexModule.js b/guacamole/src/main/webapp/app/index/indexModule.js index e1bbd5173..22ab84f96 100644 --- a/guacamole/src/main/webapp/app/index/indexModule.js +++ b/guacamole/src/main/webapp/app/index/indexModule.js @@ -23,6 +23,7 @@ angular.module('index', [ 'auth', 'client', + 'clipboard', 'home', 'login', 'manage', diff --git a/guacamole/src/main/webapp/images/checker.png b/guacamole/src/main/webapp/images/checker.png new file mode 100644 index 000000000..06f89032d Binary files /dev/null and b/guacamole/src/main/webapp/images/checker.png differ