From e6b643c526e79fd2fbcdf732769d0b677e7eb419 Mon Sep 17 00:00:00 2001 From: Juanjo Diaz Date: Wed, 18 Dec 2019 15:24:03 +0200 Subject: [PATCH] Add automatic clipboard support --- core/clipboard.js | 46 +++++++++++++++++++++++++++++++++++ core/rfb.js | 24 ++++++++++++++---- docs/API-internal.md | 24 +++++++++++++++++- tests/test.clipboard.js | 54 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 core/clipboard.js create mode 100644 tests/test.clipboard.js diff --git a/core/clipboard.js b/core/clipboard.js new file mode 100644 index 000000000..a4a681322 --- /dev/null +++ b/core/clipboard.js @@ -0,0 +1,46 @@ +export default class Clipboard { + constructor(target) { + this._target = target; + + this._eventHandlers = { + 'copy': this._handleCopy.bind(this), + 'paste': this._handlePaste.bind(this) + }; + + // ===== EVENT HANDLERS ===== + + this.onpaste = () => {}; + } + + // ===== PRIVATE METHODS ===== + + _handleCopy(e) { + if (navigator.clipboard.writeText) { + navigator.clipboard.writeText(e.clipboardData.getData('text/plain')).catch(() => {/* Do nothing */}); + } + } + + _handlePaste(e) { + if (navigator.clipboard.readText) { + navigator.clipboard.readText().then(this.onpaste).catch(() => {/* Do nothing */}); + } else if (e.clipboardData) { + this.onpaste(e.clipboardData.getData('text/plain')); + } + } + + // ===== PUBLIC METHODS ===== + + grab() { + if (!Clipboard.isSupported) return; + this._target.addEventListener('copy', this._eventHandlers.copy); + this._target.addEventListener('paste', this._eventHandlers.paste); + } + + ungrab() { + if (!Clipboard.isSupported) return; + this._target.removeEventListener('copy', this._eventHandlers.copy); + this._target.removeEventListener('paste', this._eventHandlers.paste); + } +} + +Clipboard.isSupported = (navigator && navigator.clipboard) ? true : false; diff --git a/core/rfb.js b/core/rfb.js index e58496051..e5f248a09 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -12,6 +12,7 @@ import { decodeUTF8 } from './util/strings.js'; import { dragThreshold } from './util/browser.js'; import EventTargetMixin from './util/eventtarget.js'; import Display from "./display.js"; +import Clipboard from "./clipboard.js"; import Keyboard from "./input/keyboard.js"; import Mouse from "./input/mouse.js"; import Cursor from "./util/cursor.js"; @@ -88,6 +89,7 @@ export default class RFB extends EventTargetMixin { this._sock = null; // Websock object this._display = null; // Display object this._flushing = false; // Display flushing state + this._clipboard = null; // Clipboard object this._keyboard = null; // Keyboard input handler object this._mouse = null; // Mouse input handler object @@ -173,6 +175,9 @@ export default class RFB extends EventTargetMixin { } this._display.onflush = this._onFlush.bind(this); + this._clipboard = new Clipboard(this._canvas); + this._clipboard.onpaste = this.clipboardPasteFrom.bind(this); + this._keyboard = new Keyboard(this._canvas); this._keyboard.onkeyevent = this._handleKeyEvent.bind(this); @@ -264,9 +269,11 @@ export default class RFB extends EventTargetMixin { if (viewOnly) { this._keyboard.ungrab(); this._mouse.ungrab(); + this._clipboard.ungrab(); } else { this._keyboard.grab(); this._mouse.grab(); + this._clipboard.grab(); } } } @@ -1227,8 +1234,11 @@ export default class RFB extends EventTargetMixin { this._setDesktopName(name); this._resize(width, height); - if (!this._viewOnly) { this._keyboard.grab(); } - if (!this._viewOnly) { this._mouse.grab(); } + if (!this._viewOnly) { + this._keyboard.grab(); + this._mouse.grab(); + this._clipboard.grab(); + } this._fb_depth = 24; @@ -1337,9 +1347,13 @@ export default class RFB extends EventTargetMixin { if (this._viewOnly) { return true; } - this.dispatchEvent(new CustomEvent( - "clipboard", - { detail: { text: text } })); + this.dispatchEvent(new CustomEvent("clipboard", { detail: { text: text } })); + + if (Clipboard.isSupported) { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + this._canvas.dispatchEvent(new ClipboardEvent('copy', { clipboardData })); + } return true; } diff --git a/docs/API-internal.md b/docs/API-internal.md index f7346a9ee..8e9d2fad1 100644 --- a/docs/API-internal.md +++ b/docs/API-internal.md @@ -21,6 +21,8 @@ keysym values. * __Display__ (core/display.js): Efficient 2D rendering abstraction layered on the HTML5 canvas element. +* __Clipboard__ (core/clipboard.js): Clipboard event handler. + * __Websock__ (core/websock.js): Websock client from websockify with transparent binary data support. [Websock API](https://github.com/novnc/websockify-js/wiki/websock.js) wiki page. @@ -28,7 +30,7 @@ with transparent binary data support. ## 1.2 Callbacks -For the Mouse, Keyboard and Display objects the callback functions are +For the Mouse, Keyboard, Display and Clipboard objects the callback functions are assigned to configuration attributes, just as for the RFB object. The WebSock module has a method named 'on' that takes two parameters: the callback event name, and the callback function. @@ -118,3 +120,23 @@ None | name | parameters | description | ------- | ---------- | ------------ | onflush | () | A display flush has been requested and we are now ready to resume FBU processing + + +## 2.4 Clipboard Module + +### 2.4.1 Configuration Attributes + +None + +### 2.4.2 Methods + +| name | parameters | description +| ------------------ | ----------------- | ------------ +| grab | () | Begin capturing clipboard events +| ungrab | () | Stop capturing clipboard events + +### 2.3.3 Callbacks + +| name | parameters | description +| ------- | ---------- | ------------ +| onpaste | (text) | Called with the text content of the clipboard when the user paste something diff --git a/tests/test.clipboard.js b/tests/test.clipboard.js new file mode 100644 index 000000000..ada9b8b63 --- /dev/null +++ b/tests/test.clipboard.js @@ -0,0 +1,54 @@ +const expect = chai.expect; + +import Clipboard from '../core/clipboard.js'; + +describe('Automatic Clipboard Sync', function () { + "use strict"; + + if (Clipboard.isSupported) { + beforeEach(function () { + if (navigator.clipboard.writeText) { + sinon.spy(navigator.clipboard, 'writeText'); + } + if (navigator.clipboard.readText) { + sinon.spy(navigator.clipboard, 'readText'); + } + }); + + afterEach(function () { + if (navigator.clipboard.writeText) { + navigator.clipboard.writeText.restore(); + } + if (navigator.clipboard.readText) { + navigator.clipboard.readText.restore(); + } + }); + } + + it('incoming clipboard data from the server is copied to the local clipboard', function () { + const text = 'Random string for testing'; + const clipboard = new Clipboard(); + if (Clipboard.isSupported) { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + clipboard._handleCopy(new ClipboardEvent('paste', { clipboardData })); + if (navigator.clipboard.writeText) { + expect(navigator.clipboard.writeText).to.have.been.calledWith(text); + } + } + }); + + it('should copy local pasted data to the server clipboard', function () { + const text = 'Another random string for testing'; + const clipboard = new Clipboard(); + clipboard.onpaste = pasterText => expect(pasterText).to.equal(text); + if (Clipboard.isSupported) { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + clipboard._handlePaste(new ClipboardEvent('paste', { clipboardData })); + if (navigator.clipboard.readText) { + expect(navigator.clipboard.readText).to.have.been.called; + } + } + }); +});