import { InputModalityDetector } from '@angular/cdk/a11y';
import {
OverlayRef,
createFlexibleConnectedPositionStrategy,
createOverlayRef,
createRepositionScrollStrategy
} from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { ComponentRef, Injectable, Injector, NgZone, OnDestroy, inject } from '@angular/core';
import { Subscription } from 'rxjs';
import { AclInspectorService } from '../inspector/inspector.service';
import { AclHintOriginRect, snapshotAclHintOrigin } from './acl-hint-origin';
import { AclHintState } from './acl-hint-rules';
import type { AclHintSettingsComponent } from './acl-hint-settings.component';
import type { AclTagHintComponent } from './acl-tag-hint.component';
const FLYOUT_OFFSET_Y = 10;
const NAME_TAG_OFFSET_X = 8;
// Mirrors the tag height in acl-tag-hint.component.scss, as the tag straddles the edge the flyout
// flips to and the flyout has to clear it.
const NAME_TAG_HEIGHT = 32;
const OVERLAY_BASE_CONFIG = {
panelClass: 'acl-hint-overlay',
usePopover: false
} as const;
export type AclHintDecoration = 'none' | 'hover' | 'active';
export interface AclHintTarget {
origin: Element;
aclPath: () => string;
decorate: (decoration: AclHintDecoration) => void;
// The ACL services can be provided as deep as the tagged component itself, which this service's
// own injector cannot see, so the inspector is resolved through the tag that asked for a hint.
injector: Injector;
}
@Injectable({ providedIn: 'root' })
export class AclHintOverlayService implements OnDestroy {
private injector = inject(Injector);
private ngZone = inject(NgZone);
private inputModality = inject(InputModalityDetector);
private nameTagOverlay?: OverlayRef;
private nameTagRef?: ComponentRef<AclTagHintComponent>;
private nameTagSubscriptions = new Subscription();
private settingsOverlay?: OverlayRef;
private settingsRef?: ComponentRef<AclHintSettingsComponent>;
private settingsSubscriptions = new Subscription();
private tagHintComponent?: Promise<typeof AclTagHintComponent | undefined>;
private settingsComponent?: Promise<typeof AclHintSettingsComponent | undefined>;
private hovered: AclHintTarget[] = [];
// A disposed overlay can never report a pointer leave, so the hints it was under are tracked
// per overlay instead of as one flag that would stay set for good.
private hoveredOverlays = new Set<OverlayRef>();
private activeTarget?: AclHintTarget;
private settingsTarget?: AclHintTarget;
// Targets whose origin left the DOM: their hints keep floating on the last measured rect until
// the pointer leaves them, so picking `Hidden` stays undoable.
private frozen = new Set<AclHintTarget>();
private lastRects = new Map<AclHintTarget, AclHintOriginRect>();
private pendingReanchors = new Map<AclHintTarget, () => void>();
private frozenPointerWatch?: () => void;
ngOnDestroy() {
this.closeSettings();
this.detachNameTag();
this.hovered = [];
this.resetOriginTracking();
if (this.nameTagOverlay) {
this.hoveredOverlays.delete(this.nameTagOverlay);
this.nameTagOverlay.dispose();
}
this.nameTagOverlay = undefined;
}
enter(target: AclHintTarget) {
if (!this.hovered.includes(target)) {
this.hovered.push(target);
}
this.updateActiveHint();
}
private inspectorOf(target: AclHintTarget) {
return target.injector.get(AclInspectorService);
}
leave(target: AclHintTarget, relatedTarget: EventTarget | null = null) {
// The name tag overlaps the element, so reaching for it also reports a leave here — acting
// on that would make the hint unclickable.
if (this.frozen.has(target) || this.isOwnOverlay(relatedTarget)) {
return;
}
this.hovered = this.hovered.filter((item) => item !== target);
this.updateActiveHint();
}
originDetached(target: AclHintTarget) {
if (!this.isShownFor(target)) {
this.release(target);
return;
}
this.rememberRect(target);
this.frozen.add(target);
this.watchPointerWhileFrozen();
this.reposition(target);
}
originAttached(target: AclHintTarget) {
if (!this.frozen.has(target)) {
return;
}
if (hasGeometry(target.origin)) {
this.unfreeze(target);
} else {
this.reanchorWhenLaidOut(target);
}
}
release(target: AclHintTarget) {
this.pendingReanchors.get(target)?.();
this.pendingReanchors.delete(target);
this.hovered = this.hovered.filter((item) => item !== target);
this.frozen.delete(target);
this.lastRects.delete(target);
this.stopPointerWatchWhenNothingFrozen();
if (this.settingsTarget === target) {
this.closeSettings();
}
this.updateActiveHint();
}
private unfreeze(target: AclHintTarget) {
this.frozen.delete(target);
this.stopPointerWatchWhenNothingFrozen();
this.reposition(target);
if (this.isShownFor(target)) {
target.decorate(this.settingsTarget === target ? 'active' : 'hover');
}
}
private reanchorWhenLaidOut(target: AclHintTarget) {
this.pendingReanchors.get(target)?.();
const stop = this.ngZone.runOutsideAngular(() => {
const frame = requestAnimationFrame(() => {
this.pendingReanchors.delete(target);
if (!this.frozen.has(target)) {
return;
}
if (!hasGeometry(target.origin)) {
this.reanchorWhenLaidOut(target);
return;
}
this.ngZone.run(() => this.unfreeze(target));
});
return () => cancelAnimationFrame(frame);
});
this.pendingReanchors.set(target, stop);
}
private isShownFor(target: AclHintTarget) {
return this.activeTarget === target || this.settingsTarget === target;
}
private updateActiveHint() {
const target = this.settingsTarget ?? this.innermostHovered();
if (!target) {
this.detachNameTag();
return;
}
if (this.activeTarget !== target) {
this.activeTarget?.decorate('none');
this.activeTarget = target;
}
this.attachNameTag(target);
this.syncNameTagInputs();
target.decorate(this.settingsTarget === target ? 'active' : 'hover');
}
private innermostHovered() {
return this.hovered.reduce<AclHintTarget | undefined>(
(deepest, item) => (!deepest || depthOf(item) >= depthOf(deepest) ? item : deepest),
undefined
);
}
private attachNameTag(target: AclHintTarget) {
if (!this.nameTagOverlay) {
this.nameTagOverlay = createOverlayRef(this.injector, {
...OVERLAY_BASE_CONFIG,
scrollStrategy: createRepositionScrollStrategy(this.injector),
positionStrategy: this.nameTagPositionStrategy(target)
});
this.trackOverlayHover(this.nameTagOverlay);
this.trackOverlayFocus(this.nameTagOverlay);
} else {
this.nameTagOverlay.updatePositionStrategy(this.nameTagPositionStrategy(target));
}
if (!this.nameTagRef) {
const overlay = this.nameTagOverlay;
this.loadTagHintComponent().then((component) => {
// The pointer can leave again before the chunk arrives, which leaves no target to name.
if (
!component ||
this.nameTagRef ||
this.nameTagOverlay !== overlay ||
this.activeTarget !== target
) {
return;
}
this.nameTagRef = overlay.attach(new ComponentPortal(component));
this.nameTagSubscriptions.add(
this.nameTagRef.instance.toggleSettings.subscribe(() => this.toggleSettings())
);
this.syncNameTagInputs();
});
}
this.nameTagOverlay.updateSize({ maxWidth: this.originRect(target).width });
}
private loadTagHintComponent() {
this.tagHintComponent ??= loadHintChunk(
() => import('./acl-tag-hint.component').then((m) => m.AclTagHintComponent),
() => (this.tagHintComponent = undefined)
);
return this.tagHintComponent;
}
private detachNameTag() {
this.activeTarget?.decorate('none');
this.activeTarget = undefined;
this.nameTagSubscriptions.unsubscribe();
this.nameTagSubscriptions = new Subscription();
this.nameTagOverlay?.detach();
this.nameTagRef = undefined;
}
private syncNameTagInputs() {
if (!this.nameTagRef || !this.activeTarget) {
return;
}
this.nameTagRef.setInput('aclPath', this.activeTarget.aclPath());
this.nameTagRef.setInput('settingsOpen', this.settingsTarget === this.activeTarget);
}
private toggleSettings() {
const target = this.activeTarget;
if (!target) {
return;
}
if (this.settingsTarget === target) {
this.closeSettings();
} else {
this.openSettings(target);
}
this.updateActiveHint();
}
private openSettings(target: AclHintTarget) {
this.closeSettings();
this.settingsTarget = target;
const overlay = createOverlayRef(this.injector, {
...OVERLAY_BASE_CONFIG,
scrollStrategy: createRepositionScrollStrategy(this.injector),
positionStrategy: this.settingsPositionStrategy(target)
});
this.settingsOverlay = overlay;
this.trackOverlayHover(overlay);
this.trackOverlayFocus(overlay);
this.settingsSubscriptions.add(
overlay.outsidePointerEvents().subscribe((event) => {
// The name tag is the flyout's own toggle: dismissing here would let its click re-open it.
if (!this.isOwnOverlay(event.target)) {
this.dismissSettings();
}
})
);
this.loadSettingsComponent().then((component) => {
if (
!component ||
this.settingsRef ||
this.settingsOverlay !== overlay ||
this.settingsTarget !== target
) {
return;
}
// The flyout injects the inspector itself, so it is attached with the tag's injector.
this.settingsRef = overlay.attach(new ComponentPortal(component, null, target.injector));
this.settingsRef.setInput('aclPath', target.aclPath());
this.settingsSubscriptions.add(
this.settingsRef.instance.closeFlyout.subscribe(() => this.dismissSettings())
);
this.settingsSubscriptions.add(
this.inspectorOf(target)
.currentState$(target.aclPath())
.subscribe((state) => {
this.settingsRef?.setInput('state', state as AclHintState);
})
);
});
}
private loadSettingsComponent() {
this.settingsComponent ??= loadHintChunk(
() => import('./acl-hint-settings.component').then((m) => m.AclHintSettingsComponent),
() => (this.settingsComponent = undefined)
);
return this.settingsComponent;
}
private dismissSettings() {
const target = this.settingsTarget;
this.closeSettings();
if (target && !target.origin.isConnected) {
this.release(target);
} else {
this.updateActiveHint();
}
}
private closeSettings() {
this.settingsSubscriptions.unsubscribe();
this.settingsSubscriptions = new Subscription();
if (this.settingsOverlay) {
this.hoveredOverlays.delete(this.settingsOverlay);
this.settingsOverlay.dispose();
}
this.settingsOverlay = undefined;
this.settingsRef = undefined;
this.settingsTarget = undefined;
}
private trackOverlayHover(overlayRef: OverlayRef) {
const element = overlayRef.overlayElement;
this.ngZone.runOutsideAngular(() => {
element.addEventListener('pointerenter', () => {
this.hoveredOverlays.add(overlayRef);
});
element.addEventListener('pointerleave', (event) => {
if (this.isOwnOverlay(event.relatedTarget)) {
return;
}
this.hoveredOverlays.delete(overlayRef);
this.ngZone.run(() => this.leaveOverlay(event.relatedTarget));
});
});
}
private trackOverlayFocus(overlayRef: OverlayRef) {
const element = overlayRef.overlayElement;
this.ngZone.runOutsideAngular(() => {
element.addEventListener('focusout', (event) => {
if (this.isOwnOverlay(event.relatedTarget)) {
return;
}
// Tabbing off the hints reports no element receiving the focus, and so does a press on a
// part of the flyout that cannot take focus — only the input that did it tells them apart.
if (event.relatedTarget === null && this.inputModality.mostRecentModality !== 'keyboard') {
return;
}
this.ngZone.run(() => this.focusLeft());
});
});
}
// The hover is not the only way out of a hint: focusing something else ends it too.
private focusLeft() {
if (this.settingsTarget) {
this.dismissSettings();
}
[...this.frozen]
.filter((target) => !target.origin.isConnected)
.forEach((target) => this.release(target));
}
private isOwnOverlay(node: EventTarget | null) {
if (!(node instanceof Node)) {
return false;
}
return (
this.nameTagOverlay?.overlayElement.contains(node) === true ||
this.settingsOverlay?.overlayElement.contains(node) === true
);
}
private leaveOverlay(relatedTarget: EventTarget | null) {
const node = relatedTarget instanceof Node ? relatedTarget : null;
this.hovered = this.hovered.filter((target) => node !== null && target.origin.contains(node));
this.updateActiveHint();
}
// A frozen hint has no element left to report a pointer leave, so the pointer position decides:
// once it is outside both the frozen rect and the hints, the hint goes away.
private watchPointerWhileFrozen() {
if (this.frozenPointerWatch) {
return;
}
const onPointerMove = (event: PointerEvent) => {
if (this.hoveredOverlays.size > 0) {
return;
}
const stale = [...this.frozen].filter(
(target) =>
!target.origin.isConnected &&
!containsPoint(this.lastKnownRect(target), event.clientX, event.clientY)
);
if (stale.length > 0) {
this.ngZone.run(() => stale.forEach((target) => this.release(target)));
}
};
this.ngZone.runOutsideAngular(() => {
document.addEventListener('pointermove', onPointerMove, true);
});
this.frozenPointerWatch = () => {
document.removeEventListener('pointermove', onPointerMove, true);
};
}
private stopPointerWatchWhenNothingFrozen() {
if (this.frozen.size === 0) {
this.frozenPointerWatch?.();
this.frozenPointerWatch = undefined;
}
}
private resetOriginTracking() {
this.frozen.clear();
this.lastRects.clear();
this.pendingReanchors.forEach((cancel) => cancel());
this.pendingReanchors.clear();
this.frozenPointerWatch?.();
this.frozenPointerWatch = undefined;
}
private reposition(target: AclHintTarget) {
if (this.activeTarget === target) {
this.nameTagOverlay?.updatePositionStrategy(this.nameTagPositionStrategy(target));
this.nameTagOverlay?.updateSize({ maxWidth: this.originRect(target).width });
}
if (this.settingsTarget === target) {
this.settingsOverlay?.updatePositionStrategy(this.settingsPositionStrategy(target));
}
}
private nameTagPositionStrategy(target: AclHintTarget) {
return this.hintPositionStrategy(target).withPositions([
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'center',
offsetX: NAME_TAG_OFFSET_X
},
// Kept within the element rather than straddling its bottom edge, which is where the flyout
// is when the element is too close to the top of the viewport for the tag to sit above it.
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'bottom',
offsetX: NAME_TAG_OFFSET_X
}
]);
}
private settingsPositionStrategy(target: AclHintTarget) {
return this.hintPositionStrategy(target).withPositions([
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: FLYOUT_OFFSET_Y
},
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -(NAME_TAG_HEIGHT / 2 + FLYOUT_OFFSET_Y)
}
]);
}
private hintPositionStrategy(target: AclHintTarget) {
// Pushed back into the viewport, the hints would end up piled onto each other at its edge
// instead of leaving it with the element they belong to.
return createFlexibleConnectedPositionStrategy(
this.injector,
this.positionOrigin(target)
).withPush(false);
}
private positionOrigin(target: AclHintTarget) {
return this.canMeasure(target) ? target.origin : this.lastKnownRect(target);
}
private originRect(target: AclHintTarget): AclHintOriginRect {
this.rememberRect(target);
return this.lastKnownRect(target);
}
private rememberRect(target: AclHintTarget) {
if (this.canMeasure(target)) {
this.lastRects.set(target, snapshotAclHintOrigin(target.origin));
}
}
private canMeasure(target: AclHintTarget) {
return !this.frozen.has(target) && target.origin.isConnected;
}
private lastKnownRect(target: AclHintTarget): AclHintOriginRect {
return this.lastRects.get(target) ?? { x: 0, y: 0, width: 0, height: 0 };
}
}
function loadHintChunk<T>(load: () => Promise<T>, forget: () => void): Promise<T | undefined> {
return load().catch((error: unknown) => {
forget();
console.error('ACL: Could not load the ACL hint. Hover the element again to retry.', error);
return undefined;
});
}
function depthOf(target: AclHintTarget) {
return target.aclPath().split('/').length;
}
function containsPoint(rect: AclHintOriginRect, x: number, y: number) {
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
}
function hasGeometry(element: Element) {
if (!element.isConnected) {
return false;
}
const { width, height } = element.getBoundingClientRect();
// A block-level wrapper measures its full width while the Building Block inside it has yet to
// render, so either dimension alone would anchor the hints to an empty rect.
return width > 0 && height > 0;
}