@o3r/components
Version:
This module contains component-related features (Component replacement, CMS compatibility, helpers, pipes, debugging developer tools...) It comes with an integrated ng builder to help you generate components compatible with Otter features (CMS integration
1,177 lines (1,153 loc) • 79.7 kB
JavaScript
import { of, from, observeOn, animationFrameScheduler, BehaviorSubject, firstValueFrom, fromEvent, Subject, ReplaySubject, sample } from 'rxjs';
import { mergeMap, bufferCount, concatMap, delay, scan, tap, filter, distinctUntilChanged, map, mapTo, switchMap } from 'rxjs/operators';
import * as i0 from '@angular/core';
import { InjectionToken, NgModule, inject, DestroyRef, Injectable, ViewContainerRef, KeyValueDiffers, Injector, SimpleChange, forwardRef, Input, Directive, makeEnvironmentProviders, Pipe, ChangeDetectorRef, ViewEncapsulation, ChangeDetectionStrategy, Component } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import * as i1 from '@ngrx/store';
import { createAction, props, on, createReducer, StoreModule, createFeatureSelector, createSelector, Store } from '@ngrx/store';
import { asyncProps, createEntityAsyncRequestAdapter, asyncStoreItemAdapter, asyncEntitySerializer, otterComponentInfoPropertyName, sendOtterMessage, filterMessageContent } from '@o3r/core';
import { LoggerService } from '@o3r/logger';
import { createEntityAdapter } from '@ngrx/entity';
import * as i1$1 from '@angular/common';
import { DOCUMENT, CommonModule } from '@angular/common';
import { NgControl, NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms';
/**
* Buffers and emits data for lazy/progressive rendering of big lists
* That could solve issues with long-running tasks when trying to render an array
* of similar components.
* @param delayMs Delay between data emits
* @param concurrency Amount of elements that should be emitted at once
*/
function lazyArray(delayMs = 0, concurrency = 2) {
let isFirstEmission = true;
return (source$) => {
return source$.pipe(mergeMap((items) => {
if (!isFirstEmission) {
return of(items);
}
const items$ = from(items);
return items$.pipe(bufferCount(concurrency), concatMap((value, index) => {
return of(value).pipe(observeOn(animationFrameScheduler), delay(index * delayMs));
}), scan((acc, steps) => {
return [...acc, ...steps];
}, []), tap((scannedItems) => {
const scanDidComplete = scannedItems.length === items.length;
if (scanDidComplete) {
isFirstEmission = false;
}
}));
}));
};
}
/**
* Determine if the given message is a Components message
* @param message message to check
*/
const isComponentsMessage = (message) => {
return message && (message.dataType === 'requestMessages'
|| message.dataType === 'connect'
|| message.dataType === 'selectedComponentInfo'
|| message.dataType === 'isComponentSelectionAvailable'
|| message.dataType === 'placeholderMode'
|| message.dataType === 'toggleInspector'
|| message.dataType === 'toggleHighlight'
|| message.dataType === 'changeHighlightConfiguration');
};
const ACTION_FAIL_ENTITIES = '[PlaceholderRequest] fail entities';
const ACTION_SET_ENTITY_FROM_URL = '[PlaceholderRequest] set entity from url';
const ACTION_CANCEL_REQUEST = '[PlaceholderRequest] cancel request';
const ACTION_UPDATE_ENTITY = '[PlaceholderRequest] update entity';
const ACTION_UPDATE_ENTITY_SYNC = '[PlaceholderRequest] update entity sync';
/** Action to cancel a Request ID registered in the store. Can happen from effect based on a switchMap for instance */
const cancelPlaceholderRequest = createAction(ACTION_CANCEL_REQUEST, props());
/** Action to update failureStatus for PlaceholderRequestModels */
const failPlaceholderRequestEntity = createAction(ACTION_FAIL_ENTITIES, props());
/** Action to update an entity */
const updatePlaceholderRequestEntity = createAction(ACTION_UPDATE_ENTITY, props());
/** Action to update an entity without impact on request id */
const updatePlaceholderRequestEntitySync = createAction(ACTION_UPDATE_ENTITY_SYNC, props());
/** Action to update PlaceholderRequest with known IDs, will create the entity with only the url, the call will be created in the effect */
const setPlaceholderRequestEntityFromUrl = createAction(ACTION_SET_ENTITY_FROM_URL, asyncProps());
/**
* PlaceholderRequest Store adapter
*/
const placeholderRequestAdapter = createEntityAsyncRequestAdapter(createEntityAdapter({
selectId: (model) => model.id
}));
/**
* PlaceholderRequest Store initial value
*/
const placeholderRequestInitialState = placeholderRequestAdapter.getInitialState({
requestIds: []
});
/**
* Reducers of Placeholder request store that handles the call to the placeholder template URL
*/
const placeholderRequestReducerFeatures = [
on(cancelPlaceholderRequest, (state, action) => {
const id = action.id;
if (!id || !state.entities[id]) {
return state;
}
return placeholderRequestAdapter.updateOne({
id: action.id,
changes: asyncStoreItemAdapter.resolveRequest(state.entities[id], action.requestId)
}, asyncStoreItemAdapter.resolveRequest(state, action.requestId));
}),
on(updatePlaceholderRequestEntity, (state, action) => {
const currentEntity = state.entities[action.entity.id];
const newEntity = asyncStoreItemAdapter.resolveRequest({ ...action.entity, ...asyncStoreItemAdapter.extractAsyncStoreItem(currentEntity) }, action.requestId);
return placeholderRequestAdapter.updateOne({
id: newEntity.id,
changes: newEntity
}, asyncStoreItemAdapter.resolveRequest(state, action.requestId));
}),
on(updatePlaceholderRequestEntitySync, (state, action) => {
return placeholderRequestAdapter.updateOne({
id: action.entity.id,
changes: {
...action.entity
}
}, state);
}),
on(setPlaceholderRequestEntityFromUrl, (state, payload) => {
const currentEntity = state.entities[payload.id];
// Nothing to update if resolved URLs already match
if (currentEntity && currentEntity.resolvedUrl === payload.resolvedUrl) {
return state;
}
let newEntity = {
id: payload.id,
resolvedUrl: payload.resolvedUrl,
used: true
};
if (currentEntity) {
newEntity = { ...asyncStoreItemAdapter.extractAsyncStoreItem(currentEntity), ...newEntity };
}
return placeholderRequestAdapter.addOne(asyncStoreItemAdapter.addRequest(asyncStoreItemAdapter.initialize(newEntity), payload.requestId), asyncStoreItemAdapter.addRequest(state, payload.requestId));
}),
on(failPlaceholderRequestEntity, (state, payload) => {
return placeholderRequestAdapter.failRequestMany(asyncStoreItemAdapter.resolveRequest(state, payload.requestId), payload && payload.ids, payload.requestId);
})
];
/**
* PlaceholderRequest Store reducer
*/
const placeholderRequestReducer = createReducer(placeholderRequestInitialState, ...placeholderRequestReducerFeatures);
/**
* Name of the PlaceholderRequest Store
*/
const PLACEHOLDER_REQUEST_STORE_NAME = 'placeholderRequest';
/** Token of the PlaceholderRequest reducer */
const PLACEHOLDER_REQUEST_REDUCER_TOKEN = new InjectionToken('Feature PlaceholderRequest Reducer');
/** Provide default reducer for PlaceholderRequest store */
function getDefaultplaceholderRequestReducer() {
return placeholderRequestReducer;
}
class PlaceholderRequestStoreModule {
static forRoot(reducerFactory) {
return {
ngModule: PlaceholderRequestStoreModule,
providers: [
{ provide: PLACEHOLDER_REQUEST_REDUCER_TOKEN, useFactory: reducerFactory }
]
};
}
/** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: PlaceholderRequestStoreModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
/** @nocollapse */ static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.7", ngImport: i0, type: PlaceholderRequestStoreModule, imports: [i1.StoreFeatureModule] }); }
/** @nocollapse */ static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: PlaceholderRequestStoreModule, providers: [
{ provide: PLACEHOLDER_REQUEST_REDUCER_TOKEN, useFactory: getDefaultplaceholderRequestReducer }
], imports: [StoreModule.forFeature(PLACEHOLDER_REQUEST_STORE_NAME, PLACEHOLDER_REQUEST_REDUCER_TOKEN)] }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: PlaceholderRequestStoreModule, decorators: [{
type: NgModule,
args: [{
imports: [
StoreModule.forFeature(PLACEHOLDER_REQUEST_STORE_NAME, PLACEHOLDER_REQUEST_REDUCER_TOKEN)
],
providers: [
{ provide: PLACEHOLDER_REQUEST_REDUCER_TOKEN, useFactory: getDefaultplaceholderRequestReducer }
]
}]
}] });
const selectPlaceholderRequestState = createFeatureSelector(PLACEHOLDER_REQUEST_STORE_NAME);
const { selectEntities: selectEntities$1 } = placeholderRequestAdapter.getSelectors();
/** Select the dictionary of PlaceholderRequest entities */
const selectPlaceholderRequestEntities = createSelector(selectPlaceholderRequestState, (state) => state && selectEntities$1(state));
/**
* Select a specific PlaceholderRequest entity using a raw url as id
* @param rawUrl
*/
const selectPlaceholderRequestEntityUsage = (rawUrl) => createSelector(selectPlaceholderRequestState, (state) => {
return state?.entities[rawUrl] ? state.entities[rawUrl].used : undefined;
});
const placeholderRequestStorageSerializer = asyncEntitySerializer;
const placeholderRequestStorageDeserializer = (rawObject) => {
if (!rawObject || !rawObject.ids) {
return placeholderRequestInitialState;
}
const storeObject = placeholderRequestAdapter.getInitialState(rawObject);
for (const id of rawObject.ids) {
storeObject.entities[id] = rawObject.entities[id];
}
return storeObject;
};
const placeholderRequestStorageSync = {
serialize: placeholderRequestStorageSerializer,
deserialize: placeholderRequestStorageDeserializer
};
const ACTION_DELETE_ENTITY = '[PlaceholderTemplate] delete entity';
const ACTION_SET_ENTITY = '[PlaceholderTemplate] set entity';
const ACTION_TOGGLE_MODE = '[PlaceholderTemplate] toggle mode';
/** Action to delete a specific entity */
const deletePlaceholderTemplateEntity = createAction(ACTION_DELETE_ENTITY, props());
/** Action to clear all placeholderTemplate and fill the store with the payload */
const setPlaceholderTemplateEntity = createAction(ACTION_SET_ENTITY, props());
const togglePlaceholderModeTemplate = createAction(ACTION_TOGGLE_MODE, props());
/**
* PlaceholderTemplate Store adapter
*/
const placeholderTemplateAdapter = createEntityAdapter({
selectId: (model) => model.id
});
/**
* PlaceholderTemplate Store initial value
*/
const placeholderTemplateInitialState = placeholderTemplateAdapter.getInitialState({
mode: 'normal'
});
/**
* List of basic actions for PlaceholderTemplate Store
*/
const placeholderTemplateReducerFeatures = [
on(setPlaceholderTemplateEntity, (state, payload) => placeholderTemplateAdapter.addOne(payload.entity, placeholderTemplateAdapter.removeOne(payload.entity.id, state))),
on(deletePlaceholderTemplateEntity, (state, payload) => {
const id = payload.id;
if (!id || !state.entities[id]) {
return state;
}
return placeholderTemplateAdapter.removeOne(id, state);
}),
on(togglePlaceholderModeTemplate, (state, payload) => {
return {
...state,
mode: payload.mode
};
})
];
/**
* PlaceholderTemplate Store reducer
*/
const placeholderTemplateReducer = createReducer(placeholderTemplateInitialState, ...placeholderTemplateReducerFeatures);
/**
* Name of the PlaceholderTemplate Store
*/
const PLACEHOLDER_TEMPLATE_STORE_NAME = 'placeholderTemplate';
/** Token of the PlaceholderTemplate reducer */
const PLACEHOLDER_TEMPLATE_REDUCER_TOKEN = new InjectionToken('Feature PlaceholderTemplate Reducer');
/** Provide default reducer for PlaceholderTemplate store */
function getDefaultPlaceholderTemplateReducer() {
return placeholderTemplateReducer;
}
class PlaceholderTemplateStoreModule {
static forRoot(reducerFactory) {
return {
ngModule: PlaceholderTemplateStoreModule,
providers: [
{ provide: PLACEHOLDER_TEMPLATE_REDUCER_TOKEN, useFactory: reducerFactory }
]
};
}
/** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: PlaceholderTemplateStoreModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
/** @nocollapse */ static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.7", ngImport: i0, type: PlaceholderTemplateStoreModule, imports: [i1.StoreFeatureModule] }); }
/** @nocollapse */ static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: PlaceholderTemplateStoreModule, providers: [
{ provide: PLACEHOLDER_TEMPLATE_REDUCER_TOKEN, useFactory: getDefaultPlaceholderTemplateReducer }
], imports: [StoreModule.forFeature(PLACEHOLDER_TEMPLATE_STORE_NAME, PLACEHOLDER_TEMPLATE_REDUCER_TOKEN)] }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: PlaceholderTemplateStoreModule, decorators: [{
type: NgModule,
args: [{
imports: [
StoreModule.forFeature(PLACEHOLDER_TEMPLATE_STORE_NAME, PLACEHOLDER_TEMPLATE_REDUCER_TOKEN)
],
providers: [
{ provide: PLACEHOLDER_TEMPLATE_REDUCER_TOKEN, useFactory: getDefaultPlaceholderTemplateReducer }
]
}]
}] });
const { selectEntities } = placeholderTemplateAdapter.getSelectors();
const selectPlaceholderTemplateState = createFeatureSelector(PLACEHOLDER_TEMPLATE_STORE_NAME);
/** Select the dictionary of PlaceholderTemplate entities */
const selectPlaceholderTemplateEntities = createSelector(selectPlaceholderTemplateState, (state) => state && selectEntities(state));
/**
* Select a specific PlaceholderTemplate
* @param placeholderId
*/
const selectPlaceholderTemplateEntity = (placeholderId) => createSelector(selectPlaceholderTemplateState, (state) => state?.entities[placeholderId]);
/**
* Select the ordered rendered placeholder template full data (url, priority etc.) for a given placeholderId
* Return undefined if the placeholder is not found
* Returns {orderedRenderedTemplates: undefined, isPending: true} if any of the request is still pending
* @param placeholderId
*/
const selectSortedTemplates = (placeholderId) => createSelector(selectPlaceholderTemplateEntity(placeholderId), selectPlaceholderRequestState, (placeholderTemplate, placeholderRequestState) => {
if (!placeholderTemplate || !placeholderRequestState) {
return;
}
// The isPending will be considered true if any of the Url is still pending
let isPending = false;
const templates = [];
placeholderTemplate.urlsWithPriority.forEach((urlWithPriority) => {
const placeholderRequest = placeholderRequestState.entities[urlWithPriority.rawUrl];
if (placeholderRequest) {
// If one of the items is pending, we will wait to display all contents at the same time
isPending = isPending || placeholderRequest.isPending;
// Templates in failure will be ignored from the list
if (!placeholderRequest.isFailure) {
templates.push({
rawUrl: urlWithPriority.rawUrl,
resolvedUrl: placeholderRequest.resolvedUrl,
priority: urlWithPriority.priority,
renderedTemplate: placeholderRequest.renderedTemplate
});
}
}
});
// No need to perform sorting if still pending
if (isPending) {
return { orderedTemplates: undefined, isPending };
}
// Sort templates by priority
const orderedTemplates = templates.sort((template1, template2) => {
return (template2.priority - template1.priority) || 1;
}).filter((templateData) => !!templateData.renderedTemplate);
return { orderedTemplates, isPending };
});
const selectPlaceholderTemplateMode = createSelector(selectPlaceholderTemplateState, (state) => state.mode);
const placeholderTemplateStorageDeserializer = (rawObject) => {
if (!rawObject || !rawObject.ids) {
return placeholderTemplateInitialState;
}
const storeObject = placeholderTemplateAdapter.getInitialState(rawObject);
for (const id of rawObject.ids) {
storeObject.entities[id] = rawObject.entities[id];
}
return storeObject;
};
const placeholderTemplateStorageSync = {
deserialize: placeholderTemplateStorageDeserializer
};
const OTTER_COMPONENTS_DEVTOOLS_DEFAULT_OPTIONS = {
isActivatedOnBootstrap: false
};
const OTTER_COMPONENTS_DEVTOOLS_OPTIONS = new InjectionToken('Otter Components Devtools options');
/**
* Class applied on the wrapper of highlight elements
*/
const HIGHLIGHT_WRAPPER_CLASS = 'highlight-wrapper';
/**
* Class applied on the overlay elements
*/
const HIGHLIGHT_OVERLAY_CLASS = 'highlight-overlay';
/**
* Class applied on the chip elements
*/
const HIGHLIGHT_CHIP_CLASS = 'highlight-chip';
/**
* Default value for maximum number of ancestors
*/
const DEFAULT_MAX_DEPTH = 10;
/**
* Default value for element min height
*/
const DEFAULT_ELEMENT_MIN_HEIGHT = 30;
/**
* Default value for element min width
*/
const DEFAULT_ELEMENT_MIN_WIDTH = 60;
/**
* Default value for throttle interval
*/
const DEFAULT_THROTTLE_INTERVAL = 500;
/**
* Default value for chips opacity
*/
const DEFAULT_CHIPS_OPACITY = 1;
/**
* Default value for auto refresh activation
*/
const DEFAULT_AUTO_REFRESH = true;
/**
* Retrieve the identifier of the element
* @param element
*/
function getIdentifier(element) {
const { tagName, attributes, classList } = element.htmlElement;
const regexp = new RegExp(element.regexp, 'i');
if (!regexp.test(tagName)) {
const attribute = Array.from(attributes).find((attr) => regexp.test(attr.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;
}
/**
* Filters a list of HTML elements and returns those that match specific group information.
*
* Each element is checked against a set of criteria:
* - The element's dimensions must meet the minimum height and width requirements.
* - The element's tag name, attributes, or class names must match a regular expression defined in the group information.
* @param elements An array of HTML elements to filter.
* @param elementMinHeight The min height required for each element to be considered in the computation
* @param elementMinWidth The min width required for each element to be considered in the computation
* @param groupsInfo The config that describes the HTML tags to check
* @returns An array of objects containing the matching elements and their associated group information
*/
function filterElementsWithInfo(elements, elementMinHeight, elementMinWidth, groupsInfo) {
return elements.reduce((acc, element) => {
const { height, width } = element.getBoundingClientRect();
if (height < elementMinHeight || width < elementMinWidth) {
return acc;
}
const elementInfo = Object.values(groupsInfo).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({ ...elementInfo, htmlElement: element });
}
return acc;
}, []);
}
/**
* Compute the number of ancestors of a given element based on a list of elements
* @param element
* @param elementList
*/
function computeNumberOfAncestors(element, elementList) {
return elementList.filter((el) => el.contains(element)).length;
}
/**
* Throttle {@link fn} with a {@link delay}
* @param fn method to run
* @param delay given in ms
*/
function throttle(fn, delay) {
let timerFlag = null;
const throttleFn = (...args) => {
if (timerFlag === null) {
fn(...args);
timerFlag = setTimeout(() => {
timerFlag = null;
}, delay);
}
};
return throttleFn;
}
/**
* Run {@link refreshFn} if {@link mutations} implies to refresh elements inside {@link highlightWrapper}
* @param mutations
* @param highlightWrapper
* @param refreshFn
*/
function runRefreshIfNeeded(mutations, highlightWrapper, refreshFn) {
if (mutations.some((mutation) => mutation.target !== highlightWrapper
|| (mutation.target === document.body
&& Array.from(mutation.addedNodes.values())
.concat(...mutation.removedNodes.values())
.some((node) => !node.classList.contains(HIGHLIGHT_WRAPPER_CLASS))))) {
refreshFn();
}
}
/**
* Create an overlay element
* @param doc HTML Document
* @param opts
* @param depth
*/
function createOverlay(doc, opts, depth) {
const overlay = doc.createElement('div');
overlay.classList.add(HIGHLIGHT_OVERLAY_CLASS);
// All static style could be moved in a <style>
overlay.style.top = opts.top;
overlay.style.left = opts.left;
overlay.style.width = opts.width;
overlay.style.height = opts.height;
overlay.style.border = `2px ${depth % 2 === 0 ? 'solid' : 'dotted'} ${opts.backgroundColor}`;
overlay.style.zIndex = '10000';
overlay.style.position = opts.position;
overlay.style.pointerEvents = 'none';
return overlay;
}
/**
* Create a chip element
* @param doc HTML Document
* @param opts
* @param overlay
*/
function createChip(doc, opts, overlay) {
const chip = doc.createElement('div');
chip.classList.add(HIGHLIGHT_CHIP_CLASS);
chip.textContent = `${opts.displayName} ${opts.depth}`;
// All static style could be moved in a <style>
chip.style.top = opts.top;
chip.style.left = opts.left;
chip.style.backgroundColor = opts.backgroundColor;
chip.style.color = opts.color ?? '#FFF';
chip.style.position = opts.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';
chip.style.opacity = opts.opacity?.toString() ?? '1';
chip.title = opts.name;
chip.addEventListener('click', () => {
// Should we log in the console as well ?
void navigator.clipboard.writeText(opts.name);
});
chip.addEventListener('mouseover', () => {
chip.style.opacity = '1';
overlay.style.boxShadow = `0 0 10px 3px ${opts.backgroundColor}`;
});
chip.addEventListener('mouseout', () => {
chip.style.opacity = opts.opacity?.toString() ?? '1';
overlay.style.boxShadow = 'none';
});
overlay.style.transition = 'box-shadow 0.3s ease-in-out';
return chip;
}
class HighlightService {
constructor() {
/**
* Group information
* Value could be changed through chrome extension options
*/
this.groupsInfo = {};
/**
* Maximum number of components ancestor
* Value could be changed through chrome extension view
*/
this.maxDepth = DEFAULT_MAX_DEPTH;
/**
* Element min height to be considered
* Value could be changed through chrome extension options
*/
this.elementMinHeight = DEFAULT_ELEMENT_MIN_HEIGHT;
/**
* Element min width to be considered
* Value could be changed through chrome extension options
*/
this.elementMinWidth = DEFAULT_ELEMENT_MIN_WIDTH;
/**
* Throttle interval to refresh the highlight elements
* Value could be changed through chrome extension options
*/
this.throttleInterval = DEFAULT_THROTTLE_INTERVAL;
/**
* Opacity of the chips
* Value could be changed through chrome extension options
*/
this.chipsOpacity = DEFAULT_CHIPS_OPACITY;
/**
* Whether to activate the auto refresh of the highlight
* Value could be changed through chrome extension view
*/
this.autoRefresh = DEFAULT_AUTO_REFRESH;
this.singleRun = false;
this.document = inject(DOCUMENT);
this.mutationObserver = new MutationObserver((mutations) => runRefreshIfNeeded(mutations, this.getHighlightWrapper(), () => this.throttleRun?.()));
this.resizeObserver = new ResizeObserver(() => this.throttleRun?.());
inject(DestroyRef).onDestroy(() => this.stop());
}
getHighlightWrapper() {
return this.document.body.querySelector(`.${HIGHLIGHT_WRAPPER_CLASS}`);
}
cleanHighlightWrapper() {
this.getHighlightWrapper()?.querySelectorAll('*').forEach((node) => node.remove());
}
initializeHighlightWrapper() {
let wrapper = this.getHighlightWrapper();
if (!wrapper) {
wrapper = this.document.createElement('div');
wrapper.classList.add(HIGHLIGHT_WRAPPER_CLASS);
this.document.body.append(wrapper);
}
this.cleanHighlightWrapper();
}
run() {
this.initializeHighlightWrapper();
const wrapper = this.getHighlightWrapper();
// We have to select all elements from document because
// with CSS Selector it's impossible to select element
// by regex on their `tagName`, attribute name or attribute value.
const allHTMLElements = Array.from(this.document.body.querySelectorAll('*'));
const elementsWithInfo = filterElementsWithInfo(allHTMLElements, this.elementMinHeight, this.elementMinWidth, this.groupsInfo);
const elementsWithInfoAndDepth = elementsWithInfo
.reduce((acc, elementWithInfo, _, array) => {
const depth = computeNumberOfAncestors(elementWithInfo.htmlElement, array.map((e) => e.htmlElement));
if (depth <= this.maxDepth) {
return acc.concat({
...elementWithInfo,
depth
});
}
return acc;
}, []);
const overlayData = {};
elementsWithInfoAndDepth.forEach((item) => {
const { htmlElement: element, backgroundColor, color, displayName, depth } = item;
const rect = element.getBoundingClientRect();
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`;
const overlay = createOverlay(this.document, {
top, left, width: `${rect.width}px`, height: `${rect.height}px`, position, backgroundColor
}, depth);
const chip = createChip(this.document, {
displayName,
depth,
top,
left,
backgroundColor,
color,
position,
name: getIdentifier(item),
opacity: this.chipsOpacity
}, overlay);
wrapper.append(overlay);
wrapper.append(chip);
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) {
// In case of overlap,
// we should translate the chip to have it visible
// and reduce the size of the overlay.
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}`;
}
});
});
}
/**
* Returns true if the highlight is displayed
*/
isRunning() {
return !!this.throttleRun || this.singleRun;
}
/**
* Start the highlight of elements
*/
start() {
this.stop();
if (!this.autoRefresh) {
this.run();
this.singleRun = true;
return;
}
this.throttleRun = throttle(() => this.run(), this.throttleInterval);
this.throttleRun();
this.document.addEventListener('scroll', this.throttleRun, true);
this.resizeObserver.observe(this.document.body);
this.mutationObserver.observe(this.document.body, { childList: true, subtree: true });
}
/**
* Stop the highlight of elements
*/
stop() {
this.cleanHighlightWrapper();
if (this.throttleRun) {
this.document.removeEventListener('scroll', this.throttleRun, true);
this.resizeObserver.disconnect();
this.mutationObserver.disconnect();
this.throttleRun = undefined;
this.singleRun = false;
}
}
/** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: HighlightService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
/** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: HighlightService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: HighlightService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root'
}]
}], ctorParameters: () => [] });
/**
* Otter inspector css class
*/
const INSPECTOR_CLASS = 'otter-devtools-inspector';
/**
* Determine if a node is an Otter container
* @param node Element to check
* @returns true if the node is an Otter container
*/
const isContainer = (node) => {
return !!node?.tagName.toLowerCase().endsWith('cont');
};
/**
* Determine the config id of a component instance
* @param instance component instance
* @returns the config id of the component instance
*/
const getConfigId = (instance) => {
return instance[otterComponentInfoPropertyName]?.configId;
};
/**
* Recursive method to determin the translations of a node
* @param node HTMLElement to check
* @param rec recursive method
* @returns the trasnslations associated to their component name
*/
function getTranslationsRec(node, rec) {
const angularDevTools = window.ng;
const o3rInfoProperty = '__otter-info__';
if (!node || !angularDevTools) {
return;
}
const component = angularDevTools.getComponent(node);
const subTranslations = Array.from(node.children).map((child) => rec(child, rec));
const translations = {};
subTranslations.forEach((s) => {
Object.entries(s || {}).forEach(([k, v]) => {
if (v.length > 0) {
translations[k] = v;
}
});
});
if (component) {
const componentName = component.constructor.name;
const componentTranslations = Object.values(component[o3rInfoProperty]?.translations || {}).filter((t) => typeof t === 'string');
if (componentTranslations.length > 0) {
translations[componentName] = componentTranslations;
}
}
return Object.keys(translations).length > 0 ? translations : undefined;
}
/**
* Determine the translations of a node
* @param node HTMLElement to check
* @returns the translations associated to their component name
*/
const getTranslations = (node) => getTranslationsRec(node, getTranslations);
/**
* Recursive method to determine the analytics of a node
* @param node Element to check
* @param rec recursive method
* @returns the analytics associated to their component name
*/
function getAnalyticEventsRec(node, rec) {
const angularDevTools = window.ng;
const o3rInfoProperty = '__otter-info__';
if (!node || !angularDevTools) {
return;
}
const component = angularDevTools.getComponent(node);
const subEvents = Array.from(node.children).map((child) => rec(child, rec));
const events = {};
subEvents.forEach((s) => {
Object.entries(s || {}).forEach(([k, v]) => {
if (v.length > 0) {
events[k] = v;
}
});
});
if (component && component[o3rInfoProperty]) {
const componentName = component.constructor.name;
const componentEvents = Object.values(component.analyticsEvents || {}).map((eventConstructor) => eventConstructor.name);
if (componentEvents.length > 0) {
events[componentName] = componentEvents;
}
}
return Object.keys(events).length > 0 ? events : undefined;
}
/**
* Determine the analytics of a node
* @param node Element to check
* @returns the analytics associated to their component name
*/
const getAnalyticEvents = (node) => getAnalyticEventsRec(node, getAnalyticEvents);
/**
* Determine all info from an Otter component
* @param componentClassInstance component instance
* @param host HTML element hosting the component
* @returns all info from an Otter component
*/
const getOtterLikeComponentInfo = (componentClassInstance, host) => {
const configId = getConfigId(componentClassInstance);
const translations = getTranslations(host);
const analytics = getAnalyticEvents(host);
return {
// Cannot use anymore `constructor.name` else all components are named `_a`
componentName: componentClassInstance.constructor.ɵcmp?.debugInfo?.className || componentClassInstance.constructor.name,
configId,
translations,
analytics
};
};
/**
* Service to handle the custom inspector for the Otter Devtools Chrome extension.
*/
class OtterInspectorService {
constructor() {
this.elementMouseOverCallback = this.elementMouseOver.bind(this);
this.elementClickCallback = this.elementClick.bind(this);
this.cancelEventCallback = this.cancelEvent.bind(this);
this.inspectorDiv = null;
this.otterLikeComponentInfoToBeSent = new BehaviorSubject(undefined);
/**
* Stream of component info to be sent to the extension app.
*/
this.otterLikeComponentInfoToBeSent$ = this.otterLikeComponentInfoToBeSent.asObservable();
this.angularDevTools = window.ng;
}
startInspecting() {
window.addEventListener('mouseover', this.elementMouseOverCallback, true);
window.addEventListener('click', this.elementClickCallback, true);
window.addEventListener('mouseout', this.cancelEventCallback, true);
}
elementClick(e) {
e.stopImmediatePropagation();
e.stopPropagation();
e.preventDefault();
if (!this.selectedComponent || !this.angularDevTools) {
return;
}
const parentElement = this.selectedComponent.host.parentElement;
const parent = parentElement && (this.angularDevTools.getComponent(parentElement) || this.angularDevTools.getOwningComponent(parentElement));
const parentHost = parent && this.angularDevTools.getHostElement(parent);
const container = isContainer(parentHost)
? getOtterLikeComponentInfo(parent, parentHost)
: undefined;
const { component, host, ...infoToBeSent } = this.selectedComponent;
this.otterLikeComponentInfoToBeSent.next({
...infoToBeSent,
container
});
}
isOtterLikeComponent(info) {
const hasConfigId = !!info.configId;
const hasTranslations = !!info.translations?.length;
const hasAnalytics = Object.keys(info.analytics || {}).length > 0;
return hasConfigId || hasTranslations || hasAnalytics;
}
findComponentInfo(node) {
if (!this.angularDevTools) {
return;
}
let componentClassInstance = this.angularDevTools.getComponent(node) || this.angularDevTools.getOwningComponent(node);
let o3rLikeComponentInfo;
let isO3rLike;
if (!componentClassInstance) {
return;
}
do {
o3rLikeComponentInfo = getOtterLikeComponentInfo(componentClassInstance, this.angularDevTools.getHostElement(componentClassInstance));
isO3rLike = this.isOtterLikeComponent(o3rLikeComponentInfo);
if (!isO3rLike) {
componentClassInstance = this.angularDevTools.getOwningComponent(componentClassInstance);
}
} while (!isO3rLike && componentClassInstance);
if (isO3rLike) {
return {
component: componentClassInstance,
host: this.angularDevTools.getHostElement(componentClassInstance),
...o3rLikeComponentInfo
};
}
}
elementMouseOver(e) {
this.cancelEvent(e);
const el = e.target;
if (el) {
const selectedComponent = this.findComponentInfo(el);
if (selectedComponent?.host !== this.selectedComponent?.host) {
this.unHighlight();
this.highlight(selectedComponent);
}
}
}
highlight(selectedComponent) {
this.selectedComponent = selectedComponent;
if (this.selectedComponent?.host && this.inspectorDiv) {
const rect = this.selectedComponent.host.getBoundingClientRect();
this.inspectorDiv.style.width = `${rect.width}px`;
this.inspectorDiv.style.height = `${rect.height}px`;
this.inspectorDiv.style.top = `${rect.top}px`;
this.inspectorDiv.style.left = `${rect.left}px`;
this.inspectorDiv.firstChild.textContent = `<${this.selectedComponent.componentName}>`;
}
}
unHighlight() {
if (this.selectedComponent?.host && this.inspectorDiv) {
this.inspectorDiv.style.width = '0';
this.inspectorDiv.style.height = '0';
this.inspectorDiv.style.top = '0';
this.inspectorDiv.style.left = '0';
}
this.selectedComponent = undefined;
}
cancelEvent(e) {
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
}
/**
* Prepare the inspector div and add it to the DOM.
*/
prepareInspector() {
if (this.inspectorDiv) {
return;
}
const inspectorCss = document.createElement('style');
inspectorCss.textContent = `
.${INSPECTOR_CLASS} {
z-index: 9999999;
width: 0;
height: 0;
background: rgba(104, 182, 255, 0.35);
position: fixed;
left: 0;
top: 0;
pointer-events: none;
}
.${INSPECTOR_CLASS} > span {
bottom: -25px;
position: absolute;
right: 10px;
background: rgba(104, 182, 255, 0.9);;
padding: 5px;
border-radius: 5px;
color: white;
}`;
const inspectorDiv = document.createElement('div');
const inspectorSpan = document.createElement('span');
inspectorDiv.append(inspectorSpan);
inspectorDiv.classList.add(INSPECTOR_CLASS);
document.head.append(inspectorCss);
document.body.append(inspectorDiv);
this.inspectorDiv = inspectorDiv;
}
/**
* Toggle the inspector.
* @param isRunning true if the inspector is running
*/
toggleInspector(isRunning) {
if (isRunning) {
this.startInspecting();
}
else {
this.stopInspecting();
}
}
stopInspecting() {
this.unHighlight();
window.removeEventListener('mouseover', this.elementMouseOverCallback, true);
window.removeEventListener('click', this.elementClickCallback, true);
window.removeEventListener('mouseout', this.cancelEventCallback, true);
}
}
class ComponentsDevtoolsMessageService {
constructor() {
this.logger = inject(LoggerService);
this.store = inject(Store);
this.sendMessage = (sendOtterMessage);
this.destroyRef = inject(DestroyRef);
this.highlightService = inject(HighlightService);
const options = inject(OTTER_COMPONENTS_DEVTOOLS_OPTIONS, { optional: true });
this.options = {
...OTTER_COMPONENTS_DEVTOOLS_DEFAULT_OPTIONS,
...options
};
this.inspectorService = new OtterInspectorService();
if (this.options.isActivatedOnBootstrap) {
this.activate();
}
}
/**
* Function to connect the plugin to the Otter DevTools extension
*/
async connectPlugin() {
this.logger.debug('Otter DevTools is plugged to components service of the application');
const selectComponentInfo = await firstValueFrom(this.inspectorService.otterLikeComponentInfoToBeSent$);
if (selectComponentInfo) {
await this.sendCurrentSelectedComponent();
}
}
async sendCurrentSelectedComponent() {
const selectComponentInfo = await firstValueFrom(this.inspectorService.otterLikeComponentInfoToBeSent$);
if (selectComponentInfo) {
this.sendMessage('selectedComponentInfo', selectComponentInfo);
}
}
sendIsComponentSelectionAvailable() {
this.sendMessage('isComponentSelectionAvailable', { available: !!window.ng });
}
/**
* Function to trigger a re-send a requested messages to the Otter Chrome DevTools extension
* @param only restricted list of messages to re-send
*/
handleReEmitRequest(only) {
if (!only) {
void this.sendCurrentSelectedComponent();
this.sendIsComponentSelectionAvailable();
return;
}
if (only.includes('selectedComponentInfo')) {
void this.sendCurrentSelectedComponent();
}
if (only.includes('isComponentSelectionAvailable')) {
this.sendIsComponentSelectionAvailable();
}
}
/**
* Function to handle the incoming messages from Otter Chrome DevTools extension
* @param message message coming from the Otter Chrome DevTools extension
*/
async handleEvents(message) {
this.logger.debug('Message handling by the components service', message);
switch (message.dataType) {
case 'connect': {
await this.connectPlugin();
break;
}
case 'requestMessages': {
this.handleReEmitRequest(message.only);
break;
}
case 'toggleInspector': {
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 (message.chipsOpacity) {
this.highlightService.chipsOpacity = message.chipsOpacity;
}
if (message.autoRefresh !== undefined) {
this.highlightService.autoRefresh = message.autoRefresh;
}
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;
}
default: {
this.logger.warn('Message ignored by the components service', message);
}
}
}
/** @inheritDoc */
activate() {
fromEvent(window, 'message').pipe(takeUntilDestroyed(this.destroyRef), filterMessageContent(isComponentsMessage)).subscribe((e) => this.handleEvents(e));
this.inspectorService.prepareInspector();
this.inspectorService.otterLikeComponentInfoToBeSent$
.pipe(takeUntilDestroyed(this.destroyRef), filter((info) => !!info)).subscribe((info) => this.sendMessage('selectedComponentInfo', info));
}
/** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: ComponentsDevtoolsMessageService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
/** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: ComponentsDevtoolsMessageService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: ComponentsDevtoolsMessageService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root'
}]
}], ctorParameters: () => [] });
class ComponentsDevtoolsModule {
/**
* Initialize Otter Devtools
* @param options
*/
static instrument(options) {
return {
ngModule: ComponentsDevtoolsModule,
providers: [
{ provide: OTTER_COMPONENTS_DEVTOOLS_OPTIONS, useValue: { ...OTTER_COMPONENTS_DEVTOOLS_DEFAULT_OPTIONS, ...options }, multi: false },
ComponentsDevtoolsMessageService
]
};
}
/** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: ComponentsDevtoolsModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
/** @nocollapse */ static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.7", ngImport: i0, type: ComponentsDevtoolsModule, imports: [StoreModule,
PlaceholderTemplateStoreModule] }); }
/** @nocollapse */ static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: ComponentsDevtoolsModule, providers: [
{ provide: OTTER_COMPONENTS_DEVTOOLS_OPTIONS, useValue: OTTER_COMPONENTS_DEVTOOLS_DEFAULT_OPTIONS },
ComponentsDevtoolsMessageService
], imports: [StoreModule,
PlaceholderTemplateStoreModule] }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: ComponentsDevtoolsModule, decorators: [{
type: NgModule,
args: [{
imports: [
StoreModule,
PlaceholderTemplateStoreModule
],
providers: [
{ provide: OTTER_COMPONENTS_DEVTOOLS_OPTIONS, useValue: OTTER_COMPONENTS_DEVTOOLS_DEFAULT_OPTIONS },
ComponentsDevtoolsMessageService
]
}]
}] });
class C11nDirective {
constructor() {
this.viewContainerRef = inject(ViewContainerRef);
this.differsService = inject(KeyValueDiffers);
this.injector = inject(Injector);
this.componentSubscriptions = [];
/** Set of inputs when the component was created. */
this.uninitializedInputs = new Set();
}
/** The input setter */
set inputs(value) {
this._inputs = value;
if (!this.differInputs && value) {
// eslint-disable-next-line unicorn/no-array-callback-reference -- KeyValueDiffers.find is n