diff --git a/app/styles/base.css b/app/styles/base.css index 33f0f359..5a37c0f1 100644 --- a/app/styles/base.css +++ b/app/styles/base.css @@ -612,7 +612,6 @@ html { list-style: none; padding: 0px; } -#noVNC_settings button, #noVNC_settings select, #noVNC_settings textarea, #noVNC_settings input:not([type=checkbox]):not([type=radio]) { @@ -620,6 +619,10 @@ html { /* Prevent inputs in settings from being too wide */ 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 { width: 80px; @@ -643,6 +646,83 @@ html { 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 * ---------------------------------------- diff --git a/app/styles/constants.css b/app/styles/constants.css index 1123a3ef..42239b2f 100644 --- a/app/styles/constants.css +++ b/app/styles/constants.css @@ -21,6 +21,8 @@ --novnc-green: rgb(0, 128, 0); --novnc-yellow: rgb(255, 255, 0); + --novnc-red-rgb: 229, 57, 53; + --novnc-red: rgb(var(--novnc-red-rgb)); } /* ------ MISC PROPERTIES ------ */ diff --git a/app/ui.js b/app/ui.js index 24d32d55..8c7e2eca 100644 --- a/app/ui.js +++ b/app/ui.js @@ -124,6 +124,7 @@ const UI = { UI.addConnectionControlHandlers(); UI.addClipboardHandlers(); UI.addSettingsHandlers(); + UI.initIgnoreKeysTooltip(); document.getElementById("noVNC_status") .addEventListener('click', UI.hideStatus); @@ -191,6 +192,7 @@ const UI = { UI.initSetting('reconnect', false); UI.initSetting('reconnect_delay', 5000); UI.initSetting('keep_device_awake', false); + UI.initSetting('ignore_keys', ''); }, // Adds a link to the label elements on the corresponding input elements setupSettingLabels() { @@ -383,6 +385,13 @@ const UI = { UI.addSettingChangeHandler('logging', UI.updateLogging); UI.addSettingChangeHandler('reconnect'); 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() { @@ -802,13 +811,28 @@ const UI = { } } } else { - ctrl.value = value; + ctrl.value = value ?? ''; } }, - // Save control setting to cookie - saveSetting(name) { - const ctrl = document.getElementById('noVNC_setting_' + name); + // Update cookie and form control setting. If value is not set, then + // updates from control to current cookie setting. + 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; if (ctrl.type === 'checkbox') { val = ctrl.checked; @@ -817,6 +841,17 @@ const UI = { } else { 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); //Log.Debug("Setting saved '" + name + "=" + val + "'"); return val; @@ -896,6 +931,7 @@ const UI = { UI.updateSetting('logging'); UI.updateSetting('reconnect'); UI.updateSetting('reconnect_delay'); + UI.updateSetting('ignore_keys'); document.getElementById('noVNC_settings') .classList.add("noVNC_open"); @@ -1093,6 +1129,8 @@ const UI = { return; } + UI.wrapRfbSendKey(); + UI.rfb.addEventListener("connect", UI.connectFinished); UI.rfb.addEventListener("disconnect", UI.disconnectFinished); UI.rfb.addEventListener("serververification", UI.serverVerify); @@ -1593,6 +1631,25 @@ const UI = { 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 // the keyboardinput element instead and generate the corresponding key events. // This code is required since some browsers on Android are inconsistent in @@ -1864,6 +1921,170 @@ const UI = { 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 ``; + }, + + 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 * ============== diff --git a/karma.conf.cjs b/karma.conf.cjs index 7d6dbe1a..a54008eb 100644 --- a/karma.conf.cjs +++ b/karma.conf.cjs @@ -37,6 +37,7 @@ module.exports = (config) => { { pattern: 'node_modules/sinon-chai/**', included: false }, // modules to test { 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/webutil.js', included: false, type: 'module' }, { pattern: 'core/**/*.js', included: false, type: 'module' }, diff --git a/tests/test.ui.js b/tests/test.ui.js new file mode 100644 index 00000000..cded2e74 --- /dev/null +++ b/tests/test.ui.js @@ -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; + }); + }); + }); +}); diff --git a/vnc.html b/vnc.html index c36a0f07..4d2828ed 100644 --- a/vnc.html +++ b/vnc.html @@ -220,6 +220,21 @@

  • +
  • +
    + + + + +
    + Comma-separated list
    of keys to ignore. +
    +
    + + +
  • +