NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Derpibooru Comment Enhancements // @description Improvements to Derpibooru's comment section // @version 1.5.12 // @author Marker // @license MIT // @namespace https://github.com/marktaiwan/ // @homepageURL https://github.com/marktaiwan/Derpibooru-Link-Preview // @supportURL https://github.com/marktaiwan/Derpibooru-Link-Preview/issues // @match https://*.derpibooru.org/* // @match https://*.trixiebooru.org/* // @grant none // @inject-into content // @noframes // @require https://raw.githubusercontent.com/soufianesakhi/node-creation-observer-js/master/release/node-creation-observer-latest.js // @require https://github.com/marktaiwan/Derpibooru-Unified-Userscript-Ui/raw/master/derpi-four-u.js?v1.2.3 // ==/UserScript== (function () { 'use strict'; // ==== User Config ==== const config = ConfigManager('Derpibooru Comment Enhancements', 'derpi_comment_enhancements'); config.registerSetting({ title: 'Linkify images', key: 'link_images', description: 'Link embedded images to their sources. Images already containing links will be unaffected.', type: 'checkbox', defaultValue: false }); config.registerSetting({ title: 'Disable native preview', key: 'disable_native_preview', description: 'This will disable the site\'s native feature that inserts the linked comment above the current one when clicked, and instead navigates to the targted comment.', type: 'checkbox', defaultValue: false }); const spoilerSettings = config.addFieldset('Reveal spoiler...'); spoilerSettings.registerSetting({ title: '...in comment preview', key: 'show_preview_spoiler', description: 'Reveal spoilers in the pop-up preview.', type: 'checkbox', defaultValue: true }); spoilerSettings.registerSetting({ title: '...in comment highlight', key: 'show_highlight_spoiler', description: 'Reveal spoilers in the highlighted comment.', type: 'checkbox', defaultValue: true }); const LINK_IMAGES = config.getEntry('link_images'); const DISABLE_NATIVE_PREVIEW = config.getEntry('disable_native_preview'); const SHOW_PREVIEW_SPOILER = config.getEntry('show_preview_spoiler'); const SHOW_HIGHLIGHT_SPOILER = config.getEntry('show_highlight_spoiler'); // ==== /User Config ==== NodeCreationObserver.init('comment-enhancements-observer'); const HOVER_ATTRIBUTE = 'comment-preview-active'; const fetchCache = {}; const backlinksCache = {}; function $(selector, parent = document) { return parent.querySelector(selector); } function $$(selector, parent = document) { return parent.querySelectorAll(selector); } /* Unminified code from https://derpibooru.org/meta/booru-on-rails-inquiry/post/3823503#post_3823503 */ function timeAgo(args) { const strings = { seconds: 'less than a minute', minute: 'about a minute', minutes: '%d minutes', hour: 'about an hour', hours: 'about %d hours', day: 'a day', days: '%d days', month: 'about a month', months: '%d months', year: 'about a year', years: '%d years', }; function distance(time) { return new Date() - time; } function substitute(key, amount) { return strings[key].replace('%d', Math.round(amount)); } function setTimeAgo(el) { const date = new Date(el.getAttribute('datetime')); const distMillis = distance(date); /* eslint-disable no-multi-spaces */ const seconds = Math.abs(distMillis) / 1000; const minutes = seconds / 60; const hours = minutes / 60; const days = hours / 24; const months = days / 30; const years = days / 365; const words = seconds < 45 && substitute('seconds', seconds) || seconds < 90 && substitute('minute', 1) || minutes < 45 && substitute('minutes', minutes) || minutes < 90 && substitute('hour', 1) || hours < 24 && substitute('hours', hours) || hours < 42 && substitute('day', 1) || days < 30 && substitute('days', days) || days < 45 && substitute('month', 1) || days < 365 && substitute('months', months) || years < 1.5 && substitute('year', 1) || substitute('years', years); /* eslint-enable no-multi-spaces */ if (!el.getAttribute('title')) { el.setAttribute('title', el.textContent); } el.textContent = words + (distMillis < 0 ? ' from now' : ' ago'); } [].forEach.call(args, el => setTimeAgo(el)); } function displayHover(comment, sourceLink) { const PADDING = 5; // in pixels comment = comment.cloneNode(true); comment.id = 'hover_preview'; comment.style.position = 'absolute'; comment.style.maxWidth = '980px'; comment.style.minWidth = '490px'; comment.style.boxShadow = '0px 0px 12px 0px rgba(0, 0, 0, 0.4)'; if (SHOW_PREVIEW_SPOILER) { revealSpoiler(comment); } // relative time timeAgo($$('time', comment)); const container = document.getElementById('comments') || document.getElementById('content'); if (container) container.appendChild(comment); // calculate link position const linkRect = sourceLink.getBoundingClientRect(); const linkTop = linkRect.top + viewportPosition().top; const linkLeft = linkRect.left + viewportPosition().left; const commentRect = comment.getBoundingClientRect(); let commentTop; let commentLeft; if (sourceLink.parentElement.classList.contains('comment_backlinks')) { // When there is room, place the preview below the link, // otherwise place it above the link if (document.documentElement.clientHeight - linkRect.bottom > commentRect.height + PADDING) { commentTop = linkTop + linkRect.height + PADDING; commentLeft = (commentRect.width + linkLeft < document.documentElement.clientWidth) ? ( linkLeft ) : ( document.documentElement.clientWidth - (commentRect.width + PADDING) ); } else { commentTop = linkTop - commentRect.height - PADDING; commentLeft = (commentRect.width + linkLeft < document.documentElement.clientWidth) ? ( linkLeft ) : ( document.documentElement.clientWidth - (commentRect.width + PADDING) ); } } else { // When there is room, place the preview above the link // otherwise place it to the right and aligns it to the top of viewport if (linkRect.top > commentRect.height + PADDING) { commentTop = linkTop - commentRect.height - PADDING; commentLeft = (commentRect.width + linkLeft < document.documentElement.clientWidth) ? ( linkLeft ) : ( document.documentElement.clientWidth - (commentRect.width + PADDING) ); } else { commentTop = viewportPosition().top + PADDING; commentLeft = linkLeft + linkRect.width + PADDING; } } comment.style.top = commentTop + 'px'; comment.style.left = commentLeft + 'px'; } function linkEnter(sourceLink, targetCommentID, isForumPost) { sourceLink.setAttribute(HOVER_ATTRIBUTE, 1); const selector = isForumPost ? 'post_' : 'comment_'; const targetComment = document.getElementById(selector + targetCommentID); if (targetComment !== null) { highlightReplyLink(targetComment, sourceLink, isForumPost); if (!elementInViewport(targetComment)) { displayHover(targetComment, sourceLink); } if (SHOW_HIGHLIGHT_SPOILER) { revealSpoiler(targetComment); } // Highlight linked post targetComment.children[0].style.backgroundColor = 'rgba(230,230,30,0.3)'; if ($('.comment_backlinks', targetComment) !== null) targetComment.children[1].style.backgroundColor = 'rgba(230,230,30,0.3)'; } else if (!isForumPost) { const handleResponseError = (response) => (response.ok) ? response : Promise.reject('Unable to fetch from: ' + response.url); const fetchCommentHtml = (id, commentId) => fetch( `${window.location.origin}/images/${id}/comments/${commentId}`, {credentials: 'same-origin'} ).then(handleResponseError); // External post, display from cached response if possible if (fetchCache[targetCommentID] !== undefined) { displayHover(fetchCache[targetCommentID], sourceLink); } else { const imageId = getImageId(sourceLink.href); fetchCommentHtml(imageId, targetCommentID) .then((response) => (response.url !== window.location.href) ? response : Promise.reject(new Error('image_merged')) ) .catch(e => { if (e.message !== 'image_merged') throw e; // target image merged // use the comments api to find the new image id return fetch(`${window.location.origin}/api/v1/json/comments/${targetCommentID}`) .then(handleResponseError) .then(response => response.json()) .then(json => fetchCommentHtml(json.comment.image_id, targetCommentID)); }) .then(response => response.text()) .then((text) => { if (fetchCache[targetCommentID] === undefined && sourceLink.getAttribute(HOVER_ATTRIBUTE) !== '0') { const d = document.createElement('div'); d.innerHTML = text; fetchCache[targetCommentID] = d.firstChild; displayHover(d.firstChild, sourceLink); } }); } } } function linkLeave(sourceLink, targetCommentID, isForumPost) { sourceLink.setAttribute(HOVER_ATTRIBUTE, 0); const selector = isForumPost ? 'post_' : 'comment_'; const targetComment = document.getElementById(selector + targetCommentID); const preview = document.getElementById('hover_preview'); if (targetComment !== null) { // remove comment highlight targetComment.children[0].style.backgroundColor = ''; if ($('.comment_backlinks', targetComment) !== null) targetComment.children[1].style.backgroundColor = ''; // remove link highlight let ele = sourceLink; while (ele.parentElement !== null && !ele.matches('article')) ele = ele.parentElement; const sourceCommentID = ele.id.slice(selector.length); const list = $$('a[href$="#' + selector + sourceCommentID + '"]', targetComment); for (let i = 0; i < list.length; i++) { list[i].style.textDecoration = ''; } // unreveal spoilers // we use the 'reveal-preview-spoiler' attribute to avoid reverting spoilers manually revealed by users const spoilers = $$('.spoiler-revealed[reveal-preview-spoiler]', targetComment); const imgspoilers = $$('.imgspoiler-revealed[reveal-preview-spoiler]', targetComment); for (const spoiler of spoilers) { spoiler.classList.remove('spoiler-revealed'); spoiler.classList.add('spoiler'); spoiler.removeAttribute('reveal-preview-spoiler'); } for (const imgspoiler of imgspoilers) { imgspoiler.classList.remove('imgspoiler-revealed'); imgspoiler.classList.add('imgspoiler'); imgspoiler.removeAttribute('reveal-preview-spoiler'); } } if (preview !== null) preview.parentElement.removeChild(preview); } // Chrome/Firefox compatibility hack for getting viewport position function viewportPosition() { return { top: (document.documentElement.scrollTop || document.body.scrollTop), left: (document.documentElement.scrollLeft || document.body.scrollLeft) }; } function elementInViewport(el) { // Calculate the ratio of post height and viewport height, and clamp it to a min/max value, // and use it to decide whether to use highlight or preview on a comment that's partially in view. // The script will prefer the use of highlights on long comments, // when using the preview might take up most of the viewport const rect = el.getBoundingClientRect(); const ratio = Math.max(0.25, Math.min(0.95, rect.height / document.documentElement.clientHeight)); const margin = Math.round(rect.height * ratio); // pixels outside of viewport before element is considered out of view return ( rect.top + margin >= 0 && rect.bottom - margin <= document.documentElement.clientHeight ); } function createBacklinksContainer(commentBody) { let ele = $('div.comment_backlinks', commentBody); if (ele === null) { ele = document.createElement('div'); ele.className = 'block__content comment_backlinks'; ele.style.fontSize = '12px'; ele.style.borderBottom = 'none'; const wrapper = document.createElement('div'); wrapper.append(ele); commentBody.insertBefore(wrapper, $('.communication__options', commentBody)); } return ele; } function insertBacklink(backlink, commentID, isForumPost) { // add to cache if (backlinksCache[commentID] === undefined) backlinksCache[commentID] = []; if (backlinksCache[commentID].findIndex((ele) => (ele.hash == backlink.hash)) == -1) { backlinksCache[commentID].push(backlink); } const selector = isForumPost ? 'post_' : 'comment_'; const commentBody = document.getElementById(selector + commentID); if (commentBody !== null) { const linksContainer = createBacklinksContainer(commentBody); // insertion sort the links so they are ordered by id if (linksContainer.children.length > 0) { const iLinkID = getCommentId(backlink); for (let i = 0; i < linksContainer.children.length; i++) { const iTempID = getCommentId(linksContainer.children[i]); if (iLinkID == iTempID) { // prevent links to the same comment from being added multiple times return; } if (iLinkID < iTempID) { linksContainer.insertBefore(backlink, linksContainer.children[i]); return; } } } linksContainer.appendChild(backlink); return; } } function revealSpoiler(comment) { const spoilers = $$('.spoiler', comment); const imgspoilers = $$('.imgspoiler', comment); for (const spoiler of spoilers) { spoiler.classList.remove('spoiler'); spoiler.classList.add('spoiler-revealed'); spoiler.setAttribute('reveal-preview-spoiler', '1'); } for (const imgspoiler of imgspoilers) { imgspoiler.classList.remove('imgspoiler'); imgspoiler.classList.add('imgspoiler-revealed'); imgspoiler.setAttribute('reveal-preview-spoiler', '1'); } } function highlightReplyLink(comment, sourceLink, isForumPost) { const selector = isForumPost ? 'post_' : 'comment_'; let ele = sourceLink; while (ele.parentElement !== null && !ele.matches('article')) ele = ele.parentElement; const sourceCommentID = ele.id.slice(selector.length); const list = $$('a[href$="#' + selector + sourceCommentID + '"]', comment); for (let i = 0; i < list.length; i++) { list[i].style.textDecoration = 'underline dashed'; } } function getQueryVariable(key, HTMLAnchorElement) { const array = HTMLAnchorElement.search.substring(1).split('&'); for (let i = 0; i < array.length; i++) { if (key == array[i].split('=')[0]) { return array[i].split('=')[1]; } } } function getImageId(url) { const regex = new RegExp('https?://(?:www\\.|philomena\\.)?(?:(?:derpibooru\\.org|trixiebooru\\.org)/(?:images/)?(\\d+)(?:\\?.*|/|\\.html)?|derpicdn\\.net/img/(?:view/|download/)?\\d+/\\d+/\\d+/(\\d+))', 'i'); const array = url.match(regex); return (array !== null) ? array[1] || array[2] : null; } function getCommentId(backlink) { // the regex expects the comment id in the format of '#post_1234' or '#comment_5678' const regex = new RegExp('^#(?:post_|comment_)(\\d+)$'); return parseInt(regex.exec(backlink.hash)[1], 10); } function insertButton(displayText) { const commentsBlock = $('#comments'); const ele = document.createElement('div'); ele.className = 'block__header'; ele.id = 'comment_loading_button'; ele.style.textAlign = 'center'; ele.appendChild(document.createElement('a')); ele.firstChild.style.padding = '0px'; ele.firstChild.style.width = '100%'; ele.firstChild.innerText = displayText; commentsBlock.insertBefore(ele, commentsBlock.lastElementChild); return ele; } function loadComments(e, nextPage) { const btn = document.getElementById('comment_loading_button'); const imageId = getImageId(window.location.href); const fetchURL = `${window.location.origin}/images/${imageId}/comments?page=${nextPage}`; btn.firstElementChild.innerText = 'Loading...'; fetch(fetchURL, {credentials: 'same-origin'}) // cookie needed for correct pagination .then((response) => response.text()) .then((text) => { // response text => documentFragment const ele = document.createElement('div'); const range = document.createRange(); ele.innerHTML = text; range.selectNodeContents(ele); const fragment = range.extractContents(); const commentsBlock = document.getElementById('comments'); // update pagination blocks commentsBlock.replaceChild(fragment.firstChild, commentsBlock.firstElementChild); commentsBlock.replaceChild(fragment.lastChild, commentsBlock.lastElementChild); // page marker ele.innerHTML = ''; ele.className = 'block block__header'; ele.style.textAlign = 'center'; ele.innerText = 'Page ' + nextPage; // relative time timeAgo($$('time', fragment)); fragment.insertBefore(ele, fragment.firstElementChild); commentsBlock.insertBefore(fragment, commentsBlock.lastElementChild); // configure button to load the next batch of comments btn.remove(); const navbar = $('nav', commentsBlock); const btnNextPage = [...navbar.childNodes].find(node => node.innerHTML === 'Next ›'); if (btnNextPage) { const btn = insertButton('Load more comments'); btn.addEventListener('click', (e) => { loadComments(e, nextPage + 1); }); } }); } NodeCreationObserver.onCreation('article[id^="comment_"], article[id^="post_"]', function (sourceCommentBody) { const isForumPost = sourceCommentBody.matches('[id^="post_"]'); const selector = isForumPost ? 'post_' : 'comment_'; const links = $$(`.communication__body__text a[href*="#${selector}"]`, sourceCommentBody); const sourceCommentID = sourceCommentBody.id.slice(selector.length); const ele = $('.communication__body__sender-name > strong', sourceCommentBody); const sourceAuthor = (ele.firstElementChild !== null && ele.firstElementChild.matches('a')) ? ele.firstElementChild.innerText : ele.innerHTML; links.forEach((link) => { const targetCommentID = link.hash.slice(selector.length + 1); // Example: link.hash == "#comment_5430424" or link.hash == "#post_5430424" // add backlink if the comment is not part of a quote // and not fetched if (!link.matches('blockquote a') && !sourceCommentBody.matches('.fetched-comment')) { const backlink = document.createElement('a'); backlink.style.marginRight = '5px'; backlink.href = '#' + selector + sourceCommentID; backlink.textContent = '►'; backlink.innerHTML += sourceAuthor; backlink.addEventListener('mouseenter', () => { linkEnter(backlink, sourceCommentID, isForumPost); }); backlink.addEventListener('mouseleave', () => { linkLeave(backlink, sourceCommentID, isForumPost); }); backlink.addEventListener('click', () => { // force pageload instead of trying to navigate to a nonexistent anchor on the current page if (document.getElementById(selector + sourceCommentID) === null) window.location.reload(); }); insertBacklink(backlink, targetCommentID, isForumPost); } if (DISABLE_NATIVE_PREVIEW && !isForumPost) { link.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); // quoted links doesn't contain query strings, this prevents page reload on links like "derpibooru.org/1234?q=tag" const a = document.createElement('a'); if (document.getElementById(selector + targetCommentID) === null) { a.href = e.currentTarget.href; } else { a.href = window.location.pathname + window.location.search + e.currentTarget.hash; } // Firefox requires the element to be inserted on the page for this to work document.body.appendChild(a); a.click(); a.remove(); // for paginated comments, when comment for the same image is on another page if (window.location.pathname == e.currentTarget.pathname && document.getElementById(selector + targetCommentID) === null) { window.location.reload(); } }); } link.addEventListener('mouseenter', () => { linkEnter(link, targetCommentID, isForumPost); }); link.addEventListener('mouseleave', () => { linkLeave(link, targetCommentID, isForumPost); }); }); // If other pages had replied to this comment if (backlinksCache[sourceCommentID] !== undefined) { backlinksCache[sourceCommentID].forEach((backlink) => { insertBacklink(backlink, sourceCommentID, isForumPost); }); } }); // Load and append more comments NodeCreationObserver.onCreation('#comments nav.pagination', function (navbar) { if (document.getElementById('comment_loading_button') !== null) return; const childNodes = [...navbar.childNodes]; const btnNextPage = childNodes.find(node => node.innerHTML === 'Next ›'); if (!btnNextPage) return; const nextPage = parseInt(getQueryVariable('page', btnNextPage), 10); const btn = insertButton('Load more comments'); btn.addEventListener('click', (e) => { loadComments(e, nextPage); }); }); // Add clickable links to hotlinked images if (LINK_IMAGES) { NodeCreationObserver.onCreation('.communication__body__text .imgspoiler>img, .image-description .imgspoiler>img', img => { if (img.closest('a') !== null) return; // Image is already part of link so we do nothing. const imgParent = img.parentElement; const anchor = document.createElement('a'); anchor.referrerPolicy = 'origin'; anchor.relList.add('noreferrer', 'noopener'); const imageId = getImageId(img.src); if (imageId !== null) { // image is on Derpibooru anchor.href = `/${imageId}`; } else { // external image: ext.derpicdn.net link const src = img.src; const b64ReTempplate = '(?:[A-Za-z0-9-_]{4})*(?:[A-Za-z0-9-_]{2}(?:==)?|[A-Za-z0-9-_]{3}=?)?'; const extRegExp = new RegExp(`/${b64ReTempplate}/(${b64ReTempplate})$`); // https://newbedev.com/using-javascript-s-atob-to-decode-base64-doesn-t-properly-decode-utf-8-strings const b64DecodeUnicode = str => { // Going backwards: from bytestream, to percent-encoding, to original string. return decodeURIComponent( window.atob(str) .split('') .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) .join('') ); }; const extResult = src.match(extRegExp); if (extResult[1]) { // Note: ext uses RFC 4648 variant of Base64 to encode digest and // url, which replaces '/' and '+' with '_' and '-' respectively. const b64string = extResult[1] .replace(/_/g, '/') .replace(/-/g, '+'); anchor.href = b64DecodeUnicode(b64string); } else { throw new Error('Unable to linkify image with src: ' + src); } } anchor.appendChild(img); imgParent.appendChild(anchor); }); } })();