Derpibooru Comment Enhancements

Improvements to Derpibooru's comment section

От 26.05.2018. Виж последната версия.

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