File

libs/acl/angular/src/lib/acl-hint/acl-hint-overlay.service.ts

Index

Properties

Properties

aclPath
Type function
decorate
Type function
injector
Type Injector
origin
Type Element
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;
}

results matching ""

    No results matching ""