Files
blog/themes/Ying/assets/js/modules/floating-tools.js
T
zqlit 85b49754b2 fix(comments): 悄悄话允许管理员发表 + 邮件标题优先用实时标题;返回顶部改 UIkit 同款动画
- comments.ts: 去掉旧匿名语义遗留的 !admin 前置条件 —— 博主(登录态)发的悄悄话
  以前被忽略标志、入库 is_anonymous=0 变成公开评论
- 邮件标题改用本次请求带来的 page_title(前端 .Title 总是最新),D1 pages.title
  仅兜底:存量脏数据 /comment.html 曾存成字面量 "留言",邮件渲染出《"留言"》
  (线上已清洗 17 行带引号标题 + 3 篇被污染成「留言」的文章页标题)
- 返回顶部:CSS 挂类+scrollTo 同帧执行会被浏览器按旧计算样式瞬移("一下子就上去"),
  改 UIkit scrollIntoView 同款 rAF + 余弦 ease-in-out + 40*dist^0.375 时长(上限 1.2s),
  滚轮/触摸打断、reduced-motion 瞬移;main.css 移除 .css-smooth-scroll
2026-10-05 10:52:13 +08:00

292 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 平滑滚动 —— 逻辑 1:1 对齐 UIkit 的 scrollIntoView(rAF + 余弦缓动 + 按距离算时长)。
// 踩坑记录:之前试过「滚动期间给 <html> 挂 scroll-behavior:smooth 类再 scrollTo」的
// 纯 CSS 方案,类和 scrollTo 在同一帧执行,浏览器可能拿旧的计算样式直接瞬移;
// UIkit 的做法也印证了这点 —— 它在 JS 动画期间反而强制 scroll-behavior:auto,
// 避免 CSS 平滑滚动和 JS 动画互相打架。rAF 每帧只是改一次 scrollTop,主线程开销
// 极小;时长按距离动态计算(UIkit 同款 40 * dist^0.375),短距离不拖沓、长距离不瞬移。
window.smoothScrollTo = function(targetY) {
targetY = Math.max(0, Math.floor(targetY || 0));
if (Math.abs(window.scrollY - targetY) < 2) return;
if (window.__smoothScrollRAF) cancelAnimationFrame(window.__smoothScrollRAF);
var reduced = false;
try { reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {}
if (reduced) { window.scrollTo(0, targetY); return; } // 用户要求减少动态效果:瞬移
var startY = window.scrollY;
var dist = targetY - startY;
// UIkit 同款时长:100px≈240ms、1000px≈530ms、5000px≈1.1s,上限 1.2s 兜底
var duration = Math.min(1200, 40 * Math.pow(Math.abs(dist), 0.375));
var t0 = null, stopped = false;
var stop = function () { stopped = true; }; // 用户滚轮/触摸接管,立即让位
window.addEventListener('wheel', stop, { once: true, passive: true });
window.addEventListener('touchstart', stop, { once: true, passive: true });
// UIkit 同款缓动:余弦式 ease-in-out,缓起缓收、中段最快
var ease = function (k) { return 0.5 * (1 - Math.cos(Math.PI * k)); };
var step = function (ts) {
if (stopped) return;
if (t0 === null) t0 = ts;
var p = Math.min(1, (ts - t0) / duration);
window.scrollTo(0, startY + dist * ease(p));
if (p < 1) window.__smoothScrollRAF = requestAnimationFrame(step);
else window.__smoothScrollRAF = null;
};
window.__smoothScrollRAF = requestAnimationFrame(step);
};
window.refreshFloatingTools = function() {
if (typeof window.initFloatingTools !== 'function') return;
var delays = [0, 60, 180];
delays.forEach(function(delay) {
setTimeout(function() {
requestAnimationFrame(function() {
try {
window.initFloatingTools();
} catch (e) {
console.error('initFloatingTools error:', e);
}
});
}, delay);
});
};
window.initFloatingTools = function() {
try {
// 0. Global Toolbar Visibility (Homepage Check)
var container = document.querySelector('.floating-tools');
// Robust Homepage Check:
// 1. Check for Homepage-specific element (.recent-posts)
// 2. Ensure NOT on a Post page (.post-content)
// 3. Fallback: Check pathname (only if DOM check is ambiguous)
var pjaxContainer = document.getElementById('pjax-container');
var hasRecentPosts = !!(pjaxContainer && pjaxContainer.querySelector('.recent-posts'));
var hasArticle = !!(pjaxContainer && (pjaxContainer.querySelector('#post-writing') || pjaxContainer.querySelector('#Comments')));
var path = window.location.pathname;
var isHomePage = !hasArticle && (hasRecentPosts || path === '/' || path === '/index.html' || path.startsWith('/page/'));
if (container) {
if (isHomePage) {
container.style.display = 'none';
} else {
container.style.display = 'flex';
container.classList.remove('hidden');
container.style.visibility = 'visible';
container.style.opacity = '1';
container.style.pointerEvents = 'auto';
}
}
// 1. Back to Top (Optimized with IntersectionObserver)
var backToTopButton = document.getElementById('back-to-top');
if (backToTopButton) {
// Clean up old scroll handler if exists (from previous Pjax or version)
if (window.backToTopScrollHandler) {
window.removeEventListener('scroll', window.backToTopScrollHandler);
window.backToTopScrollHandler = null;
}
// Create or reuse sentinel for IntersectionObserver
var sentinelId = 'back-to-top-sentinel';
var sentinel = document.getElementById(sentinelId);
if (!sentinel) {
sentinel = document.createElement('div');
sentinel.id = sentinelId;
// Sentinel covers the top 300px. When it exits viewport, we show button.
sentinel.style.cssText = 'position: absolute; top: 0; left: 0; width: 1px; height: 300px; pointer-events: none; visibility: hidden; z-index: -9999;';
document.body.appendChild(sentinel);
}
if (window.IntersectionObserver) {
if (window.backToTopObserver) {
window.backToTopObserver.disconnect();
}
window.backToTopObserver = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (!entry.isIntersecting) {
backToTopButton.classList.add('show');
} else {
backToTopButton.classList.remove('show');
}
});
});
window.backToTopObserver.observe(sentinel);
} else {
// Fallback for very old browsers
var ticking = false;
var update = function() {
if (window.scrollY > 300) {
backToTopButton.classList.add('show');
} else {
backToTopButton.classList.remove('show');
}
ticking = false;
};
window.backToTopScrollHandler = function() {
if (!ticking) {
window.requestAnimationFrame(update);
ticking = true;
}
};
window.addEventListener('scroll', window.backToTopScrollHandler, { passive: true });
}
// Re-bind click event
var newBtn = backToTopButton.cloneNode(true);
if (backToTopButton.parentNode) {
backToTopButton.parentNode.replaceChild(newBtn, backToTopButton);
backToTopButton = newBtn;
}
backToTopButton.addEventListener('click', function(e) {
e.preventDefault();
window.smoothScrollTo(0);
});
}
// 2. Theme Toggle
var themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
var newToggle = themeToggle.cloneNode(true);
if (themeToggle.parentNode) {
themeToggle.parentNode.replaceChild(newToggle, themeToggle);
themeToggle = newToggle;
}
// Update icon based on current theme
var updateIcon = function() {
var currentTheme = document.documentElement.getAttribute('data-theme');
var icon = themeToggle.querySelector('i');
if (icon) {
if (currentTheme === 'dark') {
icon.className = 'ri-moon-line';
} else {
icon.className = 'ri-sun-line';
}
}
};
updateIcon();
themeToggle.addEventListener('click', function(e) {
var currentTheme = document.documentElement.getAttribute('data-theme');
var newTheme = currentTheme === 'dark' ? 'light' : 'dark';
if (typeof themeSweep === 'function') {
var apply = function() {
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateIcon();
if (window.artalk && typeof window.artalk.setDarkMode === 'function') {
window.artalk.setDarkMode(newTheme === 'dark');
}
if (window.showToast) {
window.showToast('已切换至 ' + (newTheme === 'dark' ? '深色' : '浅色') + '模式', 'success');
}
};
themeSweep(newTheme, apply);
} else {
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateIcon();
if (window.artalk && typeof window.artalk.setDarkMode === 'function') {
window.artalk.setDarkMode(newTheme === 'dark');
}
if (window.showToast) {
window.showToast('已切换至 ' + (newTheme === 'dark' ? '深色' : '浅色') + '模式', 'success');
}
}
});
}
// 3. To Comments
var toComments = document.getElementById('to-comments');
if (toComments) {
// Logic: Only show if comments section exists
var checkCommentVisibility = function(btn) {
if (!btn) return;
var hasComments = document.getElementById('Comments') || document.querySelector('.artalk');
if (hasComments) {
btn.style.display = ''; // Show
} else {
btn.style.display = 'none'; // Hide
}
};
// Initial check
// Use setTimeout to avoid initial render flickering
setTimeout(function() {
checkCommentVisibility(toComments);
}, 100);
var newToComments = toComments.cloneNode(true);
if (toComments.parentNode) {
toComments.parentNode.replaceChild(newToComments, toComments);
toComments = newToComments;
}
// Re-run check on new element
checkCommentVisibility(toComments);
// Double-check after a delay for PJAX/Async rendering
setTimeout(function() {
var currentBtn = document.getElementById('to-comments');
if (currentBtn) {
checkCommentVisibility(currentBtn);
}
}, 500);
toComments.addEventListener('click', function(e) {
e.preventDefault();
var comments = document.getElementById('Comments') || document.querySelector('.artalk');
if (comments) {
var y = comments.getBoundingClientRect().top + window.scrollY - 20;
window.smoothScrollTo(y);
}
});
}
// 4. Lightbox Observer (ViewImage)
// Hide floating tools when lightbox is open
if (window.lightboxObserver) {
window.lightboxObserver.disconnect();
}
if (container && !isHomePage) {
var lightboxToolsHidden = null;
var syncLightboxState = function() {
var lightbox = document.querySelector('.view-image');
var lightboxOpen = false;
if (lightbox) {
var rect = lightbox.getBoundingClientRect();
var style = window.getComputedStyle(lightbox);
lightboxOpen = style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && rect.width > 0 && rect.height > 0;
}
if (lightboxToolsHidden === lightboxOpen) return;
lightboxToolsHidden = lightboxOpen;
if (lightboxOpen) {
container.classList.add('hidden');
} else {
container.classList.remove('hidden');
container.style.display = 'flex';
container.style.visibility = 'visible';
container.style.opacity = '1';
container.style.pointerEvents = 'auto';
}
};
window.lightboxObserver = new MutationObserver(function() {
requestAnimationFrame(syncLightboxState);
});
window.lightboxObserver.observe(document.body, { childList: true, subtree: true });
syncLightboxState();
}
} catch (e) {
console.error('Error in initFloatingTools:', e);
}
};