This commit is contained in:
Taylor Jasko 2026-05-23 19:52:14 +02:00 committed by GitHub
commit a002e3e6e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 370 additions and 5 deletions

View File

@ -791,7 +791,6 @@ html {
list-style: none; list-style: none;
padding: 0px; padding: 0px;
} }
#noVNC_settings button,
#noVNC_settings select, #noVNC_settings select,
#noVNC_settings textarea, #noVNC_settings textarea,
#noVNC_settings input:not([type=checkbox]):not([type=radio]) { #noVNC_settings input:not([type=checkbox]):not([type=radio]) {
@ -799,6 +798,10 @@ html {
/* Prevent inputs in settings from being too wide */ /* Prevent inputs in settings from being too wide */
max-width: calc(100% - 6px - var(--input-xpadding) * 2); max-width: calc(100% - 6px - var(--input-xpadding) * 2);
} }
#noVNC_settings button:not(#noVNC_ignore_keys_help_button) {
margin-left: 6px;
max-width: calc(100% - 6px - var(--input-xpadding) * 2);
}
#noVNC_setting_port { #noVNC_setting_port {
width: 80px; width: 80px;
@ -822,6 +825,83 @@ html {
display: none; display: none;
} }
/* Help tooltips */
.noVNC_setting_with_help {
position: relative;
display: flex;
align-items: center;
gap: 0.4rem;
}
.noVNC_setting_with_help label {
flex: 0 1 auto;
}
#noVNC_settings .noVNC_tooltip_list {
margin: 0.4rem 0 0 0;
padding-left: 0.8rem;
list-style: disc;
}
.noVNC_tooltip_list li {
margin: 0.15rem 0;
}
#noVNC_setting_ignore_keys::placeholder {
color: var(--novnc-grey);
opacity: 0.7;
}
#noVNC_setting_ignore_keys.noVNC_invalid,
#noVNC_setting_ignore_keys.noVNC_invalid:focus {
border-color: var(--novnc-red);
box-shadow: 0 0 2px rgba(var(--novnc-red-rgb), 0.5);
}
#noVNC_ignore_keys_help_button {
flex: 0 0 auto;
width: 1.6rem;
height: 1.6rem;
min-width: 1.6rem;
max-width: 1.6rem;
padding: 0;
margin: 0;
border-radius: 50%;
background: transparent;
border: 1px solid rgba(0,0,0,0.25);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
#noVNC_ignore_keys_tooltip {
display: none;
position: absolute;
z-index: 1000;
left: 1rem;
padding: 0.5rem;
margin: 0;
border-radius: 0.4rem;
background: rgba(20, 20, 20, 0.96);
color: #fff;
box-shadow: 0 0.4rem 1rem rgba(0, 0, 0, 0.35);
font-size: 0.75rem;
line-height: .9rem;
}
#noVNC_ignore_keys_tooltip.noVNC_open {
display: flex !important;
flex-direction: column;
align-items: flex-start;
}
/* ---------------------------------------- /* ----------------------------------------
* Status dialog * Status dialog
* ---------------------------------------- * ----------------------------------------

View File

@ -21,6 +21,8 @@
--novnc-green: rgb(0, 128, 0); --novnc-green: rgb(0, 128, 0);
--novnc-yellow: rgb(255, 255, 0); --novnc-yellow: rgb(255, 255, 0);
--novnc-red-rgb: 229, 57, 53;
--novnc-red: rgb(var(--novnc-red-rgb));
} }
/* ------ MISC PROPERTIES ------ */ /* ------ MISC PROPERTIES ------ */

229
app/ui.js
View File

@ -129,6 +129,7 @@ const UI = {
UI.addConnectionControlHandlers(); UI.addConnectionControlHandlers();
UI.addClipboardHandlers(); UI.addClipboardHandlers();
UI.addSettingsHandlers(); UI.addSettingsHandlers();
UI.initIgnoreKeysTooltip();
document.getElementById("noVNC_status") document.getElementById("noVNC_status")
.addEventListener('click', UI.hideStatus); .addEventListener('click', UI.hideStatus);
@ -196,6 +197,7 @@ const UI = {
UI.initSetting('reconnect', false); UI.initSetting('reconnect', false);
UI.initSetting('reconnect_delay', 5000); UI.initSetting('reconnect_delay', 5000);
UI.initSetting('keep_device_awake', false); UI.initSetting('keep_device_awake', false);
UI.initSetting('ignore_keys', '');
}, },
// Adds a link to the label elements on the corresponding input elements // Adds a link to the label elements on the corresponding input elements
setupSettingLabels() { setupSettingLabels() {
@ -388,6 +390,13 @@ const UI = {
UI.addSettingChangeHandler('logging', UI.updateLogging); UI.addSettingChangeHandler('logging', UI.updateLogging);
UI.addSettingChangeHandler('reconnect'); UI.addSettingChangeHandler('reconnect');
UI.addSettingChangeHandler('reconnect_delay'); UI.addSettingChangeHandler('reconnect_delay');
UI.addSettingChangeHandler('ignore_keys');
const input = document.getElementById('noVNC_setting_ignore_keys');
if (input && !input.dataset.validationBound) {
input.addEventListener('input', UI.validateIgnoreKeysInput);
input.addEventListener('blur', UI.validateIgnoreKeysInput);
input.dataset.validationBound = 'true';
}
}, },
addFullscreenHandlers() { addFullscreenHandlers() {
@ -875,13 +884,28 @@ const UI = {
} }
} }
} else { } else {
ctrl.value = value; ctrl.value = value ?? '';
} }
}, },
// Save control setting to cookie // Update cookie and form control setting. If value is not set, then
saveSetting(name) { // updates from control to current cookie setting.
const ctrl = document.getElementById('noVNC_setting_' + name); saveSetting(nameOrEvent) {
let ctrl = null;
if (typeof nameOrEvent === 'string') {
ctrl = document.getElementById('noVNC_setting_' + nameOrEvent);
} else if (nameOrEvent && nameOrEvent.target) {
ctrl = nameOrEvent.target;
} else if (this instanceof Element) {
ctrl = this;
}
if (!ctrl) {
Log.Warn("saveSetting called without valid control");
return null;
}
let val; let val;
if (ctrl.type === 'checkbox') { if (ctrl.type === 'checkbox') {
val = ctrl.checked; val = ctrl.checked;
@ -890,6 +914,17 @@ const UI = {
} else { } else {
val = ctrl.value; val = ctrl.value;
} }
let name = ctrl.id;
if (name && name.startsWith('noVNC_setting_')) {
name = name.slice('noVNC_setting_'.length);
}
if (!name) {
Log.Warn("saveSetting could not determine setting name");
return val;
}
WebUtil.writeSetting(name, val); WebUtil.writeSetting(name, val);
//Log.Debug("Setting saved '" + name + "=" + val + "'"); //Log.Debug("Setting saved '" + name + "=" + val + "'");
return val; return val;
@ -969,6 +1004,7 @@ const UI = {
UI.updateSetting('logging'); UI.updateSetting('logging');
UI.updateSetting('reconnect'); UI.updateSetting('reconnect');
UI.updateSetting('reconnect_delay'); UI.updateSetting('reconnect_delay');
UI.updateSetting('ignore_keys');
document.getElementById('noVNC_settings') document.getElementById('noVNC_settings')
.classList.add("noVNC_open"); .classList.add("noVNC_open");
@ -1166,6 +1202,8 @@ const UI = {
return; return;
} }
UI.wrapRfbSendKey();
UI.rfb.addEventListener("connect", UI.connectFinished); UI.rfb.addEventListener("connect", UI.connectFinished);
UI.rfb.addEventListener("disconnect", UI.disconnectFinished); UI.rfb.addEventListener("disconnect", UI.disconnectFinished);
UI.rfb.addEventListener("serververification", UI.serverVerify); UI.rfb.addEventListener("serververification", UI.serverVerify);
@ -1666,6 +1704,25 @@ const UI = {
UI.rfb.sendKey(keysym, code, down); UI.rfb.sendKey(keysym, code, down);
}, },
wrapRfbSendKey() {
if (!UI.rfb || !UI.rfb.sendKey || UI.rfb._ignoreKeysWrapped) {
return;
}
const originalSendKey = UI.rfb.sendKey.bind(UI.rfb);
UI.rfb.sendKey = function (keysym, code, down) {
if (UI.shouldIgnoreKey(code)) {
Log.Debug("Key ignored: " + code);
return;
}
return originalSendKey(keysym, code, down);
};
UI.rfb._ignoreKeysWrapped = true;
},
// When normal keyboard events are left uncought, use the input events from // When normal keyboard events are left uncought, use the input events from
// the keyboardinput element instead and generate the corresponding key events. // the keyboardinput element instead and generate the corresponding key events.
// This code is required since some browsers on Android are inconsistent in // This code is required since some browsers on Android are inconsistent in
@ -1937,6 +1994,170 @@ const UI = {
selectbox.options.add(optn); selectbox.options.add(optn);
}, },
supportedIgnoreKeys: [
{ label: 'Escape', aliases: ['esc', 'escape'] },
{ label: 'Tab', aliases: ['tab'] },
{ label: 'Enter', aliases: ['enter', 'return'] },
{ label: 'Delete', aliases: ['del', 'delete'] },
{ label: 'Backspace', aliases: ['bs', 'backspace'] },
{ label: 'ControlLeft', aliases: ['ctrl', 'ctl', 'controlleft'] },
{ label: 'AltLeft', aliases: ['alt', 'altleft'] },
{ label: 'MetaLeft', aliases: ['win', 'cmd', 'super', 'metaleft'] },
],
buildIgnoreKeysTooltipText() {
return `<ul class="noVNC_tooltip_list">` +
UI.supportedIgnoreKeys
.map(({ label, aliases }) => {
if (!aliases.length) {
return `<li>${label}</li>`;
}
return `<li>${label} (${aliases.join(', ')})</li>`;
})
.join('') +
`</ul>`;
},
buildIgnoreKeysPlaceholder() {
return UI.supportedIgnoreKeys
.map(({ aliases, label }) => aliases[0] || label)
.slice(0, 4)
.join(', ') + '...';
},
initIgnoreKeysTooltip() {
const input = document.getElementById('noVNC_setting_ignore_keys');
if (!input) return;
const tooltip = document.getElementById('noVNC_ignore_keys_tooltip');
const button = document.getElementById('noVNC_ignore_keys_help_button');
if (!tooltip || !button) return;
if (!input.placeholder) {
input.placeholder = UI.buildIgnoreKeysPlaceholder();
}
if (!tooltip.dataset.examplesAppended) {
tooltip.innerHTML += UI.buildIgnoreKeysTooltipText();
tooltip.dataset.examplesAppended = 'true';
}
const show = () => {
tooltip.classList.add('noVNC_open');
button.setAttribute('aria-expanded', 'true');
};
const hide = () => {
tooltip.classList.remove('noVNC_open');
button.setAttribute('aria-expanded', 'false');
};
const toggle = (event) => {
event.preventDefault();
event.stopPropagation();
if (tooltip.classList.contains('noVNC_open')) {
hide();
} else {
show();
}
};
if (!button.dataset.tooltipBound) {
button.addEventListener('mouseenter', show);
button.addEventListener('mouseleave', hide);
button.addEventListener('focus', show);
button.addEventListener('blur', hide);
button.addEventListener('click', toggle);
tooltip.addEventListener('mouseenter', show);
tooltip.addEventListener('mouseleave', hide);
document.addEventListener('click', (event) => {
if (!button.contains(event.target) && !tooltip.contains(event.target)) {
hide();
}
});
button.dataset.tooltipBound = 'true';
}
},
parseIgnoredKeys() {
const raw = UI.getSetting('ignore_keys');
if (!raw) return new Set();
return new Set(
raw.split(',')
.map(k => k.trim().toLowerCase())
.filter(Boolean)
);
},
normalizeIgnoreKey(value) {
const normalized = (value || '').trim().toLowerCase();
if (!normalized) return '';
for (const { label, aliases } of UI.supportedIgnoreKeys) {
const canonical = label.toLowerCase();
if (canonical === normalized) {
return canonical;
}
for (const alias of aliases) {
if (alias.toLowerCase() === normalized) {
return canonical;
}
}
}
return normalized;
},
shouldIgnoreKey: (code) => {
const ignored = UI.parseIgnoredKeys();
if (ignored.size === 0) return false;
const codeCanonical = UI.normalizeIgnoreKey(code);
if (!codeCanonical) return false;
for (const key of ignored) {
if (UI.normalizeIgnoreKey(key) === codeCanonical) {
return true;
}
}
return false;
},
validateIgnoreKeysInput() {
const input = document.getElementById('noVNC_setting_ignore_keys');
if (!input) return true;
const tokens = input.value
.split(',')
.map(k => k.trim())
.filter(Boolean);
if (tokens.length === 0) {
input.classList.remove('noVNC_invalid');
return true;
}
const validSet = new Set(
UI.supportedIgnoreKeys.map(k => k.label.toLowerCase())
);
const isValid = tokens.every((k) => {
const normalized = UI.normalizeIgnoreKey(k);
return validSet.has(normalized);
});
input.classList.toggle('noVNC_invalid', !isValid);
return isValid;
},
/* ------^------- /* ------^-------
* /MISC * /MISC
* ============== * ==============

View File

@ -37,6 +37,7 @@ module.exports = (config) => {
{ pattern: 'node_modules/sinon-chai/**', included: false }, { pattern: 'node_modules/sinon-chai/**', included: false },
// modules to test // modules to test
{ pattern: 'app/localization.js', included: false, type: 'module' }, { pattern: 'app/localization.js', included: false, type: 'module' },
{ pattern: 'app/ui.js', included: false, type: 'module' },
{ pattern: 'app/wakelock.js', included: false, type: 'module' }, { pattern: 'app/wakelock.js', included: false, type: 'module' },
{ pattern: 'app/webutil.js', included: false, type: 'module' }, { pattern: 'app/webutil.js', included: false, type: 'module' },
{ pattern: 'core/**/*.js', included: false, type: 'module' }, { pattern: 'core/**/*.js', included: false, type: 'module' },

46
tests/test.ui.js Normal file
View File

@ -0,0 +1,46 @@
import UI from '../app/ui.js';
import * as WebUtil from '../app/webutil.js';
describe('UI', function () {
"use strict";
describe('Ignore Keys Feature', function () {
let originalSupportedIgnoreKeys;
beforeEach(async function () {
await WebUtil.initSettings();
// Save original reference
originalSupportedIgnoreKeys = UI.supportedIgnoreKeys;
// Clone + remove one key (MetaLeft) for testing
UI.supportedIgnoreKeys = UI.supportedIgnoreKeys.filter(
k => k.label !== 'MetaLeft'
);
});
afterEach(function () {
UI.rfb = null;
// Restore original list
UI.supportedIgnoreKeys = originalSupportedIgnoreKeys;
WebUtil.eraseSetting('ignore_keys');
});
describe('shouldIgnoreKey()', function () {
it('returns false for removed keys', function () {
WebUtil.setSetting('ignore_keys', 'cmd,win');
expect(UI.shouldIgnoreKey('MetaLeft')).to.be.false;
});
it('still works for remaining keys', function () {
WebUtil.setSetting('ignore_keys', 'esc,ctrl');
expect(UI.shouldIgnoreKey('Escape')).to.be.true;
expect(UI.shouldIgnoreKey('ControlLeft')).to.be.true;
});
});
});
});

View File

@ -218,6 +218,21 @@
</label> </label>
</li> </li>
<li><hr></li> <li><hr></li>
<li class="noVNC_setting">
<div class="noVNC_setting_with_help">
<label for="noVNC_setting_ignore_keys">Ignored Keys:</label>
<button id="noVNC_ignore_keys_help_button" type="button">?</button>
<div id="noVNC_ignore_keys_tooltip"
class="noVNC_tooltip">
Comma-separated list<br/>of keys to ignore.
</div>
</div>
<input id="noVNC_setting_ignore_keys" type="text">
</li>
<li><hr></li>
<li> <li>
<label> <label>
<input id="noVNC_setting_view_clip" type="checkbox" <input id="noVNC_setting_view_clip" type="checkbox"