feat: 添加站点卡片悬停提示功能

- 为所有站点卡片添加 data-tooltip 属性,包含完整的标题和描述信息
- tooltip 显示逻辑:
  * 鼠标悬停在整个卡片上即可触发(触发区域大,操作自然)
  * 跟随鼠标移动,实时更新位置
  * 智能边界检测,避免 tooltip 超出视口范围
  * 鼠标移出时自动隐藏
- 解决文本截断问题,用户可通过悬停查看完整内容

实现:
- 模板层:在 site-card.hbs 中为卡片添加 data-tooltip 属性
- 交互层:在 script.js 中实现 tooltip 的创建、显示、移动和隐藏逻辑
- 样式层:通过 CSS 类控制 tooltip 的可见性和位置

Issue: #31
This commit is contained in:
rbetree
2026-01-03 18:02:37 +08:00
parent 3473aaebd7
commit 2bebefbfe8
3 changed files with 592 additions and 428 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -274,6 +274,7 @@ window.MeNav = {
newSite.href = siteUrl; newSite.href = siteUrl;
newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : ''); newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : '');
newSite.setAttribute('data-tooltip', siteName + (siteDescription ? ' - ' + siteDescription : '')); // 添加自定义 tooltip
if (/^https?:\/\//i.test(siteUrl)) { if (/^https?:\/\//i.test(siteUrl)) {
newSite.target = '_blank'; newSite.target = '_blank';
newSite.rel = 'noopener'; newSite.rel = 'noopener';
@@ -1909,3 +1910,73 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
}); });
// Tooltip functionality for truncated text
document.addEventListener('DOMContentLoaded', () => {
// Create tooltip element
const tooltip = document.createElement('div');
tooltip.className = 'custom-tooltip';
document.body.appendChild(tooltip);
let activeElement = null;
// Show tooltip on hover
document.addEventListener('mouseover', (e) => {
const target = e.target.closest('[data-tooltip]');
if (target) {
const tooltipText = target.getAttribute('data-tooltip');
if (tooltipText) {
activeElement = target;
tooltip.textContent = tooltipText;
tooltip.classList.add('visible');
updateTooltipPosition(e);
}
}
});
// Move tooltip with cursor
document.addEventListener('mousemove', (e) => {
if (activeElement) {
updateTooltipPosition(e);
}
});
// Hide tooltip on mouse out
document.addEventListener('mouseout', (e) => {
const target = e.target.closest('[data-tooltip]');
if (target && target === activeElement) {
// Check if we really left the element (not just went to a child)
if (!target.contains(e.relatedTarget)) {
activeElement = null;
tooltip.classList.remove('visible');
}
}
});
function updateTooltipPosition(e) {
// Position tooltip 15px below/right of cursor
const x = e.clientX + 15;
const y = e.clientY + 15;
// Boundary checks to keep inside viewport
const rect = tooltip.getBoundingClientRect();
const winWidth = window.innerWidth;
const winHeight = window.innerHeight;
let finalX = x;
let finalY = y;
// If tooltip goes off right edge
if (x + rect.width > winWidth) {
finalX = e.clientX - rect.width - 10;
}
// If tooltip goes off bottom edge
if (y + rect.height > winHeight) {
finalY = e.clientY - rect.height - 10;
}
tooltip.style.left = finalX + 'px';
tooltip.style.top = finalY + 'px';
}
});

View File

@@ -1,203 +1,173 @@
{{#if url}} {{#if url}}
<a href="{{url}}" class="site-card{{#if style}} site-card-{{style}}{{/if}}" <a href="{{url}}" class="site-card{{#if style}} site-card-{{style}}{{/if}}" {{#if external}}target="_blank"
{{#if external}}target="_blank" rel="noopener"{{/if}} rel="noopener" {{/if}} data-type="{{#if type}}{{type}}{{else}}site{{/if}}" data-name="{{name}}" data-url="{{url}}"
data-type="{{#if type}}{{type}}{{else}}site{{/if}}" data-icon="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}}" {{#if faviconUrl}}data-favicon-url="{{faviconUrl}}"
data-name="{{name}}" {{/if}} {{#if forceIconMode}}data-force-icon-mode="{{forceIconMode}}" {{/if}}
data-url="{{url}}" data-description="{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}"
data-icon="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}}" data-tooltip="{{#if name}}{{name}}{{else}}未命名站点{{/if}}{{#if description}} - {{description}}{{else}} - {{extractDomain url}}{{/if}}"
{{#if faviconUrl}}data-favicon-url="{{faviconUrl}}"{{/if}} {{#if publishedAt}}data-published-at="{{publishedAt}}" {{/if}} {{#if source}}data-source="{{source}}" {{/if}}>
{{#if forceIconMode}}data-force-icon-mode="{{forceIconMode}}"{{/if}} {{!-- articles首行图标+标题;下方“时间/来源 + 简介”全宽对齐,不被图标列缩进 --}}
data-description="{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}" {{#ifEquals type "article"}}
{{#if publishedAt}}data-published-at="{{publishedAt}}"{{/if}} <div class="article-card-header">
{{#if source}}data-source="{{source}}"{{/if}}> <div class="site-card-icon" aria-hidden="true">
{{!-- articles首行图标+标题;下方“时间/来源 + 简介”全宽对齐,不被图标列缩进 --}} {{!-- 站点图标优先级faviconUrl > forceIconMode > 全局 icons.mode --}}
{{#ifEquals type "article"}} {{#if faviconUrl}}
<div class="article-card-header"> <div class="icon-container">
<div class="site-card-icon" aria-hidden="true"> <i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
{{!-- 站点图标优先级faviconUrl > forceIconMode > 全局 icons.mode --}} <img class="favicon-icon" src="{{faviconUrl}}" alt="{{name}} favicon" loading="lazy"
{{#if faviconUrl}} onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
<div class="icon-container"> onerror="this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');" />
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i> <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
<img </div>
class="favicon-icon" {{else}}
src="{{faviconUrl}}" {{#ifEquals forceIconMode "manual"}}
alt="{{name}} favicon" <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
loading="lazy" {{else}}
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');" {{#ifEquals forceIconMode "favicon"}}
onerror="this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');" {{#ifHttpUrl url}}
/> <div class="icon-container">
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i> <i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
</div> <img class="favicon-icon" src="{{faviconUrl url}}" alt="{{name}} favicon" loading="lazy"
{{else}} onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
{{#ifEquals forceIconMode "manual"}} onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; this.src = '{{faviconFallbackUrl url}}'; return; } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');" />
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i> <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
{{else}} </div>
{{#ifEquals forceIconMode "favicon"}} {{else}}
{{#ifHttpUrl url}} <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
<div class="icon-container"> {{/ifHttpUrl}}
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i> {{else}}
<img {{#ifEquals @root.icons.mode "favicon"}}
class="favicon-icon" {{#ifHttpUrl url}}
src="{{faviconUrl url}}" <div class="icon-container">
alt="{{name}} favicon" <i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
loading="lazy" <img class="favicon-icon" src="{{faviconUrl url}}" alt="{{name}} favicon" loading="lazy"
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');" onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; this.src = '{{faviconFallbackUrl url}}'; return; } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');" onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; this.src = '{{faviconFallbackUrl url}}'; return; } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');" />
/> <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i> </div>
</div> {{else}}
{{else}} <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i> {{/ifHttpUrl}}
{{/ifHttpUrl}} {{else}}
{{else}} <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{#ifEquals @root.icons.mode "favicon"}} {{/ifEquals}}
{{#ifHttpUrl url}} {{/ifEquals}}
<div class="icon-container"> {{/ifEquals}}
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i> {{/if}}
<img </div>
class="favicon-icon" <div class="article-card-title">
src="{{faviconUrl url}}" <h3>{{#if name}}{{name}}{{else}}未命名站点{{/if}}</h3>
alt="{{name}} favicon" </div>
loading="lazy" </div>
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; this.src = '{{faviconFallbackUrl url}}'; return; } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
/>
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
</div>
{{else}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifHttpUrl}}
{{else}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifEquals}}
{{/ifEquals}}
{{/ifEquals}}
{{/if}}
</div>
<div class="article-card-title">
<h3>{{#if name}}{{name}}{{else}}未命名站点{{/if}}</h3>
</div>
</div>
<div class="article-card-body"> <div class="article-card-body">
{{#ifCond publishedAt '||' source}} {{#ifCond publishedAt '||' source}}
<div class="site-card-meta"> <div class="site-card-meta">
{{#if publishedAt}} {{#if publishedAt}}
<span class="site-card-meta-date">{{formatDate publishedAt "YYYY-MM-DD"}}</span> <span class="site-card-meta-date">{{formatDate publishedAt "YYYY-MM-DD"}}</span>
{{/if}} {{/if}}
{{#ifCond publishedAt '&&' source}} {{#ifCond publishedAt '&&' source}}
<span class="site-card-meta-sep">·</span> <span class="site-card-meta-sep">·</span>
{{/ifCond}} {{/ifCond}}
{{#if source}} {{#if source}}
<span class="site-card-meta-source">{{source}}</span> <span class="site-card-meta-source">{{source}}</span>
{{/if}} {{/if}}
</div> </div>
{{/ifCond}} {{/ifCond}}
<p>{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}</p> <p>{{#if
</div> description}}{{description}}{{else}}{{extractDomain url}}{{/if}}</p>
{{else}} </div>
{{!-- projects代码仓库风格卡片保留 data-* 结构,便于扩展识别与写回) --}} {{else}}
{{#ifEquals style "repo"}} {{!-- projects代码仓库风格卡片保留 data-* 结构,便于扩展识别与写回) --}}
<div class="repo-header"> {{#ifEquals style "repo"}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-code{{/if}} repo-icon" aria-hidden="true"></i> <div class="repo-header">
<div class="repo-title">{{#if name}}{{name}}{{else}}未命名项目{{/if}}</div> <i class="{{#if icon}}{{icon}}{{else}}fas fa-code{{/if}} repo-icon" aria-hidden="true"></i>
</div> <div class="repo-title">{{#if name}}{{name}}{{else}}未命名项目{{/if}}</div>
</div>
<div class="repo-desc">{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}</div> <div class="repo-desc">{{#if
description}}{{description}}{{else}}{{extractDomain url}}{{/if}}</div>
{{#ifCond language '||' stars}} {{#ifCond language '||' stars}}
<div class="repo-stats"> <div class="repo-stats">
{{#if language}} {{#if language}}
<div class="stat-item"> <div class="stat-item">
<span class="lang-dot" style="background-color: {{#if languageColor}}{{languageColor}}{{else}}#909296{{/if}};"></span> <span class="lang-dot"
{{language}} style="background-color: {{#if languageColor}}{{languageColor}}{{else}}#909296{{/if}};"></span>
</div> {{language}}
{{/if}} </div>
{{#if stars}} {{/if}}
<div class="stat-item"> {{#if stars}}
<i class="far fa-star" aria-hidden="true"></i> {{stars}} <div class="stat-item">
</div> <i class="far fa-star" aria-hidden="true"></i> {{stars}}
{{/if}} </div>
{{#if forks}} {{/if}}
<div class="stat-item"> {{#if forks}}
<i class="fas fa-code-branch" aria-hidden="true"></i> {{forks}} <div class="stat-item">
</div> <i class="fas fa-code-branch" aria-hidden="true"></i> {{forks}}
{{/if}} </div>
{{#if issues}} {{/if}}
<div class="stat-item"> {{#if issues}}
<i class="fas fa-exclamation-circle" aria-hidden="true"></i> {{issues}} <div class="stat-item">
</div> <i class="fas fa-exclamation-circle" aria-hidden="true"></i> {{issues}}
{{/if}} </div>
</div> {{/if}}
{{/ifCond}} </div>
{{else}} {{/ifCond}}
<div class="site-card-icon" aria-hidden="true"> {{else}}
{{!-- 站点图标优先级faviconUrl > forceIconMode > 全局 icons.mode --}} <div class="site-card-icon" aria-hidden="true">
{{#if faviconUrl}} {{!-- 站点图标优先级faviconUrl > forceIconMode > 全局 icons.mode --}}
<div class="icon-container"> {{#if faviconUrl}}
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i> <div class="icon-container">
<img <i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
class="favicon-icon" <img class="favicon-icon" src="{{faviconUrl}}" alt="{{name}} favicon" loading="lazy"
src="{{faviconUrl}}" onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
alt="{{name}} favicon" onerror="this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');" />
loading="lazy" <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');" </div>
onerror="this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');" {{else}}
/> {{#ifEquals forceIconMode "manual"}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i> <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
</div> {{else}}
{{else}} {{#ifEquals forceIconMode "favicon"}}
{{#ifEquals forceIconMode "manual"}} {{#ifHttpUrl url}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i> <div class="icon-container">
{{else}} <i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
{{#ifEquals forceIconMode "favicon"}} <img class="favicon-icon" src="{{faviconUrl url}}" alt="{{name}} favicon" loading="lazy"
{{#ifHttpUrl url}} onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
<div class="icon-container"> onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; this.src = '{{faviconFallbackUrl url}}'; return; } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');" />
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i> <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
<img </div>
class="favicon-icon" {{else}}
src="{{faviconUrl url}}" <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
alt="{{name}} favicon" {{/ifHttpUrl}}
loading="lazy" {{else}}
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');" {{#ifEquals @root.icons.mode "favicon"}}
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; this.src = '{{faviconFallbackUrl url}}'; return; } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');" {{#ifHttpUrl url}}
/> <div class="icon-container">
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i> <i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
</div> <img class="favicon-icon" src="{{faviconUrl url}}" alt="{{name}} favicon" loading="lazy"
{{else}} onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i> onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; this.src = '{{faviconFallbackUrl url}}'; return; } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');" />
{{/ifHttpUrl}} <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
{{else}} </div>
{{#ifEquals @root.icons.mode "favicon"}} {{else}}
{{#ifHttpUrl url}} <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
<div class="icon-container"> {{/ifHttpUrl}}
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i> {{else}}
<img <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
class="favicon-icon" {{/ifEquals}}
src="{{faviconUrl url}}" {{/ifEquals}}
alt="{{name}} favicon" {{/ifEquals}}
loading="lazy" {{/if}}
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');" </div>
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; this.src = '{{faviconFallbackUrl url}}'; return; } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
/>
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
</div>
{{else}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifHttpUrl}}
{{else}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifEquals}}
{{/ifEquals}}
{{/ifEquals}}
{{/if}}
</div>
<div class="site-card-content"> <div class="site-card-content">
<h3>{{#if name}}{{name}}{{else}}未命名站点{{/if}}</h3> <h3>{{#if name}}{{name}}{{else}}未命名站点{{/if}}</h3>
<p>{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}</p> <p>{{#if
</div> description}}{{description}}{{else}}{{extractDomain url}}{{/if}}</p>
{{/ifEquals}} </div>
{{/ifEquals}} {{/ifEquals}}
{{/ifEquals}}
</a> </a>
{{/if}} {{/if}}