hexo-floating
Version:
Simple floating content box for HEXO blogs.
450 lines (377 loc) • 16.5 kB
JavaScript
/**
* floating.js v1.0 | Custom floating hover boxes
*
* {% sethover name:<name> %}
* hover content
* {% endsethover %}
*
* {% hover name:<name> %}
* display text
* {% endhover %}
*
* {% explain <display> %}
* content (hover text)
* {% endexplain %}
*/
'use strict';
// Global storage for hover content
const hoverContent = {};
// Helper function to generate a unique ID for each hover element
function generateUniqueId() {
return 'hover-' + Math.random().toString(36).substring(2, 11);
}
// Process the hover content to ensure it displays correctly with enhanced formatting
function processHoverContent(content, hexo) {
if (!content || content.trim() === '') {
return '';
}
// Render the content with markdown
const rendered = hexo.render.renderSync({ text: content, engine: 'markdown' });
// For hover content, we want to keep the structure but optimize it for tooltips
// Remove outer <p> tags only if there's just one paragraph
// This preserves formatting for multi-paragraph content
if (rendered.match(/<p>.*?<\/p>/gs)?.length === 1) {
return rendered.replace(/^\s*<p>([\s\S]*)<\/p>\s*$/, '$1');
}
return rendered;
}
// sethover tag: stores content for later use
function sethover(ctx) {
return function (args, content) {
args = ctx.args.map(args, ['name'])
// Extract name parameter
let name = args.name;
if (name) {
// Store the processed content with the name as key
hoverContent[name] = processHoverContent(content, ctx);
}
// This tag doesn't output anything visible
return '';
};
}
// hover tag: references stored content by name
function hover(ctx) {
return function (args, content) {
args = ctx.args.map(args, ['name'])
// Extract name parameter
let name = args.name;
if (!name || !hoverContent[name]) {
return `<span style="color:red">Error: No hover content found for name '${name}'.</span>`;
}
const uniqueId = generateUniqueId();
const displayText = processHoverContent(content, ctx);
const storedContent = hoverContent[name];
// Instead of using a div inside the custom element, we'll use data attributes
// and generate the hover content with JavaScript later
let result = `<span id="${uniqueId}" class="floating-hover-target" data-hover-content="${encodeURIComponent(storedContent)}">${displayText}</span>`;
return result;
};
}
// explain tag: simpler version that doesn't need pre-stored content
function explain(ctx) {
return function (args, content) {
// Extract content parameter
args = ctx.args.map(args, [''], ['display'])
let hoverText = content;
if (!hoverText) {
return `<span style="color:red">Error: No content provided in explain tag.</span>`;
}
const uniqueId = generateUniqueId();
let displayText = processHoverContent(args.display, ctx);
const processedHoverText = processHoverContent(hoverText, ctx);
// Use data attributes instead of nested divs
let result = `<span id="${uniqueId}" class="floating-hover-target" data-hover-content="${encodeURIComponent(processedHoverText)}">${displayText}</span>`;
return result;
};
}
// Add CSS styles to the page
function injectCSS(hexo) {
hexo.extend.injector.register('head_end', () => {
return `<style>
/* Apply styles to the hover target class */
.floating-hover-target {
font-weight: 500;
position: relative;
display: inline;
cursor: help;
background: linear-gradient(to bottom, transparent 0%, transparent 90%, rgba(var(--theme-color), 0.3) 90%, rgba(var(--theme-color), 0.3) 100%);
padding: 0 2px;
border-radius: 2px;
transition: all 0.2s ease;
color: var(--text-color, #333);
border-bottom: 1px dotted rgba(var(--theme-color, 0, 0, 0), 0.5);
text-decoration: none;
outline-style: dashed;
}
.floating-hover-target:hover {
background: linear-gradient(to bottom, rgba(var(--theme-color), 0.08) 0%, rgba(var(--theme-color), 0.08) 90%, rgba(var(--theme-color), 0.5) 90%, rgba(var(--theme-color), 0.5) 100%);
}
.floating-hover-target::after {
content: var(--hover-indicator, "");
font-size: 0.85em;
vertical-align: super;
opacity: 0.6;
margin-left: 1px;
transition: opacity 0.2s ease;
}
/* Different indicator styles - can be set via CSS variables */
:root {
--hover-indicator: ""; /* Default indicator */
}
.floating-hover-content {
position: fixed; /* Changed from absolute to fixed */
visibility: hidden;
opacity: 0;
transition: opacity 0.3s ease, visibility 0.3s ease;
background-color: var(--card-background, #fff);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.10), 0 4px 8px -4px rgba(0, 0, 0, 0.12);
padding: 16px 20px;
width: max-content;
max-width: 380px;
z-index: 9999; /* Increased z-index value */
left: 0;
top: 0; /* Will be set dynamically by JS */
overflow-y: auto;
max-height: 400px;
border: 1px solid rgba(0, 0, 0, 0.06);
font-weight: normal;
color: var(--text-color, #333);
font-size: 0.95em;
line-height: 1.6;
pointer-events: auto; /* Ensures hover content can be interacted with */
backdrop-filter: blur(4px);
}
.floating-hover-content::before {
content: '';
position: absolute;
width: 12px;
height: 12px;
background-color: var(--card-background, #fff);
transform: rotate(45deg);
z-index: -1; /* Place behind the content */
}
/* Arrow positioning based on hover position */
.floating-hover-content.positioned-bottom::before {
top: -6px;
left: 20px;
border-left: 1px solid rgba(0, 0, 0, 0.06);
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.floating-hover-content.positioned-top::before {
bottom: -6px;
left: 20px;
border-right: 1px solid rgba(0, 0, 0, 0.06);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.floating-hover-content.positioned-right::before {
left: auto;
right: 20px;
}
.floating-hover-content p {
margin: 0.8em 0;
}
.floating-hover-content p:first-child {
margin-top: 0;
}
.floating-hover-content p:last-child {
margin-bottom: 0;
}
/* Show hover content when target is hovered */
.floating-hover-target:hover .floating-hover-content {
visibility: visible;
opacity: 1;
}
/* Improve the indicator appearance on hover */
.floating-hover-target:hover::after {
opacity: 1;
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark), (html[data-theme='dark']) {
.floating-hover-content {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15), 0 3px 6px -4px rgba(0, 0, 0, 0.25);
}
}
/* Fix position for hover boxes on smaller screens */
@media (max-width: 768px) {
.floating-hover-content {
max-width: 85vw; /* Limit width on mobile */
}
/* Don't center the hover boxes on mobile as they're now positioned from JS */
.floating-hover-content.positioned-centered::before {
left: 50%;
margin-left: -6px;
}
/* Adjust the info indicator to be more prominent on mobile */
.floating-hover-target::after {
font-size: 0.9em;
opacity: 0.8;
}
}
/* Make the hover content work with touch devices */
@media (pointer: coarse) {
.floating-hover-target {
-webkit-tap-highlight-color: rgba(0,0,0,0);
}
/* Give feedback when tapping */
.floating-hover-target:active {
background: linear-gradient(to bottom, transparent 0%, transparent 90%, rgba(var(--theme-color), 0.7) 90%, rgba(var(--theme-color), 0.7) 100%);
}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Create a container for all hover content elements at the document root
const hoverContainer = document.createElement('div');
hoverContainer.id = 'floating-hover-container';
hoverContainer.style.position = 'fixed';
hoverContainer.style.top = '0';
hoverContainer.style.left = '0';
hoverContainer.style.width = '0';
hoverContainer.style.height = '0';
hoverContainer.style.overflow = 'visible';
hoverContainer.style.pointerEvents = 'none'; // Don't interfere with page interaction
hoverContainer.style.zIndex = '9999';
document.body.appendChild(hoverContainer);
// Create the hover content elements dynamically
const hoverTargets = document.querySelectorAll('.floating-hover-target');
// Track currently active hover element
let activeHover = null;
hoverTargets.forEach(target => {
const hoverContent = target.getAttribute('data-hover-content');
if (!hoverContent) return;
// Create hover content div but append it to the container instead of the target
const hoverDiv = document.createElement('div');
hoverDiv.className = 'floating-hover-content';
hoverDiv.innerHTML = decodeURIComponent(hoverContent);
hoverDiv.style.pointerEvents = 'auto'; // Make the hover content interactive
hoverContainer.appendChild(hoverDiv);
// Store a reference to the hover div on the target
target.hoverDiv = hoverDiv;
// Position hover content based on target position
function positionHoverContent() {
const targetRect = target.getBoundingClientRect();
const hoverRect = hoverDiv.getBoundingClientRect();
const padding = 16; // Safe distance from viewport edges
// Reset any previous positioning
hoverDiv.style.left = '';
hoverDiv.style.right = '';
hoverDiv.style.top = '';
hoverDiv.style.bottom = '';
// Remove any previous position classes
hoverDiv.classList.remove('positioned-left', 'positioned-right', 'positioned-top', 'positioned-bottom');
// Default positioning below the target
hoverDiv.style.left = targetRect.left + 'px';
hoverDiv.style.top = (targetRect.bottom + 8) + 'px';
// After setting the initial position, get the hover dimensions
const updatedHoverRect = hoverDiv.getBoundingClientRect();
// Check if the hover would go off the right edge of the screen
if (updatedHoverRect.right > window.innerWidth - padding) {
// Align the right edge of the hover with the right edge of the target
hoverDiv.style.left = 'auto';
hoverDiv.style.right = (window.innerWidth - targetRect.right) + 'px';
hoverDiv.classList.add('positioned-right');
} else {
hoverDiv.classList.add('positioned-left');
}
// Check if the hover would go off the bottom of the screen
if (updatedHoverRect.bottom > window.innerHeight - padding) {
// Position above the target instead
hoverDiv.style.top = 'auto';
hoverDiv.style.bottom = (window.innerHeight - targetRect.top + 8) + 'px';
hoverDiv.classList.add('positioned-top');
} else {
hoverDiv.classList.add('positioned-bottom');
}
// Update the arrow position
const arrowPosition = hoverDiv.classList.contains('positioned-top') ? 'bottom' : 'top';
hoverDiv.style.setProperty('--arrow-position', arrowPosition);
}
// Show hover content on mouseenter
target.addEventListener('mouseenter', function() {
// Hide any previously shown hover content
if (activeHover && activeHover !== hoverDiv) {
activeHover.style.visibility = 'hidden';
activeHover.style.opacity = '0';
}
// Set this as the active hover
activeHover = hoverDiv;
// Position and show the hover content
hoverDiv.style.visibility = 'visible';
positionHoverContent();
// Trigger fade-in animation
setTimeout(() => {
hoverDiv.style.opacity = '1';
}, 10);
});
// Hide hover content on mouseleave
target.addEventListener('mouseleave', function(e) {
// Check if the mouse moved to the hover content itself
if (e.relatedTarget === hoverDiv || hoverDiv.contains(e.relatedTarget)) {
return;
}
hoverDiv.style.opacity = '0';
setTimeout(() => {
if (hoverDiv.style.opacity === '0') {
hoverDiv.style.visibility = 'hidden';
activeHover = null;
}
}, 300); // Match the transition duration
});
// Add mouseleave event to the hover content to hide it when mouse leaves
hoverDiv.addEventListener('mouseleave', function(e) {
// Check if the mouse moved back to the target
if (e.relatedTarget === target || target.contains(e.relatedTarget)) {
return;
}
hoverDiv.style.opacity = '0';
setTimeout(() => {
if (hoverDiv.style.opacity === '0') {
hoverDiv.style.visibility = 'hidden';
activeHover = null;
}
}, 300); // Match the transition duration
});
});
// Update hover positions on window resize
window.addEventListener('resize', function() {
if (activeHover) {
const target = Array.from(hoverTargets).find(t => t.hoverDiv === activeHover);
if (target) {
const hoverDiv = target.hoverDiv;
const targetRect = target.getBoundingClientRect();
const padding = 16;
// Update position logic here similar to positionHoverContent function
hoverDiv.style.left = targetRect.left + 'px';
hoverDiv.style.top = (targetRect.bottom + 8) + 'px';
const updatedHoverRect = hoverDiv.getBoundingClientRect();
if (updatedHoverRect.right > window.innerWidth - padding) {
hoverDiv.style.left = 'auto';
hoverDiv.style.right = (window.innerWidth - targetRect.right) + 'px';
}
if (updatedHoverRect.bottom > window.innerHeight - padding) {
hoverDiv.style.top = 'auto';
hoverDiv.style.bottom = (window.innerHeight - targetRect.top + 8) + 'px';
}
}
}
});
// Close hover content when clicking elsewhere on the page
document.addEventListener('click', function(event) {
if (activeHover && !event.target.closest('.floating-hover-target') &&
!event.target.closest('.floating-hover-content')) {
activeHover.style.opacity = '0';
setTimeout(() => {
activeHover.style.visibility = 'hidden';
activeHover = null;
}, 300);
}
});
});
</script>`;
});
}
injectCSS(hexo);
hexo.extend.tag.register('sethover', sethover(hexo), true)
hexo.extend.tag.register('hover', hover(hexo), true)
hexo.extend.tag.register('explain', explain(hexo), true)