// ==UserScript==// @name Heyuri Backlinks Extended// @namespace http://tampermonkey.net/// @version 0.2// @description Adds backlinks to posts on Heyuri imageboard—including when posts are referenced by filename or partial comment (like >filename.jpg) as well as by post number—for easier navigation.// @author Your Name// @match https://*.heyuri.net/*// @grant none// ==/UserScript==(function() { 'use strict'; // Wait for the page to fully load window.addEventListener('load', function() { // Collect all posts (both OP and replies) into a map for quick lookup. const posts = document.querySelectorAll('.post.reply, .post.op'); const postMap = {}; posts.forEach(post => { // Each post element should have an id like "p48". postMap[post.id] = post; }); // --- Helper functions from your preview script --- // Returns true if the given post appears above the context element. function isPostAboveContext(post, contextElement) { const postRect = post.getBoundingClientRect(); const contextRect = contextElement.getBoundingClientRect(); return postRect.top < contextRect.top; } // Finds a matching post id by checking the quoted text against previous posts. // includeUnkfunc is typically true for double-arrow (>>43) quotes and false for single (e.g. >filename.jpg). function findMatchingPostId(quotedText, contextElement, includeUnkfunc) { // First, check if the quoted text is in "No. 43" format. const noMatch = quotedText.match(/^No\. ?(\d+)$/); if (noMatch) { const postId = `p${noMatch[1]}`; const post = document.getElementById(postId); if (post && isPostAboveContext(post, contextElement)) { return postId; } return 'notFound'; } // Look only at posts that are above the current (context) post. const contextRect = contextElement.getBoundingClientRect(); const candidatePosts = Array.from(document.querySelectorAll('.post.reply, .post.op')) .filter(post => { const postRect = post.getBoundingClientRect(); return postRect.top < contextRect.top; }) .reverse(); // Search starting with the most recent // Try matching based on comment text or filename. for (const post of candidatePosts) { const comment = post.querySelector('.comment'); const filenameLink = post.querySelector('.filesize a'); if (comment) { let textToCheck; if (!includeUnkfunc) { // If not including .unkfunc elements (i.e. for non-double quotes), remove them before matching. const clone = comment.cloneNode(true); clone.querySelectorAll('.unkfunc').forEach(el => el.remove()); textToCheck = clone.textContent; } else { textToCheck = comment.textContent; } if (textToCheck.includes(quotedText)) { return post.id; } } // Also check for an exact filename match. if (filenameLink) { const visibleFilename = filenameLink.textContent.trim(); const fullFilename = filenameLink.getAttribute('onmouseover') ?.match(/this\.textContent='([^']+)'/)?.[1] || visibleFilename; if (fullFilename === quotedText) { return post.id; } } } return 'notFound'; } // --- Backlinks processing --- // Process each post (the quoting post) posts.forEach(post => { // Get the post number from the .postnum .qu element (this labels the backlink). const quAnchor = post.querySelector('.postnum .qu'); if (!quAnchor) return; const quotingPostNum = quAnchor.textContent.trim(); // Look for quote triggers inside the comment. // This covers both standard quotelinks (a.quotelink) and preview triggers (elements with class .unkfunc). const quoteElements = post.querySelectorAll('.comment a.quotelink, .comment .unkfunc'); quoteElements.forEach(quoteEl => { const rawText = quoteEl.textContent.trim(); if (!rawText.startsWith('>')) return; let targetId = null; // If an href exists and contains a hash, use it (this usually works for numeric quotes like >>43). const href = quoteEl.getAttribute('href'); if (href && href.indexOf('#') !== -1) { targetId = href.substring(href.indexOf('#') + 1); } else { // Otherwise, use the preview matching logic. // Remove the first ">"; if an extra ">" is found, mark this as a double-quote. let quotedText = rawText.slice(1).trim(); let isDoubleQuote = false; if (quotedText.startsWith('>')) { isDoubleQuote = true; quotedText = quotedText.slice(1).trim(); } // Use the current post as the context element. targetId = findMatchingPostId(quotedText, post, isDoubleQuote); } // Skip if no valid target was found. if (!targetId || targetId === 'notFound') return; const targetPost = postMap[targetId]; if (!targetPost) return; // Look for the backlinks container in the target post. const backlinksContainer = targetPost.querySelector('.backlinks'); if (!backlinksContainer) return; // Prevent duplicate backlinks. if (backlinksContainer.querySelector(`a[href="#${post.id}"]`)) return; // Create the backlink element. const backlink = document.createElement('a'); backlink.href = `#${post.id}`; backlink.textContent = `>>${quotingPostNum}`; backlink.className = 'backlink'; // If the container already has content, insert a space. if (backlinksContainer.textContent.trim() !== '') { backlinksContainer.appendChild(document.createTextNode(' ')); } backlinksContainer.appendChild(backlink); }); }); });})();
(function () { 'use strict'; const MIN_WIDTH = 0; // Minimum width of the preview box const OFFSET_X = 10; // Offset from the mouse cursor const RIGHT_MARGIN = 30; // Margin from the right edge of the viewport const PREVIEW_DELAY = 300; // delay before showing preview (ms) const REMOVAL_DELAY = 50; // delay before removal check (ms) // Inject style for highlighted quoted text const highlightStyle = document.createElement('style'); highlightStyle.innerHTML = '.quoteHighlight { background-color: yellow; }'; document.head.appendChild(highlightStyle); // Each preview object contains: // { box, trigger, parent, contextPost } let previewStack = []; // Creates and appends a preview box. function createPreviewBox(notFound = false) { const box = document.createElement('div'); box.classList.add('previewBox'); // Apply basic positioning styles box.style.position = 'absolute'; box.style.zIndex = '1000'; box.style.boxSizing = 'border-box'; // We use a dynamic min width; for mobile this remains flexible. box.style.minWidth = `${MIN_WIDTH}px`; // Set a maxWidth to 90% of viewport width so it doesn’t cover the entire screen. // Max height is also limited to avoid huge posts overtaking the viewport. box.style.maxWidth = `${Math.floor(window.innerWidth * 0.9)}px`; box.style.maxHeight = `${Math.floor(window.innerHeight * 0.9)}px`; box.style.overflowY = 'auto'; if (notFound) { box.innerHTML = ` <div class="post reply"> Quote source not found </div> `; } document.body.appendChild(box); return box; } // Called when hovering over a .unkfunc element. function startHover(event) { const trigger = event.currentTarget; if (trigger.hoverTimeout) return; // Capture updated mouse position let latestMouseEvent = event; function updateMouse(e) { latestMouseEvent = e; } document.addEventListener('mousemove', updateMouse); trigger.hoverTimeout = setTimeout(() => { trigger.hoverTimeout = null; // Remove the mousemove listener now that we have the updated mouse event document.removeEventListener('mousemove', updateMouse); const rawText = trigger.textContent; if (!rawText.startsWith('>')) return; let quotedText = rawText.slice(1).trim(); // Remove ">" and trim whitespace const isDoubleQuote = quotedText.startsWith('>'); if (isDoubleQuote) { quotedText = quotedText.slice(1).trim(); // Remove extra ">" for double >> } // Determine proper lookup context: // If inside a preview, use the original post that was previewed (stored as contextPost). // Otherwise, use the closest .post element. let contextElement; let parentPreviewObj = null; const parentBox = trigger.closest('.previewBox'); if (parentBox) { parentPreviewObj = previewStack.find(obj => obj.box === parentBox) || null; } if (parentPreviewObj && parentPreviewObj.contextPost) { contextElement = parentPreviewObj.contextPost; } else { contextElement = trigger.closest('.post'); if (!contextElement) return; } // Record the parent preview (if any) let parentPreview = parentPreviewObj || null; const matchingPostId = findMatchingPostId(quotedText, contextElement, isDoubleQuote); const post = document.getElementById(matchingPostId); const isOP = post && post.classList.contains('op'); const previewBox = createPreviewBox(matchingPostId === 'notFound'); // Create a preview object. // Note: contextPost is the original post element from which the preview was made. let previewObj = { box: previewBox, trigger: trigger, parent: parentPreview, contextPost: post ? post : contextElement }; previewStack.push(previewObj); if (post && matchingPostId !== 'notFound') { previewBox.innerHTML = ''; // Clear any existing content // Clone the entire post and append it to the preview box const clonedPost = post.cloneNode(true); clonedPost.removeAttribute('id'); clonedPost.style.margin = '0'; // Remove any margins previewBox.appendChild(clonedPost); // Highlight the quoted text inside the preview box highlightQuotedText(previewBox, quotedText); } attachPreviewHoverHandlers(previewObj); // Apply the hover event listeners to the quotes within the preview box applyHoverListeners(previewBox); // Position the preview box according to the mouse position and viewport constraints positionPreviewBox(previewBox, latestMouseEvent); previewBox.style.display = 'block'; }, PREVIEW_DELAY); } // Clears the hover timeout on the trigger. function stopHover(event) { const trigger = event.currentTarget; if (trigger.hoverTimeout) { clearTimeout(trigger.hoverTimeout); trigger.hoverTimeout = null; } // Also trigger a global check shortly after the mouse leaves setTimeout(checkPreviews, REMOVAL_DELAY); } // Attach mouseenter/mouseleave handlers to both preview box and trigger. function attachPreviewHoverHandlers(previewObj) { const { box, trigger } = previewObj; box.addEventListener('mouseenter', () => { /* no-op */ }); box.addEventListener('mouseleave', () => { setTimeout(checkPreviews, REMOVAL_DELAY); }); trigger.addEventListener('mouseenter', () => { /* no-op */ }); trigger.addEventListener('mouseleave', () => { setTimeout(checkPreviews, REMOVAL_DELAY); }); // Update preview position as the mouse moves trigger.addEventListener('mousemove', (event) => { positionPreviewBox(box, event); }); } // Global removal check: // Iterate through all previews and remove any (and their descendants) // that aren’t hovered (or do not have a descendant that is hovered). function checkPreviews() { previewStack.slice().forEach(previewObj => { if (!isPreviewOrDescendantHovered(previewObj)) { removePreviewAndDescendants(previewObj); } }); } // Recursively checks if this preview or any of its descendants are hovered. function isPreviewOrDescendantHovered(previewObj) { if (previewObj.box.matches(':hover') || previewObj.trigger.matches(':hover')) { return true; } const children = previewStack.filter(obj => obj.parent === previewObj); for (const child of children) { if (isPreviewOrDescendantHovered(child)) { return true; } } return false; } // Recursively remove the preview and its descendants. function removePreviewAndDescendants(previewObj) { const children = previewStack.filter(obj => obj.parent === previewObj); for (const child of children) { removePreviewAndDescendants(child); } if (previewObj.box.parentNode) { previewObj.box.parentNode.removeChild(previewObj.box); } previewStack = previewStack.filter(obj => obj !== previewObj); } // Positions the preview box near the mouse. function positionPreviewBox(previewBox, event) { const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; // Update max dimensions to keep the preview from overwhelming the screen. previewBox.style.maxWidth = `${Math.floor(viewportWidth * 0.9)}px`; previewBox.style.maxHeight = `${Math.floor(viewportHeight * 0.9)}px`; previewBox.style.overflowY = 'auto'; previewBox.style.display = 'block'; // Ensure the preview box is displayed for correct measurement const boxWidth = previewBox.offsetWidth; let left; // Decide based on available space on right vs left of the mouse cursor const availableRight = viewportWidth - event.clientX; const availableLeft = event.clientX; if (availableRight > availableLeft) { left = event.clientX + OFFSET_X; // Make sure the preview doesn't go off the right edge if (left + boxWidth > viewportWidth - RIGHT_MARGIN) { left = viewportWidth - boxWidth - RIGHT_MARGIN; } } else { left = event.clientX - OFFSET_X - boxWidth; if (left < 0) { left = 0; } } previewBox.style.left = `${left}px`; // Determine vertical position const rect = event.target.getBoundingClientRect(); const previewHeight = previewBox.offsetHeight; const topBelow = rect.bottom + window.scrollY; // Position below the quote const topAbove = rect.top + window.scrollY - previewHeight; // Position above the quote // Check if there's enough space below; if not, use the position above (ensuring it doesn't go off the top) if (rect.bottom + previewHeight > viewportHeight) { previewBox.style.top = `${Math.max(topAbove, window.scrollY)}px`; } else { previewBox.style.top = `${topBelow}px`; } } // Searches for the matching post based on quoted text. function findMatchingPostId(quotedText, contextElement, includeUnkfunc) { // Check if the quoted text matches the "No. xxx" or "No.xxx" format const noMatch = quotedText.match(/^No\. ?(\d+)$/); if (noMatch) { const postId = `p${noMatch[1]}`; // Extract the post number and prepend "p" // Now try to find the post by ID const post = document.getElementById(postId); if (post && isPostAboveContext(post, contextElement)) { return postId; } return 'notFound'; } const contextRect = contextElement.getBoundingClientRect(); // Find matching post by filename or partial comment match const posts = Array.from(document.querySelectorAll('.post.reply, .post.op')) .filter(post => { const postRect = post.getBoundingClientRect(); return postRect.top < contextRect.top; }) .reverse(); // Reverse to start searching from the latest for (const post of posts) { const comment = post.querySelector('.comment'); const filenameLink = post.querySelector('.filesize a'); if (comment) { let textToCheck; if (!includeUnkfunc) { const clone = comment.cloneNode(true); clone.querySelectorAll('.unkfunc').forEach(el => el.remove()); textToCheck = clone.textContent; } else { textToCheck = comment.textContent; // Match by full comment text (allows partial matches) } if (textToCheck.includes(quotedText)) { return post.id; } } // Match by exact filename if (filenameLink) { const visibleFilename = filenameLink.textContent.trim(); const fullFilename = filenameLink.getAttribute('onmouseover') ?.match(/this\.textContent='([^']+)'/)?.[1] || visibleFilename; if (fullFilename === quotedText) { return post.id; } } } // Return a default that will not match anything return 'notFound'; } // Returns true if the post is above the context element. function isPostAboveContext(post, contextElement) { const postRect = post.getBoundingClientRect(); const contextRect = contextElement.getBoundingClientRect(); return postRect.top < contextRect.top; } // Highlight the quoted text inside the preview box's .comment elements. function highlightQuotedText(container, quotedText) { if (!quotedText) return; const comments = container.querySelectorAll('.comment'); const regex = new RegExp(escapeRegExp(quotedText), 'gi'); comments.forEach(comment => { comment.innerHTML = comment.innerHTML.replace(regex, '<span class="quoteHighlight">$&</span>'); }); } // Escape special characters for RegExp function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } // Attach hover listeners to all .unkfunc elements within the given element. function applyHoverListeners(element) { element.querySelectorAll('.unkfunc').forEach((quoted) => { quoted.addEventListener('mouseover', startHover); quoted.addEventListener('mouseout', stopHover); }); } // Also run a global check on mousemove. let mousemoveTimeout; document.addEventListener('mousemove', () => { clearTimeout(mousemoveTimeout); mousemoveTimeout = setTimeout(checkPreviews, REMOVAL_DELAY); }); applyHoverListeners(document);})();