Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,14 @@ import {
import {initializeOrGetDirectiveForestHooks} from '../hooks';
import {ComponentTreeNode} from '../interfaces';

interface Type<T> extends Function {
new (...args: any[]): T;
}
export interface ComponentInspectorOptions {
onComponentEnter: (id: number) => void;
onComponentSelect: (id: number) => void;
onComponentLeave: () => void;
}

export class ComponentInspector {
private _selectedComponent!: {component: Type<unknown>; host: HTMLElement | null};
private _selectedComponent!: {component: unknown; host: Element | null};
private readonly _onComponentEnter;
private readonly _onComponentSelect;
private readonly _onComponentLeave;
Expand Down Expand Up @@ -73,8 +70,8 @@ export class ComponentInspector {
elementMouseOver(e: MouseEvent): void {
this.cancelEvent(e);

const el = e.target as HTMLElement;
if (el) {
const el = e.target;
if (el instanceof Node) {
this._selectedComponent = findComponentAndHost(el);
}

Expand Down Expand Up @@ -103,7 +100,7 @@ export class ComponentInspector {

highlightByPosition(position: ElementPosition): void {
const forest: ComponentTreeNode[] = initializeOrGetDirectiveForestHooks().getDirectiveForest();
const elementToHighlight: HTMLElement | null = findNodeInForest(position, forest);
const elementToHighlight: Element | null = findNodeInForest(position, forest);
if (elementToHighlight) {
highlightSelectedElement(elementToHighlight);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -723,9 +723,10 @@ export const queryDirectiveForest = (
export const findNodeInForest = (
position: ElementPosition,
forest: ComponentTreeNode[],
): HTMLElement | null => {
): Element | null => {
const foundComponent: ComponentTreeNode | null = queryDirectiveForest(position, forest);
return foundComponent ? (foundComponent.nativeElement as HTMLElement) : null;
const nativeElement = foundComponent?.nativeElement;
return nativeElement instanceof Element ? nativeElement : null;
};

export const findNodeFromSerializedPosition = (
Expand Down
125 changes: 125 additions & 0 deletions devtools/projects/ng-devtools-backend/src/lib/highlighter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,34 @@ describe('highlighter', () => {
expect(data.component).toEqual(data.host);
});

it('should return same component and host if component exists on an SVG element', () => {
(window as any).ng = {
getComponent: (el: any) => el,
};
const element = document.createElementNS('http://www.w3.org/2000/svg', 'g');
const data = highlighter.findComponentAndHost(element);
expect(data.component).toBeTruthy();
expect(data.host).toBeTruthy();
expect(data.component).toEqual(data.host);
});

it('should return a directive-only host before a parent component host', () => {
const parentComponent = new (class ParentComponent {})();
const routerLink = new (class RouterLink {})();
const parent = document.createElement('app-parent');
const anchor = document.createElement('a');
parent.appendChild(anchor);

(window as any).ng = {
getComponent: (el: Element) => (el === parent ? parentComponent : undefined),
getDirectives: (el: Element) => (el === anchor ? [routerLink] : []),
};

const data = highlighter.findComponentAndHost(anchor);
expect(data.component).toBe(routerLink);
expect(data.host).toBe(anchor);
});

it('should return null component and host if component do not exists', () => {
(window as any).ng = {
getComponent: () => undefined,
Expand Down Expand Up @@ -184,6 +212,12 @@ describe('highlighter', () => {
});

describe('highlightSelectedElement', () => {
afterEach(() => {
highlighter.unHighlight();
document.body.innerHTML = '';
delete (window as any).ng;
});

function createElement(name: string) {
const element = document.createElement(name);
element.style.width = '25px';
Expand Down Expand Up @@ -222,5 +256,96 @@ describe('highlighter', () => {
const overlay = document.body.querySelectorAll('.ng-devtools-overlay');
expect(overlay.length).toBe(1);
});

it('should show overlay again when highlighting the same element after unhighlighting', () => {
const appNode = createElement('app');
(window as any).ng = {
getComponent: (el: any) => new (class FakeComponent {})(),
};

highlighter.highlightSelectedElement(appNode);
highlighter.unHighlight();
highlighter.highlightSelectedElement(appNode);

const overlay = document.body.querySelectorAll('.ng-devtools-overlay');
expect(overlay.length).toBe(1);
});

it('should show overlay again if the previous overlay was removed externally', () => {
const appNode = createElement('app');
(window as any).ng = {
getComponent: (el: any) => new (class FakeComponent {})(),
};

highlighter.highlightSelectedElement(appNode);
document.body.querySelector('.ng-devtools-overlay')?.remove();
highlighter.highlightSelectedElement(appNode);

const overlay = document.body.querySelectorAll('.ng-devtools-overlay');
expect(overlay.length).toBe(1);
});

it('should retry overlay creation for the same element if it was initially hidden', () => {
const appNode = createElement('app');
spyOn(appNode, 'getBoundingClientRect').and.returnValues(
new DOMRect(0, 0, 0, 0),
new DOMRect(0, 0, 25, 20),
);
(window as any).ng = {
getComponent: (el: any) => new (class FakeComponent {})(),
};

highlighter.highlightSelectedElement(appNode);
expect(document.body.querySelectorAll('.ng-devtools-overlay').length).toBe(0);

highlighter.highlightSelectedElement(appNode);

const overlay = document.body.querySelectorAll('.ng-devtools-overlay');
expect(overlay.length).toBe(1);
});

it('should preserve subpixel overlay dimensions', () => {
const appNode = createElement('app');
spyOn(appNode, 'getBoundingClientRect').and.returnValue(new DOMRect(0, 0, 0.5, 0.5));
(window as any).ng = {
getComponent: (el: any) => new (class FakeComponent {})(),
};

highlighter.highlightSelectedElement(appNode);

const overlay = document.body.querySelector<HTMLElement>('.ng-devtools-overlay');
expect(overlay?.style.width).toBe('0.5px');
expect(overlay?.style.height).toBe('0.5px');
});

it('should show overlay for an SVG element', () => {
const svgNode = document.createElementNS('http://www.w3.org/2000/svg', 'g');
spyOn(svgNode, 'getBoundingClientRect').and.returnValue(new DOMRect(0, 0, 25, 20));
document.body.appendChild(svgNode);
(window as any).ng = {
getComponent: (el: any) => el,
};

highlighter.highlightSelectedElement(svgNode);

const overlay = document.body.querySelectorAll('.ng-devtools-overlay');
expect(overlay.length).toBe(1);
expect(overlay[0].innerHTML).toContain('SVGGElement');
});

it('should show overlay for a directive-only element', () => {
const anchor = createElement('a');
const routerLink = new (class RouterLink {})();
(window as any).ng = {
getComponent: () => undefined,
getDirectives: (el: Element) => (el === anchor ? [routerLink] : []),
};

highlighter.highlightSelectedElement(anchor);

const overlay = document.body.querySelectorAll('.ng-devtools-overlay');
expect(overlay.length).toBe(1);
expect(overlay[0].innerHTML).toContain('RouterLink');
});
});
});
41 changes: 21 additions & 20 deletions devtools/projects/ng-devtools-backend/src/lib/highlighter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,22 @@ function createOverlay(color: RgbColor): {overlay: HTMLElement; overlayContent:

export function findComponentAndHost(el: Node | undefined): {
component: any;
host: HTMLElement | null;
host: Element | null;
} {
const ng = ngDebugClient();
if (!el) {
return {component: null, host: null};
}
while (el) {
const component = el instanceof HTMLElement && ng.getComponent!(el);
if (component) {
return {component, host: el as HTMLElement};
if (el instanceof Element) {
const component = ng.getComponent?.(el);
if (component) {
return {component, host: el};
}
const directive = ng.getDirectives?.(el)?.[0];
if (directive) {
return {component: directive, host: el};
}
}
if (!el.parentElement) {
break;
Expand All @@ -84,12 +90,12 @@ export function getDirectiveName(dir: Type<unknown> | undefined | null): string
}

export function highlightSelectedElement(el: Node): void {
if (el === selectedElement) {
if (el === selectedElement && selectedElementOverlay?.isConnected) {
return;
}
unHighlight();
selectedElementOverlay = addHighlightForElement(el);
selectedElement = el;
selectedElement = selectedElementOverlay ? el : null;
}

export function highlightHydrationElement(el: Node, status: HydrationStatus) {
Expand All @@ -109,23 +115,18 @@ export function highlightHydrationElement(el: Node, status: HydrationStatus) {

export function unHighlight(): void {
if (!selectedElementOverlay) {
selectedElement = null;
return;
}

for (const node of document.body.childNodes) {
if (node === selectedElementOverlay) {
document.body.removeChild(selectedElementOverlay);

break;
}
}

selectedElementOverlay.remove();
selectedElementOverlay = null;
selectedElement = null;
}

export function removeHydrationHighlights(): void {
hydrationOverlayItems.forEach((overlay) => {
document.body.removeChild(overlay);
overlay.remove();
});
hydrationOverlayItems = [];
}
Expand Down Expand Up @@ -182,7 +183,7 @@ function addHighlightForElement(
}

function getComponentRect(el: Node): DOMRect | undefined {
if (!(el instanceof HTMLElement)) {
if (!(el instanceof Element)) {
return;
}
if (!inDoc(el)) {
Expand All @@ -199,10 +200,10 @@ function showOverlay(
labelPosition: 'inside' | 'outside',
): void {
const {width, height, top, left} = dimensions;
overlay.style.width = ~~width + 'px';
overlay.style.height = ~~height + 'px';
overlay.style.top = ~~top + window.scrollY + 'px';
overlay.style.left = ~~left + window.scrollX + 'px';
overlay.style.width = `${width}px`;
overlay.style.height = `${height}px`;
overlay.style.top = `${top + window.scrollY}px`;
overlay.style.left = `${left + window.scrollX}px`;

positionOverlayContent(overlayContent, dimensions, labelPosition);
overlayContent.replaceChildren();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ export class DirectiveExplorerComponent {
}

highlight(node: FlatNode): void {
if (!node.original.component) {
if (!node.hasNativeElement) {
return;
}
this._messageBus.emit('createHighlightOverlay', [node.position]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,36 @@ describe('DirectiveExplorerComponent', () => {
});
});

describe('highlight', () => {
it('should create a highlight overlay for directive-only nodes', () => {
comp.highlight({
original: {
component: null,
directives: [{id: 9, name: 'RouterLink'}],
hasNativeElement: true,
},
position: [0],
hasNativeElement: true,
} as any);

expect(messageBusMock.emit).toHaveBeenCalledWith('createHighlightOverlay', [[0]]);
});

it('should not create a highlight overlay for nodes without native elements', () => {
comp.highlight({
original: {
component: null,
directives: [{id: 9, name: 'RouterLink'}],
hasNativeElement: false,
},
position: [0],
hasNativeElement: false,
} as any);

expect(messageBusMock.emit).not.toHaveBeenCalledWith('createHighlightOverlay', [[0]]);
});
});

describe('applicaton operations', () => {
describe('view source', () => {
it('should not call application operations view source if no frames are detected', () => {
Expand Down
Loading
Loading