Skip to content

Commit f923307

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

File tree

2 files changed

+215
-0
lines changed

2 files changed

+215
-0
lines changed

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

+5
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: '',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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

Comments
 (0)