Ng-Zorro Extends TooltipDirective

156 阅读3分钟

问题背景

在项目中,由于支持了国际化,导致语言为英文时,部分文字会超出元素。故有些地方需要使用“...”方式来截断文字并显示气泡,当没有截断文字时不显示气泡,ng-zorro原生tooltip无法支持,需要继承该指令并自定义逻辑。

image.png image.png

实现方案

继承NzTooltipDirective,完成初始化参数传递

export class VisibleTooltipDirective extends NzTooltipDirective implements AfterViewInit {
  @Input() nzDynamicVisibleTooltip: boolean = true;
  readonly SPECIAL_CHARS_REGEXP: RegExp = /([\:\-\_]+(.))/g;
  readonly MOZ_HACK_REGEXP: RegExp = /^moz([A-Z])/;

  private overlayElement: HTMLElement;
  private timer: NodeJS.Timeout = null;
  
  /**
   * 初始化构造函数
   *
   * @param el 模版
   * @param render
   */
  constructor(
    elementRef: ElementRef,
    hostView: ViewContainerRef,
    resolver: ComponentFactoryResolver,
    renderer: Renderer2,
    @Host() @Optional() noAnimation?: NzNoAnimationDirective
  ) {
    super(elementRef, hostView, resolver, renderer, noAnimation);
  }
}

注册自定义鼠标监听方法,通过判断元素clientWidth与scrollWidth的关系来控制气泡的显隐。由于scrollWidth和clientWidth都会四舍五入取整数,所以存在文字溢出scrollWidth===clientWidth,故这里利用Range来计算scrollWidth的精确宽度。

/**
   * 注册监听鼠标移入移出方法
   */
  registerTriggersOfNull(): void {
    this.triggerDisposables.push(
      this.renderer.listen(this.elementRef.nativeElement, 'mouseenter', () => {
        if (!this.nzDynamicVisibleTooltip) return;
        clearTimeout(this.timer);
        const range: Range = document.createRange();
        range.setStart(this.elementRef.nativeElement, 0);
        range.setEnd(this.elementRef.nativeElement, this.elementRef.nativeElement.childNodes.length);
        // 计算滚动内容精确宽度,保留两位小数
        const rangeWidth: number = Math.floor(range.getBoundingClientRect().width * 100) / 100;
        // 计算内边距
        const padding: number = (parseInt(this.getStyle(this.elementRef.nativeElement, 'paddingLeft'), 10) || 0) +
        (parseInt(this.getStyle(this.elementRef.nativeElement, 'paddingRight'), 10) || 0);
        // 容器精确宽度,保留两位小数
        const containerWidth: number = Math.floor(this.elementRef.nativeElement.getBoundingClientRect().width * 100) / 100;
        // 判断text-overflow是否溢出,溢出则显示气泡
        if ((rangeWidth + padding > containerWidth || this.elementRef.nativeElement.scrollWidth > this.elementRef.nativeElement.offsetWidth)) {
          this.delayEnterLeaveOverride(true, true, this._mouseEnterDelay);
        } else {
          this.hide();
        }
      })
    );
    this.triggerDisposables.push(
      this.renderer.listen(this.elementRef.nativeElement, 'mouseleave', () => {
        if (!this.nzDynamicVisibleTooltip) return;
        this.delayEnterLeaveOverride(true, false, this._mouseLeaveDelay);
        if (this.component.overlay.overlayRef?.overlayElement && !this.overlayElement) {
          this.overlayElement = this.component.overlay.overlayRef?.overlayElement;
          this.triggerDisposables.push(
            this.renderer.listen(this.overlayElement, 'mouseenter', () => {
              this.delayEnterLeaveOverride(false, true, this._mouseEnterDelay);
            })
          );
          this.triggerDisposables.push(
            this.renderer.listen(this.overlayElement, 'mouseleave', () => {
              this.delayEnterLeaveOverride(false, false, this._mouseEnterDelay);
            })
          );
        }
      })
    );
  }

在ngAfterViewInit初始化气泡配置

/**
   * dom渲染完成,可获取dom节点
   */
  ngAfterViewInit(): void {
    // 配合截断文字动态显示气泡
    if (this.nzDynamicVisibleTooltip) {
      this.trigger = null;
    }
    this.createComponent();
    if (this.nzDynamicVisibleTooltip) {
      this.registerTriggersOfNull();
    } else {
      this.registerTriggers();
    }
  }

气泡显隐执行方法

/**
   * 延迟执行
   *
   * @param isOrigin
   * @param isEnter
   * @param delay
   */
  private delayEnterLeaveOverride(isOrigin: boolean, isEnter: boolean, delay: number = -1): void {
    if (this.timer) {
      this.clearTogglingTimerOverride();
    } else if (delay > 0) {
      this.timer = setTimeout(() => {
        this.timer = undefined;
        isEnter ? this.show() : this.hide();
      }, delay * 1000);
    } else {
      isEnter && isOrigin ? this.show() : this.hide();
    }
  }

  /**
   * 清除延时器
   */
  private clearTogglingTimerOverride(): void {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = undefined;
    }
  }

💡 Tips:由于目前项目只是需要根据截断文字显隐气泡,故通过调用show和hide方法就能够实现。如有更复杂的需求,还可重写@Input属性和其余方法。也可以通过重写componentFactory自定义气泡组件。具体可以看看Tooltip源码(NzTooltipDirective、NzToolTipComponent、NzTooltipBaseDirective、NzTooltipBaseComponent).

完整代码

import {
  Directive,
  ElementRef,
  Renderer2,
  ViewContainerRef,
  ComponentFactoryResolver,
  Host,
  Optional,
  AfterViewInit,
  Input
} from '@angular/core';
import { NzNoAnimationDirective } from 'ng-zorro-antd/core/no-animation';
import { NzTooltipDirective } from 'ng-zorro-antd/tooltip';

@Directive({
  selector: '[appVisibleTooltip]'
})
export class VisibleTooltipDirective extends NzTooltipDirective implements AfterViewInit {
  @Input() nzDynamicVisibleTooltip: boolean = true;
  readonly SPECIAL_CHARS_REGEXP: RegExp = /([\:\-\_]+(.))/g;
  readonly MOZ_HACK_REGEXP: RegExp = /^moz([A-Z])/;

  private overlayElement: HTMLElement;
  private timer: NodeJS.Timeout = null;
  
  /**
   * 初始化构造函数
   *
   * @param el 模版
   * @param render
   */
  constructor(
    elementRef: ElementRef,
    hostView: ViewContainerRef,
    resolver: ComponentFactoryResolver,
    renderer: Renderer2,
    @Host() @Optional() noAnimation?: NzNoAnimationDirective
  ) {
    super(elementRef, hostView, resolver, renderer, noAnimation);
  }

  /**
   * dom渲染完成,可获取dom节点
   */
  ngAfterViewInit(): void {
    // 配合截断文字动态显示气泡
    if (this.nzDynamicVisibleTooltip) {
      this.trigger = null;
    }
    this.createComponent();
    if (this.nzDynamicVisibleTooltip) {
      this.registerTriggersOfNull();
    } else {
      this.registerTriggers();
    }
  }

  /**
   * 注册监听鼠标移入移出方法
   */
  registerTriggersOfNull(): void {
    this.triggerDisposables.push(
      this.renderer.listen(this.elementRef.nativeElement, 'mouseenter', () => {
        if (!this.nzDynamicVisibleTooltip) return;
        clearTimeout(this.timer);
        const range: Range = document.createRange();
        range.setStart(this.elementRef.nativeElement, 0);
        range.setEnd(this.elementRef.nativeElement, this.elementRef.nativeElement.childNodes.length);
        // 计算滚动内容精确宽度,保留两位小数
        const rangeWidth: number = Math.floor(range.getBoundingClientRect().width * 100) / 100;
        // 计算内边距
        const padding: number = (parseInt(this.getStyle(this.elementRef.nativeElement, 'paddingLeft'), 10) || 0) +
        (parseInt(this.getStyle(this.elementRef.nativeElement, 'paddingRight'), 10) || 0);
        // 容器精确宽度,保留两位小数
        const containerWidth: number = Math.floor(this.elementRef.nativeElement.getBoundingClientRect().width * 100) / 100;
        // 判断text-overflow是否溢出,溢出则显示气泡
        if ((rangeWidth + padding > containerWidth || this.elementRef.nativeElement.scrollWidth > this.elementRef.nativeElement.offsetWidth)) {
          this.delayEnterLeaveOverride(true, true, this._mouseEnterDelay);
        } else {
          this.hide();
        }
      })
    );
    this.triggerDisposables.push(
      this.renderer.listen(this.elementRef.nativeElement, 'mouseleave', () => {
        if (!this.nzDynamicVisibleTooltip) return;
        this.delayEnterLeaveOverride(true, false, this._mouseLeaveDelay);
        if (this.component.overlay.overlayRef?.overlayElement && !this.overlayElement) {
          this.overlayElement = this.component.overlay.overlayRef?.overlayElement;
          this.triggerDisposables.push(
            this.renderer.listen(this.overlayElement, 'mouseenter', () => {
              this.delayEnterLeaveOverride(false, true, this._mouseEnterDelay);
            })
          );
          this.triggerDisposables.push(
            this.renderer.listen(this.overlayElement, 'mouseleave', () => {
              this.delayEnterLeaveOverride(false, false, this._mouseEnterDelay);
            })
          );
        }
      })
    );
  }

  /**
   * 延迟执行
   *
   * @param isOrigin
   * @param isEnter
   * @param delay
   */
  private delayEnterLeaveOverride(isOrigin: boolean, isEnter: boolean, delay: number = -1): void {
    if (this.timer) {
      this.clearTogglingTimerOverride();
    } else if (delay > 0) {
      this.timer = setTimeout(() => {
        this.timer = undefined;
        isEnter ? this.show() : this.hide();
      }, delay * 1000);
    } else {
      isEnter && isOrigin ? this.show() : this.hide();
    }
  }

  /**
   * 清除延时器
   */
  private clearTogglingTimerOverride(): void {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = undefined;
    }
  }

  /**
   * 
   * @param element 
   * @param styleName 
   * @returns 
   */
  private getStyle(element: any, styleName: string): string {
    if (!element || !styleName) return null;
    styleName = this.camelCase(styleName);
    if (styleName === 'float') {
      styleName = 'cssFloat';
    }
    try {
      var computed: any = document.defaultView.getComputedStyle(element, '');
      return element.style[styleName] || computed ? computed[styleName] : null;
    } catch (e) {
      return element.style[styleName];
    }
  }

  /**
   * 
   * @param name 
   * @returns 
   */
  camelCase(name: string): string {
    return name.replace(this.SPECIAL_CHARS_REGEXP, (_, separator, letter, offset) => {
      return offset ? letter.toUpperCase() : letter;
    }).replace(this.MOZ_HACK_REGEXP, 'Moz$1');
  };
}

💡 Tips:欢迎小伙伴们共同讨论更优方法哦!!!