Skip to content

Commit

Permalink
feat(devexp): highlight components by group
Browse files Browse the repository at this point in the history
  • Loading branch information
matthieu-crouzet committed Dec 3, 2024
1 parent 78718fe commit 01e1747
Show file tree
Hide file tree
Showing 2 changed files with 249 additions and 0 deletions.
5 changes: 5 additions & 0 deletions apps/showcase/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ import {
import {
SideNavLinksGroup,
} from '../components/index';
import {
HighlightService,
} from '../services/highlight';

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

public readonly service = inject(HighlightService);

public linksGroups: SideNavLinksGroup[] = [
{
label: '',
Expand Down
244 changes: 244 additions & 0 deletions apps/showcase/src/services/highlight/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import {
Injectable,
OnDestroy,
} from '@angular/core';

const HIGHLIGHT_WRAPPER_CLASS = 'highlight-wrapper';
const HIGHLIGHT_OVERLAY_CLASS = 'highlight-overlay';
const HIGHLIGHT_CHIP_CLASS = 'highlight-chip';
// Should we set it customizable (if yes, chrome extension view or options)
const ELEMENT_MIN_HEIGHT = 30;
// Should we set it customizable (if yes, chrome extension view or options)
const ELEMENT_MIN_WIDTH = 60;
// Should we set it customizable (if yes, chrome extension view or options)
const THROTTLE_INTERVAL = 500;

interface ElementInfo {
color?: string;
backgroundColor: string;
displayName: string;
regexp: string;
}

interface ElementWithSelectorInfo {
element: HTMLElement;
info: ElementInfo;
}

interface ElementWithSelectorInfoAndDepth extends ElementWithSelectorInfo {
depth: number;
}

function getIdentifier(element: HTMLElement, info: ElementInfo): string {
const tagName = element.tagName.toLowerCase();
const regexp = new RegExp(info.regexp, 'i');
if (!regexp.test(element.tagName)) {
const attribute = Array.from(element.attributes).find((att) => regexp.test(att.name));
if (attribute) {
return `${attribute.name}${attribute.value ? `="${attribute.value}"` : ''}`;
}
const className = Array.from(element.classList).find((cName) => regexp.test(cName));
if (className) {
return className;
}
}
return tagName;
}

/**
* Compute the number of ancestors of a given element based on a list of elements
* @param element
* @param elementList
*/
function computeNumberOfAncestors(element: HTMLElement, elementList: HTMLElement[]) {
return elementList.filter((el: HTMLElement) => el.contains(element)).length;
}

function throttle<T extends (...args: any[]) => any>(fn: T, delay: number): (...args: Parameters<T>) => void {
let timerFlag: ReturnType<typeof setTimeout> | null = null;

const throttleFn = (...args: Parameters<T>) => {
if (timerFlag === null) {
fn(...args);
timerFlag = setTimeout(() => {
fn(...args);
timerFlag = null;
}, delay);
}
};
return throttleFn;
}

@Injectable({
providedIn: 'root'
})
export class HighlightService implements OnDestroy {
// Should be customizable from the chrome extension view
public maxDepth = 10;

// Should be customizable from the chrome extension options
public elementsInfo: Record<string, ElementInfo> = {
otter: {
backgroundColor: '#f4dac6',
color: 'black',
regexp: '^o3r',
displayName: 'o3r'
},
designFactory: {
backgroundColor: '#000835',
regexp: '^df',
displayName: 'df'
},
ngBootstrap: {
backgroundColor: '#0d6efd',
regexp: '^ngb',
displayName: 'ngb'
}
};

private readonly throttleRun = throttle(this.run.bind(this), THROTTLE_INTERVAL);

// private interval: ReturnType<typeof setInterval> | null = null;

private readonly mutationObserver = new MutationObserver((mutations) => {
const wrapper = document.querySelector(`.${HIGHLIGHT_WRAPPER_CLASS}`);
if (mutations.some((mutation) =>
mutation.target !== wrapper
|| (
mutation.target === document.body
&& Array.from<HTMLElement>(mutation.addedNodes.values() as any)
.concat(...mutation.removedNodes.values() as any)
.some((node) => !node.classList.contains(HIGHLIGHT_WRAPPER_CLASS))
)
)) {
this.throttleRun();
}
});

private readonly resizeObserver = new ResizeObserver(this.throttleRun.bind(this));

Check failure on line 118 in apps/showcase/src/services/highlight/index.ts

View workflow job for this annotation

GitHub Actions / UT Tests report-ubuntu-latest

AppComponent ► apps/showcase/src/app/app.component.spec.ts ► AppComponent should create the app

Failed test found in: apps/showcase/dist-test/junit.xml Error: ReferenceError: ResizeObserver is not defined
Raw output
ReferenceError: ResizeObserver is not defined
    at new ResizeObserver (/home/runner/work/otter/otter/apps/showcase/src/services/highlight/index.ts:118:41)
    at Object.HighlightService_Factory [as factory] (ng:///HighlightService/ɵfac.js:5:10)
    at /home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:3237:47
    at runInInjectorProfilerContext (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:872:9)
    at R3Injector.hydrate (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:3236:21)
    at R3Injector.get (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:3099:33)
    at ChainedInjector.get (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:5454:36)
    at lookupTokenUsingModuleInjector (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:5807:39)
    at getOrCreateInjectable (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:5855:12)
    at ɵɵdirectiveInject (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:11933:19)
    at ɵɵinject (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:1113:42)
    at inject (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:1199:12)
    at new AppComponent (/home/runner/work/otter/otter/apps/showcase/src/app/app.component.ts:42:35)
    at NodeInjectorFactory.AppComponent_Factory [as factory] (ng:///AppComponent/ɵfac.js:5:10)
    at getNodeInjectable (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:6067:44)
    at createRootComponent (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:16916:35)
    at ComponentFactory.create (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:16767:29)
    at initComponent (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/testing.mjs:2039:51)
    at _ZoneDelegate.invoke (/home/runner/work/otter/otter/.yarn/cache/zone.js-npm-0.14.10-f84b9a2b62-a7bed2f9a7.zip/node_modules/zone.js/bundles/zone.umd.js:416:32)
    at ProxyZoneSpec.Object.<anonymous>.ProxyZoneSpec.onInvoke (/home/runner/work/otter/otter/.yarn/cache/zone.js-npm-0.14.10-f84b9a2b62-a7bed2f9a7.zip/node_modules/zone.js/bundles/zone-testing.umd.js:2176:43)
    at _ZoneDelegate.invoke (/home/runner/work/otter/otter/.yarn/cache/zone.js-npm-0.14.10-f84b9a2b62-a7bed2f9a7.zip/node_modules/zone.js/bundles/zone.umd.js:415:38)
    at Object.onInvoke (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:7251:33)
    at _ZoneDelegate.invoke (/home/runner/work/otter/otter/.yarn/cache/zone.js-npm-0.14.10-f84b9a2b62-a7bed2f9a7.zip/node_modules/zone.js/bundles/zone.umd.js:415:38)
    at ZoneImpl.run (/home/runner/work/otter/otter/.yarn/cache/zone.js-npm-0.14.10-f84b9a2b62-a7bed2f9a7.zip/node_modules/zone.js/bundles/zone.umd.js:147:47)
    at NgZone.run (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:7097:28)
    at _TestBedImpl.createComponent (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/testing.mjs:2051:41)
    at Function.createComponent (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/testing.mjs:1842:37)
    at /home/runner/work/otter/otter/apps/showcase/src/app/app.component.spec.ts:66:29
    at _ZoneDelegate.invoke (/home/runner/work/otter/otter/.yarn/cache/zone.js-npm-0.14.10-f84b9a2b62-a7bed2f9a7.zip/node_modules/zone.js/bundles/zone.umd.js:416:32)
    at ProxyZoneSpec.Object.<anonymous>.ProxyZoneSpec.onInvoke (/home/runner/work/otter/otter/.yarn/cache/zone.js-npm-0.14.10-f84b9a2b62-a7bed2f9a7.zip/node_modules/zone.js/bundles/zone-testing.umd.js:2176:43)
    at _ZoneDelegate.invoke (/home/runner/work/otter/otter/.yarn/cache/zone.js-npm-0.14.10-f84b9a2b62-a7bed2f9a7.zip/node_modules/zone.js/bundles/zone.umd.js:415:38)
    at ZoneImpl.run (/home/runner/work/otter/otter/.yarn/cache/zone.js-npm-0.14.10-f84b9a2b62-a7bed2f9a7.zip/node_modules/zone.js/bundles/zone.umd.js:147:47)
    at Object.wrappedFunc (/home/runner/work/otter/otter/.yarn/cache/zone.js-npm-0.14.10-f84b9a2b62-a7bed2f9a7.zip/node_modules/zone.js/bundles/zone-testing.umd.js:450:38)
    at Promise.then.completed (/home/runner/work/otter/otter/.yarn/cache/jest-circus-npm-29.7.0-f7679858c6-716a8e3f40.zip/node_modules/jest-circus/build/utils.js:298:28)
    at new Promise (<anonymous>)
    at callAsyncCircusFn (/home/runner/work/otter/otter/.yarn/cache/jest-circus-npm-29.7.0-f7679858c6-716a8e3f40.zip/node_modules/jest-circus/build/utils.js:231:10)
    at _callCircusTest (/home/runner/work/otter/otter/.yarn/cache/jest-circus-npm-29.7.0-f7679858c6-716a8e3f40.zip/node_modules/jest-circus/build/run.js:316:40)
    at processTicksAndRejections (node:internal/process/task_queues:95:5)
    at _runTest (/home/runner/work/otter/otter/.yarn/cache/jest-circus-npm-29.7.0-f7679858c6-716a8e3f40.zip/node_modules/jest-circus/build/run.js:252:3)
    at _runTestsForDescribeBlock (/home/runner/work/otter/otter/.yarn/cache/jest-circus-npm-29.7.0-f7679858c6-716a8e3f40.zip/node_modules/jest-circus/build/run.js:126:9)
    at _runTestsForDescribeBlock (/home/runner/work/otter/otter/.yarn/cache/jest-circus-npm-29.7.0-f7679858c6-716a8e3f40.zip/node_modules/jest-circus/build/run.js:121:9)
    at run (/home/runner/work/otter/otter/.yarn/cache/jest-circus-npm-29.7.0-f7679858c6-716a8e3f40.zip/node_modules/jest-circus/build/run.js:71:3)
    at runAndTransformResultsToJestFormat (/home/runner/work/otter/otter/.yarn/cache/jest-circus-npm-29.7.0-f7679858c6-716a8e3f40.zip/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)

constructor() {
this.start();
}

public start() {
this.throttleRun();
document.addEventListener('scroll', this.throttleRun, true);
this.resizeObserver.observe(document.body);
this.mutationObserver.observe(document.body, { childList: true, subtree: true });
}

public stop() {
document.removeEventListener('scroll', this.throttleRun, true);
this.resizeObserver.disconnect();
this.mutationObserver.disconnect();
}

public run() {
let wrapper = document.querySelector(`.${HIGHLIGHT_WRAPPER_CLASS}`);
if (wrapper) {
wrapper.querySelectorAll('*').forEach((node) => node.remove());
} else {
wrapper = document.createElement('div');
wrapper.classList.add(HIGHLIGHT_WRAPPER_CLASS);
document.body.append(wrapper);
}

// We have to select all elements from document because
// with CSSSelector it's impossible to select element by regex on their `tagName`, attribute name or attribute value
const elementsWithInfo = Array.from(document.querySelectorAll<HTMLElement>('*'))
.reduce((acc: ElementWithSelectorInfo[], element) => {
const rect = element.getBoundingClientRect();
if (rect.height < ELEMENT_MIN_HEIGHT || rect.width < ELEMENT_MIN_WIDTH) {
return acc;
}
const elementInfo = Object.values(this.elementsInfo).find((info) => {
const regexp = new RegExp(`^${info.regexp}`, 'i');

return regexp.test(element.tagName)
|| Array.from(element.attributes).some((attr) => regexp.test(attr.name))
|| Array.from(element.classList).some((cName) => regexp.test(cName));
});
if (elementInfo) {
return acc.concat({ element, info: elementInfo });
}
return acc;
}, [])
.reduce((acc: ElementWithSelectorInfoAndDepth[], elementWithInfo, _, array) => {
const depth = computeNumberOfAncestors(elementWithInfo.element, array.map((e) => e.element));
if (depth <= this.maxDepth) {
return acc.concat({
...elementWithInfo,
depth
});
}
return acc;
}, []);

const overlayData: Record<string, { chip: HTMLElement; overlay: HTMLElement; depth: number }[]> = {};
elementsWithInfo.forEach(({ element, info, depth }) => {
const { backgroundColor, color, displayName } = info;
const rect = element.getBoundingClientRect();
const overlay = document.createElement('div');
const chip = document.createElement('div');
const position = element.computedStyleMap().get('position')?.toString() === 'fixed' ? 'fixed' : 'absolute';
const top = `${position === 'fixed' ? rect.top : (rect.top + window.scrollY)}px`;
const left = `${position === 'fixed' ? rect.left : (rect.left + window.scrollX)}px`;
overlay.classList.add(HIGHLIGHT_OVERLAY_CLASS);
// All static style could be moved in a <style>
overlay.style.top = top;
overlay.style.left = left;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
overlay.style.border = `1px solid ${backgroundColor}`;
overlay.style.zIndex = '10000';
overlay.style.position = position;
overlay.style.pointerEvents = 'none';
wrapper.append(overlay);
chip.classList.add(HIGHLIGHT_CHIP_CLASS);
chip.textContent = `${displayName} ${depth}`;
// All static style could be moved in a <style>
chip.style.top = top;
chip.style.left = left;
chip.style.backgroundColor = backgroundColor;
chip.style.color = color ?? '#FFF';
chip.style.position = position;
chip.style.display = 'inline-block';
chip.style.padding = '2px 4px';
chip.style.borderRadius = '0 0 4px';
chip.style.cursor = 'pointer';
chip.style.zIndex = '10000';
chip.style.textWrap = 'no-wrap';
const name = getIdentifier(element, info);
chip.title = name;
wrapper.append(chip);
chip.addEventListener('click', () => {
// Should we log in the console as well ?
void navigator.clipboard.writeText(name);
});
const positionKey = `${top};${left}`;
if (!overlayData[positionKey]) {
overlayData[positionKey] = [];
}
overlayData[positionKey].push({ chip, overlay, depth });
});
Object.values(overlayData).forEach((chips) => {
chips
.sort(({ depth: depthA }, { depth: depthB }) => depthA - depthB)
.forEach(({ chip, overlay }, index, array) => {
if (index !== 0) {
const translateX = array.slice(0, index).reduce((sum, e) => sum + e.chip.getBoundingClientRect().width, 0);
chip.style.transform = `translateX(${translateX}px)`;
overlay.style.margin = `${index}px 0 0 ${index}px`;
overlay.style.width = `${+overlay.style.width.replace('px', '') - 2 * index}px`;
overlay.style.height = `${+overlay.style.height.replace('px', '') - 2 * index}px`;
overlay.style.zIndex = `${+overlay.style.zIndex - index}`;
}
});
});
}

public ngOnDestroy() {
this.stop();
}
}

0 comments on commit 01e1747

Please sign in to comment.