diff --git a/guacamole/src/main/webapp/app/clipboard/directives/guacClipboard.js b/guacamole/src/main/webapp/app/clipboard/directives/guacClipboard.js
index a55f398a6..3a44d382f 100644
--- a/guacamole/src/main/webapp/app/clipboard/directives/guacClipboard.js
+++ b/guacamole/src/main/webapp/app/clipboard/directives/guacClipboard.js
@@ -113,160 +113,6 @@ angular.module('clipboard').directive('guacClipboard', ['$injector',
*/
var element = $element[0];
- /**
- * Modifies the contents of the given element such that it contains
- * only plain text. All non-text child elements will be stripped and
- * replaced with their text equivalents. As this function performs the
- * conversion through incremental changes only, cursor position within
- * the given element is preserved.
- *
- * @param {Element} element
- * The elements whose contents should be converted to plain text.
- */
- var convertToText = function convertToText(element) {
-
- // For each child of the given element
- var current = element.firstChild;
- while (current) {
-
- // Preserve the next child in the list, in case the current
- // node is replaced
- var next = current.nextSibling;
-
- // If the child is not already a text node, replace it with its
- // own text contents
- if (current.nodeType !== Node.TEXT_NODE) {
- var textNode = document.createTextNode(current.textContent);
- current.parentElement.replaceChild(textNode, current);
- }
-
- // Advance to next child
- current = next;
-
- }
-
- };
-
- /**
- * 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.
- */
- var 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 });
-
- };
-
- /**
- * 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.
- */
- var setTextContent = function setTextContent(element, text) {
-
- // Strip out any non-text content while preserving cursor position
- convertToText(element);
-
- // Reset text content only if doing so will actually change the content
- if (element.textContent !== 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.
- */
- var 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.
- */
- var setImageContent = function setImageContent(element, url) {
-
- // Retrieve the URL of the current image contents, if any
- var currentImage = 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);
-
- }
-
- };
-
// Intercept paste events, handling image data specifically
element.addEventListener('paste', function dataPasted(e) {
@@ -320,11 +166,11 @@ angular.module('clipboard').directive('guacClipboard', ['$injector',
// If the clipboard contains a single image, parse and assign the
// image data to the internal clipboard
- var currentImage = getImageContent(element);
+ var currentImage = clipboardService.getImageContent(element);
if (currentImage) {
// Convert the image's data URL into a blob
- var blob = parseDataURL(currentImage);
+ var blob = clipboardService.parseDataURL(currentImage);
if (blob) {
// Complete the assignment if conversion was successful
@@ -372,7 +218,7 @@ angular.module('clipboard').directive('guacClipboard', ['$injector',
// If the clipboard data is a string, render it as text
if (typeof data.data === 'string')
- setTextContent(element, data.data);
+ clipboardService.setTextContent(element, data.data);
// Render Blob/File contents based on mimetype
else if (data.data instanceof Blob) {
@@ -380,7 +226,7 @@ angular.module('clipboard').directive('guacClipboard', ['$injector',
// If the copied data was an image, display it as such
if (/^image\//.exec(data.type)) {
reader.onload = function updateImageURL() {
- setImageContent(element, reader.result);
+ clipboardService.setImageContent(element, reader.result);
};
reader.readAsDataURL(data.data);
}
diff --git a/guacamole/src/main/webapp/app/clipboard/services/clipboardService.js b/guacamole/src/main/webapp/app/clipboard/services/clipboardService.js
index ac0640004..0dc2b7aa3 100644
--- a/guacamole/src/main/webapp/app/clipboard/services/clipboardService.js
+++ b/guacamole/src/main/webapp/app/clipboard/services/clipboardService.js
@@ -24,36 +24,134 @@ angular.module('clipboard').factory('clipboardService', ['$injector',
function clipboardService($injector) {
// Get required services
- var $q = $injector.get('$q');
+ var $q = $injector.get('$q');
+ var $window = $injector.get('$window');
+
+ // Required types
+ var ClipboardData = $injector.get('ClipboardData');
var service = {};
/**
- * A div which is used to hide the clipboard textarea and remove it from
- * document flow.
+ * Reference to the window.document object.
*
- * @type Element
+ * @private
+ * @type HTMLDocument
*/
- var clipElement = document.createElement('div');
+ var document = $window.document;
/**
* The textarea that will be used to hold the local clipboard contents.
*
* @type Element
*/
- var clipboardContent = document.createElement('textarea');
+ var clipboardContent = document.createElement('div');
- // Ensure textarea is selectable but not visible
- clipElement.appendChild(clipboardContent);
- clipElement.style.position = 'fixed';
- clipElement.style.width = '1px';
- clipElement.style.height = '1px';
- clipElement.style.left = '-1px';
- clipElement.style.top = '-1px';
- clipElement.style.overflow = 'hidden';
+ // Ensure clipboard target is selectable but not visible
+ clipboardContent.setAttribute('contenteditable', 'true');
+ clipboardContent.className = 'clipboard-service-target';
- // Add textarea to DOM
- document.body.appendChild(clipElement);
+ // Add clipboard target to DOM
+ document.body.appendChild(clipboardContent);
+
+ /**
+ * 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);
+
+ };
+
+ /**
+ * Modifies the contents of the given element such that it contains only
+ * plain text. All non-text child elements will be stripped and replaced
+ * with their text equivalents. As this function performs the conversion
+ * through incremental changes only, cursor position within the given
+ * element is preserved.
+ *
+ * @param {Element} element
+ * The elements whose contents should be converted to plain text.
+ */
+ var convertToText = function convertToText(element) {
+
+ // For each child of the given element
+ var current = element.firstChild;
+ while (current) {
+
+ // Preserve the next child in the list, in case the current
+ // node is replaced
+ var next = current.nextSibling;
+
+ // If the child is not already a text node, replace it with its
+ // own text contents
+ if (current.nodeType !== Node.TEXT_NODE) {
+ var textNode = document.createTextNode(current.textContent);
+ current.parentElement.replaceChild(textNode, current);
+ }
+
+ // Advance to next child
+ current = next;
+
+ }
+
+ };
/**
* Sets the local clipboard, if possible, to the given text.
@@ -71,30 +169,20 @@ angular.module('clipboard').factory('clipboardService', ['$injector',
// Track the originally-focused element prior to changing focus
var originalElement = document.activeElement;
+ pushSelection();
// Copy the given value into the clipboard DOM element
- clipboardContent.value = 'X';
- clipboardContent.select();
+ 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);
+ }
- // 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 }));
-
- };
+ // Select all data within the clipboard target
+ selectAll(clipboardContent);
// Attempt to copy data from clipboard element into local clipboard
if (document.execCommand('copy'))
@@ -106,10 +194,128 @@ angular.module('clipboard').factory('clipboardService', ['$injector',
// 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 });
+
+ };
+
+ /**
+ * 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 non-text content while preserving cursor position
+ convertToText(element);
+
+ // Reset text content only if doing so will actually change the content
+ if (element.textContent !== 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.
*
@@ -124,24 +330,50 @@ angular.module('clipboard').factory('clipboardService', ['$injector',
// 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() {
+ $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.value = '';
+ clipboardContent.innerHTML = '';
clipboardContent.focus();
- clipboardContent.select();
-
- // FIXME: Only handling text data
+ selectAll(clipboardContent);
// Attempt paste local clipboard into clipboard DOM element
- if (document.activeElement === clipboardContent && document.execCommand('paste'))
- deferred.resolve(new ClipboardData({
- type : 'text/plain',
- data : clipboardContent.value
- }));
+ 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();
@@ -149,6 +381,7 @@ angular.module('clipboard').factory('clipboardService', ['$injector',
// restoring whichever element was originally focused
clipboardContent.blur();
originalElement.focus();
+ popSelection();
}, 100);
diff --git a/guacamole/src/main/webapp/app/clipboard/styles/clipboard.css b/guacamole/src/main/webapp/app/clipboard/styles/clipboard.css
index 10e3eaf66..ab02af03f 100644
--- a/guacamole/src/main/webapp/app/clipboard/styles/clipboard.css
+++ b/guacamole/src/main/webapp/app/clipboard/styles/clipboard.css
@@ -48,3 +48,12 @@
border: 1px solid black;
background: url('images/checker.png');
}
+
+.clipboard-service-target {
+ position: fixed;
+ left: -1px;
+ right: -1px;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+}