Created
September 6, 2026 20:26
-
-
Save PeterGabaldon/ba976ccee8bce57abb52a8e0c4d24e94 to your computer and use it in GitHub Desktop.
noVNC Paste
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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