Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🎨 Improve tooltip #13476

Closed
wants to merge 27 commits into from
Closed

🎨 Improve tooltip #13476

wants to merge 27 commits into from

Conversation

TCOTC
Copy link
Contributor

@TCOTC TCOTC commented Dec 15, 2024

  • 优化元素更新
  • 改变 hideTooltip() 的方式为添加类名 .fn__none
  • 增加 .tooltip--tab_header 和 .tooltip--emoji
  • 用 span 包裹链接标题
  • 修复数据库资源字段链接不含标题时多余的 <div class="fn__hr"></div>
    image
  • 支持传入多个额外类名、新增 data-tooltipclass 属性用于自定义 tooltip 的额外类名
  • 关联字段选项文本过长的条目加上悬浮提示 关联字段弹窗需要限制最大宽度 #13359 (comment)

.fn__none 的用途是配合 CSS 和 JS 实现这样的效果:

video.webm

顺便分享一下主题用的对应代码片段:

/* ————————————————————悬浮提示———————————————————— */

.tooltip {
    box-shadow: 0 0 0 1px rgba(0,0,0,.1),0 2px 6px 0 rgba(0,0,0,.1);
    background-color: var(--mix-theme_primary_background);
    color: var(--b3-theme-on-background);
    animation-duration: 10ms; /* 默认动画 zoomIn 更快过渡 */
    animation-delay: 400ms;   /* 延迟显示 */
    pointer-events: none;
}
/* 路径信息的悬浮提示统一显示在左下角 */
@keyframes tooltipFadeOut {
    to {
        opacity: 0;
    }
}
.tooltip--tab_header,
.tooltip--href {
    overflow: hidden;        /* 隐藏超出元素宽度的内容 */
    text-overflow: ellipsis; /* 使用省略号表示被截断的文本 */
    white-space: nowrap;     /* 不换行 */
    position: absolute;      /* 显示在左下角 */
    top: unset !important;
    left: 0 !important;
    bottom: 0 !important;
    border-radius:0 6px 0 0;
    max-width: 350px;        /* 初始链接宽度(小于这个宽度的情况下字体会变细,很怪) */
    animation-name: none;    /* 禁用动画,直接显示 */
    animation-fill-mode: both;
}
.tooltip__wider {
    max-width: 1000px;       /* 最大链接宽度 */
    transition: max-width 0.2s 0.5s; /* 过渡动画 */
}
.tooltip--tab_header.fn__none,
.tooltip--href.fn__none {
    display: block !important; /* 保持显示直到淡出 */
    animation-name: tooltipFadeOut;
    animation-duration: 300ms;
    animation-delay: 200ms;
    /*animation-fill-mode: both;*/
    max-width: 1001px;         /* 能够使元素添加 .fn__none 之后宽度不变(用于宽度刚好展开一半的情况) */
    transition: max-width 0s 2s;
}
(function() {
    let tooltipObserver;

    window.destroyTheme = () => {
        // 停止监听 body 子元素
        if (bodyObserver) {
            bodyObserver.disconnect();
        }
        // 停止监听 .tooltip--href 的更新
        if (tooltipObserver) {
            tooltipObserver.disconnect();
        }
    }

    // 存储每个节点的定时器 ID
    const nodeTimeoutMap = new Map();

    // 监听 body 的直接子元素 #tooltip 的添加
    const bodyObserver = new MutationObserver((mutationsList) => {
        for (let mutation of mutationsList) {
            if (mutation.type === 'childList') {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE && node.id === 'tooltip') {
                        console.log('#tooltip 添加到 DOM');
                        // 停止监听 body 的变化
                        bodyObserver.disconnect();

                        initTooltipObserver(); // 监听 #tooltip 元素的更新

                        if (node.classList.contains('tooltip--href') || node.classList.contains('tooltip--tab_header')) {
                            // 执行初始的添加类名逻辑
                            addClassWithDelay(node);
                        }
                    }
                });
            }
        }
    });

    const config = { childList: true, subtree: false };

    function initTooltipObserver() {
        function debounce(func, wait) {
            let timeout;
            return function(...args) {
                clearTimeout(timeout);
                timeout = setTimeout(() => func.apply(this, args), wait);
            };
        }
        // 启动新的 MutationObserver 监听 #tooltip 元素的更新
        tooltipObserver = new MutationObserver(debounce((mutationsList) => {
            for (let mutation of mutationsList) {
                if (mutation.type === 'attributes' && mutation.attributeName === 'class') {

                    const newClasses = node.className;

                    console.log(`类名从 "${currentClasses}" 更改为 "${newClasses}"`);
                    if (node.classList.contains('tooltip--href')) {
                        if (node.classList.contains('fn__none') && node.classList.contains('tooltip__wider')) {
                            // 元素包含 .fn__none 类名时移除 .tooltip--href 和 .tooltip__wider 类名
                            console.log('#tooltip 包含 .fn__none,删除 tooltip__wider');
                            // 清除之前的定时器
                            if (nodeTimeoutMap.has(node)) {
                                clearTimeout(nodeTimeoutMap.get(node));
                            }
                            // node.classList.remove('tooltip--href');
                            node.classList.remove('tooltip__wider');
                        } else if (!node.classList.contains('fn__none') && !node.classList.contains('tooltip__wider')) {
                            console.log('#tooltip 不包含 .fn__none,添加 tooltip__wider with delay');
                            addClassWithDelay(node);
                        }
                    }

                    // 记录当前的类名
                    currentClasses = node.className;
                }
            }
        }, 500)); // 500 毫秒的延迟

        const node = document.body.querySelector('#tooltip');
        console.log('获取 #tooltip 元素:', node); // 输出获取到的元素
        let currentClasses = node.className;

        // 配置监听属性变化
        const tooltipConfig = { attributes: true, attributeFilter: ['class'] };
        tooltipObserver.observe(node, tooltipConfig);
    }

    (async () => {
        // 检查 #tooltip 是否已经存在
        const tooltipElement = document.body.querySelector('#tooltip');

        if (tooltipElement) {
            console.log('#tooltip 已经存在,直接启动监听');
            initTooltipObserver();
        } else {
            console.log(bodyObserver);
            console.log(document.body);
            console.log(config);
            console.log('Observing document.body');
            bodyObserver.observe(document.body, config);
        }
    })();

    // 辅助函数:延迟添加类名
    function addClassWithDelay(node) {
        // 清除之前的定时器
        if (nodeTimeoutMap.has(node)) {
            clearTimeout(nodeTimeoutMap.get(node));
        }

        // 设置新的定时器
        const timeoutId = setTimeout(() => {
            // 再次检查元素是否存在
            if (document.body.contains(node)) {
                console.log('向 #tooltip 添加 tooltip__wider');
                node.classList.add('tooltip__wider');
            }
            // 删除定时器 ID
            nodeTimeoutMap.delete(node);
        }, 500);

        // 存储新的定时器 ID
        nodeTimeoutMap.set(node, timeoutId);
    }

    (async () => {
        blockTrackMain();
    })();
})();

更新:发现纯 CSS 就能实现,不过需要 tooltip 元素一开始就存在

/* ————————————————————悬浮提示———————————————————— */

.tooltip {
    box-shadow: 0 0 0 1px rgba(0,0,0,.1),0 2px 6px 0 rgba(0,0,0,.1);
    background-color: var(--mix-theme_primary_background);
    color: var(--b3-theme-on-background);
    animation-duration: 10ms; /* 默认动画 zoomIn 更快过渡 */
    animation-delay: 400ms;   /* 延迟显示 */
    pointer-events: none;
    max-width: 350px;         /* 初始链接宽度(小于这个宽度的情况下字体会变细,很怪) */
    transition: max-width 0.5s 1.5s;
}

/* 路径信息的悬浮提示统一显示在左下角 */
@keyframes tooltipFadeIn {
    to {
        opacity: 1;
    }
}
@keyframes tooltipFadeOut {
    from {
        opacity: 1;
    }
    to {
        opacity: 0;
    }
}
.tooltip--tab_header,
.tooltip--href {
    overflow: hidden;        /* 隐藏超出元素宽度的内容 */
    text-overflow: ellipsis; /* 使用省略号表示被截断的文本 */
    white-space: nowrap;     /* 不换行 */
    position: absolute;      /* 显示在左下角 */
    top: unset !important;
    left: 0 !important;
    bottom: 0 !important;
    border-radius:0 6px 0 0;
    max-width: 90vw;         /* 最大链接宽度 */
    animation-name: tooltipFadeIn;
    animation-duration: 300ms;
    animation-delay: 200ms;
    animation-fill-mode: both;
}
.tooltip.fn__none {
    transition: max-width 0s 2s;
    animation-name: tooltipFadeOut;
    animation-duration: 500ms;
    animation-delay: 200ms;
}
.tooltip--tab_header.fn__none,
.tooltip--href.fn__none {
    display: block !important;     /* 保持显示直到淡出 */
    animation-name: tooltipFadeOut;
    animation-duration: 300ms;
    animation-delay: 200ms;
    max-width: 350px;              /* 能够使元素添加 .fn__none 之后宽度不变(用于宽度刚好展开一半的情况) */
    transition: max-width 0s 0.2s; /* 隐藏超过 0.5s 就立即缩回*/
}

- 优化元素更新
- 改变 hideTooltip() 的方式为添加类名 .fn__none
- 增加 .tooltip--tab_header
@TCOTC

This comment was marked as resolved.

@Vanessa219
Copy link
Member

这个没注意有 PR,修改后导致目前有冲突,还麻烦解决一下。移上去的提示就不需要红色了,太扎眼了。
image

@TCOTC
Copy link
Contributor Author

TCOTC commented Dec 20, 2024

改了一下

@TCOTC TCOTC marked this pull request as draft December 21, 2024 18:53
@TCOTC TCOTC marked this pull request as ready for review December 21, 2024 19:55
@Vanessa219
Copy link
Member

#13359

@TCOTC
Copy link
Contributor Author

TCOTC commented Jan 5, 2025

@@ -170,7 +170,7 @@ ${unicode2Emoji(item.unicode, undefined, false, true)}</button>`;
window.siyuan.config.editor.emoji.forEach(emojiUnicode => {
const emoji = recentEmojis.filter((item) => item.unicode === emojiUnicode);
if (emoji[0]) {
recentHTML += `<button data-unicode="${emoji[0].unicode}" class="emojis__item ariaLabel" aria-label="${getEmojiDesc(emoji[0])}">
recentHTML += `<button data-unicode="${emoji[0].unicode}" class="emojis__item ariaLabel" data-tooltipclass="emoji" aria-label="${getEmojiDesc(emoji[0])}">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个 emoji 样式添加的作用是使用这个 css ?

image

Copy link
Contributor Author

@TCOTC TCOTC Jan 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data-tooltipclass="emoji" 的作用是给 tooltip 元素添加一个 .tooltip--emoji 的类名,思源没有对应的样式,因为这个是我做主题需要用的。

冲突我解决了。

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这样侵入性太强了,而且不是通用的。能否使用其他方式?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

主要是想复用这段逻辑:

image

否则每次需要加类名都只能多加一个 else if:

image

@TCOTC
Copy link
Contributor Author

TCOTC commented Jan 28, 2025

刚刚用 JS 实现了给特定元素的悬浮提示添加类名,有空再把这个 PR 里面不需要改的地方改回去

// 判断是否为手机
let isMobile;
(async () => {
    // TODO跟进 https://github.com/siyuan-note/siyuan/issues/13952 如果支持了切换界面,需要在切换界面之后重新执行被跳过的程序
    isMobile = !!document.getElementById("sidebar");
})();

// 获取包含特定属性和值的上层元素
// 属性值只能是 string 类型,if 条件优化掉了 `typeof value === "string" &&`
const hasClosestByAttribute = (e, attr, value) => {
    // 到达 <html> 元素时,e.parentElement 会返回 null,跳出循环
    while (e) {
        if (e.getAttribute(attr)?.includes(value)) return e; // 找到匹配的元素,直接返回;为了提高性能,优化掉了 .split(" ")
        e = e.parentElement; // 继续向上查找
    }
    return false; // 未找到匹配的元素
};

// 把类名添加到 tooltip 元素的 classList 中
const addClass2Tooltip = (tooltipClass) => {
    // 类名不存在才添加类名
    // 感觉理论上会有 tooltip 显示又隐藏了才添加类名的情况,但实际没测出来。不过即使 tooltip 隐藏了也要添加类名,因为可以有 .tooltip--custom.fn__none 的样式
    if (!tooltipElement.classList.contains(tooltipClass)) {
        tooltipElement.classList.add(tooltipClass);
    }
};

// 判断元素是否需要添加类名
// 这个函数不能弄防抖,因为原生的 showTooltip() 没有防抖,会覆盖掉类名
let tooltipObserver;
const checkAndAddClassOnHover = (event) => {
    tooltipObserver?.disconnect(); // 避免重复创建监听器

    if (!event.target || event.target.nodeType === 9) return false;
    const element = event.target.nodeType === 3 ? event.target.parentElement : event.target;

    // 表情选择器上的表情、底部选项
    if (element.classList.contains("emojis__item") || element.classList.contains("emojis__type")) {
        addClass2Tooltip("tooltip--emoji");
        return;
    }
    // 数据库资源字段中的链接、属性面板数据库资源字段中的链接
    if (element.parentElement?.closest('[data-dtype="mAsset"]') || element.parentElement?.closest('[data-type="mAsset"]')) {
        addClass2Tooltip("tooltip--href_av");
        return;
    }
    // 页签
    const tabHeaderElement = hasClosestByAttribute(element, "data-type", "tab-header");
    if (tabHeaderElement) {
        // 如果页签没有 aria-label 属性,说明 tooltip 也还没有被添加
        // 要监听 tooltipElement 元素的类名变化,等 fn__none 类名被移除之后再调用 addClass2Tooltip() 和卸载监听
        tooltipObserver = new MutationObserver((mutationsList) => {
            for (let mutation of mutationsList) {
                if (!tooltipElement.classList.contains('fn__none')) {
                    addClass2Tooltip("tooltip--tab_header");
                    tooltipObserver?.disconnect(); // 卸载监听
                }
            }
        });
        tooltipObserver.observe(tooltipElement, { attributeFilter: ['class'] });
        // 先判断再监听,中间可能会有时间差,故改为先监听再判断
        if (tabHeaderElement.hasAttribute("aria-label")) {
            tooltipObserver?.disconnect(); // 卸载监听
            addClass2Tooltip("tooltip--tab_header");
        }
        // return;
    }
};

// 功能:鼠标悬浮在特定元素上时,给当前显示的 tooltip 添加类名
let tooltipElement;
(async () => {
    if (isMobile) return;
    tooltipElement = document.getElementById("tooltip");
    if (tooltipElement) {
        // 参考原生的 initBlockPopover 函数
        document.addEventListener('mouseover', checkAndAddClassOnHover);
    } else {
        console.log("Whisper: tooltip element does not exist.");
    }
})();

更新:换了一种性能更好的方式,把添加类名换成添加属性

(function() {
    // 判断是否为手机
    let isMobile;
    (async () => {
        // TODO跟进 https://github.com/siyuan-note/siyuan/issues/13952 如果支持了切换界面,需要在切换界面之后重新执行被跳过的程序
        isMobile = !!document.getElementById("sidebar");
    })();

    // 关闭或卸载主题
    window.destroyTheme = () => {
        document.removeEventListener('mouseover', updateTooltipData);
        tooltipElement?.removeAttribute("data-whisper-tooltip");
    }

    const isLocalPath = (link) => {
        if (!link) return false;

        link = link.trim();
        if (1 > link.length) return false;

        link = link.toLowerCase();
        if (link.startsWith("assets/") || link.startsWith("file://") || link.startsWith("\\\\") /* Windows 网络共享路径 */) {
            return true;
        }

        const colonIdx = link.indexOf(":");
        return 1 === colonIdx; // 冒号前面只有一个字符认为是 Windows 盘符而不是网络协议
    };

    // 给 tooltip 元素添加 data-whisper-tooltip 属性值
    const setTooltipData = (data) => {
        if (tooltipElement.dataset?.whisperTooltip !== data) {
            tooltipElement.dataset.whisperTooltip = data;
        }
    };

    // 判断元素是否需要添加特定属性。原生的 showTooltip() 会覆盖掉类名,改成添加 data-* 属性就不会冲突了
    const updateTooltipData = (event) => {
        if (!event.target || event.target.nodeType === 9) return;
        const e = event.target.nodeType === 3 ? event.target.parentElement : event.target;

        // 文本超链接
        if (e.getAttribute("data-href")) {
            // 资源文件链接
            if (isLocalPath(e.getAttribute("data-href"))) {
                setTooltipData("href_asset");
                return;
            }
            // 普通链接
            setTooltipData("href");
            return;
        }
        // 页签
        if (e.parentElement?.closest('[data-type="tab-header"]') || e.closest('[data-type="tab-header"]')) {
            setTooltipData("tab_header");
            return;
        }
        // 数据库超链接
        if (e.classList.contains("av__celltext--url")) {
            setTooltipData("href_av");
            return;
        }
        // 表情选择器上的表情、底部选项
        if (e.classList.contains("emojis__item") || e.classList.contains("emojis__type")) {
            setTooltipData("emoji");
            return;
        }
        // 如果正在显示的 tooltip 不属于特定元素,就将属性置空
        if (!tooltipElement.classList.contains("fn__none")) {
            tooltipElement.dataset.whisperTooltip = "";
        }
    };

    // 功能:鼠标悬浮在特定元素上时,给当前显示的 tooltip 添加特定属性
    let tooltipElement;
    (async () => {
        if (isMobile) return;
        tooltipElement = document.getElementById("tooltip");
        if (tooltipElement) {
            // 参考原生的 initBlockPopover 函数
            document.addEventListener('mouseover', updateTooltipData);
        } else {
            // TODO跟进 PR 合并后才能用这个功能,不过没合并之前也不会有问题,会执行 else 分支 https://github.com/siyuan-note/siyuan/pull/13966
            console.log("Whisper: tooltip element does not exist.");
        }
    })();
})();

@TCOTC TCOTC mentioned this pull request Jan 29, 2025
@TCOTC
Copy link
Contributor Author

TCOTC commented Jan 29, 2025

重新改了:#13966

@TCOTC TCOTC closed this Jan 29, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants