Skip to content

Commit 01e1747

Browse files
feat(devexp): highlight components by group
1 parent 78718fe commit 01e1747

File tree

2 files changed

+249
-0
lines changed

2 files changed

+249
-0
lines changed

apps/showcase/src/app/app.component.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ import {
2626
import {
2727
SideNavLinksGroup,
2828
} from '../components/index';
29+
import {
30+
HighlightService,
31+
} from '../services/highlight';
2932

3033
@O3rComponent({ componentType: 'Component' })
3134
@Component({
@@ -36,6 +39,8 @@ import {
3639
export class AppComponent implements OnDestroy {
3740
public title = 'showcase';
3841

42+
public readonly service = inject(HighlightService);
43+
3944
public linksGroups: SideNavLinksGroup[] = [
4045
{
4146
label: '',
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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 THROTTLE_INTERVAL = 500;
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+
function throttle<T extends (...args: any[]) => any>(fn: T, delay: number): (...args: Parameters<T>) => void {
58+
let timerFlag: ReturnType<typeof setTimeout> | null = null;
59+
60+
const throttleFn = (...args: Parameters<T>) => {
61+
if (timerFlag === null) {
62+
fn(...args);
63+
timerFlag = setTimeout(() => {
64+
fn(...args);
65+
timerFlag = null;
66+
}, delay);
67+
}
68+
};
69+
return throttleFn;
70+
}
71+
72+
@Injectable({
73+
providedIn: 'root'
74+
})
75+
export class HighlightService implements OnDestroy {
76+
// Should be customizable from the chrome extension view
77+
public maxDepth = 10;
78+
79+
// Should be customizable from the chrome extension options
80+
public elementsInfo: Record<string, ElementInfo> = {
81+
otter: {
82+
backgroundColor: '#f4dac6',
83+
color: 'black',
84+
regexp: '^o3r',
85+
displayName: 'o3r'
86+
},
87+
designFactory: {
88+
backgroundColor: '#000835',
89+
regexp: '^df',
90+
displayName: 'df'
91+
},
92+
ngBootstrap: {
93+
backgroundColor: '#0d6efd',
94+
regexp: '^ngb',
95+
displayName: 'ngb'
96+
}
97+
};
98+
99+
private readonly throttleRun = throttle(this.run.bind(this), THROTTLE_INTERVAL);
100+
101+
// private interval: ReturnType<typeof setInterval> | null = null;
102+
103+
private readonly mutationObserver = new MutationObserver((mutations) => {
104+
const wrapper = document.querySelector(`.${HIGHLIGHT_WRAPPER_CLASS}`);
105+
if (mutations.some((mutation) =>
106+
mutation.target !== wrapper
107+
|| (
108+
mutation.target === document.body
109+
&& Array.from<HTMLElement>(mutation.addedNodes.values() as any)
110+
.concat(...mutation.removedNodes.values() as any)
111+
.some((node) => !node.classList.contains(HIGHLIGHT_WRAPPER_CLASS))
112+
)
113+
)) {
114+
this.throttleRun();
115+
}
116+
});
117+
118+
private readonly resizeObserver = new ResizeObserver(this.throttleRun.bind(this));
119+
120+
constructor() {
121+
this.start();
122+
}
123+
124+
public start() {
125+
this.throttleRun();
126+
document.addEventListener('scroll', this.throttleRun, true);
127+
this.resizeObserver.observe(document.body);
128+
this.mutationObserver.observe(document.body, { childList: true, subtree: true });
129+
}
130+
131+
public stop() {
132+
document.removeEventListener('scroll', this.throttleRun, true);
133+
this.resizeObserver.disconnect();
134+
this.mutationObserver.disconnect();
135+
}
136+
137+
public run() {
138+
let wrapper = document.querySelector(`.${HIGHLIGHT_WRAPPER_CLASS}`);
139+
if (wrapper) {
140+
wrapper.querySelectorAll('*').forEach((node) => node.remove());
141+
} else {
142+
wrapper = document.createElement('div');
143+
wrapper.classList.add(HIGHLIGHT_WRAPPER_CLASS);
144+
document.body.append(wrapper);
145+
}
146+
147+
// We have to select all elements from document because
148+
// with CSSSelector it's impossible to select element by regex on their `tagName`, attribute name or attribute value
149+
const elementsWithInfo = Array.from(document.querySelectorAll<HTMLElement>('*'))
150+
.reduce((acc: ElementWithSelectorInfo[], element) => {
151+
const rect = element.getBoundingClientRect();
152+
if (rect.height < ELEMENT_MIN_HEIGHT || rect.width < ELEMENT_MIN_WIDTH) {
153+
return acc;
154+
}
155+
const elementInfo = Object.values(this.elementsInfo).find((info) => {
156+
const regexp = new RegExp(`^${info.regexp}`, 'i');
157+
158+
return regexp.test(element.tagName)
159+
|| Array.from(element.attributes).some((attr) => regexp.test(attr.name))
160+
|| Array.from(element.classList).some((cName) => regexp.test(cName));
161+
});
162+
if (elementInfo) {
163+
return acc.concat({ element, info: elementInfo });
164+
}
165+
return acc;
166+
}, [])
167+
.reduce((acc: ElementWithSelectorInfoAndDepth[], elementWithInfo, _, array) => {
168+
const depth = computeNumberOfAncestors(elementWithInfo.element, array.map((e) => e.element));
169+
if (depth <= this.maxDepth) {
170+
return acc.concat({
171+
...elementWithInfo,
172+
depth
173+
});
174+
}
175+
return acc;
176+
}, []);
177+
178+
const overlayData: Record<string, { chip: HTMLElement; overlay: HTMLElement; depth: number }[]> = {};
179+
elementsWithInfo.forEach(({ element, info, depth }) => {
180+
const { backgroundColor, color, displayName } = info;
181+
const rect = element.getBoundingClientRect();
182+
const overlay = document.createElement('div');
183+
const chip = document.createElement('div');
184+
const position = element.computedStyleMap().get('position')?.toString() === 'fixed' ? 'fixed' : 'absolute';
185+
const top = `${position === 'fixed' ? rect.top : (rect.top + window.scrollY)}px`;
186+
const left = `${position === 'fixed' ? rect.left : (rect.left + window.scrollX)}px`;
187+
overlay.classList.add(HIGHLIGHT_OVERLAY_CLASS);
188+
// All static style could be moved in a <style>
189+
overlay.style.top = top;
190+
overlay.style.left = left;
191+
overlay.style.width = `${rect.width}px`;
192+
overlay.style.height = `${rect.height}px`;
193+
overlay.style.border = `1px solid ${backgroundColor}`;
194+
overlay.style.zIndex = '10000';
195+
overlay.style.position = position;
196+
overlay.style.pointerEvents = 'none';
197+
wrapper.append(overlay);
198+
chip.classList.add(HIGHLIGHT_CHIP_CLASS);
199+
chip.textContent = `${displayName} ${depth}`;
200+
// All static style could be moved in a <style>
201+
chip.style.top = top;
202+
chip.style.left = left;
203+
chip.style.backgroundColor = backgroundColor;
204+
chip.style.color = color ?? '#FFF';
205+
chip.style.position = position;
206+
chip.style.display = 'inline-block';
207+
chip.style.padding = '2px 4px';
208+
chip.style.borderRadius = '0 0 4px';
209+
chip.style.cursor = 'pointer';
210+
chip.style.zIndex = '10000';
211+
chip.style.textWrap = 'no-wrap';
212+
const name = getIdentifier(element, info);
213+
chip.title = name;
214+
wrapper.append(chip);
215+
chip.addEventListener('click', () => {
216+
// Should we log in the console as well ?
217+
void navigator.clipboard.writeText(name);
218+
});
219+
const positionKey = `${top};${left}`;
220+
if (!overlayData[positionKey]) {
221+
overlayData[positionKey] = [];
222+
}
223+
overlayData[positionKey].push({ chip, overlay, depth });
224+
});
225+
Object.values(overlayData).forEach((chips) => {
226+
chips
227+
.sort(({ depth: depthA }, { depth: depthB }) => depthA - depthB)
228+
.forEach(({ chip, overlay }, index, array) => {
229+
if (index !== 0) {
230+
const translateX = array.slice(0, index).reduce((sum, e) => sum + e.chip.getBoundingClientRect().width, 0);
231+
chip.style.transform = `translateX(${translateX}px)`;
232+
overlay.style.margin = `${index}px 0 0 ${index}px`;
233+
overlay.style.width = `${+overlay.style.width.replace('px', '') - 2 * index}px`;
234+
overlay.style.height = `${+overlay.style.height.replace('px', '') - 2 * index}px`;
235+
overlay.style.zIndex = `${+overlay.style.zIndex - index}`;
236+
}
237+
});
238+
});
239+
}
240+
241+
public ngOnDestroy() {
242+
this.stop();
243+
}
244+
}

0 commit comments

Comments
 (0)