diff --git a/core/clipboard.js b/core/clipboard.js new file mode 100644 index 000000000..d0740016f --- /dev/null +++ b/core/clipboard.js @@ -0,0 +1,83 @@ +export default class Clipboard { + constructor(target) { + this._target = target; + + this._eventHandlers = { + 'copy': this._handleCopy.bind(this), + 'paste': this._handlePaste.bind(this) + }; + /** + * @type {string} + */ + this._remoteClipboard = null; + // ===== EVENT HANDLERS ===== + + this.onpaste = () => {}; + } + + // ===== PRIVATE METHODS ===== + + _handleCopy(e) { + this._remoteClipboard = e.clipboardData.getData('text/plain'); + this._copy(this._remoteClipboard); + } + /** + * Has a better browser support compared with navigator.clipboard.writeText. + * Also, no permission required. + */ + _copy(text) { + const textarea = document.createElement('textarea'); + textarea.innerHTML = text; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } + /** + * @param {ClipboardEvent} e + */ + _handlePaste(e) { + if (!this._isVncEvent()) { + return; + } + if (e.clipboardData) { + const localClipboard = e.clipboardData.getData('text/plain'); + if (localClipboard === this._remoteClipboard) { + this._pasteVncServerInternalClipboard(); + return; + } + this.onpaste(localClipboard); + } + } + /** + * The vnc server clipboard can be non ascii text and server might only support ascii code. + * In that case, localClipboard received from the vnc server is garbled. + * For example, if you copied chinese text "你好" in the vnc server the local clipboard will be changed to "??". + * If you press Ctrl+V, the vnc server should paste "你好" instead of "??". + * So, we shouldn't send the local clipboard to the vnc server because the local clipboard is garbled in this case. + */ + _pasteVncServerInternalClipboard() { + this.onpaste("", false); + } + _isVncEvent() { + const isTargetFocused = document.activeElement === this._target; + return isTargetFocused; + } + + // ===== PUBLIC METHODS ===== + + grab() { + if (!Clipboard.isSupported) return; + this._target.addEventListener('copy', this._eventHandlers.copy); + // _target can not listen the paste event. + document.body.addEventListener('paste', this._eventHandlers.paste); + } + + ungrab() { + if (!Clipboard.isSupported) return; + this._target.removeEventListener('copy', this._eventHandlers.copy); + document.body.removeEventListener('paste', this._eventHandlers.paste); + } +} + +Clipboard.isSupported = (navigator && navigator.clipboard) ? true : false; diff --git a/core/input/keyboard.js b/core/input/keyboard.js index 9068e9e9f..d24dedf5d 100644 --- a/core/input/keyboard.js +++ b/core/input/keyboard.js @@ -85,6 +85,11 @@ export default class Keyboard { _handleKeyDown(e) { const code = this._getKeyCode(e); + const isPasteKeydownEvent = (browser.isWindows() && code === "KeyV" && e.ctrlKey) + || (browser.isMac() && code === "KeyV" && e.metaKey); + if (isPasteKeydownEvent) { + return; + } let keysym = KeyboardUtil.getKeysym(e); let numlock = e.getModifierState('NumLock'); let capslock = e.getModifierState('CapsLock'); diff --git a/core/rfb.js b/core/rfb.js index c71d6b88f..57bd019bf 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -15,6 +15,7 @@ import { clientToElement } from './util/element.js'; import { setCapture } from './util/events.js'; import EventTargetMixin from './util/eventtarget.js'; import Display from "./display.js"; +import Clipboard from "./clipboard.js"; import Inflator from "./inflator.js"; import Deflator from "./deflator.js"; import Keyboard from "./input/keyboard.js"; @@ -35,6 +36,7 @@ import TightDecoder from "./decoders/tight.js"; import TightPNGDecoder from "./decoders/tightpng.js"; import ZRLEDecoder from "./decoders/zrle.js"; import JPEGDecoder from "./decoders/jpeg.js"; +import * as browser from "./util/browser.js"; // How many seconds to wait for a disconnect to finish const DISCONNECT_TIMEOUT = 3; @@ -158,6 +160,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._gestures = null; // Gesture input handler object this._resizeObserver = null; // Resize observer object @@ -258,6 +261,9 @@ export default class RFB extends EventTargetMixin { throw exc; } + this._clipboard = new Clipboard(this._canvas); + this._clipboard.onpaste = this._handlePasteEvent.bind(this); + this._keyboard = new Keyboard(this._canvas); this._keyboard.onkeyevent = this._handleKeyEvent.bind(this); this._remoteCapsLock = null; // Null indicates unknown or irrelevant @@ -311,8 +317,10 @@ export default class RFB extends EventTargetMixin { this._rfbConnectionState === "connected") { if (viewOnly) { this._keyboard.ungrab(); + this._clipboard.ungrab(); } else { this._keyboard.grab(); + this._clipboard.grab(); } } } @@ -493,6 +501,25 @@ export default class RFB extends EventTargetMixin { this._canvas.blur(); } + _handlePasteEvent(text, shouldUpdateRemoteClipboard = true) { + if (shouldUpdateRemoteClipboard) { + this.clipboardPasteFrom(text); + } + if (browser.isMac()) { + this.sendKey(KeyTable.XK_Meta_L, "Meta", true); + this.sendKey(KeyTable.XK_V, "KeyV", true); + this.sendKey(KeyTable.XK_V, "KeyV", false); + this.sendKey(KeyTable.XK_Meta_L, "Meta", false); + return; + } + if (browser.isWindows()) { + this.sendKey(KeyTable.XK_Control_L, "ControlLeft", true); + this.sendKey(KeyTable.XK_V, "KeyV", true); + this.sendKey(KeyTable.XK_V, "KeyV", false); + this.sendKey(KeyTable.XK_Control_L, "ControlLeft", false); + } + } + clipboardPasteFrom(text) { if (this._rfbConnectionState !== 'connected' || this._viewOnly) { return; } @@ -2092,6 +2119,7 @@ export default class RFB extends EventTargetMixin { this._resize(width, height); if (!this._viewOnly) { this._keyboard.grab(); } + if (!this._viewOnly) { this._clipboard.grab(); } this._fbDepth = 24; @@ -2220,9 +2248,18 @@ export default class RFB extends EventTargetMixin { 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); + const clipboardEvent = new ClipboardEvent('copy', { clipboardData }); + // Force initialization since the constructor is broken in Firefox + if (!clipboardEvent.clipboardData.items.length) { + clipboardEvent.clipboardData.items.add(text, "text/plain"); + } + this._canvas.dispatchEvent(clipboardEvent); + } } else { //Extended msg. diff --git a/docs/API-internal.md b/docs/API-internal.md index c41e0f326..c5671c2a0 100644 --- a/docs/API-internal.md +++ b/docs/API-internal.md @@ -18,6 +18,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. @@ -25,7 +27,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. @@ -81,3 +83,23 @@ None | blitImage | (x, y, width, height, arr, offset, from_queue) | Blit pixels (of R,G,B,A) to the display | drawImage | (img, x, y) | Draw image and track damage | autoscale | (containerWidth, containerHeight) | Scale the display + + +## 2.3 Clipboard Module + +### 2.3.1 Configuration Attributes + +None + +### 2.3.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..e2382bcaa --- /dev/null +++ b/tests/test.clipboard.js @@ -0,0 +1,59 @@ +const expect = chai.expect; + +import Clipboard from '../core/clipboard.js'; + +describe('Automatic Clipboard Sync', function () { + "use strict"; + + it('is supported on all target browsers', function () { + expect(Clipboard.isSupported).to.be.true; + }); + + 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 clipboardEvent = getClipboardEvent(text); + sinon.spy(clipboard, '_copy'); + clipboard._handleCopy(clipboardEvent); + expect(clipboard._copy).to.have.been.calledWith(text); + expect(clipboard._remoteClipboard).to.eq(text); + } + }); + + it('should copy local pasted data to the server clipboard', function () { + const text = 'Another random string for testing'; + const clipboard = new Clipboard(); + if (Clipboard.isSupported) { + const clipboardEvent = getClipboardEvent(text); + sinon.stub(clipboard, '_isVncEvent').returns(true); + sinon.spy(clipboard, 'onpaste'); + clipboard._handlePaste(clipboardEvent); + expect(clipboard.onpaste).to.have.been.calledWith(text); + } + }); + + it('should not copy local pasted data to the server clipboard', function () { + const text = 'Another random string for testing'; + const clipboard = new Clipboard(); + clipboard._remoteClipboard = text; + if (Clipboard.isSupported) { + const clipboardEvent = getClipboardEvent(text); + sinon.stub(clipboard, '_isVncEvent').returns(true); + sinon.spy(clipboard, 'onpaste'); + clipboard._handlePaste(clipboardEvent); + expect(clipboard.onpaste).to.have.been.calledWith("", false); + } + }); +}); + +function getClipboardEvent(text) { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + const clipboardEvent = new ClipboardEvent('paste', { clipboardData }); + // Force initialization since the constructor is broken in Firefox + if (!clipboardEvent.clipboardData.items.length) { + clipboardEvent.clipboardData.items.add(text, "text/plain"); + } + return clipboardEvent; +} \ No newline at end of file