Derpibooru Comment Enhancements

Improvements to Derpibooru's comment section

As of 2019-12-25. See the latest version.

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