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/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/types/ManagedClient.js b/guacamole/src/main/webapp/app/client/types/ManagedClient.js index 40a4dbf13..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 @@ -385,7 +388,7 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector', // Send any clipboard data already provided if (managedClient.clipboardData) - client.setClipboard(managedClient.clipboardData); + ManagedClient.setClipboard(managedClient, managedClient.clipboardData); // Begin streaming audio input if possible requestAudioStream(client); @@ -421,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() + }); + }); + }; + } }; @@ -527,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/client/directives/guacClipboard.js b/guacamole/src/main/webapp/app/clipboard/directives/guacClipboard.js similarity index 72% rename from guacamole/src/main/webapp/app/client/directives/guacClipboard.js rename to guacamole/src/main/webapp/app/clipboard/directives/guacClipboard.js index 745577ad9..2307f8755 100644 --- a/guacamole/src/main/webapp/app/client/directives/guacClipboard.js +++ b/guacamole/src/main/webapp/app/clipboard/directives/guacClipboard.js @@ -27,7 +27,11 @@ * "data" attribute, changes to clipboard data will be broadcast on the scope * via "guacClipboard" events. */ -angular.module('client').directive('guacClipboard', [function guacClipboard() { +angular.module('clipboard').directive('guacClipboard', ['$injector', + function guacClipboard($injector) { + + // Required types + var ClipboardData = $injector.get('ClipboardData'); /** * Configuration object for the guacClipboard directive. @@ -37,7 +41,7 @@ angular.module('client').directive('guacClipboard', [function guacClipboard() { var config = { restrict : 'E', replace : true, - templateUrl : 'app/client/templates/guacClipboard.html' + templateUrl : 'app/clipboard/templates/guacClipboard.html' }; // Scope properties exposed by the guacClipboard directive @@ -51,7 +55,7 @@ angular.module('client').directive('guacClipboard', [function guacClipboard() { * field. Changes to this value will be rendered within the field and, * if possible, will be pushed to the local clipboard. * - * @type String|Blob + * @type ClipboardData */ data : '=' @@ -81,17 +85,42 @@ angular.module('client').directive('guacClipboard', [function guacClipboard() { * contents received while those keys were pressed. All keys not * currently pressed will not have entries within this map. * - * @type Object. + * @type Object. */ var clipboardDataFromKey = {}; /** - * The URL of the image is currently stored within the clipboard. If - * the clipboard currently contains text, this will be null. + * The FileReader to use to read File or Blob data received from the + * clipboard. * - * @type String + * @type FileReader */ - $scope.imageURL = null; + var reader = new FileReader(); + + /** + * Properties which contain the current clipboard contents. Each + * property is mutually exclusive, and will only contain data if the + * clipboard contents are of a particular type. + */ + $scope.content = { + + /** + * The text contents of the clipboard. If the clipboard contents + * is not text, this will be null. + * + * @type String + */ + text : null, + + /** + * The URL of the image is currently stored within the clipboard. If + * the clipboard currently contains text, this will be null. + * + * @type String + */ + imageURL : null + + }; // Intercept paste events, handling image data specifically $element[0].addEventListener('paste', function dataPasted(e) { @@ -103,9 +132,15 @@ angular.module('client').directive('guacClipboard', [function guacClipboard() { // If the item is an image, attempt to read that image if (items[i].kind === 'file' && /^image\//.exec(items[i].type)) { + // Retrieven contents as a File + var file = items[i].getAsFile(); + // Set clipboard data to contents $scope.$apply(function setClipboardData() { - $scope.data = items[i].getAsFile(); + $scope.data = new ClipboardData({ + type : file.type, + data : file + }); }); // Do not paste @@ -127,7 +162,7 @@ angular.module('client').directive('guacClipboard', [function guacClipboard() { * otherwise. */ $scope.isImage = function isImage() { - return !!$scope.imageURL; + return !!$scope.content.imageURL; }; /** @@ -149,10 +184,25 @@ angular.module('client').directive('guacClipboard', [function guacClipboard() { $scope.resetClipboard = function resetClipboard() { // Reset to blank - $scope.data = ''; + $scope.data = new ClipboardData({ + type : 'text/plain', + data : '' + }); }; + // Keep data in sync with changes to text + $scope.$watch('content.text', function textChanged(text) { + + if (text) { + $scope.data = new ClipboardData({ + type : $scope.data.type, + data : text + }); + } + + }); + // Watch clipboard for new data, associating it with any pressed keys $scope.$watch('data', function clipboardChanged(data) { @@ -160,21 +210,35 @@ angular.module('client').directive('guacClipboard', [function guacClipboard() { for (var keysym in keysCurrentlyPressed) clipboardDataFromKey[keysym] = data; - // Revoke old image URL, if any - if ($scope.imageURL) { - URL.revokeObjectURL($scope.imageURL); - $scope.imageURL = null; + // Stop any current read process + reader.abort(); + + // If the clipboard data is a string, render it as text + if (typeof data.data === 'string') { + $scope.content.text = data.data; + $scope.content.imageURL = null; } - // If the copied data was an image, display it as such - if (data instanceof Blob) { - $scope.imageURL = URL.createObjectURL(data); - $rootScope.$broadcast('guacClipboard', data.type, 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() { + $scope.$apply(function imageURLLoaded() { + $scope.content.text = null; + $scope.content.imageURL = reader.result; + }); + }; + reader.readAsDataURL(data.data); + } + + // Ignore other data types + } - // Otherwise, the data is simply text - else - $rootScope.$broadcast('guacClipboard', 'text/plain', data); + // Notify of change + $rootScope.$broadcast('guacClipboard', data); }); diff --git a/guacamole/src/main/webapp/app/client/services/clipboardService.js b/guacamole/src/main/webapp/app/clipboard/services/clipboardService.js similarity index 77% rename from guacamole/src/main/webapp/app/client/services/clipboardService.js rename to guacamole/src/main/webapp/app/clipboard/services/clipboardService.js index a81dd0e73..ac0640004 100644 --- a/guacamole/src/main/webapp/app/client/services/clipboardService.js +++ b/guacamole/src/main/webapp/app/clipboard/services/clipboardService.js @@ -20,7 +20,7 @@ /** * A service for accessing local clipboard data. */ -angular.module('client').factory('clipboardService', ['$injector', +angular.module('clipboard').factory('clipboardService', ['$injector', function clipboardService($injector) { // Get required services @@ -58,14 +58,14 @@ angular.module('client').factory('clipboardService', ['$injector', /** * Sets the local clipboard, if possible, to the given text. * - * @param {String} text - * The text to which the local clipboard should be set. + * @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(text) { + service.setLocalClipboard = function setLocalClipboard(data) { var deferred = $q.defer(); @@ -73,9 +73,29 @@ angular.module('client').factory('clipboardService', ['$injector', var originalElement = document.activeElement; // Copy the given value into the clipboard DOM element - clipboardContent.value = text; + clipboardContent.value = 'X'; clipboardContent.select(); + // Override copied contents of clipboard with the provided value + clipboardContent.oncopy = function overrideContent(e) { + + // Override the contents of the clipboard + e.preventDefault(); + + // Remove anything already present within the clipboard + var items = e.clipboardData.items; + items.clear(); + + // If the provided data is a string, add it as such + if (typeof data.data === 'string') + items.add(data.data, data.type); + + // Otherwise, add as a File + else + items.add(new File([data.data], 'data', { type : data.type })); + + }; + // Attempt to copy data from clipboard element into local clipboard if (document.execCommand('copy')) deferred.resolve(); @@ -93,7 +113,7 @@ angular.module('client').factory('clipboardService', ['$injector', /** * Get the current value of the local clipboard. * - * @return {Promise} + * @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. @@ -114,9 +134,14 @@ angular.module('client').factory('clipboardService', ['$injector', clipboardContent.focus(); clipboardContent.select(); + // FIXME: Only handling text data + // Attempt paste local clipboard into clipboard DOM element if (document.activeElement === clipboardContent && document.execCommand('paste')) - deferred.resolve(clipboardContent.value); + deferred.resolve(new ClipboardData({ + type : 'text/plain', + data : clipboardContent.value + })); else deferred.reject(); diff --git a/guacamole/src/main/webapp/app/client/styles/clipboard.css b/guacamole/src/main/webapp/app/clipboard/styles/clipboard.css similarity index 100% rename from guacamole/src/main/webapp/app/client/styles/clipboard.css rename to guacamole/src/main/webapp/app/clipboard/styles/clipboard.css diff --git a/guacamole/src/main/webapp/app/client/templates/guacClipboard.html b/guacamole/src/main/webapp/app/clipboard/templates/guacClipboard.html similarity index 66% rename from guacamole/src/main/webapp/app/client/templates/guacClipboard.html rename to guacamole/src/main/webapp/app/clipboard/templates/guacClipboard.html index af4501956..8cb77c1e4 100644 --- a/guacamole/src/main/webapp/app/client/templates/guacClipboard.html +++ b/guacamole/src/main/webapp/app/clipboard/templates/guacClipboard.html @@ -1,9 +1,9 @@
- +
- +
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; + +}]);