Skip to content

Commit 29dfdcc

Browse files
feat(devexp): highlight components by group
1 parent f4e354c commit 29dfdcc

File tree

7 files changed

+579
-0
lines changed

7 files changed

+579
-0
lines changed

packages/@o3r/components/src/devkit/components-devkit.interface.ts

+41
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import type {
88
import type {
99
PlaceholderMode,
1010
} from '../stores';
11+
import {
12+
GroupInfo,
13+
} from './highlight/models';
1114
import {
1215
OtterLikeComponentInfo,
1316
} from './inspector';
@@ -32,6 +35,40 @@ export interface ToggleInspectorMessage extends OtterMessageContent<'toggleInspe
3235
isRunning: boolean;
3336
}
3437

38+
/**
39+
* Message to toggle the highlight
40+
*/
41+
export interface ToggleHighlightMessage extends OtterMessageContent<'toggleHighlight'> {
42+
/** Is the highlight displayed */
43+
isRunning: boolean;
44+
}
45+
46+
/**
47+
* Message the change the configuration of the `HighlightService`
48+
*/
49+
export interface ChangeHighlightConfiguration extends OtterMessageContent<'changeHighlightConfiguration'> {
50+
/**
51+
* Minimum width of HTMLElement to be considered
52+
*/
53+
elementMinWidth?: number;
54+
/**
55+
* Minimum height of HTMLElement to be considered
56+
*/
57+
elementMinHeight?: number;
58+
/**
59+
* Throttle interval
60+
*/
61+
throttleInterval?: number;
62+
/**
63+
* Group information to detect elements
64+
*/
65+
groupsInfo?: Record<string, GroupInfo>;
66+
/**
67+
* Maximum number of ancestors
68+
*/
69+
maxDepth?: number;
70+
}
71+
3572
/**
3673
* Message to toggle the placeholder mode
3774
*/
@@ -51,6 +88,8 @@ type ComponentsMessageContents =
5188
| IsComponentSelectionAvailableMessage
5289
| SelectedComponentInfoMessage
5390
| ToggleInspectorMessage
91+
| ToggleHighlightMessage
92+
| ChangeHighlightConfiguration
5493
| PlaceholderModeMessage;
5594

5695
/** List of possible DataTypes for Components messages */
@@ -74,5 +113,7 @@ export const isComponentsMessage = (message: any): message is AvailableComponent
74113
|| message.dataType === 'isComponentSelectionAvailable'
75114
|| message.dataType === 'placeholderMode'
76115
|| message.dataType === 'toggleInspector'
116+
|| message.dataType === 'toggleHighlight'
117+
|| message.dataType === 'changeHighlightConfiguration'
77118
);
78119
};

packages/@o3r/components/src/devkit/components-devtools.message.service.ts

+36
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ import {
4040
OTTER_COMPONENTS_DEVTOOLS_DEFAULT_OPTIONS,
4141
OTTER_COMPONENTS_DEVTOOLS_OPTIONS,
4242
} from './components-devtools.token';
43+
import {
44+
HighlightService,
45+
} from './highlight/highlight.service';
4346
import {
4447
OtterInspectorService,
4548
OtterLikeComponentInfo,
@@ -51,6 +54,7 @@ import {
5154
export class ComponentsDevtoolsMessageService implements DevtoolsServiceInterface {
5255
private readonly options: ComponentsDevtoolsServiceOptions;
5356
private readonly inspectorService: OtterInspectorService;
57+
private readonly highlightService: HighlightService;
5458
private readonly sendMessage = sendOtterMessage<AvailableComponentsMessageContents>;
5559
private readonly destroyRef = inject(DestroyRef);
5660

@@ -65,6 +69,8 @@ export class ComponentsDevtoolsMessageService implements DevtoolsServiceInterfac
6569
};
6670

6771
this.inspectorService = new OtterInspectorService();
72+
this.highlightService = new HighlightService();
73+
6874
if (this.options.isActivatedOnBootstrap) {
6975
this.activate();
7076
}
@@ -130,6 +136,36 @@ export class ComponentsDevtoolsMessageService implements DevtoolsServiceInterfac
130136
this.inspectorService.toggleInspector(message.isRunning);
131137
break;
132138
}
139+
case 'toggleHighlight': {
140+
if (message.isRunning) {
141+
this.highlightService.start();
142+
} else {
143+
this.highlightService.stop();
144+
}
145+
break;
146+
}
147+
case 'changeHighlightConfiguration': {
148+
if (message.elementMinWidth) {
149+
this.highlightService.elementMinWidth = message.elementMinWidth;
150+
}
151+
if (message.elementMinHeight) {
152+
this.highlightService.elementMinHeight = message.elementMinHeight;
153+
}
154+
if (message.throttleInterval) {
155+
this.highlightService.throttleInterval = message.throttleInterval;
156+
}
157+
if (message.groupsInfo) {
158+
this.highlightService.groupsInfo = message.groupsInfo;
159+
}
160+
if (message.maxDepth) {
161+
this.highlightService.maxDepth = message.maxDepth;
162+
}
163+
if (this.highlightService.isRunning()) {
164+
// Re-start to recompute the highlight with the new configuration
165+
this.highlightService.start();
166+
}
167+
break;
168+
}
133169
case 'placeholderMode': {
134170
this.store.dispatch(togglePlaceholderModeTemplate({ mode: message.mode }));
135171
break;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Class applied on the wrapper of highlight elements
3+
*/
4+
export const HIGHLIGHT_WRAPPER_CLASS = 'highlight-wrapper';
5+
6+
/**
7+
* Class applied on the overlay elements
8+
*/
9+
export const HIGHLIGHT_OVERLAY_CLASS = 'highlight-overlay';
10+
11+
/**
12+
* Class applied on the chip elements
13+
*/
14+
export const HIGHLIGHT_CHIP_CLASS = 'highlight-chip';
15+
16+
/**
17+
* Default value for maximum number of ancestors
18+
*/
19+
export const DEFAULT_MAX_DEPTH = 10;
20+
21+
/**
22+
* Default value for element min height
23+
*/
24+
export const DEFAULT_ELEMENT_MIN_HEIGHT = 30;
25+
/**
26+
* Default value for element min width
27+
*/
28+
export const DEFAULT_ELEMENT_MIN_WIDTH = 60;
29+
/**
30+
* Default value for throttle interval
31+
*/
32+
export const DEFAULT_THROTTLE_INTERVAL = 500;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {
2+
getIdentifier,
3+
} from './helpers';
4+
import {
5+
ElementWithGroupInfo,
6+
} from './models';
7+
8+
describe('Highlight helpers', () => {
9+
describe('getIdentifier', () => {
10+
it('should return the tagName', () => {
11+
const element = {
12+
htmlElement: {
13+
tagName: 'prefix-selector'
14+
} as HTMLElement,
15+
regexp: '^prefix',
16+
backgroundColor: 'blue',
17+
displayName: 'prefix'
18+
} satisfies ElementWithGroupInfo;
19+
expect(getIdentifier(element)).toBe('prefix-selector');
20+
});
21+
22+
it('should return the first attributeName matching', () => {
23+
const element = {
24+
htmlElement: {
25+
tagName: 'custom-selector',
26+
attributes: [
27+
{ name: 'custom-attribute' },
28+
{ name: 'prefix-attribute' },
29+
{ name: 'prefix-attribute-2' }
30+
] as any as NamedNodeMap
31+
} as HTMLElement,
32+
regexp: '^prefix',
33+
backgroundColor: 'blue',
34+
displayName: 'prefix'
35+
} satisfies ElementWithGroupInfo;
36+
expect(getIdentifier(element)).toBe('prefix-attribute');
37+
});
38+
39+
it('should return the first attributeName matching with its value', () => {
40+
const element = {
41+
htmlElement: {
42+
tagName: 'custom-selector',
43+
attributes: [{ name: 'prefix-attribute', value: 'value' }] as any as NamedNodeMap
44+
} as HTMLElement,
45+
regexp: '^prefix',
46+
backgroundColor: 'blue',
47+
displayName: 'prefix'
48+
} satisfies ElementWithGroupInfo;
49+
expect(getIdentifier(element)).toBe('prefix-attribute="value"');
50+
});
51+
52+
it('should return the first className matching', () => {
53+
const element = {
54+
htmlElement: {
55+
tagName: 'custom-selector',
56+
attributes: [] as any as NamedNodeMap,
57+
classList: ['custom-class', 'prefix-class', 'prefix-class-2'] as any as DOMTokenList
58+
} as HTMLElement,
59+
regexp: '^prefix',
60+
backgroundColor: 'blue',
61+
displayName: 'prefix'
62+
} satisfies ElementWithGroupInfo;
63+
expect(getIdentifier(element)).toBe('prefix-class');
64+
});
65+
});
66+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import {
2+
HIGHLIGHT_CHIP_CLASS,
3+
HIGHLIGHT_OVERLAY_CLASS,
4+
HIGHLIGHT_WRAPPER_CLASS,
5+
} from './constants';
6+
import type {
7+
ElementWithGroupInfo,
8+
} from './models';
9+
10+
/**
11+
* Retrieve the identifier of the element
12+
* @param element
13+
*/
14+
export function getIdentifier(element: ElementWithGroupInfo): string {
15+
const { tagName, attributes, classList } = element.htmlElement;
16+
const regexp = new RegExp(element.regexp, 'i');
17+
if (!regexp.test(tagName)) {
18+
const attribute = Array.from(attributes).find((attr) => regexp.test(attr.name));
19+
if (attribute) {
20+
return `${attribute.name}${attribute.value ? `="${attribute.value}"` : ''}`;
21+
}
22+
const className = Array.from(classList).find((cName) => regexp.test(cName));
23+
if (className) {
24+
return className;
25+
}
26+
}
27+
return tagName;
28+
}
29+
30+
/**
31+
* Compute the number of ancestors of a given element based on a list of elements
32+
* @param element
33+
* @param elementList
34+
*/
35+
export function computeNumberOfAncestors(element: HTMLElement, elementList: HTMLElement[]) {
36+
return elementList.filter((el: HTMLElement) => el.contains(element)).length;
37+
}
38+
39+
/**
40+
* Throttle {@link fn} with a {@link delay}
41+
* @param fn method to run
42+
* @param delay given in ms
43+
*/
44+
export function throttle<T extends (...args: any[]) => any>(fn: T, delay: number): (...args: Parameters<T>) => void {
45+
let timerFlag: ReturnType<typeof setTimeout> | null = null;
46+
47+
const throttleFn = (...args: Parameters<T>) => {
48+
if (timerFlag === null) {
49+
fn(...args);
50+
timerFlag = setTimeout(() => {
51+
fn(...args);
52+
timerFlag = null;
53+
}, delay);
54+
}
55+
};
56+
return throttleFn;
57+
}
58+
59+
/**
60+
* Run {@link refreshFn} if {@link mutations} implies to refresh elements inside {@link highlightWrapper}
61+
* @param mutations
62+
* @param highlightWrapper
63+
* @param refreshFn
64+
*/
65+
export function runRefreshIfNeeded(mutations: MutationRecord[], highlightWrapper: Element | null, refreshFn: () => void) {
66+
if (
67+
mutations.some((mutation) =>
68+
mutation.target !== highlightWrapper
69+
|| (
70+
mutation.target === document.body
71+
&& Array.from<HTMLElement>(mutation.addedNodes.values() as any)
72+
.concat(...mutation.removedNodes.values() as any)
73+
.some((node) => !node.classList.contains(HIGHLIGHT_WRAPPER_CLASS))
74+
)
75+
)
76+
) {
77+
refreshFn();
78+
}
79+
}
80+
81+
/**
82+
* Options to create an overlay element
83+
*/
84+
export interface CreateOverlayOptions {
85+
top: string;
86+
left: string;
87+
position: string;
88+
width: string;
89+
height: string;
90+
backgroundColor: string;
91+
}
92+
93+
/**
94+
* Create an overlay element
95+
* @param doc HTML Document
96+
* @param opts
97+
*/
98+
export function createOverlay(doc: Document, opts: CreateOverlayOptions) {
99+
const overlay = doc.createElement('div');
100+
overlay.classList.add(HIGHLIGHT_OVERLAY_CLASS);
101+
// All static style could be moved in a <style>
102+
overlay.style.top = opts.top;
103+
overlay.style.left = opts.left;
104+
overlay.style.width = opts.width;
105+
overlay.style.height = opts.height;
106+
overlay.style.border = `1px solid ${opts.backgroundColor}`;
107+
overlay.style.zIndex = '10000';
108+
overlay.style.position = opts.position;
109+
overlay.style.pointerEvents = 'none';
110+
return overlay;
111+
}
112+
113+
/**
114+
* Options to create a chip element
115+
*/
116+
export interface CreateChipOptions {
117+
displayName: string;
118+
depth: number;
119+
top: string;
120+
left: string;
121+
position: string;
122+
backgroundColor: string;
123+
color?: string;
124+
name: string;
125+
}
126+
127+
/**
128+
* Create a chip element
129+
* @param doc HTML Document
130+
* @param opts
131+
*/
132+
export function createChip(doc: Document, opts: CreateChipOptions) {
133+
const chip = doc.createElement('div');
134+
chip.classList.add(HIGHLIGHT_CHIP_CLASS);
135+
chip.textContent = `${opts.displayName} ${opts.depth}`;
136+
// All static style could be moved in a <style>
137+
chip.style.top = opts.top;
138+
chip.style.left = opts.left;
139+
chip.style.backgroundColor = opts.backgroundColor;
140+
chip.style.color = opts.color ?? '#FFF';
141+
chip.style.position = opts.position;
142+
chip.style.display = 'inline-block';
143+
chip.style.padding = '2px 4px';
144+
chip.style.borderRadius = '0 0 4px';
145+
chip.style.cursor = 'pointer';
146+
chip.style.zIndex = '10000';
147+
chip.style.textWrap = 'no-wrap';
148+
chip.title = opts.name;
149+
chip.addEventListener('click', () => {
150+
// Should we log in the console as well ?
151+
void navigator.clipboard.writeText(opts.name);
152+
});
153+
return chip;
154+
}

0 commit comments

Comments
 (0)