diyhrt/transfemscience.org/assets/js/hover-refs.js

382 lines
14 KiB
JavaScript
Raw Permalink Normal View History

2026-02-07 02:11:00 +00:00
/**
* Hover References Feature
*
* Matches inline citation links with their corresponding full reference
* from the reference list by comparing URLs (hrefs).
*/
(function () {
document.addEventListener('DOMContentLoaded', function () {
initHoverReferences();
});
function initHoverReferences() {
// 1. Find the References section and list
const referencesHeader = findReferencesHeader();
if (!referencesHeader) return;
// Helper to normalize URLs for matching
// 1. Decode URI (handles %5B vs [ mismatch)
// 2. Lowercase
const normalizeRefUrl = (url) => {
let u = "";
try {
u = decodeURI(url).toLowerCase();
} catch (e) {
u = url.toLowerCase();
}
u = u.replace(/^(https?:)?\/\//, '').replace(/^www\./, '');
// Check for archive.org URL (e.g. web.archive.org/web/20070430161048/http://... or .../20210717084442if_/...)
const archiveRegex = /^web\.archive\.org\/web\/\d+(?:if_)?\//;
if (archiveRegex.test(u)) {
u = u.replace(archiveRegex, '');
// Re-strip protocol and www from the target URL
u = u.replace(/^(https?:)?\/\//, '').replace(/^www\./, '');
}
return u.split('#')[0].replace(/\/$/, '');
};
// Find all reference lists within the References section
// The References section might have subsections, each with their own list
// We need to find the container element that encompasses all References content
// Strategy: Find the parent section/container, or collect all siblings until next same-level header
let referenceLists = [];
let currentElement = referencesHeader.nextElementSibling;
const headerLevel = parseInt(referencesHeader.tagName.charAt(1)) || 2;
while (currentElement) {
// Stop if we hit another header of same or higher level (lower number)
if (/^H[1-6]$/.test(currentElement.tagName)) {
const currentLevel = parseInt(currentElement.tagName.charAt(1));
if (currentLevel <= headerLevel) {
break; // End of References section
}
}
// Collect all UL and OL elements (including nested ones in subsections)
if (currentElement.tagName === 'UL' || currentElement.tagName === 'OL') {
referenceLists.push(currentElement);
}
// Also check for lists inside divs or other container elements
const nestedLists = currentElement.querySelectorAll('ul, ol');
nestedLists.forEach(list => referenceLists.push(list));
currentElement = currentElement.nextElementSibling;
}
if (referenceLists.length === 0) return;
// 2. Parse all reference list items from all lists
// Map of "URL" -> HTML content
const urlMap = new Map();
referenceLists.forEach(referenceList => {
const listItems = referenceList.querySelectorAll(':scope > li'); // Direct children only to avoid duplicates
listItems.forEach(item => {
// Find all links in the reference item
const links = item.querySelectorAll('a');
links.forEach(link => {
const rawHref = link.getAttribute('href');
if (rawHref && !rawHref.startsWith('#')) { // Ignore anchor links if any
const href = normalizeRefUrl(rawHref); // Normalized for storage
// Store the item HTML for this URL
// If multiple refs share a URL (unlikely but possible), the last one wins,
// or we could store an array. For citations, usually unique DOI/URL per ref.
urlMap.set(href, item.innerHTML);
}
});
});
});
// 3. Find inline citation links
// We look for links inside the article body
const articleBody = document.getElementById('article');
if (!articleBody) return;
const links = articleBody.querySelectorAll('a');
// Create the hover box element
const hoverBox = document.createElement('div');
hoverBox.classList.add('reference-hover-box');
hoverBox.style.display = 'none';
document.body.appendChild(hoverBox);
let hideTimeout;
// Keep box open when hovering over it
hoverBox.addEventListener('mouseenter', () => {
if (hideTimeout) clearTimeout(hideTimeout);
});
hoverBox.addEventListener('mouseleave', () => {
hideTimeout = setTimeout(() => {
hoverBox.style.display = 'none';
}, 100);
});
links.forEach(link => {
const rawHref = link.getAttribute('href');
// Skip links inside any of the reference lists
const isInReferenceList = referenceLists.some(list => list.contains(link));
if (isInReferenceList) return;
2026-02-14 02:11:37 +00:00
// We only care if the link HAS an href
2026-02-07 02:11:00 +00:00
if (rawHref && !rawHref.startsWith('#')) {
2026-02-14 02:11:37 +00:00
// FIRST: Check if this link should show a hover box based on parentheses
// Check if the link itself contains parentheses with a year (e.g. "(2005)" or "(2005a)" or "(Aly, 2020)")
// We allow other text inside the parens, but it MUST contain a year-like number.
const yearParensRegex = /\([^)]*\b\d{4}[a-z]?\b[^)]*\)/i;
const textHasParens = link.textContent.includes('(') || link.textContent.includes(')');
const textMatchingParensYear = yearParensRegex.test(link.textContent);
// Check if enclosed in parentheses by walking siblings
let isEnclosed = false;
// We only scan if:
// 1. The text itself doesn't contain matching parens (if it does, we already know if it's valid or not)
// OR
// 2. The text doesn't contain parens at all (so we look for surrounding ones)
if (!textMatchingParensYear && !textHasParens) {
// If text has parens but didn't match yearParensRegex, it's invalid (e.g. "Kuhl (Citation)")
// So we only scan if text does NOT have parens.
let openParenCount = 0;
let foundOpen = false;
let curr = link.previousSibling;
let scans = 0;
const MAX_SCANS = 100; // Reasonable lookbehind limit
while (curr && scans < MAX_SCANS) {
if (curr.nodeType === 3) { // Text node
const txt = curr.textContent;
// Count parens from right to left
for (let i = txt.length - 1; i >= 0; i--) {
const c = txt[i];
if (c === ')') openParenCount--;
else if (c === '(') openParenCount++;
if (openParenCount > 0) {
foundOpen = true;
break;
}
}
} else if (curr.nodeType === 1) { // Element node
const tagName = curr.tagName;
// Stop at block boundaries
if (/^(DIV|P|BODY|MAIN|SECTION|BLOCKQUOTE|UL|OL|LI|TABLE|BR|HR|H[1-6])$/.test(tagName)) {
break;
}
// Check text content of inline elements
const txt = curr.textContent;
for (let i = txt.length - 1; i >= 0; i--) {
const c = txt[i];
if (c === ')') openParenCount--;
else if (c === '(') openParenCount++;
if (openParenCount > 0) {
foundOpen = true;
break;
}
}
}
if (foundOpen) break;
curr = curr.previousSibling;
scans++;
}
if (foundOpen) {
isEnclosed = true;
}
}
// Only proceed if link has parentheses (in text or structurally enclosed)
if (!textMatchingParensYear && !isEnclosed) return;
// SECOND: Try to find matching reference in urlMap
2026-02-07 02:11:00 +00:00
const exactHref = normalizeRefUrl(rawHref);
const baseHref = exactHref.split('#')[0];
let bestMatchHTML = urlMap.get(exactHref);
if (!bestMatchHTML) {
bestMatchHTML = urlMap.get(baseHref);
}
2026-02-14 02:11:37 +00:00
// THIRD: Fallback for unmatched links - show the URL itself
if (!bestMatchHTML) {
// Exception: Skip if link text starts with lowercase (e.g. "(see here)" or "(more info)")
// These are likely general parenthetical links, not citations
const linkText = link.textContent.trim();
const firstChar = linkText.charAt(0);
if (firstChar && firstChar === firstChar.toLowerCase() && firstChar !== firstChar.toUpperCase()) {
return; // Skip lowercase-starting unmatched links
2026-02-07 02:11:00 +00:00
}
2026-02-14 02:11:37 +00:00
let displayUrl = rawHref;
if (rawHref.startsWith('/')) {
displayUrl = 'https://transfemscience.org' + rawHref;
2026-02-07 02:11:00 +00:00
}
2026-02-14 02:11:37 +00:00
bestMatchHTML = `<div class="fallback-url-content"><a href="${rawHref}" target="_blank">${displayUrl}</a></div>`;
2026-02-07 02:11:00 +00:00
}
2026-02-14 02:11:37 +00:00
2026-02-07 02:11:00 +00:00
if (bestMatchHTML) {
link.classList.add('reference-link');
link.addEventListener('mouseenter', (e) => {
// Clear any pending hide timeout
if (hideTimeout) clearTimeout(hideTimeout);
hoverBox.innerHTML = bestMatchHTML;
hoverBox.style.display = 'block';
// Use getClientRects to handle multi-line links; find the rect under the mouse
const rects = link.getClientRects();
let rect = rects.length > 0 ? rects[0] : link.getBoundingClientRect();
// Find the rect that contains the mouse Y position
if (rects.length > 1) {
let bestRect = rects[0];
let minDistance = Infinity;
for (let i = 0; i < rects.length; i++) {
const r = rects[i];
// Check if mouse Y is within this rect's vertical bounds
if (e.clientY >= r.top && e.clientY <= r.bottom) {
bestRect = r;
break; // Found exact line match
}
// Fallback: distance to vertical center
const centerY = r.top + (r.height / 2);
const dist = Math.abs(e.clientY - centerY);
if (dist < minDistance) {
minDistance = dist;
bestRect = r;
}
}
rect = bestRect;
}
// Positioning
let top = rect.bottom + window.scrollY; // 0px gap
let left = rect.left + window.scrollX;
// Boundary checks
if (left + hoverBox.offsetWidth > window.innerWidth) {
left = window.innerWidth - hoverBox.offsetWidth - 10;
}
hoverBox.style.top = `${top}px`;
hoverBox.style.left = `${left}px`;
});
link.addEventListener('mouseleave', () => {
// Set a timeout to hide the box, giving time to move into it
hideTimeout = setTimeout(() => {
hoverBox.style.display = 'none';
}, 100);
// Mobile Long Press Support (Touch) AND Desktop Click-and-Hold
let longPressTimer;
let isLongPress = false;
const startPress = (e) => {
// Only left click for mouse (button 0)
if (e.type === 'mousedown' && e.button !== 0) return;
isLongPress = false;
longPressTimer = setTimeout(() => {
isLongPress = true;
// Show hover box
if (hideTimeout) clearTimeout(hideTimeout);
hoverBox.innerHTML = bestMatchHTML;
hoverBox.style.display = 'block';
hoverBox.style.display = 'block';
const rects = link.getClientRects();
const rect = rects.length > 0 ? rects[0] : link.getBoundingClientRect();
let top = rect.bottom + window.scrollY;
let left = rect.left + window.scrollX;
if (left + hoverBox.offsetWidth > window.innerWidth) {
left = window.innerWidth - hoverBox.offsetWidth - 10;
}
hoverBox.style.top = `${top}px`;
hoverBox.style.left = `${left}px`;
}, 500); // 500ms for long press
};
const cancelPress = () => {
clearTimeout(longPressTimer);
};
const endPress = (e) => {
clearTimeout(longPressTimer);
if (isLongPress) {
e.preventDefault(); // Prevent default action (click/navigate)
// Note for desktop: 'click' event might still fire after mouseup if we don't prevent it there too
}
};
// Touch Listeners
link.addEventListener('touchstart', startPress, { passive: true });
link.addEventListener('touchend', endPress);
link.addEventListener('touchmove', cancelPress);
// Mouse Listeners (Desktop)
link.addEventListener('mousedown', startPress);
link.addEventListener('mouseup', endPress);
link.addEventListener('mouseleave', cancelPress);
link.addEventListener('click', (e) => {
if (isLongPress) {
e.preventDefault();
e.stopPropagation();
isLongPress = false; // Reset
}
});
link.addEventListener('contextmenu', (e) => {
if (isLongPress) {
e.preventDefault(); // Prevent default context menu
isLongPress = false; // Reset
}
});
});
}
}
});
}
function findReferencesHeader() {
// Try by ID first
let header = document.getElementById('references');
if (header) return header;
// Try by text content
const headers = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (const h of headers) {
if (h.textContent.trim().toLowerCase() === 'references') {
return h;
}
}
return null;
}
})();