From ea5ee1825b2190d2c62b3873d20e275377b059ab Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Tue, 28 Jun 2016 13:43:31 -0700 Subject: [PATCH] GUACAMOLE-55: Update clipboardService to support non-text contents. --- .../app/clipboard/directives/guacClipboard.js | 162 +-------- .../clipboard/services/clipboardService.js | 327 +++++++++++++++--- .../webapp/app/clipboard/styles/clipboard.css | 9 + 3 files changed, 293 insertions(+), 205 deletions(-) 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; +}