|
| 1 | +import { |
| 2 | + Injectable, |
| 3 | + OnDestroy, |
| 4 | +} from '@angular/core'; |
| 5 | + |
| 6 | +const HIGHLIGHT_WRAPPER_CLASS = 'highlight-wrapper'; |
| 7 | +const HIGHLIGHT_OVERLAY_CLASS = 'highlight-overlay'; |
| 8 | +const HIGHLIGHT_CHIP_CLASS = 'highlight-chip'; |
| 9 | +// Should we set it customizable (if yes, chrome extension view or options) |
| 10 | +const ELEMENT_MIN_HEIGHT = 30; |
| 11 | +// Should we set it customizable (if yes, chrome extension view or options) |
| 12 | +const ELEMENT_MIN_WIDTH = 60; |
| 13 | +// Should we set it customizable (if yes, chrome extension view or options) |
| 14 | +const REFRESH_INTERVAL = 2000; |
| 15 | + |
| 16 | +interface ElementInfo { |
| 17 | + color?: string; |
| 18 | + backgroundColor: string; |
| 19 | + displayName: string; |
| 20 | + regexp: string; |
| 21 | +} |
| 22 | + |
| 23 | +interface ElementWithSelectorInfo { |
| 24 | + element: HTMLElement; |
| 25 | + info: ElementInfo; |
| 26 | +} |
| 27 | + |
| 28 | +interface ElementWithSelectorInfoAndDepth extends ElementWithSelectorInfo { |
| 29 | + depth: number; |
| 30 | +} |
| 31 | + |
| 32 | +function getIdentifier(element: HTMLElement, info: ElementInfo): string { |
| 33 | + const tagName = element.tagName.toLowerCase(); |
| 34 | + const regexp = new RegExp(info.regexp, 'i'); |
| 35 | + if (!regexp.test(element.tagName)) { |
| 36 | + const attribute = Array.from(element.attributes).find((att) => regexp.test(att.name)); |
| 37 | + if (attribute) { |
| 38 | + return `${attribute.name}${attribute.value ? `="${attribute.value}"` : ''}`; |
| 39 | + } |
| 40 | + const className = Array.from(element.classList).find((cName) => regexp.test(cName)); |
| 41 | + if (className) { |
| 42 | + return className; |
| 43 | + } |
| 44 | + } |
| 45 | + return tagName; |
| 46 | +} |
| 47 | + |
| 48 | +/** |
| 49 | + * Compute the number of ancestors of a given element based on a list of elements |
| 50 | + * @param element |
| 51 | + * @param elementList |
| 52 | + */ |
| 53 | +function computeNumberOfAncestors(element: HTMLElement, elementList: HTMLElement[]) { |
| 54 | + return elementList.filter((el: HTMLElement) => el.contains(element)).length; |
| 55 | +} |
| 56 | + |
| 57 | +@Injectable({ |
| 58 | + providedIn: 'root' |
| 59 | +}) |
| 60 | +export class HighlightService implements OnDestroy { |
| 61 | + // Should be customizable from the chrome extension view |
| 62 | + public maxDepth = 10; |
| 63 | + |
| 64 | + // Should be customizable from the chrome extension options |
| 65 | + public elementsInfo: Record<string, ElementInfo> = { |
| 66 | + otter: { |
| 67 | + backgroundColor: '#f4dac6', |
| 68 | + color: 'black', |
| 69 | + regexp: '^o3r', |
| 70 | + displayName: 'o3r' |
| 71 | + }, |
| 72 | + designFactory: { |
| 73 | + backgroundColor: '#000835', |
| 74 | + regexp: '^df', |
| 75 | + displayName: 'df' |
| 76 | + }, |
| 77 | + ngBootstrap: { |
| 78 | + backgroundColor: '#0d6efd', |
| 79 | + regexp: '^ngb', |
| 80 | + displayName: 'ngb' |
| 81 | + } |
| 82 | + }; |
| 83 | + |
| 84 | + private interval: ReturnType<typeof setInterval> | null = null; |
| 85 | + |
| 86 | + constructor() { |
| 87 | + this.start(); |
| 88 | + } |
| 89 | + |
| 90 | + public start() { |
| 91 | + if (!this.interval) { |
| 92 | + this.interval = setInterval(this.run.bind(this), REFRESH_INTERVAL); |
| 93 | + } |
| 94 | + } |
| 95 | + |
| 96 | + public stop() { |
| 97 | + if (this.interval) { |
| 98 | + clearInterval(this.interval); |
| 99 | + this.interval = null; |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + public run() { |
| 104 | + let wrapper = document.querySelector(`.${HIGHLIGHT_WRAPPER_CLASS}`); |
| 105 | + if (wrapper) { |
| 106 | + wrapper.childNodes.forEach((node) => node.remove()); |
| 107 | + } else { |
| 108 | + wrapper = document.createElement('div'); |
| 109 | + wrapper.classList.add(HIGHLIGHT_WRAPPER_CLASS); |
| 110 | + document.body.append(wrapper); |
| 111 | + } |
| 112 | + |
| 113 | + // We have to select all elements from document because |
| 114 | + // with CSSSelector it's impossible to select element by regex on their `tagName`, attribute name or attribute value |
| 115 | + const elementsWithInfo = Array.from(document.querySelectorAll<HTMLElement>('*')) |
| 116 | + .reduce((acc: ElementWithSelectorInfo[], element) => { |
| 117 | + const rect = element.getBoundingClientRect(); |
| 118 | + if (rect.height < ELEMENT_MIN_HEIGHT || rect.width < ELEMENT_MIN_WIDTH) { |
| 119 | + return acc; |
| 120 | + } |
| 121 | + const elementInfo = Object.values(this.elementsInfo).find((info) => { |
| 122 | + const regexp = new RegExp(`^${info.regexp}`, 'i'); |
| 123 | + |
| 124 | + return regexp.test(element.tagName) |
| 125 | + || Array.from(element.attributes).some((attr) => regexp.test(attr.name)) |
| 126 | + || Array.from(element.classList).some((cName) => regexp.test(cName)); |
| 127 | + }); |
| 128 | + if (elementInfo) { |
| 129 | + return acc.concat({ element, info: elementInfo }); |
| 130 | + } |
| 131 | + return acc; |
| 132 | + }, []) |
| 133 | + .reduce((acc: ElementWithSelectorInfoAndDepth[], elementWithInfo, _, array) => { |
| 134 | + const depth = computeNumberOfAncestors(elementWithInfo.element, array.map((e) => e.element)); |
| 135 | + if (depth <= this.maxDepth) { |
| 136 | + return acc.concat({ |
| 137 | + ...elementWithInfo, |
| 138 | + depth |
| 139 | + }); |
| 140 | + } |
| 141 | + return acc; |
| 142 | + }, []); |
| 143 | + |
| 144 | + const overlayData: Record<string, { chip: HTMLElement; overlay: HTMLElement; depth: number }[]> = {}; |
| 145 | + elementsWithInfo.forEach(({ element, info, depth }) => { |
| 146 | + const { backgroundColor, color, displayName } = info; |
| 147 | + const rect = element.getBoundingClientRect(); |
| 148 | + const overlay = document.createElement('div'); |
| 149 | + const chip = document.createElement('div'); |
| 150 | + const position = element.computedStyleMap().get('position')?.toString() === 'fixed' ? 'fixed' : 'absolute'; |
| 151 | + const top = `${position === 'fixed' ? rect.top : (rect.top + window.scrollY)}px`; |
| 152 | + const left = `${position === 'fixed' ? rect.left : (rect.left + window.scrollX)}px`; |
| 153 | + overlay.classList.add(HIGHLIGHT_OVERLAY_CLASS); |
| 154 | + // All static style could be moved in a <style> |
| 155 | + overlay.style.top = top; |
| 156 | + overlay.style.left = left; |
| 157 | + overlay.style.width = `${rect.width}px`; |
| 158 | + overlay.style.height = `${rect.height}px`; |
| 159 | + overlay.style.border = `1px solid ${backgroundColor}`; |
| 160 | + overlay.style.zIndex = '10000'; |
| 161 | + overlay.style.position = position; |
| 162 | + overlay.style.pointerEvents = 'none'; |
| 163 | + wrapper.append(overlay); |
| 164 | + chip.classList.add(HIGHLIGHT_CHIP_CLASS); |
| 165 | + chip.textContent = `${displayName} ${depth}`; |
| 166 | + // All static style could be moved in a <style> |
| 167 | + chip.style.top = top; |
| 168 | + chip.style.left = left; |
| 169 | + chip.style.backgroundColor = backgroundColor; |
| 170 | + chip.style.color = color ?? '#FFF'; |
| 171 | + chip.style.position = position; |
| 172 | + chip.style.display = 'inline-block'; |
| 173 | + chip.style.padding = '2px 4px'; |
| 174 | + chip.style.borderRadius = '0 0 4px'; |
| 175 | + chip.style.cursor = 'pointer'; |
| 176 | + chip.style.zIndex = '10000'; |
| 177 | + chip.style.textWrap = 'no-wrap'; |
| 178 | + const name = getIdentifier(element, info); |
| 179 | + chip.title = name; |
| 180 | + wrapper.append(chip); |
| 181 | + chip.addEventListener('click', () => { |
| 182 | + // Should we log in the console as well ? |
| 183 | + void navigator.clipboard.writeText(name); |
| 184 | + }); |
| 185 | + const positionKey = `${top};${left}`; |
| 186 | + if (!overlayData[positionKey]) { |
| 187 | + overlayData[positionKey] = []; |
| 188 | + } |
| 189 | + overlayData[positionKey].push({ chip, overlay, depth }); |
| 190 | + }); |
| 191 | + Object.values(overlayData).forEach((chips) => { |
| 192 | + chips |
| 193 | + .sort(({ depth: depthA }, { depth: depthB }) => depthA - depthB) |
| 194 | + .forEach(({ chip, overlay }, index, array) => { |
| 195 | + if (index !== 0) { |
| 196 | + const translateX = array.slice(0, index).reduce((sum, e) => sum + e.chip.getBoundingClientRect().width, 0); |
| 197 | + chip.style.transform = `translateX(${translateX}px)`; |
| 198 | + overlay.style.margin = `${index}px 0 0 ${index}px`; |
| 199 | + overlay.style.width = `${+overlay.style.width.replace('px', '') - 2 * index}px`; |
| 200 | + overlay.style.height = `${+overlay.style.height.replace('px', '') - 2 * index}px`; |
| 201 | + overlay.style.zIndex = `${+overlay.style.zIndex - index}`; |
| 202 | + } |
| 203 | + }); |
| 204 | + }); |
| 205 | + } |
| 206 | + |
| 207 | + public ngOnDestroy() { |
| 208 | + this.stop(); |
| 209 | + } |
| 210 | +} |
0 commit comments