| /** | 
 |  * @license | 
 |  * Copyright (C) 2020 The Android Open Source Project | 
 |  * | 
 |  * Licensed under the Apache License, Version 2.0 (the "License"); | 
 |  * you may not use this file except in compliance with the License. | 
 |  * You may obtain a copy of the License at | 
 |  * | 
 |  * http://www.apache.org/licenses/LICENSE-2.0 | 
 |  * | 
 |  * Unless required by applicable law or agreed to in writing, software | 
 |  * distributed under the License is distributed on an "AS IS" BASIS, | 
 |  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 
 |  * See the License for the specific language governing permissions and | 
 |  * limitations under the License. | 
 |  */ | 
 | import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom'; | 
 | import {check} from './common-util'; | 
 |  | 
 | /** | 
 |  * Event emitted from polymer elements. | 
 |  */ | 
 | export interface PolymerEvent extends EventApi, Event {} | 
 |  | 
 | interface ElementWithShadowRoot extends Element { | 
 |   shadowRoot: ShadowRoot; | 
 | } | 
 |  | 
 | /** | 
 |  * Type guard for element with a shadowRoot. | 
 |  */ | 
 | function isElementWithShadowRoot( | 
 |   el: Element | ShadowRoot | 
 | ): el is ElementWithShadowRoot { | 
 |   return 'shadowRoot' in el; | 
 | } | 
 |  | 
 | export function isElement(node: Node): node is Element { | 
 |   return node.nodeType === 1; | 
 | } | 
 |  | 
 | export function isElementTarget( | 
 |   target: EventTarget | null | undefined | 
 | ): target is Element { | 
 |   if (!target) return false; | 
 |   return 'nodeType' in target && isElement(target as Node); | 
 | } | 
 |  | 
 | // TODO: maybe should have a better name for this | 
 | function getPathFromNode(el: EventTarget) { | 
 |   let tagName = ''; | 
 |   let id = ''; | 
 |   let className = ''; | 
 |   if (el instanceof Element) { | 
 |     tagName = el.tagName; | 
 |     id = el.id; | 
 |     className = el.className; | 
 |   } | 
 |   if ( | 
 |     !tagName || | 
 |     'GR-APP' === tagName || | 
 |     el instanceof DocumentFragment || | 
 |     el instanceof HTMLSlotElement | 
 |   ) { | 
 |     return ''; | 
 |   } | 
 |   let path = ''; | 
 |   if (tagName) { | 
 |     path += tagName.toLowerCase(); | 
 |   } | 
 |   if (id) { | 
 |     path += `#${id}`; | 
 |   } | 
 |   if (className) { | 
 |     path += `.${className.replace(/ /g, '.')}`; | 
 |   } | 
 |   return path; | 
 | } | 
 |  | 
 | /** | 
 |  * Get computed style value. | 
 |  */ | 
 | export function getComputedStyleValue(name: string, el: Element) { | 
 |   return getComputedStyle(el).getPropertyValue(name).trim(); | 
 | } | 
 |  | 
 | /** | 
 |  * Query selector on a dom element. | 
 |  * | 
 |  * This is shadow DOM compatible, but only works when selector is within | 
 |  * one shadow host, won't work if your selector is crossing | 
 |  * multiple shadow hosts. | 
 |  * | 
 |  */ | 
 | export function querySelector( | 
 |   el: Element | ShadowRoot, | 
 |   selector: string | 
 | ): Element | null { | 
 |   let nodes = [el]; | 
 |   let result = null; | 
 |   while (nodes.length) { | 
 |     const node = nodes.pop(); | 
 |  | 
 |     // Skip if it's an invalid node. | 
 |     if (!node || !node.querySelector) continue; | 
 |  | 
 |     // Try find it with native querySelector directly | 
 |     result = node.querySelector(selector); | 
 |  | 
 |     if (result) { | 
 |       break; | 
 |     } | 
 |  | 
 |     // Add all nodes with shadowRoot and loop through | 
 |     const allShadowNodes = [...node.querySelectorAll('*')] | 
 |       .filter(isElementWithShadowRoot) | 
 |       .map(child => child.shadowRoot); | 
 |     nodes = nodes.concat(allShadowNodes); | 
 |  | 
 |     // Add shadowRoot of current node if has one | 
 |     // as its not included in node.querySelectorAll('*') | 
 |     if (isElementWithShadowRoot(node)) { | 
 |       nodes.push(node.shadowRoot); | 
 |     } | 
 |   } | 
 |   return result; | 
 | } | 
 |  | 
 | /** | 
 |  * Query selector all dom elements matching with certain selector. | 
 |  * | 
 |  * This is shadow DOM compatible, but only works when selector is within | 
 |  * one shadow host, won't work if your selector is crossing | 
 |  * multiple shadow hosts. | 
 |  * | 
 |  * Note: this can be very expensive, only use when have to. | 
 |  */ | 
 | export function querySelectorAll( | 
 |   el: Element | ShadowRoot, | 
 |   selector: string | 
 | ): Element[] { | 
 |   let nodes = [el]; | 
 |   const results = new Set<Element>(); | 
 |   while (nodes.length) { | 
 |     const node = nodes.pop(); | 
 |  | 
 |     if (!node || !node.querySelectorAll) continue; | 
 |  | 
 |     // Try find all from regular children | 
 |     [...node.querySelectorAll(selector)].forEach(el => results.add(el)); | 
 |  | 
 |     // Add all nodes with shadowRoot and loop through | 
 |     const allShadowNodes = [...node.querySelectorAll('*')] | 
 |       .filter(isElementWithShadowRoot) | 
 |       .map(child => child.shadowRoot); | 
 |     nodes = nodes.concat(allShadowNodes); | 
 |  | 
 |     // Add shadowRoot of current node if has one | 
 |     // as its not included in node.querySelectorAll('*') | 
 |     if (isElementWithShadowRoot(node)) { | 
 |       nodes.push(node.shadowRoot); | 
 |     } | 
 |   } | 
 |   return [...results]; | 
 | } | 
 |  | 
 | export function windowLocationReload() { | 
 |   const e = new Error(); | 
 |   console.info(`Calling window.location.reload(): ${e.stack}`); | 
 |   window.location.reload(); | 
 | } | 
 |  | 
 | /** | 
 |  * Retrieves the dom path of the current event. | 
 |  * | 
 |  * If the event object contains a `path` property, then use it, | 
 |  * otherwise, construct the dom path based on the event target. | 
 |  * | 
 |  * domNode.onclick = e => { | 
 |  *  getEventPath(e); // eg: div.class1>p#pid.class2 | 
 |  * } | 
 |  */ | 
 | export function getEventPath<T extends MouseEvent>(e?: T) { | 
 |   if (!e) return ''; | 
 |  | 
 |   let path = e.composedPath(); | 
 |   if (!path || !path.length) { | 
 |     path = []; | 
 |     let el = e.target; | 
 |     while (el) { | 
 |       path.push(el); | 
 |       el = (el as Node).parentNode || (el as ShadowRoot).host; | 
 |     } | 
 |   } | 
 |  | 
 |   return path.reduce<string>((domPath: string, curEl: EventTarget) => { | 
 |     const pathForEl = getPathFromNode(curEl); | 
 |     if (!pathForEl) return domPath; | 
 |     return domPath ? `${pathForEl}>${domPath}` : pathForEl; | 
 |   }, ''); | 
 | } | 
 |  | 
 | /** | 
 |  * Are any ancestors of the element (or the element itself) members of the | 
 |  * given class. | 
 |  * | 
 |  */ | 
 | export function descendedFromClass( | 
 |   element: Element, | 
 |   className: string, | 
 |   stopElement?: Element | 
 | ) { | 
 |   let isDescendant = element.classList.contains(className); | 
 |   while ( | 
 |     !isDescendant && | 
 |     element.parentElement && | 
 |     (!stopElement || element.parentElement !== stopElement) | 
 |   ) { | 
 |     isDescendant = element.classList.contains(className); | 
 |     element = element.parentElement; | 
 |   } | 
 |   return isDescendant; | 
 | } | 
 |  | 
 | /** | 
 |  * Convert any string into a valid class name. | 
 |  * | 
 |  * For class names, naming rules: | 
 |  * Must begin with a letter A-Z or a-z | 
 |  * Can be followed by: letters (A-Za-z), digits (0-9), hyphens ("-"), and underscores ("_") | 
 |  */ | 
 | export function strToClassName(str = '', prefix = 'generated_') { | 
 |   return `${prefix}${str.replace(/[^a-zA-Z0-9-_]/g, '_')}`; | 
 | } | 
 |  | 
 | // document.activeElement is not enough, because it's not getting activeElement | 
 | // without looking inside of shadow roots. This will find best activeElement. | 
 | export function findActiveElement( | 
 |   root: Document | ShadowRoot | null, | 
 |   ignoreDialogs?: boolean | 
 | ): HTMLElement | null { | 
 |   if (root === null) { | 
 |     return null; | 
 |   } | 
 |   if ( | 
 |     ignoreDialogs && | 
 |     root.activeElement && | 
 |     root.activeElement.nodeName.toUpperCase().includes('DIALOG') | 
 |   ) { | 
 |     return null; | 
 |   } | 
 |   if (root.activeElement?.shadowRoot?.activeElement) { | 
 |     return findActiveElement(root.activeElement.shadowRoot); | 
 |   } | 
 |   if (!root.activeElement) { | 
 |     return null; | 
 |   } | 
 |   // We block some elements | 
 |   if ('BODY' === root.activeElement.nodeName.toUpperCase()) { | 
 |     return null; | 
 |   } | 
 |   return root.activeElement as HTMLElement; | 
 | } | 
 |  | 
 | // Whether the browser is Safari. Used for polyfilling unique browser behavior. | 
 | export function isSafari() { | 
 |   return ( | 
 |     /^((?!chrome|android).)*safari/i.test(navigator.userAgent) || | 
 |     /iPad|iPhone|iPod/.test(navigator.userAgent) | 
 |   ); | 
 | } | 
 |  | 
 | export function whenVisible( | 
 |   element: Element, | 
 |   callback: () => void, | 
 |   marginPx = 0 | 
 | ) { | 
 |   const observer = new IntersectionObserver( | 
 |     (entries: IntersectionObserverEntry[]) => { | 
 |       check(entries.length === 1, 'Expected one intersection observer entry.'); | 
 |       const entry = entries[0]; | 
 |       if (entry.isIntersecting) { | 
 |         observer.unobserve(entry.target); | 
 |         callback(); | 
 |       } | 
 |     }, | 
 |     {rootMargin: `${marginPx}px`} | 
 |   ); | 
 |   observer.observe(element); | 
 | } | 
 |  | 
 | /** | 
 |  * Toggles a CSS class on or off for an element. | 
 |  */ | 
 | export function toggleClass(el: Element, className: string, bool?: boolean) { | 
 |   if (bool === undefined) { | 
 |     bool = !el.classList.contains(className); | 
 |   } | 
 |   if (bool) { | 
 |     el.classList.add(className); | 
 |   } else { | 
 |     el.classList.remove(className); | 
 |   } | 
 | } | 
 |  | 
 | /** | 
 |  * For matching the `key` property of KeyboardEvents. These are known to work | 
 |  * with Firefox, Safari and Chrome. | 
 |  */ | 
 | export enum Key { | 
 |   ENTER = 'Enter', | 
 |   ESC = 'Escape', | 
 |   TAB = 'Tab', | 
 |   SPACE = ' ', | 
 |   LEFT = 'ArrowLeft', | 
 |   RIGHT = 'ArrowRight', | 
 |   UP = 'ArrowUp', | 
 |   DOWN = 'ArrowDown', | 
 | } | 
 |  | 
 | export enum Modifier { | 
 |   ALT_KEY, | 
 |   CTRL_KEY, | 
 |   META_KEY, | 
 |   SHIFT_KEY, | 
 | } | 
 |  | 
 | export enum ComboKey { | 
 |   G = 'g', | 
 |   V = 'v', | 
 | } | 
 |  | 
 | export interface Binding { | 
 |   key: string | Key; | 
 |   /** Defaults to false. */ | 
 |   docOnly?: boolean; | 
 |   /** Defaults to not being a combo shortcut. */ | 
 |   combo?: ComboKey; | 
 |   /** Defaults to no modifiers. */ | 
 |   modifiers?: Modifier[]; | 
 | } | 
 |  | 
 | const ALPHA_NUM = new RegExp(/^[A-Za-z0-9]$/); | 
 |  | 
 | /** | 
 |  * For "normal" keys we do not check that the SHIFT modifier is pressed or not, | 
 |  * because that depends on the keyboard layout. Just checking the key string is | 
 |  * sufficient. | 
 |  * | 
 |  * But for some special keys it is important whether SHIFT is pressed at the | 
 |  * same time, for example we want to distinguish Enter from Shift+Enter. | 
 |  */ | 
 | function shiftMustMatch(key: string | Key) { | 
 |   return Object.values(Key).includes(key as Key); | 
 | } | 
 |  | 
 | /** | 
 |  * For a-zA-Z0-9 and for Enter, Tab, etc. we want to check the ALT modifier. | 
 |  * | 
 |  * But for special chars like []/? we don't care whether the user is pressing | 
 |  * the ALT modifier to produce the special char. For example on a German | 
 |  * keyboard layout you have to press ALT to produce a [. | 
 |  */ | 
 | function altMustMatch(key: string | Key) { | 
 |   return ALPHA_NUM.test(key) || Object.values(Key).includes(key as Key); | 
 | } | 
 |  | 
 | export function eventMatchesShortcut( | 
 |   e: KeyboardEvent, | 
 |   shortcut: Binding | 
 | ): boolean { | 
 |   if (e.key !== shortcut.key) return false; | 
 |   const modifiers = shortcut.modifiers ?? []; | 
 |   if (e.ctrlKey !== modifiers.includes(Modifier.CTRL_KEY)) return false; | 
 |   if (e.metaKey !== modifiers.includes(Modifier.META_KEY)) return false; | 
 |   if ( | 
 |     altMustMatch(e.key) && | 
 |     e.altKey !== modifiers.includes(Modifier.ALT_KEY) | 
 |   ) { | 
 |     return false; | 
 |   } | 
 |   if ( | 
 |     shiftMustMatch(e.key) && | 
 |     e.shiftKey !== modifiers.includes(Modifier.SHIFT_KEY) | 
 |   ) { | 
 |     return false; | 
 |   } | 
 |   return true; | 
 | } | 
 |  | 
 | export function addGlobalShortcut( | 
 |   shortcut: Binding, | 
 |   listener: (e: KeyboardEvent) => void | 
 | ) { | 
 |   return addShortcut(document.body, shortcut, listener); | 
 | } | 
 |  | 
 | export function addShortcut( | 
 |   element: HTMLElement, | 
 |   shortcut: Binding, | 
 |   listener: (e: KeyboardEvent) => void, | 
 |   options: { | 
 |     shouldSuppress: boolean; | 
 |   } = { | 
 |     shouldSuppress: false, | 
 |   } | 
 | ) { | 
 |   const wrappedListener = (e: KeyboardEvent) => { | 
 |     if (e.repeat) return; | 
 |     if (options.shouldSuppress && shouldSuppress(e)) return; | 
 |     if (eventMatchesShortcut(e, shortcut)) { | 
 |       listener(e); | 
 |     } | 
 |   }; | 
 |   element.addEventListener('keydown', wrappedListener); | 
 |   return () => element.removeEventListener('keydown', wrappedListener); | 
 | } | 
 |  | 
 | export function modifierPressed(e: KeyboardEvent) { | 
 |   return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey; | 
 | } | 
 |  | 
 | export function shiftPressed(e: KeyboardEvent) { | 
 |   return e.shiftKey; | 
 | } | 
 |  | 
 | /** | 
 |  * When you listen on keyboard events, then within Gerrit's web app you may want | 
 |  * to avoid firing in certain common scenarios such as key strokes from <input> | 
 |  * elements. But this can also be undesirable, for example Ctrl-Enter from | 
 |  * <input> should trigger a save event. | 
 |  * | 
 |  * The shortcuts-service has a stateful method `shouldSuppress()` with | 
 |  * reporting functionality, which delegates to here. | 
 |  */ | 
 | export function shouldSuppress(e: KeyboardEvent): boolean { | 
 |   // Note that when you listen on document, then `e.currentTarget` will be the | 
 |   // document and `e.target` will be `<gr-app>` due to shadow dom, but by | 
 |   // using the composedPath() you can actually find the true origin of the | 
 |   // event. | 
 |   const rootTarget = e.composedPath()[0]; | 
 |   if (!isElementTarget(rootTarget)) return false; | 
 |   const tagName = rootTarget.tagName; | 
 |   const type = rootTarget.getAttribute('type'); | 
 |  | 
 |   if ( | 
 |     // Suppress shortcuts on <input> and <textarea>, but not on | 
 |     // checkboxes, because we want to enable workflows like 'click | 
 |     // mark-reviewed and then press ] to go to the next file'. | 
 |     (tagName === 'INPUT' && type !== 'checkbox') || | 
 |     tagName === 'TEXTAREA' || | 
 |     // Suppress shortcuts if the key is 'enter' | 
 |     // and target is an anchor or button or paper-tab. | 
 |     (e.keyCode === 13 && | 
 |       (tagName === 'A' || | 
 |         tagName === 'BUTTON' || | 
 |         tagName === 'GR-BUTTON' || | 
 |         tagName === 'PAPER-TAB')) | 
 |   ) { | 
 |     return true; | 
 |   } | 
 |   const path: EventTarget[] = e.composedPath() ?? []; | 
 |   for (const el of path) { | 
 |     if (!isElementTarget(el)) continue; | 
 |     if (el.tagName === 'GR-OVERLAY') return true; | 
 |   } | 
 |   return false; | 
 | } |