Holotower Inline Quoting

Inline Quoting for holotower.org

  1. // ==UserScript==
  2. // @name Holotower Inline Quoting
  3. // @namespace http://tampermonkey.net/
  4. // @version 2.2
  5. // @author grem
  6. // @license MIT
  7. // @description Inline Quoting for holotower.org
  8. // @match *://boards.holotower.org/*
  9. // @match *://holotower.org/*
  10. // @grant GM_addStyle
  11. // @grant GM_xmlhttpRequest
  12. // @require https://code.jquery.com/jquery-3.6.0.min.js
  13. // @connect self
  14. // @connect boards.holotower.org
  15. // @icon https://boards.holotower.org/favicon.gif
  16. // @run-at document-start
  17. // ==/UserScript==
  18.  
  19. /* global $ */
  20.  
  21. (function() {
  22. 'use strict';
  23.  
  24. const INLINE_CONTAINER_CLASS = 'inline-quote-container';
  25. const INLINE_ACTIVE_LINK_CLASS = 'inline-active';
  26. const LOADING_DATA_ATTR = 'data-inline-loading';
  27. const ERROR_DATA_ATTR = 'data-inline-error';
  28. const TEMP_HIGHLIGHT_CLASS = 'inline-temp-highlight';
  29. const PROCESSED_ATTR = 'data-inline-processed';
  30. const INLINED_ID_ATTR = 'data-inlined-id';
  31. const CLONED_POST_CLASS = 'inline-cloned-post';
  32. const CLONED_HOVER_PREVIEW_ID_PREFIX = 'iq-preview-';
  33. const HOVER_INITIALIZED_ATTR = 'data-iq-hover-init';
  34. const SITE_PREVIEW_BASE_CLASSES = 'post qp';
  35. const SITE_PREVIEW_REPLY_CLASS = 'reply';
  36. const SITE_PREVIEW_OP_CLASS = 'op';
  37.  
  38. const POST_SELECTOR_ID_FORMAT = (postId) => `div.post[id$='_${postId}']`;
  39. const POTENTIAL_QUOTE_LINK_SELECTOR = "a[onclick*='highlightReply'], a[href*='#q']";
  40. const CLICKABLE_QUOTE_LINK_SELECTOR = "div.post a";
  41. const QUOTE_LINK_REGEX = /^>>(\d+)/;
  42. const SITE_HOVER_TARGET_SELECTOR = 'div.body a:not([rel="nofollow"]), p.intro a[href*="#"]:not([href="#"])';
  43. const BOARD_CONTEXT_SELECTOR = '[data-board]';
  44.  
  45. GM_addStyle(`
  46. .${INLINE_CONTAINER_CLASS} { border: 1px dashed var(--subtle-border-color, #888); background-color: var(--inline-background-color, rgba(128,128,128,0.05)); padding: 5px; margin-top: 5px; margin-left: 20px; border-radius: var(--border-radius, 4px); }
  47. .${INLINE_CONTAINER_CLASS}[${INLINED_ID_ATTR}] {}
  48. .${INLINE_CONTAINER_CLASS} > .${CLONED_POST_CLASS}[data-board] { border: none !important; margin: 0 !important; padding: 0 !important; box-shadow: none !important; background: transparent !important; }
  49. a.${INLINE_ACTIVE_LINK_CLASS} { font-weight: bold !important; color: var(--link-hover-color, #d11a1a) !important; opacity: 0.85; text-decoration: underline dotted !important; }
  50. a.${INLINE_ACTIVE_LINK_CLASS}:hover { opacity: 1.0; }
  51. a[${LOADING_DATA_ATTR}="true"]::after { content: " (loading...)"; font-style: italic; color: var(--text-color-muted, #888); margin-left: 4px; }
  52. a[${ERROR_DATA_ATTR}="true"]::after { content: " (not found)"; font-style: italic; color: var(--error-text-color, #f00); margin-left: 4px; }
  53. .${TEMP_HIGHLIGHT_CLASS} { transition: outline 0.1s ease-in-out; outline: 2px solid var(--highlight-color, yellow) !important; outline-offset: 2px; }
  54. div.qp[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"] { position: absolute !important; z-index: 150 !important; max-width: 500px; }
  55. div.qp[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"] > .post { border: none !important; margin: 0 !important; padding: 0 !important; box-shadow: none !important; background: transparent !important; }
  56. div.qp[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"] .hide-post-button,
  57. div.qp[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"] .menu-button,
  58. div.qp[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"] input[type=checkbox].delete { display: none !important; }
  59. div.qp[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"].loading-preview::after { content: "Loading..."; font-style: italic; color: var(--text-color-muted, #888); padding: 5px; display:block; }
  60. div.qp[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"].error-preview::after { content: "Not found."; font-style: italic; color: var(--error-text-color, #f00); padding: 5px; display:block; }
  61. `);
  62.  
  63. function getPostIdFromLink(link) {
  64. if (!link) return null;
  65. const textMatch = link.textContent?.trim().match(QUOTE_LINK_REGEX);
  66. if (textMatch) return textMatch[1];
  67. const hrefMatch = link.getAttribute('href')?.match(/#(\d+)$/);
  68. if (hrefMatch) return hrefMatch[1];
  69. const quoteHrefMatch = link.getAttribute('href')?.match(/#q(\d+)$/);
  70. if (quoteHrefMatch) return quoteHrefMatch[1];
  71. return null;
  72. }
  73.  
  74. function fetchPostHtml(url) {
  75. const fetchUrl = url?.split('#')[0];
  76. if (!fetchUrl) return Promise.resolve(null);
  77. return new Promise((resolve) => {
  78. GM_xmlhttpRequest({
  79. method: "GET",
  80. url: fetchUrl,
  81. onload: r => { (r.status >= 200 && r.status < 300) ? resolve(r.responseText) : resolve(null); },
  82. onerror: r => { resolve(null); },
  83. ontimeout: () => { resolve(null); }
  84. });
  85. });
  86. }
  87.  
  88. function parseAndFindPost(html, postId) {
  89. try {
  90. const parser = new DOMParser();
  91. const doc = parser.parseFromString(html, 'text/html');
  92. const postElement = doc.querySelector(POST_SELECTOR_ID_FORMAT(postId));
  93. return postElement;
  94. } catch (error) {
  95. return null;
  96. }
  97. }
  98.  
  99. function isElementInViewportStrict(el) {
  100. if (!el) return false;
  101. const rect = el.getBoundingClientRect();
  102. return rect.top < window.innerHeight && rect.bottom > 0 && rect.left < window.innerWidth && rect.right > 0;
  103. }
  104.  
  105. function temporaryHighlight(el) {
  106. if (!el) return;
  107. $(el).addClass(TEMP_HIGHLIGHT_CLASS);
  108. setTimeout(() => { $(el).removeClass(TEMP_HIGHLIGHT_CLASS); }, 500);
  109. }
  110.  
  111. function processLinks(parentElement) {
  112. if (!parentElement) return;
  113. const links = parentElement.querySelectorAll(POTENTIAL_QUOTE_LINK_SELECTOR);
  114. links.forEach(link => {
  115. if (!link.hasAttribute(PROCESSED_ATTR)) {
  116. const onclickValue = link.getAttribute('onclick');
  117. if (onclickValue && onclickValue.includes('highlightReply')) {
  118. link.removeAttribute('onclick');
  119. }
  120. link.setAttribute(PROCESSED_ATTR, 'true');
  121. }
  122. });
  123. }
  124.  
  125. async function handleInlineQuoteClick(linkElement, postId) {
  126. const $link = $(linkElement);
  127. const $parentPost = $link.closest('div.post');
  128. const $insertionTarget = $parentPost;
  129. const $nextElement = $insertionTarget.next();
  130. const isAlreadyInlined = $nextElement.hasClass(INLINE_CONTAINER_CLASS) && $nextElement.attr(INLINED_ID_ATTR) === postId;
  131.  
  132. if (isAlreadyInlined) {
  133. $nextElement.remove();
  134. $link.removeClass(INLINE_ACTIVE_LINK_CLASS).removeAttr(LOADING_DATA_ATTR).removeAttr(ERROR_DATA_ATTR);
  135. } else {
  136. closeOtherInlinePosts($link);
  137. const ancestorContainers = $link.parents(`.${INLINE_CONTAINER_CLASS}`);
  138. if (ancestorContainers.length > 0 && ancestorContainers.is(`[${INLINED_ID_ATTR}="${postId}"]`)) {
  139. temporaryHighlight(ancestorContainers.filter(`[${INLINED_ID_ATTR}="${postId}"]`).first()[0]);
  140. return;
  141. }
  142. const originalPostElement = document.querySelector(POST_SELECTOR_ID_FORMAT(postId));
  143. if (originalPostElement && isElementInViewportStrict(originalPostElement)) {
  144. temporaryHighlight(originalPostElement);
  145. return;
  146. }
  147.  
  148. $link.addClass(INLINE_ACTIVE_LINK_CLASS).attr(LOADING_DATA_ATTR, "true");
  149. let targetPostElement = originalPostElement;
  150. let boardValue = null;
  151. const $linkContext = $link.closest(BOARD_CONTEXT_SELECTOR);
  152. if ($linkContext.length > 0) boardValue = $linkContext.data('board');
  153. else if (originalPostElement) {
  154. const $targetContext = $(originalPostElement).closest(BOARD_CONTEXT_SELECTOR);
  155. if ($targetContext.length > 0) boardValue = $targetContext.data('board');
  156. }
  157.  
  158. if (!targetPostElement && linkElement.href) {
  159. const postHtml = await fetchPostHtml(linkElement.href);
  160. if (postHtml) {
  161. const parsed = parseAndFindPost(postHtml, postId);
  162. if (parsed) targetPostElement = parsed;
  163. }
  164. }
  165.  
  166. $link.removeAttr(LOADING_DATA_ATTR);
  167.  
  168. if (targetPostElement) {
  169. const $container = $('<div>')
  170. .addClass(INLINE_CONTAINER_CLASS)
  171. .attr(INLINED_ID_ATTR, postId);
  172. $insertionTarget.after($container);
  173. let handled = false;
  174. if (window.g?.posts) {
  175. const boardID = boardValue;
  176. const postKey = `${boardID}.${postId}`;
  177. const postObj = g.posts.get(postKey);
  178. if (postObj && typeof postObj.addClone === 'function') {
  179. const cloneObj = postObj.addClone($container[0], /*contractThumb=*/false);
  180. $(cloneObj.nodes.root)
  181. .addClass(CLONED_POST_CLASS)
  182. .attr(PROCESSED_ATTR, 'true');
  183. handled = true;
  184. }
  185. }
  186. if (!handled) {
  187. const cloned = targetPostElement.cloneNode(true);
  188. cloned.removeAttribute('id');
  189. cloned.querySelectorAll('[id]').forEach(el => el.removeAttribute('id'));
  190. cloned.classList.add(CLONED_POST_CLASS);
  191. if (boardValue) cloned.setAttribute('data-board', boardValue);
  192. else cloned.setAttribute('data-board-missing', 'true');
  193. $container.append(cloned);
  194. initializeInlineHover(cloned);
  195. initializeInlineImageHover(cloned);
  196. }
  197. } else {
  198. $link.attr(ERROR_DATA_ATTR, "true").removeClass(INLINE_ACTIVE_LINK_CLASS);
  199. setTimeout(() => { $link.removeAttr(ERROR_DATA_ATTR); }, 3000);
  200. }
  201. }
  202. }
  203.  
  204. function closeOtherInlinePosts($triggerLink) {
  205. $(`.${INLINE_CONTAINER_CLASS}`).each(function() {
  206. const $container = $(this);
  207. const isAncestor = $triggerLink.closest($container).length > 0;
  208. if (isAncestor) return;
  209. const $parentPost = $container.prev('div.post');
  210. if ($parentPost.length === 0) return;
  211. const $possibleLink = $parentPost.find(`a.${INLINE_ACTIVE_LINK_CLASS}`);
  212. if ($possibleLink.length > 0 && !$possibleLink.is($triggerLink)) {
  213. $container.remove();
  214. $possibleLink.removeClass(INLINE_ACTIVE_LINK_CLASS).removeAttr(LOADING_DATA_ATTR).removeAttr(ERROR_DATA_ATTR);
  215. }
  216. });
  217. }
  218.  
  219. function initializeInlineHover(parentElement) {
  220. $(parentElement).find(SITE_HOVER_TARGET_SELECTOR).each(function() {
  221. const $link = $(this);
  222. if ($link.attr(HOVER_INITIALIZED_ATTR)) return;
  223.  
  224. let $preview = null;
  225. let targetPostId = null;
  226. let currentBoard = $(parentElement).closest(BOARD_CONTEXT_SELECTOR).data('board') || null;
  227. let fetchController = null;
  228. let mouseMoveTimer = null;
  229.  
  230. function updatePreviewPosition(e) {
  231. if (!$preview) return;
  232. let top = e.pageY + 10; let left = e.pageX + 10;
  233. const win = $(window); const winHeight = win.height(); const winWidth = win.width();
  234. const previewHeight = $preview.outerHeight(); const previewWidth = $preview.outerWidth();
  235. const scrollTop = win.scrollTop(); const scrollLeft = win.scrollLeft();
  236. if (previewHeight > 0 && top + previewHeight > scrollTop + winHeight) top = e.pageY - previewHeight - 10;
  237. if (top < scrollTop) top = scrollTop + 5;
  238. if (previewWidth > 0 && left + previewWidth > scrollLeft + winWidth) left = e.pageX - previewWidth - 10;
  239. if (left < scrollLeft) left = scrollLeft + 5;
  240. $preview.css({ top: top, left: left });
  241. }
  242.  
  243. function preparePreviewContent(sourceElement) {
  244. const clonedContent = sourceElement.cloneNode(true);
  245. clonedContent.removeAttribute('id'); clonedContent.querySelectorAll('[id]').forEach(el => el.removeAttribute('id'));
  246. return clonedContent;
  247. }
  248.  
  249. function createPreviewDiv(sourceElement, postId) {
  250. $(`div[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"]`).remove();
  251. const $previewContainer = $('<div>')
  252. .addClass(SITE_PREVIEW_BASE_CLASSES)
  253. .attr('id', CLONED_HOVER_PREVIEW_ID_PREFIX + postId)
  254. .appendTo('body');
  255. if (sourceElement) {
  256. if (sourceElement.classList.contains(SITE_PREVIEW_REPLY_CLASS)) $previewContainer.addClass(SITE_PREVIEW_REPLY_CLASS);
  257. else if (sourceElement.classList.contains(SITE_PREVIEW_OP_CLASS)) $previewContainer.addClass(SITE_PREVIEW_OP_CLASS);
  258. $previewContainer.append(preparePreviewContent(sourceElement));
  259. }
  260. return $previewContainer;
  261. }
  262.  
  263. $link.on('mouseenter.iqhover', function(e) {
  264. if ($link.hasClass(INLINE_ACTIVE_LINK_CLASS)) return;
  265. targetPostId = getPostIdFromLink(this);
  266. if (!targetPostId || !currentBoard) return;
  267. $(`div[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"]`).remove();
  268. const originalPostElement = document.querySelector(POST_SELECTOR_ID_FORMAT(targetPostId));
  269. if (originalPostElement && $(originalPostElement).closest(BOARD_CONTEXT_SELECTOR).data('board') === currentBoard) {
  270. $preview = createPreviewDiv(originalPostElement, targetPostId);
  271. updatePreviewPosition(e);
  272. } else {
  273. const url = $link.attr('href'); if (!url) return;
  274. $preview = createPreviewDiv(null, targetPostId);
  275. $preview.addClass('loading-preview');
  276. updatePreviewPosition(e);
  277. if (fetchController) fetchController.abort();
  278. const controller = new AbortController(); fetchController = controller;
  279. const fetchUrl = url.split('#')[0];
  280. GM_xmlhttpRequest({
  281. method: "GET", url: fetchUrl, signal: controller.signal,
  282. onload: function(response) {
  283. if (controller.signal.aborted) return; fetchController = null;
  284. if (response.status >= 200 && response.status < 300) {
  285. const fetchedPostElement = parseAndFindPost(response.responseText, targetPostId);
  286. if (fetchedPostElement) { if ($preview) { $preview.empty().removeClass('loading-preview loading-error').addClass(SITE_PREVIEW_BASE_CLASSES); if(fetchedPostElement.classList.contains(SITE_PREVIEW_REPLY_CLASS)) $preview.addClass(SITE_PREVIEW_REPLY_CLASS); else if(fetchedPostElement.classList.contains(SITE_PREVIEW_OP_CLASS)) $preview.addClass(SITE_PREVIEW_OP_CLASS); $preview.append(preparePreviewContent(fetchedPostElement)); updatePreviewPosition(e); } }
  287. else { if ($preview) $preview.empty().removeClass('loading-preview').addClass('error-preview'); }
  288. } else { if ($preview) $preview.empty().removeClass('loading-preview').addClass('error-preview'); }
  289. },
  290. onerror: function(response) { if (controller.signal.aborted) return; fetchController = null; if ($preview) $preview.empty().removeClass('loading-preview').addClass('error-preview'); },
  291. ontimeout: function() { if (controller.signal.aborted) return; fetchController = null; if ($preview) $preview.empty().removeClass('loading-preview').addClass('error-preview'); }
  292. });
  293. }
  294. }).on('mouseleave.iqhover', function(e) {
  295. if (fetchController) { fetchController.abort(); fetchController = null; }
  296. if ($preview) { $preview.remove(); $preview = null; }
  297. targetPostId = null;
  298. }).on('mousemove.iqhover', function(e) {
  299. clearTimeout(mouseMoveTimer);
  300. mouseMoveTimer = setTimeout(() => { updatePreviewPosition(e); }, 20);
  301. }).attr(HOVER_INITIALIZED_ATTR, 'true');
  302. });
  303. }
  304.  
  305. function initializeInlineImageHover(parentElement) {
  306. const $container = $(parentElement);
  307. $container.find('a').each(function() {
  308. let href = this.href;
  309. if (!href) return;
  310.  
  311. let realHref = href;
  312. const urlObj = new URL(href, window.location.origin);
  313. if (urlObj.pathname.endsWith('player.php') && urlObj.searchParams.has('v')) {
  314. realHref = urlObj.searchParams.get('v');
  315. if (!/^(https?:)?\/\//i.test(realHref)) {
  316. realHref = window.location.origin + realHref;
  317. }
  318. }
  319.  
  320. if (!/\.(jpe?g|png|gif|webm|mp4)(?:\?.*)?$/i.test(realHref)) return;
  321.  
  322. const $link = $(this);
  323. if ($link.data('iq-image-hover')) return;
  324. $link.data('iq-image-hover', true);
  325.  
  326. let $preview;
  327. function position(e) {
  328. if (!$preview) return;
  329.  
  330. const winW = window.innerWidth;
  331. const winH = window.innerHeight;
  332. const el = $preview[0];
  333.  
  334. let naturalW = el.naturalWidth || el.videoWidth || 0;
  335. let naturalH = el.naturalHeight || el.videoHeight || 0;
  336. if (!naturalW || !naturalH) return;
  337.  
  338. let scale = Math.min(1, (winW * 0.97) / naturalW, (winH * 0.97) / naturalH);
  339. let width = naturalW * scale;
  340. let height = naturalH * scale;
  341.  
  342. let left = e.clientX + 45;
  343. let top = e.clientY - 45;
  344.  
  345. if (left + width > winW) left = e.clientX - width - 45;
  346. if (top + height > winH) top = e.clientY - height;
  347. if (left < 0) left = 0;
  348. if (top < 0) top = 0;
  349.  
  350. $preview.css({ width, height, left, top });
  351. }
  352.  
  353. $link
  354. .on('mouseenter.iqimagehover', function(e) {
  355. if (/\.(webm|mp4)$/i.test(realHref)) {
  356. $preview = $('<video>', { src: realHref, autoplay: true, muted: true, loop: true });
  357. $preview.on('loadedmetadata', function() {
  358. position(e);
  359. });
  360. } else {
  361. $preview = $('<img>', { src: realHref });
  362. $preview.on('load', function() {
  363. position(e);
  364. });
  365. }
  366. $preview
  367. .css({
  368. position: 'fixed',
  369. zIndex: 9999,
  370. pointerEvents: 'none',
  371. maxWidth: '97vw',
  372. maxHeight: '97vh'
  373. })
  374. .appendTo('body');
  375. })
  376. .on('mousemove.iqimagehover', position)
  377. .on('mouseleave.iqimagehover', function() {
  378. if ($preview) { $preview.remove(); $preview = null; }
  379. });
  380. });
  381. }
  382.  
  383. document.documentElement.addEventListener('click', function(event) {
  384. const linkElement = event.target.closest('a');
  385. if (!linkElement) return;
  386. const postId = getPostIdFromLink(linkElement);
  387. if (!postId || !QUOTE_LINK_REGEX.test(linkElement.textContent?.trim() || '')) return;
  388. event.preventDefault(); event.stopImmediatePropagation();
  389. handleInlineQuoteClick(linkElement, postId);
  390. }, true);
  391.  
  392. $(document).on('mouseenter', SITE_HOVER_TARGET_SELECTOR, function(event) {
  393. const linkElement = event.currentTarget;
  394. if ($(linkElement).hasClass(INLINE_ACTIVE_LINK_CLASS)) {
  395. if ($(event.target).closest(`div[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"]`).length > 0) return;
  396. event.stopImmediatePropagation();
  397. }
  398. });
  399. $(document).on('mouseleave', SITE_HOVER_TARGET_SELECTOR, function(event) {
  400. const linkElement = event.currentTarget;
  401. if ($(linkElement).hasClass(INLINE_ACTIVE_LINK_CLASS)) {
  402. if ($(event.relatedTarget).closest(`div[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"]`).length > 0) return;
  403. event.stopImmediatePropagation();
  404. }
  405. });
  406.  
  407. function runInitialProcessing() {
  408. if (!document.body) return;
  409. processLinks(document.body);
  410. }
  411. if (document.readyState === 'loading') {
  412. document.addEventListener('DOMContentLoaded', runInitialProcessing);
  413. } else {
  414. runInitialProcessing();
  415. }
  416.  
  417. const observer = new MutationObserver(mutations => {
  418. if (!document.body) return;
  419. mutations.forEach(mutation => {
  420. if (mutation.addedNodes.length) {
  421. mutation.addedNodes.forEach(node => {
  422. if (node.nodeType === Node.ELEMENT_NODE) {
  423. if (node.matches(POTENTIAL_QUOTE_LINK_SELECTOR) || node.querySelector(POTENTIAL_QUOTE_LINK_SELECTOR)) {
  424. processLinks(node);
  425. }
  426. let containerNode = null;
  427. if (node.matches && node.matches(`.${INLINE_CONTAINER_CLASS}`)) containerNode = node;
  428. else if (node.querySelector) containerNode = node.querySelector(`.${INLINE_CONTAINER_CLASS}`);
  429. if(containerNode) {
  430. const clonedPostElement = containerNode.querySelector(`.${CLONED_POST_CLASS}`);
  431. if (clonedPostElement) {
  432. initializeInlineHover(clonedPostElement);
  433. initializeInlineImageHover(clonedPostElement);
  434. }
  435. }
  436. }
  437. });
  438. }
  439. });
  440. });
  441. observer.observe(document.documentElement, { childList: true, subtree: true });
  442.  
  443. })();