diff --git a/packages/@o3r/components/src/devkit/components-devkit.interface.ts b/packages/@o3r/components/src/devkit/components-devkit.interface.ts index f0d7200658..8763183ea4 100644 --- a/packages/@o3r/components/src/devkit/components-devkit.interface.ts +++ b/packages/@o3r/components/src/devkit/components-devkit.interface.ts @@ -8,6 +8,9 @@ import type { import type { PlaceholderMode, } from '../stores'; +import { + GroupInfo, +} from './highlight/models'; import { OtterLikeComponentInfo, } from './inspector'; @@ -32,6 +35,40 @@ export interface ToggleInspectorMessage extends OtterMessageContent<'toggleInspe isRunning: boolean; } +/** + * Message to toggle the highlight + */ +export interface ToggleHighlightMessage extends OtterMessageContent<'toggleHighlight'> { + /** Is the highlight displayed */ + isRunning: boolean; +} + +/** + * Message the change the configuration of the `HighlightService` + */ +export interface ChangeHighlightConfiguration extends OtterMessageContent<'changeHighlightConfiguration'> { + /** + * Minimum width of HTMLElement to be considered + */ + elementMinWidth?: number; + /** + * Minimum height of HTMLElement to be considered + */ + elementMinHeight?: number; + /** + * Throttle interval + */ + throttleInterval?: number; + /** + * Group information to detect elements + */ + groupsInfo?: Record; + /** + * Maximum number of ancestors + */ + maxDepth?: number; +} + /** * Message to toggle the placeholder mode */ @@ -51,6 +88,8 @@ type ComponentsMessageContents = | IsComponentSelectionAvailableMessage | SelectedComponentInfoMessage | ToggleInspectorMessage + | ToggleHighlightMessage + | ChangeHighlightConfiguration | PlaceholderModeMessage; /** List of possible DataTypes for Components messages */ @@ -74,5 +113,7 @@ export const isComponentsMessage = (message: any): message is AvailableComponent || message.dataType === 'isComponentSelectionAvailable' || message.dataType === 'placeholderMode' || message.dataType === 'toggleInspector' + || message.dataType === 'toggleHighlight' + || message.dataType === 'changeHighlightConfiguration' ); }; diff --git a/packages/@o3r/components/src/devkit/components-devtools.message.service.ts b/packages/@o3r/components/src/devkit/components-devtools.message.service.ts index 5a3b45cbb2..eddf774b32 100644 --- a/packages/@o3r/components/src/devkit/components-devtools.message.service.ts +++ b/packages/@o3r/components/src/devkit/components-devtools.message.service.ts @@ -40,6 +40,9 @@ import { OTTER_COMPONENTS_DEVTOOLS_DEFAULT_OPTIONS, OTTER_COMPONENTS_DEVTOOLS_OPTIONS, } from './components-devtools.token'; +import { + HighlightService, +} from './highlight/highlight.service'; import { OtterInspectorService, OtterLikeComponentInfo, @@ -51,6 +54,7 @@ import { export class ComponentsDevtoolsMessageService implements DevtoolsServiceInterface { private readonly options: ComponentsDevtoolsServiceOptions; private readonly inspectorService: OtterInspectorService; + private readonly highlightService: HighlightService; private readonly sendMessage = sendOtterMessage; private readonly destroyRef = inject(DestroyRef); @@ -65,6 +69,8 @@ export class ComponentsDevtoolsMessageService implements DevtoolsServiceInterfac }; this.inspectorService = new OtterInspectorService(); + this.highlightService = new HighlightService(); + if (this.options.isActivatedOnBootstrap) { this.activate(); } @@ -130,6 +136,36 @@ export class ComponentsDevtoolsMessageService implements DevtoolsServiceInterfac this.inspectorService.toggleInspector(message.isRunning); break; } + case 'toggleHighlight': { + if (message.isRunning) { + this.highlightService.start(); + } else { + this.highlightService.stop(); + } + break; + } + case 'changeHighlightConfiguration': { + if (message.elementMinWidth) { + this.highlightService.elementMinWidth = message.elementMinWidth; + } + if (message.elementMinHeight) { + this.highlightService.elementMinHeight = message.elementMinHeight; + } + if (message.throttleInterval) { + this.highlightService.throttleInterval = message.throttleInterval; + } + if (message.groupsInfo) { + this.highlightService.groupsInfo = message.groupsInfo; + } + if (message.maxDepth) { + this.highlightService.maxDepth = message.maxDepth; + } + if (this.highlightService.isRunning()) { + // Re-start to recompute the highlight with the new configuration + this.highlightService.start(); + } + break; + } case 'placeholderMode': { this.store.dispatch(togglePlaceholderModeTemplate({ mode: message.mode })); break; diff --git a/packages/@o3r/components/src/devkit/highlight/constants.ts b/packages/@o3r/components/src/devkit/highlight/constants.ts new file mode 100644 index 0000000000..83cb2c5fed --- /dev/null +++ b/packages/@o3r/components/src/devkit/highlight/constants.ts @@ -0,0 +1,32 @@ +/** + * Class applied on the wrapper of highlight elements + */ +export const HIGHLIGHT_WRAPPER_CLASS = 'highlight-wrapper'; + +/** + * Class applied on the overlay elements + */ +export const HIGHLIGHT_OVERLAY_CLASS = 'highlight-overlay'; + +/** + * Class applied on the chip elements + */ +export const HIGHLIGHT_CHIP_CLASS = 'highlight-chip'; + +/** + * Default value for maximum number of ancestors + */ +export const DEFAULT_MAX_DEPTH = 10; + +/** + * Default value for element min height + */ +export const DEFAULT_ELEMENT_MIN_HEIGHT = 30; +/** + * Default value for element min width + */ +export const DEFAULT_ELEMENT_MIN_WIDTH = 60; +/** + * Default value for throttle interval + */ +export const DEFAULT_THROTTLE_INTERVAL = 500; diff --git a/packages/@o3r/components/src/devkit/highlight/helpers.ts b/packages/@o3r/components/src/devkit/highlight/helpers.ts new file mode 100644 index 0000000000..52d2e4bf66 --- /dev/null +++ b/packages/@o3r/components/src/devkit/highlight/helpers.ts @@ -0,0 +1,154 @@ +import { + HIGHLIGHT_CHIP_CLASS, + HIGHLIGHT_OVERLAY_CLASS, + HIGHLIGHT_WRAPPER_CLASS, +} from './constants'; +import { + ElementWithGroupInfo, +} from './models'; + +/** + * Retrieve the identifier of the element + * @param element + */ +export function getIdentifier(element: ElementWithGroupInfo): string { + const { tagName, attributes, classList } = element.htmlElement; + const regexp = new RegExp(element.regexp, 'i'); + if (!regexp.test(tagName)) { + const attribute = Array.from(attributes).find((att) => regexp.test(att.name)); + if (attribute) { + return `${attribute.name}${attribute.value ? `="${attribute.value}"` : ''}`; + } + const className = Array.from(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 + */ +export function computeNumberOfAncestors(element: HTMLElement, elementList: HTMLElement[]) { + return elementList.filter((el: HTMLElement) => el.contains(element)).length; +} + +/** + * Throttle {@link fn} with a {@link delay} + * @param fn method to run + * @param delay given in ms + */ +export function throttle any>(fn: T, delay: number): (...args: Parameters) => void { + let timerFlag: ReturnType | null = null; + + const throttleFn = (...args: Parameters) => { + if (timerFlag === null) { + fn(...args); + timerFlag = setTimeout(() => { + fn(...args); + timerFlag = null; + }, delay); + } + }; + return throttleFn; +} + +/** + * Run {@link refreshFn} if {@link mutations} implies to refresh elements inside {@link highlightWrapper} + * @param mutations + * @param highlightWrapper + * @param refreshFn + */ +export function runRefreshIfNeeded(mutations: MutationRecord[], highlightWrapper: Element | null, refreshFn: () => void) { + if ( + mutations.some((mutation) => + mutation.target !== highlightWrapper + || ( + mutation.target === document.body + && Array.from(mutation.addedNodes.values() as any) + .concat(...mutation.removedNodes.values() as any) + .some((node) => !node.classList.contains(HIGHLIGHT_WRAPPER_CLASS)) + ) + ) + ) { + refreshFn(); + } +} + +/** + * Options to create an overlay element + */ +export interface CreateOverlayOptions { + top: string; + left: string; + position: string; + width: string; + height: string; + backgroundColor: string; +} + +/** + * Create an overlay element + * @param doc HTML Document + * @param opts + */ +export function createOverlay(doc: Document, opts: CreateOverlayOptions) { + const overlay = doc.createElement('div'); + overlay.classList.add(HIGHLIGHT_OVERLAY_CLASS); + // All static style could be moved in a