[REF] web: factorize some hotkey service code

- JS and DOM registrations are now shaped similarly,
  so this help to handle them with the same piece of code

- the keydown event object is now manipulated only in a single function,
  so it is clearer what it is used for and when it is eventually
  stopped or default prevented.

- some constants were converted from Sets to Arrays because they had
  no particular reason to be Sets.

closes odoo/odoo#84969

Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
This commit is contained in:
Bruno Boi
2022-02-21 20:11:55 +00:00
parent d2fc219d52
commit 030b1ba8d6
@@ -20,8 +20,8 @@ const NAV_KEYS = [
"tab", "tab",
"delete", "delete",
]; ];
const MODIFIERS = new Set(["alt", "control", "shift"]); const MODIFIERS = ["alt", "control", "shift"];
const AUTHORIZED_KEYS = new Set([...ALPHANUM_KEYS, ...NAV_KEYS, "escape"]); const AUTHORIZED_KEYS = [...ALPHANUM_KEYS, ...NAV_KEYS, "escape"];
export const hotkeyService = { export const hotkeyService = {
dependencies: ["ui"], dependencies: ["ui"],
@@ -56,15 +56,16 @@ export const hotkeyService = {
} }
const hotkey = getActiveHotkey(event); const hotkey = getActiveHotkey(event);
const { activeElement, isBlocked } = ui;
// Do not dispatch if UI is blocked // Do not dispatch if UI is blocked
if (ui.isBlocked) { if (isBlocked) {
return; return;
} }
// FIXME : this is a temporary hack. It replaces all [accesskey] attrs by [data-hotkey] on all elements. // FIXME : this is a temporary hack. It replaces all [accesskey] attrs by [data-hotkey] on all elements.
const elementsWithoutDataHotkey = getVisibleElements( const elementsWithoutDataHotkey = getVisibleElements(
ui.activeElement, activeElement,
"[accesskey]:not([data-hotkey])" "[accesskey]:not([data-hotkey])"
); );
for (const el of elementsWithoutDataHotkey) { for (const el of elementsWithoutDataHotkey) {
@@ -73,32 +74,44 @@ export const hotkeyService = {
} }
// Special case: open hotkey overlays // Special case: open hotkey overlays
if (hotkey === hotkeyService.overlayModifier) { if (!overlaysVisible && hotkey === hotkeyService.overlayModifier) {
addHotkeyOverlays(); addHotkeyOverlays(activeElement);
event.preventDefault(); event.preventDefault();
overlaysVisible = true;
return; return;
} }
// Is the pressed key NOT whitelisted ? // Is the pressed key NOT whitelisted ?
const singleKey = hotkey.split("+").pop(); const singleKey = hotkey.split("+").pop();
if (!AUTHORIZED_KEYS.has(singleKey)) { if (!AUTHORIZED_KEYS.includes(singleKey)) {
return; return;
} }
// Prepare and dispatch.
const targetIsEditable = const targetIsEditable =
(event.target instanceof Element && /input|textarea/i.test(event.target.tagName)) || (event.target instanceof Element && /input|textarea/i.test(event.target.tagName)) ||
(event.target instanceof HTMLElement && event.target.isContentEditable); (event.target instanceof HTMLElement && event.target.isContentEditable);
const shouldProtectEditable =
targetIsEditable && [...ALPHANUM_KEYS, ...NAV_KEYS].includes(singleKey);
// Finally, prepare and dispatch.
const infos = { const infos = {
activeElement,
hotkey, hotkey,
shouldProtectEditable, isRepeated: event.repeat,
_originalEvent: event, shouldProtectEditable: targetIsEditable && singleKey !== "escape",
}; };
dispatch(infos); const dispatched = dispatch(infos);
removeHotkeyOverlays(event); if (dispatched) {
// Only if event has been handled.
// Purpose: prevent browser defaults
event.preventDefault();
// Purpose: stop other window keydown listeners (e.g. home menu)
event.stopImmediatePropagation();
}
// Finally, always remove overlays at that point
if (overlaysVisible) {
removeHotkeyOverlays();
event.preventDefault();
overlaysVisible = false;
}
} }
/** /**
@@ -109,79 +122,67 @@ export const hotkeyService = {
* - then all registrations done through the DOM [data-hotkey] attribute * - then all registrations done through the DOM [data-hotkey] attribute
* *
* @param {{ * @param {{
* activeElement: HTMLElement,
* hotkey: string, * hotkey: string,
* isRepeated: boolean,
* shouldProtectEditable: boolean, * shouldProtectEditable: boolean,
* _originalEvent: KeyboardEvent
* }} infos * }} infos
* @returns {boolean} true if has been dispatched
*/ */
function dispatch(infos) { function dispatch(infos) {
let dispatched = false; const { activeElement, hotkey, isRepeated, shouldProtectEditable } = infos;
const { hotkey, shouldProtectEditable, _originalEvent: event } = infos;
const activeElement = ui.activeElement;
// Dispatch actual hotkey to all matching registrations // Prepare registrations and the common filter
for (const reg of Array.from(registrations.values()).reverse()) { const reversedRegistrations = Array.from(registrations.values()).reverse();
if (!reg.global && reg.activeElement !== activeElement) { const domRegistrations = getDomRegistrations(hotkey, activeElement);
continue; const allRegistrations = reversedRegistrations.concat(domRegistrations);
}
if (reg.hotkey !== hotkey) { // Dispatch actual hotkey to first matching registration
continue; const match = allRegistrations.find(
} (reg) =>
reg.hotkey === hotkey &&
if (!reg.allowRepeat && event.repeat) { (reg.allowRepeat || !isRepeated) &&
continue; (reg.bypassEditableProtection || !shouldProtectEditable) &&
} (reg.global || reg.activeElement === activeElement)
);
if (!reg.bypassEditableProtection && shouldProtectEditable) { if (match) {
continue; match.callback();
} return true;
reg.callback();
dispatched = true;
break;
} }
return false;
}
function getDomRegistrations(hotkey, activeElement) {
const overlayModParts = hotkeyService.overlayModifier.split("+"); const overlayModParts = hotkeyService.overlayModifier.split("+");
if ( if (!overlayModParts.every((el) => hotkey.includes(el))) {
!dispatched && return [];
!event.repeat && }
overlayModParts.every((el) => hotkey.includes(el))
) { // Get all elements having a data-hotkey attribute and matching
// Click on all elements having a data-hotkey attribute matching the actual hotkey without the overlayModifier. // the actual hotkey without the overlayModifier.
const cleanHotkey = hotkey const cleanHotkey = hotkey
.split("+") .split("+")
.filter((key) => !overlayModParts.includes(key)) .filter((key) => !overlayModParts.includes(key))
.join("+"); .join("+");
const elems = getVisibleElements( const elems = getVisibleElements(activeElement, `[data-hotkey='${cleanHotkey}' i]`);
ui.activeElement, return elems.map((el) => ({
`[data-hotkey='${cleanHotkey}' i]` hotkey,
); activeElement,
for (const el of elems) { bypassEditableProtection: true,
callback: () => {
// AAB: not sure it is enough, we might need to trigger all events that occur when you actually click // AAB: not sure it is enough, we might need to trigger all events that occur when you actually click
el.focus(); el.focus();
el.click(); el.click();
dispatched = true; },
break; }));
}
}
// Only if event has been handheld.
if (dispatched) {
// Purpose: prevent browser defaults
event.preventDefault();
// Purpose: stop other window keydown listeners (e.g. home menu)
event.stopImmediatePropagation();
}
} }
/** /**
* Add the hotkey overlays respecting the ui active element. * Add the hotkey overlays respecting the ui active element.
* @param {Element} activeElement
*/ */
function addHotkeyOverlays() { function addHotkeyOverlays(activeElement) {
if (overlaysVisible) { for (const el of getVisibleElements(activeElement, "[data-hotkey]:not(:disabled)")) {
return;
}
for (const el of getVisibleElements(ui.activeElement, "[data-hotkey]:not(:disabled)")) {
const hotkey = el.dataset.hotkey; const hotkey = el.dataset.hotkey;
const overlay = document.createElement("div"); const overlay = document.createElement("div");
overlay.className = "o_web_hotkey_overlay"; overlay.className = "o_web_hotkey_overlay";
@@ -202,21 +203,15 @@ export const hotkeyService = {
} }
overlayParent.appendChild(overlay); overlayParent.appendChild(overlay);
} }
overlaysVisible = true;
} }
/** /**
* Remove all the hotkey overlays. * Remove all the hotkey overlays.
*/ */
function removeHotkeyOverlays(event) { function removeHotkeyOverlays() {
if (!overlaysVisible) {
return;
}
for (const overlay of document.querySelectorAll(".o_web_hotkey_overlay")) { for (const overlay of document.querySelectorAll(".o_web_hotkey_overlay")) {
overlay.remove(); overlay.remove();
} }
overlaysVisible = false;
event.preventDefault();
} }
/** /**
@@ -247,11 +242,11 @@ export const hotkeyService = {
key = ev.code.slice(-1); key = ev.code.slice(-1);
} }
// Prefer physical keys for non-latin keyboard layout. // Prefer physical keys for non-latin keyboard layout.
if (!AUTHORIZED_KEYS.has(key) && ev.code && ev.code.indexOf("Key") === 0) { if (!AUTHORIZED_KEYS.includes(key) && ev.code && ev.code.indexOf("Key") === 0) {
key = ev.code.slice(-1).toLowerCase(); key = ev.code.slice(-1).toLowerCase();
} }
// Make sure we do not duplicate a modifier key // Make sure we do not duplicate a modifier key
if (!MODIFIERS.has(key)) { if (!MODIFIERS.includes(key)) {
hotkey.push(key); hotkey.push(key);
} }
return hotkey.join("+"); return hotkey.join("+");
@@ -293,8 +288,8 @@ export const hotkeyService = {
const keys = hotkey const keys = hotkey
.toLowerCase() .toLowerCase()
.split("+") .split("+")
.filter((k) => !MODIFIERS.has(k)); .filter((k) => !MODIFIERS.includes(k));
if (keys.some((k) => !AUTHORIZED_KEYS.has(k))) { if (keys.some((k) => !AUTHORIZED_KEYS.includes(k))) {
throw new Error( throw new Error(
`You are trying to subscribe for an hotkey ('${hotkey}') `You are trying to subscribe for an hotkey ('${hotkey}')
that contains parts not whitelisted: ${keys.join(", ")}` that contains parts not whitelisted: ${keys.join(", ")}`