Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add clipboard support, continued again #1817

Closed
wants to merge 13 commits into from
83 changes: 83 additions & 0 deletions core/clipboard.js
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions core/input/keyboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
43 changes: 40 additions & 3 deletions core/rfb.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
}
}
Expand Down Expand Up @@ -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; }

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down
24 changes: 23 additions & 1 deletion docs/API-internal.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@ 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.


## 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.
Expand Down Expand Up @@ -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
59 changes: 59 additions & 0 deletions tests/test.clipboard.js
Original file line number Diff line number Diff line change
@@ -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;
}