Skip to content

Instantly share code, notes, and snippets.

@PeterGabaldon
Created September 6, 2026 20:26
Show Gist options
  • Select an option

  • Save PeterGabaldon/ba976ccee8bce57abb52a8e0c4d24e94 to your computer and use it in GitHub Desktop.

Select an option

Save PeterGabaldon/ba976ccee8bce57abb52a8e0c4d24e94 to your computer and use it in GitHub Desktop.
noVNC Paste
/**
* Based on https://github.com/alew140/stupid-NoVNC-copy-paste/blob/main/Index.js
* VNCPaste
*
* Pastes clipboard text into a noVNC session by synthesising keyboard events
* on the canvas.
*
* Key points:
* - noVNC resolves the keysym from event.key, never from event.shiftKey,
* so the literal character must go in `key` ('@', not '2' + shiftKey).
* - Shift must be sent as a genuine key event (key: 'Shift', keyCode: 16),
* the same way a real keypress would reach noVNC.
* - `code` must be the physical US-layout key (Digit2, Semicolon, KeyA...),
* because QEMU-backed servers such as Proxmox use the XT scancode derived
* from `code` and ignore the keysym.
* - Every event needs bubbles: true, since some noVNC builds attach the
* keyboard handler to the container element rather than the canvas.
*/
class VNCPaste {
constructor(config = {}) {
this.config = {
selector: '#noVNC_canvas',
fallbackSelector: 'canvas',
delay: 20, // ms between characters
keyDelay: 10, // ms between keydown and keyup
enableLogging: true,
rightClickEnabled: true,
...config
};
this.canvas = null;
this.isInitialized = false;
this.shiftDown = false;
this.keyMap = VNCPaste.buildKeyMap();
this._onMouseDown = this._onMouseDown.bind(this);
this._onContextMenu = this._onContextMenu.bind(this);
}
/**
* Builds a char -> {key, code, keyCode, shift} table for a US layout.
* Rows are [code, legacy keyCode, unshifted char, shifted char].
*/
static buildKeyMap() {
const rows = [
['Backquote', 192, '`', '~'],
['Digit1', 49, '1', '!'],
['Digit2', 50, '2', '@'],
['Digit3', 51, '3', '#'],
['Digit4', 52, '4', '$'],
['Digit5', 53, '5', '%'],
['Digit6', 54, '6', '^'],
['Digit7', 55, '7', '&'],
['Digit8', 56, '8', '*'],
['Digit9', 57, '9', '('],
['Digit0', 48, '0', ')'],
['Minus', 189, '-', '_'],
['Equal', 187, '=', '+'],
['BracketLeft', 219, '[', '{'],
['BracketRight', 221, ']', '}'],
['Backslash', 220, '\\', '|'],
['Semicolon', 186, ';', ':'],
['Quote', 222, "'", '"'],
['Comma', 188, ',', '<'],
['Period', 190, '.', '>'],
['Slash', 191, '/', '?'],
['Space', 32, ' ', null]
];
for (let c = 65; c <= 90; c++) {
const upper = String.fromCharCode(c);
rows.push([`Key${upper}`, c, upper.toLowerCase(), upper]);
}
const map = new Map();
for (const [code, keyCode, plain, shifted] of rows) {
if (plain !== null) {
map.set(plain, { key: plain, code, keyCode, shift: false });
}
if (shifted !== null) {
map.set(shifted, { key: shifted, code, keyCode, shift: true });
}
}
map.set('\n', { key: 'Enter', code: 'Enter', keyCode: 13, shift: false });
map.set('\t', { key: 'Tab', code: 'Tab', keyCode: 9, shift: false });
return map;
}
log(...args) {
if (this.config.enableLogging) {
console.log('[VNCPaste]', ...args);
}
}
error(...args) {
console.error('[VNCPaste]', ...args);
}
findCanvas() {
this.canvas = document.querySelector(this.config.selector) ||
document.querySelector(this.config.fallbackSelector);
if (!this.canvas) {
throw new Error('Canvas element not found');
}
return this.canvas;
}
sleep(delay = this.config.delay) {
return new Promise(resolve => setTimeout(resolve, delay));
}
/**
* Characters outside the US layout table, accented letters for example,
* are sent without a physical code so noVNC derives the keysym from the
* Unicode value. This works on standard RFB servers but not on QEMU
* scancode mode, where such characters need a matching host layout.
*/
lookup(char) {
return this.keyMap.get(char) ||
{ key: char, code: 'Unidentified', keyCode: 0, shift: false };
}
dispatchKey(type, info) {
this.canvas.dispatchEvent(new KeyboardEvent(type, {
key: info.key,
code: info.code,
keyCode: info.keyCode,
which: info.keyCode,
shiftKey: this.shiftDown,
bubbles: true,
cancelable: true,
composed: true
}));
}
async setShift(down) {
if (this.shiftDown === down) return;
this.shiftDown = down;
this.canvas.dispatchEvent(new KeyboardEvent(down ? 'keydown' : 'keyup', {
key: 'Shift',
code: 'ShiftLeft',
keyCode: 16,
which: 16,
location: 1,
shiftKey: down,
bubbles: true,
cancelable: true,
composed: true
}));
await this.sleep(this.config.keyDelay);
}
async sendChar(char) {
const info = this.lookup(char);
await this.setShift(info.shift);
this.dispatchKey('keydown', info);
await this.sleep(this.config.keyDelay);
this.dispatchKey('keyup', info);
}
async sendString(text) {
if (!this.canvas) {
this.error('Canvas not initialised');
return;
}
// Normalise Windows and old Mac line endings
const normalised = String(text).replace(/\r\n?/g, '\n');
try {
for (const char of normalised) {
try {
await this.sendChar(char);
} catch (error) {
this.error(`Error sending character '${char}':`, error);
}
await this.sleep();
}
} finally {
// Never leave Shift stuck down on the server
await this.setShift(false);
}
}
_onContextMenu(event) {
if (this.config.rightClickEnabled && event.target === this.canvas) {
event.preventDefault();
}
}
/**
* Bound on document in the capture phase so it runs before noVNC's own
* canvas handler, which would otherwise forward the right click to the
* remote session.
*/
_onMouseDown(event) {
if (!this.config.rightClickEnabled) return;
if (event.button !== 2 || event.target !== this.canvas) return;
event.preventDefault();
event.stopImmediatePropagation();
navigator.clipboard.readText()
.then(text => this.sendString(text))
.then(() => this.log('Text pasted'))
.catch(error => this.error('Clipboard error:', error));
}
init() {
if (this.isInitialized) {
this.error('VNCPaste is already initialised');
return;
}
try {
this.findCanvas();
document.addEventListener('mousedown', this._onMouseDown, true);
document.addEventListener('contextmenu', this._onContextMenu, true);
window.sendString = this.sendString.bind(this);
this.isInitialized = true;
this.log('Initialised, right click on the canvas to paste');
} catch (error) {
this.error('Initialisation failed:', error);
throw error;
}
}
destroy() {
if (!this.isInitialized) return;
document.removeEventListener('mousedown', this._onMouseDown, true);
document.removeEventListener('contextmenu', this._onContextMenu, true);
delete window.sendString;
this.isInitialized = false;
this.log('Destroyed');
}
}
const vncPaste = new VNCPaste({
enableLogging: true,
delay: 20
});
try {
vncPaste.init();
} catch (error) {
console.error('VNCPaste failed to initialise:', error);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment