[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:
@@ -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(", ")}`
|
||||||
|
|||||||
Reference in New Issue
Block a user