mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-09 11:46:49 +00:00
feat(console): typed text follows the VM's keyboard layout
"Type it" sent each character's keysym and let QEMU pick the key from its own US map, with Shift added for the US shifted characters. A VM on another layout got other characters: on Turkish Q, '"' typed 'İ', '@' typed "'", and ğ, ü, ş, ı, ö, ç typed nothing. The Clipboard dialog now asks for the VM's keyboard layout and remembers it per browser. Each character is typed as the key that makes it on that layout (its scancode), with Shift, AltGr or both (AltGr first: pressed after Shift, the right Alt is Alt on the Linux console), so the VM's own layout makes the character. The layout is fixed when typing starts. Text with a character no key types on the layout is not typed at all, and the dialog lists those characters: a password or a command with a character left out would be a wrong one. A layout is one file in console/layouts/: its name and, for each key, the characters it types plainly, with Shift, with AltGr and with Shift+AltGr. The view offers every file, US first. dev/console_layout.py writes a file from the system's XKB data as libxkbcommon computes it (through ctypes, no new dependency), mapping keys to DOM codes with noVNC's table from QEMU's keycodemapdb; it lists the dead keys it leaves out, and drops the keys the keyboard model lacks (the key left of Z on US pc104 keyboards). English (US) and Turkish Q come with it; the README says how to add one.
This commit is contained in:
parent
2b54198965
commit
bfa0fc9fe5
10 changed files with 431 additions and 21 deletions
29
static/js/console_layouts.js
Normal file
29
static/js/console_layouts.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// The keys that type a character on the VM's keyboard layout: the VM gets
|
||||
// keys, not characters, and its own layout makes characters of them. The
|
||||
// layouts are console/layouts/*.json, put in the page by the view.
|
||||
|
||||
// the modifiers of a layout key's four characters, pressed in this order:
|
||||
// AltGr before Shift, as the right Alt pressed after Shift is Alt on the
|
||||
// Linux console
|
||||
const MODIFIERS = [[], ['ShiftLeft'], ['AltRight'], ['AltRight', 'ShiftLeft']];
|
||||
|
||||
const layouts = JSON.parse(document.getElementById('keyboard-layouts').textContent);
|
||||
|
||||
// layout id: {character: [key code, modifier codes]}; of the keys that type
|
||||
// a character, the first with the fewest modifiers
|
||||
const keys = {};
|
||||
for (const [id, layout] of Object.entries(layouts)) {
|
||||
keys[id] = {};
|
||||
MODIFIERS.forEach((modifiers, slot) => {
|
||||
for (const [code, characters] of Object.entries(layout.keys)) {
|
||||
const character = characters[slot];
|
||||
if (character !== null && !(character in keys[id])) {
|
||||
keys[id][character] = [code, modifiers];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function layoutKeys(layout, character) {
|
||||
return keys[layout][character];
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import KeyTable from './novnc/core/input/keysym.js';
|
|||
import keysyms from './novnc/core/input/keysymdef.js';
|
||||
import { isTouchDevice } from './novnc/core/util/browser.js';
|
||||
import { bindFullscreen, bindKeyAction, bindSendKeys, releaseHeld } from './console_keys.js';
|
||||
import { layoutKeys } from './console_layouts.js';
|
||||
|
||||
const config = JSON.parse(document.getElementById('vnc-config').textContent);
|
||||
const status = document.getElementById('status');
|
||||
|
|
@ -99,50 +100,88 @@ bindKeyAction('credentials_cancel', () => {
|
|||
bindSendKeys(rfb);
|
||||
bindFullscreen();
|
||||
|
||||
// the characters a US keyboard types with Shift: the VM gets keys, and its
|
||||
// key for "A" is the key for "a"
|
||||
const SHIFTED = /[A-Z~!@#$%^&*()_+{}|:"<>?]/;
|
||||
// the VM's keyboard layout, the viewer's last choice
|
||||
const layout = document.getElementById('clipboard_layout');
|
||||
try {
|
||||
layout.value = localStorage.getItem('console_layout') || layout.value;
|
||||
} catch {}
|
||||
layout.selectedIndex = Math.max(layout.selectedIndex, 0); // a layout no longer offered
|
||||
layout.addEventListener('change', () => {
|
||||
try {
|
||||
localStorage.setItem('console_layout', layout.value);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
const typedKeys = {
|
||||
'\n': [KeyTable.XK_Return, 'Enter'],
|
||||
'\b': [KeyTable.XK_BackSpace, 'Backspace'],
|
||||
'\t': [KeyTable.XK_Tab, 'Tab'],
|
||||
' ': [KeyTable.XK_space, 'Space'],
|
||||
};
|
||||
const modifierKeysyms = { ShiftLeft: KeyTable.XK_Shift_L, AltRight: KeyTable.XK_ISO_Level3_Shift };
|
||||
|
||||
async function typeCharacter(character) {
|
||||
// [keysym, key code, modifier codes], or nothing when no key types it: the
|
||||
// VM makes the character of the key (its scancode), the keysym only names it
|
||||
function keysFor(layoutId, character) {
|
||||
if (character in typedKeys) {
|
||||
return [...typedKeys[character], []];
|
||||
}
|
||||
const keys = layoutKeys(layoutId, character);
|
||||
return keys && [keysyms.lookup(character.codePointAt(0)), ...keys];
|
||||
}
|
||||
|
||||
async function typeKeys([keysym, code, modifiers]) {
|
||||
// a typist's pace: firmware drops keys that come faster
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
const [keysym, code] = typedKeys[character] || [keysyms.lookup(character.codePointAt(0)), null];
|
||||
if (SHIFTED.test(character)) {
|
||||
rfb.sendKey(KeyTable.XK_Shift_L, 'ShiftLeft', true);
|
||||
rfb.sendKey(keysym, code);
|
||||
rfb.sendKey(KeyTable.XK_Shift_L, 'ShiftLeft', false);
|
||||
} else {
|
||||
rfb.sendKey(keysym, code);
|
||||
}
|
||||
modifiers.forEach((modifier) => rfb.sendKey(modifierKeysyms[modifier], modifier, true));
|
||||
rfb.sendKey(keysym, code);
|
||||
[...modifiers].reverse().forEach((modifier) => rfb.sendKey(modifierKeysyms[modifier], modifier, false));
|
||||
}
|
||||
|
||||
// key by key, after what is still being typed: works where no clipboard does
|
||||
// (firmware, installers, login prompts); "\b" is a Backspace
|
||||
// (firmware, installers, login prompts); "\b" is a Backspace. All or nothing,
|
||||
// a password or a command with a character left out is a wrong one: returns
|
||||
// the characters no key types on the layout.
|
||||
let typing = Promise.resolve();
|
||||
function typeText(text) {
|
||||
for (const character of text.replace(/\r\n?/g, '\n')) {
|
||||
typing = typing.then(() => typeCharacter(character));
|
||||
const characters = [...text.replace(/\r\n?/g, '\n')];
|
||||
const keys = characters.map((character) => keysFor(layout.value, character));
|
||||
const missing = characters.filter((character, i) => !keys[i]);
|
||||
if (missing.length === 0) {
|
||||
for (const characterKeys of keys) {
|
||||
typing = typing.then(() => typeKeys(characterKeys));
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
// a character as the user can see it: an invisible one by its code point
|
||||
function shown(character) {
|
||||
const invisible = /[\s\p{C}]/u.test(character);
|
||||
return invisible ? 'U+' + character.codePointAt(0).toString(16).toUpperCase().padStart(4, '0') : character;
|
||||
}
|
||||
|
||||
const clipboardText = document.getElementById('clipboard_text');
|
||||
const clipboardMissing = document.getElementById('clipboard_missing');
|
||||
const clipboardDialog = bootstrap.Modal.getOrCreateInstance('#clipboard_dialog');
|
||||
rfb.addEventListener('clipboard', (e) => {
|
||||
clipboardText.value = e.detail.text;
|
||||
});
|
||||
document.getElementById('clipboard_dialog').addEventListener('show.bs.modal', () => {
|
||||
clipboardMissing.hidden = true;
|
||||
});
|
||||
bindKeyAction('clipboard_send', () => {
|
||||
rfb.clipboardPasteFrom(clipboardText.value);
|
||||
clipboardDialog.hide();
|
||||
});
|
||||
bindKeyAction('clipboard_type', () => {
|
||||
clipboardDialog.hide();
|
||||
typeText(clipboardText.value);
|
||||
const missing = typeText(clipboardText.value);
|
||||
if (missing.length === 0) {
|
||||
clipboardDialog.hide();
|
||||
} else {
|
||||
clipboardMissing.textContent = clipboardMissing.dataset.text + ' ' + [...new Set(missing)].map(shown).join(' ');
|
||||
clipboardMissing.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
const bell = document.getElementById('bell');
|
||||
|
|
@ -212,8 +251,10 @@ if (isTouchDevice) {
|
|||
while (same < Math.min(text.length, lastText.length) && text[same] === lastText[same]) {
|
||||
same++;
|
||||
}
|
||||
typeText('\b'.repeat(lastText.length - same) + text.slice(same));
|
||||
if (text.length === 0 || text.length > 200) {
|
||||
// a character the layout has no key for: the field stays as the VM has it
|
||||
if (typeText('\b'.repeat(lastText.length - same) + text.slice(same)).length > 0) {
|
||||
keyboardInput.value = lastText;
|
||||
} else if (text.length === 0 || text.length > 200) {
|
||||
resetInput();
|
||||
} else {
|
||||
lastText = text;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue