自定义指令:v-overflow-tooltip

1. 背景

  在 el-table中的 show-overflow-tooltip 功能很好用,但是不用el-table时,也想要这个效果怎么办?用el-tooltip这个组件吧,内容没超出长度时也有提示,可是想要的效果是:只有在超出长度后才出现tooltip效果,怎么办?今天咱们就来实现下这个功能吧!

2. 如何使用呢?

在html中 容器需要有以下样式:

  .content {
  width: 200px; 
  overflow: hidden; 
  white-space: nowrap; 
  text-overflow: ellipsis
  }
 //  在标签上使用代码如下:
  ```vue
  <div v-overflow-tooltip class="content ">这里有很长的文字</div>

3. 如何实现呢?

需要写一个自定义指令,并全局注册下就ok啦,代码如下所示

  • directive/overflowTooltip.ts 如下所示
import type { App, Directive, DirectiveBinding } from 'vue';

interface OverflowTooltipOptions {
    // 基础配置
    content?: string;
    placement?: 'top' | 'bottom' | 'left' | 'right';
    effect?: 'dark' | 'light';
    // 功能配置
    maxLines?: number;
    maxWidth?: number | string;
    disabled?: boolean;
    // 性能配置
    debounce?: number;
}

interface DirectiveState {
    isOverflow: boolean;
    resizeObserver: ResizeObserver | null;
    checkTimer: ReturnType<typeof setTimeout> | null;
    originalTitle: string | null;
    tooltipEl: HTMLElement | null;
}

const stateMap = new WeakMap<HTMLElement, DirectiveState>();

// 默认配置
const defaultOptions: OverflowTooltipOptions = {
    placement: 'top',
    effect: 'dark',
    maxLines: 1,
    debounce: 100,
    disabled: false,
};

// 合并配置
const mergeOptions = (binding: DirectiveBinding<string | OverflowTooltipOptions>): OverflowTooltipOptions => {
    const options = { ...defaultOptions };

    if (typeof binding.value === 'string') {
        options.content = binding.value;
    } else if (typeof binding.value === 'object') {
        Object.assign(options, binding.value);
    }

    // 处理修饰符
    if (binding.modifiers) {
        if (binding.modifiers.top) options.placement = 'top';
        if (binding.modifiers.bottom) options.placement = 'bottom';
        if (binding.modifiers.left) options.placement = 'left';
        if (binding.modifiers.right) options.placement = 'right';
        if (binding.modifiers.light) options.effect = 'light';
        if (binding.modifiers.dark) options.effect = 'dark';
        if (binding.modifiers.disabled) options.disabled = true;
    }

    return options;
};

// 获取元素文本内容
const getElementText = (el: HTMLElement): string => {
    return el.textContent?.trim() || el.innerText?.trim() || '';
};

// 检查单行溢出
const checkSingleLineOverflow = (el: HTMLElement): boolean => {
    if (!el.offsetParent || el.offsetWidth === 0) {
        return false;
    }

    const style = window.getComputedStyle(el);
    const hasEllipsis = style.whiteSpace === 'nowrap' && style.overflow === 'hidden' && style.textOverflow === 'ellipsis';

    if (hasEllipsis) {
        return el.scrollWidth > Math.floor(el.clientWidth);
    }

    return el.scrollWidth > el.clientWidth;
};

// 检查多行溢出
const checkMultiLineOverflow = (el: HTMLElement, maxLines: number): boolean => {
    if (!el.offsetParent || el.offsetWidth === 0) {
        return false;
    }

    const style = window.getComputedStyle(el);
    const lineHeight = parseInt(style.lineHeight) || 20;
    const maxHeight = maxLines * lineHeight;

    // 创建临时元素测量
    const tempEl = document.createElement('div');
    tempEl.style.cssText = `
    position: absolute;
    visibility: hidden;
    width: ${el.clientWidth}px;
    font: ${style.font};
    line-height: ${style.lineHeight};
    padding: ${style.padding};
    white-space: normal;
    word-wrap: break-word;
  `;
    tempEl.textContent = getElementText(el);

    document.body.appendChild(tempEl);
    const actualHeight = tempEl.offsetHeight;
    document.body.removeChild(tempEl);

    return actualHeight > maxHeight;
};

// 创建自定义 tooltip 元素
const createTooltipElement = (el: HTMLElement, content: string, options: OverflowTooltipOptions): HTMLElement => {
    const tooltip = document.createElement('div');
    tooltip.className = `overflow-tooltip ${options.effect}`;
    tooltip.textContent = content;
    tooltip.style.cssText = `
        position: fixed;
        z-index: 9999;
        padding: 8px 12px;
        font-size: 12px;
        line-height: 1.2;
        border-radius: 4px;
        max-width: 400px;
        word-wrap: break-word;
        pointer-events: none;
        opacity: 0;
        transition: opacity 0.2s ease-in-out;
        white-space: pre-wrap;
        word-break: break-all;
    `;

    // 设置暗色/亮色主题
    if (options.effect === 'dark') {
        tooltip.style.backgroundColor = '#303133';
        tooltip.style.color = '#fff';
    } else {
        tooltip.style.backgroundColor = '#fff';
        tooltip.style.color = '#606266';
        tooltip.style.boxShadow = '0 2px 12px 0 rgba(0, 0, 0, 0.1)';
    }

    // 先添加到 body 获取实际尺寸
    document.body.appendChild(tooltip);
    const tooltipRect = tooltip.getBoundingClientRect();

    // 计算位置
    const rect = el.getBoundingClientRect();

    let top: number, left: number;

    switch (options.placement) {
        case 'bottom':
            top = rect.bottom + 10;
            left = rect.left + (rect.width - tooltipRect.width) / 2;
            break;
        case 'left':
            top = rect.top + (rect.height - tooltipRect.height) / 2;
            left = rect.left - tooltipRect.width - 10;
            break;
        case 'right':
            top = rect.top + (rect.height - tooltipRect.height) / 2;
            left = rect.right + 10;
            break;
        default: // top
            top = rect.top - tooltipRect.height - 10;
            left = rect.left + (rect.width - tooltipRect.width) / 2;
    }

    tooltip.style.top = `${top}px`;
    tooltip.style.left = `${left}px`;

    // 添加小箭头
    const arrow = document.createElement('div');
    arrow.className = 'overflow-tooltip-arrow';
    arrow.style.cssText = `
        position: absolute;
        width: 0;
        height: 0;
        border: 6px solid transparent;
    `;

    switch (options.placement) {
        case 'bottom':
            arrow.style.top = '-12px';
            arrow.style.left = '50%';
            arrow.style.transform = 'translateX(-50%)';
            arrow.style.borderBottomColor = options.effect === 'dark' ? '#303133' : '#fff';
            break;
        case 'left':
            arrow.style.top = '50%';
            arrow.style.right = '-12px';
            arrow.style.transform = 'translateY(-50%)';
            arrow.style.borderLeftColor = options.effect === 'dark' ? '#303133' : '#fff';
            break;
        case 'right':
            arrow.style.top = '50%';
            arrow.style.left = '-12px';
            arrow.style.transform = 'translateY(-50%)';
            arrow.style.borderRightColor = options.effect === 'dark' ? '#303133' : '#fff';
            break;
        default: // top
            arrow.style.bottom = '-12px';
            arrow.style.left = '50%';
            arrow.style.transform = 'translateX(-50%)';
            arrow.style.borderTopColor = options.effect === 'dark' ? '#303133' : '#fff';
    }

    tooltip.appendChild(arrow);

    return tooltip;
};

// 显示 tooltip
const showTooltip = (el: HTMLElement, state: DirectiveState, options: OverflowTooltipOptions) => {
    if (!state.isOverflow || state.tooltipEl) return;

    const content = options.content || getElementText(el);
    if (!content) return;

    // 移除之前的 tooltip
    hideTooltip(el, state);

    // 创建新的 tooltip
    const tooltip = createTooltipElement(el, content, options);
    // document.body.appendChild(tooltip);
    state.tooltipEl = tooltip;

    // 触发显示动画
    requestAnimationFrame(() => {
        tooltip.style.opacity = '1';
    });
};

// 隐藏 tooltip
const hideTooltip = (el: HTMLElement, state: DirectiveState) => {
    if (state.tooltipEl) {
        const tooltip = state.tooltipEl;
        tooltip.style.opacity = '0';

        setTimeout(() => {
            if (document.body.contains(tooltip)) {
                document.body.removeChild(tooltip);
            }
            state.tooltipEl = null;
        }, 200);
    }
};

// 检查溢出状态
const checkOverflow = (el: HTMLElement, binding: DirectiveBinding<string | OverflowTooltipOptions>): boolean => {
    const state = stateMap.get(el);
    if (!state) return false;

    const options = mergeOptions(binding);

    if (options.disabled) {
        // 如果禁用,移除 tooltip
        hideTooltip(el, state);
        el.removeAttribute('data-overflow-tooltip');
        if (state.originalTitle !== null) {
            el.setAttribute('title', state.originalTitle);
        } else {
            el.removeAttribute('title');
        }
        state.isOverflow = false;
        return false;
    }

    let isOverflow = false;

    if (options.maxLines && options.maxLines > 1) {
        isOverflow = checkMultiLineOverflow(el, options.maxLines);
    } else {
        isOverflow = checkSingleLineOverflow(el);
    }

    state.isOverflow = isOverflow;

    const content = options.content || getElementText(el);

    if (isOverflow) {
        // 内容溢出时,保存内容并设置状态
        el.setAttribute('data-overflow-tooltip', content);
        // 临时移除原生 title 以免干扰
        el.removeAttribute('title');
    } else {
        // 内容不溢出时,移除 tooltip
        hideTooltip(el, state);
        el.removeAttribute('data-overflow-tooltip');
        if (state.originalTitle !== null) {
            el.setAttribute('title', state.originalTitle);
        } else {
            el.removeAttribute('title');
        }
    }

    return isOverflow;
};

// 防抖检查
const debouncedCheckOverflow = (el: HTMLElement, binding: DirectiveBinding<string | OverflowTooltipOptions>) => {
    const state = stateMap.get(el);
    if (!state) return;

    const options = mergeOptions(binding);
    const delay = options.debounce || 100;

    if (state.checkTimer) {
        clearTimeout(state.checkTimer);
    }

    state.checkTimer = setTimeout(() => {
        checkOverflow(el, binding);
    }, delay);
};

// 事件处理
const setupEventListeners = (el: HTMLElement, options: OverflowTooltipOptions) => {
    const handleMouseEnter = () => {
        const state = stateMap.get(el);
        if (!state) return;

        // 防抖显示
        if (state.checkTimer) {
            clearTimeout(state.checkTimer);
        }

        state.checkTimer = setTimeout(() => {
            const currentOptions = mergeOptions({ value: options } as DirectiveBinding<string | OverflowTooltipOptions>);
            showTooltip(el, state, currentOptions);
        }, 100);
    };

    const handleMouseLeave = () => {
        const state = stateMap.get(el);
        if (!state) return;

        if (state.checkTimer) {
            clearTimeout(state.checkTimer);
        }

        hideTooltip(el, state);
    };

    el.addEventListener('mouseenter', handleMouseEnter);
    el.addEventListener('mouseleave', handleMouseLeave);
    el.addEventListener('focus', handleMouseEnter);
    el.addEventListener('blur', handleMouseLeave);

    // 保存事件处理器以便清理
    (el as any)._overflowTooltipHandlers = {
        mouseenter: handleMouseEnter,
        mouseleave: handleMouseLeave,
        focus: handleMouseEnter,
        blur: handleMouseLeave,
    };
};

// 清理事件监听器
const cleanupEventListeners = (el: HTMLElement) => {
    const handlers = (el as any)._overflowTooltipHandlers;
    if (handlers) {
        el.removeEventListener('mouseenter', handlers.mouseenter);
        el.removeEventListener('mouseleave', handlers.mouseleave);
        el.removeEventListener('focus', handlers.focus);
        el.removeEventListener('blur', handlers.blur);
        delete (el as any)._overflowTooltipHandlers;
    }
};

// 销毁指令
const destroy = (el: HTMLElement) => {
    const state = stateMap.get(el);
    if (!state) return;

    // 清理定时器
    if (state.checkTimer) clearTimeout(state.checkTimer);

    // 清理观察器
    if (state.resizeObserver) {
        state.resizeObserver.disconnect();
        state.resizeObserver = null;
    }

    // 清理事件监听器
    cleanupEventListeners(el);

    // 移除 tooltip
    hideTooltip(el, state);

    // 恢复原始 title
    el.removeAttribute('data-overflow-tooltip');
    if (state.originalTitle !== null) {
        el.setAttribute('title', state.originalTitle);
    } else {
        el.removeAttribute('title');
    }

    // 清理状态
    stateMap.delete(el);
};

// 主指令
export const vOverflowTooltip: Directive = {
    mounted(el: HTMLElement, binding: DirectiveBinding<string | OverflowTooltipOptions>) {
        // 保存原始 title
        const originalTitle = el.getAttribute('title');
        el.removeAttribute('title');

        // 创建状态
        const state: DirectiveState = {
            isOverflow: false,
            resizeObserver: null,
            checkTimer: null,
            originalTitle,
            tooltipEl: null,
        };

        stateMap.set(el, state);

        const options = mergeOptions(binding);

        // 初始检查
        debouncedCheckOverflow(el, binding);

        // 设置事件监听
        setupEventListeners(el, options);

        // 监听窗口大小变化
        if ('ResizeObserver' in window) {
            state.resizeObserver = new ResizeObserver(() => {
                debouncedCheckOverflow(el, binding);
            });
            state.resizeObserver.observe(el);
        }

        // 监听内容变化(MutationObserver)
        const observer = new MutationObserver(() => {
            debouncedCheckOverflow(el, binding);
        });
        observer.observe(el, {
            childList: true,
            subtree: true,
            characterData: true,
        });
        (el as any)._mutationObserver = observer;
    },

    updated(el: HTMLElement, binding: DirectiveBinding<string | OverflowTooltipOptions>) {
        debouncedCheckOverflow(el, binding);
    },

    beforeUnmount(el: HTMLElement) {
        destroy(el);

        // 清理 MutationObserver
        const observer = (el as any)._mutationObserver;
        if (observer) {
            observer.disconnect();
            delete (el as any)._mutationObserver;
        }
    },
};

// 安装函数
export const installOverflowTooltip = (app: App) => {
    // 注册全局样式
    app.directive('overflow-tooltip', vOverflowTooltip);
};

export default {
    install: installOverflowTooltip,
};

  • main.ts 中全局注册
import { createApp } from 'vue';
import pinia from '/@/stores/index';
import App from './App.vue';
import router from './router';
import { directive } from '/@/directive';


const app = createApp(App);
// 全局自定义指令挂载
directive(app);

app
    .use(pinia) // pinia 存储
    .use(router) // 路由
    .mount('#app');

4. 看下效果

超出的展示tooltip提示,未超出的不展示。完美解决~


image.png
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容