Derpibooru Comment Enhancements

Improvements to Derpibooru's comment section

Ajankohdalta 30.5.2018. Katso uusin versio.

  1. // ==UserScript==
  2. // @name Derpibooru Comment Enhancements
  3. // @description Improvements to Derpibooru's comment section
  4. // @version 1.4.8
  5. // @author Marker
  6. // @license MIT
  7. // @namespace https://github.com/marktaiwan/
  8. // @homepageURL https://github.com/marktaiwan/Derpibooru-Link-Preview
  9. // @supportURL https://github.com/marktaiwan/Derpibooru-Link-Preview/issues
  10. // @include https://derpibooru.org/*
  11. // @include https://trixiebooru.org/*
  12. // @include https://www.derpibooru.org/*
  13. // @include https://www.trixiebooru.org/*
  14. // @include /^https?://(www\.)?(derpibooru|trixiebooru)\.org(/.*|)$/
  15. // @grant none
  16. // @noframes
  17. // @require https://openuserjs.org/src/libs/soufianesakhi/node-creation-observer.js
  18. // @require https://openuserjs.org/src/libs/mark.taiwangmail.com/Derpibooru_Unified_Userscript_UI_Utility.js
  19. // ==/UserScript==
  20.  
  21. (function() {
  22. 'use strict';
  23.  
  24. // ==== User Config ====
  25.  
  26. var config = ConfigManager('Derpibooru Comment Enhancements', 'derpi_comment_enhancements');
  27. config.registerSetting({
  28. title: 'Linkify images',
  29. key: 'link_images',
  30. description: 'Link embedded images to their sources. Images already containing links will be unaffected.',
  31. type: 'checkbox',
  32. defaultValue: false
  33. });
  34. config.registerSetting({
  35. title: 'Disable native preview',
  36. key: 'disable_native_preview',
  37. 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.',
  38. type: 'checkbox',
  39. defaultValue: false
  40. });
  41. var spoilerSettings = config.addFieldset('Reveal spoiler...');
  42. spoilerSettings.registerSetting({
  43. title: '...in comment preview',
  44. key: 'show_preview_spoiler',
  45. description: 'Reveal spoilers in the pop-up preview.',
  46. type: 'checkbox',
  47. defaultValue: true
  48. });
  49. spoilerSettings.registerSetting({
  50. title: '...in comment highlight',
  51. key: 'show_highlight_spoiler',
  52. description: 'Reveal spoilers in the highlighted comment.',
  53. type: 'checkbox',
  54. defaultValue: true
  55. });
  56.  
  57. const LINK_IMAGES = config.getEntry('link_images');
  58. const DISABLE_NATIVE_PREVIEW = config.getEntry('disable_native_preview');
  59. const SHOW_PREVIEW_SPOILER = config.getEntry('show_preview_spoiler');
  60. const SHOW_HIGHLIGHT_SPOILER = config.getEntry('show_highlight_spoiler');
  61.  
  62. // ==== /User Config ====
  63.  
  64. const HOVER_ATTRIBUTE = 'comment-preview-active';
  65. var fetchCache = {};
  66. var backlinksCache = {};
  67.  
  68. function timeAgo(ele) {
  69. // Firefox 57/Greasemonkey 4 compatibility
  70. var booru = window.booru || window.wrappedJSObject.booru;
  71. booru.timeAgo(ele);
  72. }
  73.  
  74. function displayHover(comment, sourceLink) {
  75. const PADDING = 5; // in pixels
  76.  
  77. comment = comment.cloneNode(true);
  78. comment.id = 'hover_preview';
  79. comment.style.position = 'absolute';
  80. comment.style.maxWidth = '980px';
  81. comment.style.minWidth = '490px';
  82. comment.style.boxShadow = '0px 0px 12px 0px rgba(0, 0, 0, 0.4)';
  83.  
  84. if (SHOW_PREVIEW_SPOILER) {
  85. revealSpoiler(comment);
  86. }
  87.  
  88. // relative time
  89. timeAgo(comment.querySelectorAll('time'));
  90.  
  91. var container = document.getElementById('image_comments') || document.getElementById('content');
  92. if (container) container.appendChild(comment);
  93.  
  94. // calculate link position
  95. var linkRect = sourceLink.getBoundingClientRect();
  96. var linkTop = linkRect.top + viewportPosition().top;
  97. var linkLeft = linkRect.left + viewportPosition().left;
  98.  
  99. var commentRect = comment.getBoundingClientRect();
  100. var commentTop;
  101. var commentLeft;
  102.  
  103.  
  104. if (sourceLink.parentElement.classList.contains('comment_backlinks')) {
  105. // When there is room, place the preview below the link,
  106. // otherwise place it above the link
  107. if (document.documentElement.clientHeight - linkRect.bottom > commentRect.height + PADDING) {
  108.  
  109. commentTop = linkTop + linkRect.height + PADDING;
  110. commentLeft = (commentRect.width + linkLeft < document.documentElement.clientWidth) ? (
  111. linkLeft
  112. ) : (
  113. document.documentElement.clientWidth - (commentRect.width + PADDING)
  114. );
  115.  
  116. } else {
  117.  
  118. commentTop = linkTop - commentRect.height - PADDING;
  119. commentLeft = (commentRect.width + linkLeft < document.documentElement.clientWidth) ? (
  120. linkLeft
  121. ) : (
  122. document.documentElement.clientWidth - (commentRect.width + PADDING)
  123. );
  124.  
  125. }
  126. } else {
  127. // When there is room, place the preview above the link
  128. // otherwise place it to the right and aligns it to the top of viewport
  129. if (linkRect.top > commentRect.height + PADDING) {
  130.  
  131. commentTop = linkTop - commentRect.height - PADDING;
  132. commentLeft = (commentRect.width + linkLeft < document.documentElement.clientWidth) ? (
  133. linkLeft
  134. ) : (
  135. document.documentElement.clientWidth - (commentRect.width + PADDING)
  136. );
  137.  
  138. } else {
  139.  
  140. commentTop = viewportPosition().top + PADDING;
  141. commentLeft = linkLeft + linkRect.width + PADDING;
  142.  
  143. }
  144. }
  145.  
  146. comment.style.top = commentTop + 'px';
  147. comment.style.left = commentLeft + 'px';
  148. }
  149.  
  150. function linkEnter(sourceLink, targetCommentID, isForumPost) {
  151. sourceLink.setAttribute(HOVER_ATTRIBUTE, 1);
  152. const selector = isForumPost ? 'post_' : 'comment_';
  153. var targetComment = document.getElementById(selector + targetCommentID);
  154.  
  155. if (targetComment !== null) {
  156.  
  157. highlightReplyLink(targetComment, sourceLink, isForumPost);
  158.  
  159. if (!elementInViewport(targetComment)) {
  160. displayHover(targetComment, sourceLink);
  161. }
  162. if (SHOW_HIGHLIGHT_SPOILER) {
  163. revealSpoiler(targetComment);
  164. }
  165.  
  166. // Highlight linked post
  167. targetComment.children[0].style.backgroundColor = 'rgba(230,230,30,0.3)';
  168. if (targetComment.querySelector('.comment_backlinks') !== null) targetComment.children[1].style.backgroundColor = 'rgba(230,230,30,0.3)';
  169.  
  170. }
  171. else if (!isForumPost) {
  172.  
  173. // External post, display from cached response if possible
  174. if (fetchCache[targetCommentID] !== undefined) {
  175. displayHover(fetchCache[targetCommentID], sourceLink);
  176. } else {
  177. fetch(window.location.origin + '/comment/' + targetCommentID + '.html')
  178. .then((response) => response.text())
  179. .then((text) => {
  180. if (fetchCache[targetCommentID] === undefined && sourceLink.getAttribute(HOVER_ATTRIBUTE) !== '0') {
  181. var d = document.createElement('div');
  182. d.innerHTML = text;
  183. fetchCache[targetCommentID] = d.firstChild;
  184. displayHover(d.firstChild, sourceLink);
  185. }
  186. });
  187. }
  188.  
  189. }
  190. }
  191.  
  192. function linkLeave(sourceLink, targetCommentID, isForumPost) {
  193. sourceLink.setAttribute(HOVER_ATTRIBUTE, 0);
  194. const selector = isForumPost ? 'post_' : 'comment_';
  195. var targetComment = document.getElementById(selector + targetCommentID);
  196. var preview = document.getElementById('hover_preview');
  197.  
  198. if (targetComment !== null) {
  199. // remove comment highlight
  200. targetComment.children[0].style.backgroundColor = '';
  201. if (targetComment.querySelector('.comment_backlinks') !== null) targetComment.children[1].style.backgroundColor = '';
  202.  
  203. // remove link highlight
  204. var ele = sourceLink;
  205. while (ele.parentElement !== null && !ele.matches('article')) ele = ele.parentElement;
  206. var sourceCommentID = ele.id.slice(selector.length);
  207. var list = targetComment.querySelectorAll('a[href$="#' + selector + sourceCommentID + '"]');
  208. for (var i = 0; i < list.length; i++) {
  209. list[i].style.textDecoration = '';
  210. }
  211.  
  212. // unreveal spoilers
  213. // we use the 'reveal-preview-spoiler' attribute to avoid reverting spoilers manually revealed by users
  214. var spoilers = targetComment.querySelectorAll('.spoiler-revealed[reveal-preview-spoiler]');
  215. var imgspoilers = targetComment.querySelectorAll('.imgspoiler-revealed[reveal-preview-spoiler]');
  216. for (var spoiler of spoilers) {
  217. spoiler.classList.remove('spoiler-revealed');
  218. spoiler.classList.add('spoiler');
  219. spoiler.removeAttribute('reveal-preview-spoiler');
  220. }
  221. for (var imgspoiler of imgspoilers) {
  222. imgspoiler.classList.remove('imgspoiler-revealed');
  223. imgspoiler.classList.add('imgspoiler');
  224. imgspoiler.removeAttribute('reveal-preview-spoiler');
  225. }
  226. }
  227.  
  228. if (preview !== null) preview.parentElement.removeChild(preview);
  229. }
  230.  
  231. // Chrome/Firefox compatibility hack for getting viewport position
  232. function viewportPosition() {
  233. return {
  234. top: (document.documentElement.scrollTop || document.body.scrollTop),
  235. left: (document.documentElement.scrollLeft || document.body.scrollLeft)
  236. };
  237. }
  238.  
  239. function elementInViewport(el) {
  240.  
  241. // Calculate the ratio of post height and viewport height, and clamp it to a min/max value,
  242. // and use it to decide whether to use highlight or preview on a comment that's partially in view.
  243. // The script will prefer the use of highlights on long comments,
  244. // when using the preview might take up most of the viewport
  245.  
  246. var rect = el.getBoundingClientRect();
  247. var ratio = Math.max(0.25, Math.min(0.95, rect.height / document.documentElement.clientHeight));
  248. var margin = Math.round(rect.height * ratio); // pixels outside of viewport before element is considered out of view
  249.  
  250. return (
  251. rect.top + margin >= 0 &&
  252. rect.bottom - margin <= document.documentElement.clientHeight
  253. );
  254. }
  255.  
  256. function createBacklinksContainer(commentBody) {
  257. var ele = commentBody.querySelector('div.comment_backlinks');
  258.  
  259. if (ele === null) {
  260.  
  261. ele = document.createElement('div');
  262. ele.className = 'block__content comment_backlinks';
  263. ele.style.fontSize = '12px';
  264.  
  265. // Firefox 57 Workaround: getComputedStyle(commentBody.firstChild)['border-top'] returns an empty string
  266. ele.style.borderTopStyle = window.getComputedStyle(commentBody.firstChild)['border-top-style'];
  267. ele.style.borderTopWidth = window.getComputedStyle(commentBody.firstChild)['border-top-width'];
  268. ele.style.borderTopColor = window.getComputedStyle(commentBody.firstChild)['border-top-color'];
  269.  
  270. commentBody.insertBefore(ele, commentBody.querySelector('.communication__options'));
  271. }
  272. return ele;
  273. }
  274.  
  275. function insertBacklink(backlink, commentID, isForumPost) {
  276.  
  277. // add to cache
  278. if (backlinksCache[commentID] === undefined) backlinksCache[commentID] = [];
  279. if (backlinksCache[commentID].findIndex((ele) => (ele.hash == backlink.hash)) == -1) {
  280. backlinksCache[commentID].push(backlink);
  281. }
  282. const selector = isForumPost ? 'post_' : 'comment_';
  283. var commentBody = document.getElementById(selector + commentID);
  284. if (commentBody !== null) {
  285. var linksContainer = createBacklinksContainer(commentBody);
  286.  
  287. // insertion sort the links so they are ordered by id
  288. if (linksContainer.children.length > 0) {
  289. var iLinkID = parseInt(backlink.hash.slice(9), 10);
  290. var iTempID;
  291. var i;
  292.  
  293. for (i = 0; i < linksContainer.children.length; i++) {
  294. iTempID = parseInt(linksContainer.children[i].hash.slice(9), 10);
  295.  
  296. if (iLinkID == iTempID) { // prevent links to the same comment from being added multiple times
  297. return;
  298. }
  299. if (iLinkID < iTempID) {
  300. linksContainer.insertBefore(backlink, linksContainer.children[i]);
  301. return;
  302. }
  303. }
  304. }
  305. linksContainer.appendChild(backlink);
  306. return;
  307. }
  308.  
  309. }
  310.  
  311. function revealSpoiler(comment) {
  312. var spoilers = comment.querySelectorAll('.spoiler');
  313. var imgspoilers = comment.querySelectorAll('.imgspoiler');
  314.  
  315. for (var spoiler of spoilers) {
  316. spoiler.classList.remove('spoiler');
  317. spoiler.classList.add('spoiler-revealed');
  318. spoiler.setAttribute('reveal-preview-spoiler', '1');
  319. }
  320. for (var imgspoiler of imgspoilers) {
  321. imgspoiler.classList.remove('imgspoiler');
  322. imgspoiler.classList.add('imgspoiler-revealed');
  323. imgspoiler.setAttribute('reveal-preview-spoiler', '1');
  324. }
  325. }
  326.  
  327. function highlightReplyLink(comment, sourceLink, isForumPost) {
  328. const selector = isForumPost ? 'post_' : 'comment_';
  329. var ele = sourceLink;
  330. while (ele.parentElement !== null && !ele.matches('article')) ele = ele.parentElement;
  331. var sourceCommentID = ele.id.slice(selector.length);
  332. var list = comment.querySelectorAll('a[href$="#' + selector + sourceCommentID + '"]');
  333.  
  334. for (var i = 0; i < list.length; i++) {
  335. list[i].style.textDecoration = 'underline dashed';
  336. }
  337. }
  338.  
  339. function getQueryVariable(key, HTMLAnchorElement) {
  340. var i;
  341. var array = HTMLAnchorElement.search.substring(1).split('&');
  342.  
  343. for (i = 0; i < array.length; i++) {
  344. if (key == array[i].split('=')[0]) {
  345. return array[i].split('=')[1];
  346. }
  347. }
  348. }
  349.  
  350. function getImageId(url) {
  351. const regex = (/https?:\/\/(?:www\.)?(?:(?:derpibooru\.org|trixiebooru\.org)\/(?:images\/)?(\d{1,})(?:\?|\?.{1,}|\/|\.html)?|derpicdn\.net\/img\/(?:view\/)?\d{1,}\/\d{1,}\/\d{1,}\/(\d+))/i);
  352. const array = url.match(regex);
  353. return (array !== null) ? array[1] || array[2] : null;
  354. }
  355.  
  356. function insertButton(displayText) {
  357.  
  358. var commentsBlock = document.querySelector('.js-editable-comments');
  359.  
  360. var ele = document.createElement('div');
  361. ele.className = 'block__header';
  362. ele.id = 'comment_loading_button';
  363. ele.style.textAlign = 'center';
  364.  
  365. ele.appendChild(document.createElement('a'));
  366. ele.firstChild.style.padding = '0px';
  367. ele.firstChild.style.width = '100%';
  368. ele.firstChild.innerText = displayText;
  369.  
  370. commentsBlock.insertBefore(ele, commentsBlock.lastElementChild);
  371.  
  372. return ele;
  373. }
  374.  
  375. function loadComments(e, nextPage, lastPage) {
  376. e.target.parentElement.remove();
  377.  
  378. var btn = insertButton('Loading...');
  379. var imageId = getImageId(window.location.href);
  380. var fetchURL = window.location.origin + '/images/' + imageId + '/comments?id=' + imageId + '&page=' + nextPage;
  381.  
  382. fetch(fetchURL, {credentials: 'same-origin'}) // cookie needed for correct pagination
  383. .then((response) => response.text())
  384. .then((text) => {
  385. // response text => documentFragment
  386. var ele = document.createElement('div');
  387. var range = document.createRange();
  388.  
  389. ele.innerHTML = text;
  390. range.selectNodeContents(ele.firstChild);
  391.  
  392. var fragment = range.extractContents();
  393. var commentsBlock = document.getElementById('image_comments');
  394.  
  395. // update pagination blocks
  396. commentsBlock.replaceChild(fragment.firstChild, commentsBlock.firstElementChild);
  397. commentsBlock.replaceChild(fragment.lastChild, commentsBlock.lastElementChild);
  398.  
  399. // page marker
  400. ele.innerHTML = '';
  401. ele.className = 'block block__header';
  402. ele.style.textAlign = 'center';
  403. ele.innerText = 'Page ' + nextPage;
  404.  
  405. // relative time
  406. timeAgo(fragment.querySelectorAll('time'));
  407.  
  408. fragment.insertBefore(ele, fragment.firstElementChild);
  409. commentsBlock.insertBefore(fragment, commentsBlock.lastElementChild);
  410.  
  411. // configure button to load the next batch of comments
  412. btn.remove();
  413. if (nextPage < lastPage) {
  414. btn = insertButton('Load more comments');
  415. btn.addEventListener('click', (e) => {
  416. loadComments(e, nextPage + 1, lastPage);
  417. });
  418. }
  419.  
  420. });
  421. }
  422.  
  423. NodeCreationObserver.onCreation('article[id^="comment_"], article[id^="post_"]', function (sourceCommentBody) {
  424. const isForumPost = sourceCommentBody.matches('[id^="post_"]');
  425. const selector = isForumPost ? 'post_' : 'comment_';
  426.  
  427. var links = sourceCommentBody.querySelectorAll(`.communication__body__text a[href*="#${selector}"]`);
  428. var sourceCommentID = sourceCommentBody.id.slice(selector.length);
  429. var ele = sourceCommentBody.querySelector('.communication__body__sender-name');
  430. var sourceAuthor = (ele.firstElementChild !== null && ele.firstElementChild.matches('a')) ? ele.firstElementChild.innerText : ele.innerHTML;
  431.  
  432. links.forEach((link) => {
  433. var targetCommentID = link.hash.slice(selector.length + 1); // Example: link.hash == "#comment_5430424" or link.hash == "#post_5430424"
  434. var backlink;
  435.  
  436. // add backlink if the comment is not part of a quote
  437. // and not fetched
  438. if (!link.matches('blockquote a') && !sourceCommentBody.matches('.fetched-comment')) {
  439. backlink = document.createElement('a');
  440.  
  441. backlink.style.marginRight = '5px';
  442. backlink.href = '#' + selector + sourceCommentID;
  443. backlink.textContent = '►';
  444. backlink.innerHTML += sourceAuthor;
  445.  
  446. backlink.addEventListener('mouseenter', () => {
  447. linkEnter(backlink, sourceCommentID, isForumPost);
  448. });
  449. backlink.addEventListener('mouseleave', () => {
  450. linkLeave(backlink, sourceCommentID, isForumPost);
  451. });
  452. backlink.addEventListener('click', () => {
  453. // force pageload instead of trying to navigate to a nonexistent anchor on the current page
  454. if (document.getElementById(selector + sourceCommentID) === null) window.location.reload();
  455. });
  456.  
  457. insertBacklink(backlink, targetCommentID, isForumPost);
  458. }
  459.  
  460. if (DISABLE_NATIVE_PREVIEW) {
  461. link.addEventListener('click', (e) => {
  462. e.stopPropagation();
  463. e.preventDefault();
  464.  
  465. // quoted links doesn't contain query strings, this prevents page reload on links like "derpibooru.org/1234?q=tag"
  466. var a = document.createElement('a');
  467. if (document.getElementById(selector + targetCommentID) === null) {
  468. a.href = e.currentTarget.href;
  469. } else {
  470. a.href = window.location.pathname + window.location.search + e.currentTarget.hash;
  471. }
  472. a.click();
  473.  
  474. // for paginated comments, when comment for the same image is on another page
  475. if (window.location.pathname == e.currentTarget.pathname &&
  476. document.getElementById(selector + targetCommentID) === null) {
  477. window.location.reload();
  478. }
  479. });
  480. }
  481.  
  482. // ignore quoted comments
  483. // this is terrible
  484. for (let i = 0, ele = link; i < 3; i++) {
  485. ele = ele.nextSibling;
  486. if (ele == null) break;
  487. if (i == 2 && ele.nodeType == 1 && ele.matches('blockquote[title]')) return;
  488. }
  489.  
  490. link.addEventListener('mouseenter', () => {
  491. linkEnter(link, targetCommentID, isForumPost);
  492. });
  493. link.addEventListener('mouseleave', () => {
  494. linkLeave(link, targetCommentID, isForumPost);
  495. });
  496.  
  497. });
  498.  
  499. // If other pages had replied to this comment
  500. if (backlinksCache[sourceCommentID] !== undefined) {
  501. backlinksCache[sourceCommentID].forEach((backlink) => {
  502. insertBacklink(backlink, sourceCommentID);
  503. });
  504. }
  505.  
  506. });
  507.  
  508. // Load and append more comments
  509. NodeCreationObserver.onCreation('#image_comments nav>a.js-next', function (btnNextPage) {
  510. if (document.getElementById('comment_loading_button') !== null) return;
  511.  
  512. var btnLastPage = btnNextPage.nextElementSibling;
  513. var nextPage = parseInt(getQueryVariable('page', btnNextPage), 10);
  514. var lastPage = parseInt(getQueryVariable('page', btnLastPage), 10);
  515. var btn = insertButton('Load more comments');
  516.  
  517. btn.addEventListener('click', (e) => {
  518. loadComments(e, nextPage, lastPage);
  519. });
  520. });
  521.  
  522. // Add clickable links to hotlinked images
  523. if (LINK_IMAGES) {
  524. NodeCreationObserver.onCreation('.communication__body__text .imgspoiler>img, .image-description .imgspoiler>img', img => {
  525. if (img.closest('a') !== null) return; // Image is already part of link so we do nothing.
  526.  
  527. const imgParent = img.parentElement;
  528. const anchor = document.createElement('a');
  529. const imageId = getImageId(img.src);
  530. if (imageId !== null) {
  531. // image is on Derpibooru
  532. anchor.href = `/${imageId}`;
  533. } else {
  534. // camo.derpicdn.net
  535. anchor.href = decodeURIComponent(img.src.substr(img.src.indexOf('?url=') + 5));
  536. }
  537. anchor.appendChild(img);
  538. imgParent.appendChild(anchor);
  539. });
  540. }
  541. })();