- // ==UserScript==
- // @name Holotower Inline Quoting
- // @namespace http://tampermonkey.net/
- // @version 2.2
- // @author grem
- // @license MIT
- // @description Inline Quoting for holotower.org
- // @match *://boards.holotower.org/*
- // @match *://holotower.org/*
- // @grant GM_addStyle
- // @grant GM_xmlhttpRequest
- // @require https://code.jquery.com/jquery-3.6.0.min.js
- // @connect self
- // @connect boards.holotower.org
- // @icon https://boards.holotower.org/favicon.gif
- // @run-at document-start
- // ==/UserScript==
-
- /* global $ */
-
- (function() {
- 'use strict';
-
- const INLINE_CONTAINER_CLASS = 'inline-quote-container';
- const INLINE_ACTIVE_LINK_CLASS = 'inline-active';
- const LOADING_DATA_ATTR = 'data-inline-loading';
- const ERROR_DATA_ATTR = 'data-inline-error';
- const TEMP_HIGHLIGHT_CLASS = 'inline-temp-highlight';
- const PROCESSED_ATTR = 'data-inline-processed';
- const INLINED_ID_ATTR = 'data-inlined-id';
- const CLONED_POST_CLASS = 'inline-cloned-post';
- const CLONED_HOVER_PREVIEW_ID_PREFIX = 'iq-preview-';
- const HOVER_INITIALIZED_ATTR = 'data-iq-hover-init';
- const SITE_PREVIEW_BASE_CLASSES = 'post qp';
- const SITE_PREVIEW_REPLY_CLASS = 'reply';
- const SITE_PREVIEW_OP_CLASS = 'op';
-
- const POST_SELECTOR_ID_FORMAT = (postId) => `div.post[id$='_${postId}']`;
- const POTENTIAL_QUOTE_LINK_SELECTOR = "a[onclick*='highlightReply'], a[href*='#q']";
- const CLICKABLE_QUOTE_LINK_SELECTOR = "div.post a";
- const QUOTE_LINK_REGEX = /^>>(\d+)/;
- const SITE_HOVER_TARGET_SELECTOR = 'div.body a:not([rel="nofollow"]), p.intro a[href*="#"]:not([href="#"])';
- const BOARD_CONTEXT_SELECTOR = '[data-board]';
-
- GM_addStyle(`
- .${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); }
- .${INLINE_CONTAINER_CLASS}[${INLINED_ID_ATTR}] {}
- .${INLINE_CONTAINER_CLASS} > .${CLONED_POST_CLASS}[data-board] { border: none !important; margin: 0 !important; padding: 0 !important; box-shadow: none !important; background: transparent !important; }
- a.${INLINE_ACTIVE_LINK_CLASS} { font-weight: bold !important; color: var(--link-hover-color, #d11a1a) !important; opacity: 0.85; text-decoration: underline dotted !important; }
- a.${INLINE_ACTIVE_LINK_CLASS}:hover { opacity: 1.0; }
- a[${LOADING_DATA_ATTR}="true"]::after { content: " (loading...)"; font-style: italic; color: var(--text-color-muted, #888); margin-left: 4px; }
- a[${ERROR_DATA_ATTR}="true"]::after { content: " (not found)"; font-style: italic; color: var(--error-text-color, #f00); margin-left: 4px; }
- .${TEMP_HIGHLIGHT_CLASS} { transition: outline 0.1s ease-in-out; outline: 2px solid var(--highlight-color, yellow) !important; outline-offset: 2px; }
- div.qp[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"] { position: absolute !important; z-index: 150 !important; max-width: 500px; }
- 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; }
- div.qp[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"] .hide-post-button,
- div.qp[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"] .menu-button,
- div.qp[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"] input[type=checkbox].delete { display: none !important; }
- 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; }
- 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; }
- `);
-
- function getPostIdFromLink(link) {
- if (!link) return null;
- const textMatch = link.textContent?.trim().match(QUOTE_LINK_REGEX);
- if (textMatch) return textMatch[1];
- const hrefMatch = link.getAttribute('href')?.match(/#(\d+)$/);
- if (hrefMatch) return hrefMatch[1];
- const quoteHrefMatch = link.getAttribute('href')?.match(/#q(\d+)$/);
- if (quoteHrefMatch) return quoteHrefMatch[1];
- return null;
- }
-
- function fetchPostHtml(url) {
- const fetchUrl = url?.split('#')[0];
- if (!fetchUrl) return Promise.resolve(null);
- return new Promise((resolve) => {
- GM_xmlhttpRequest({
- method: "GET",
- url: fetchUrl,
- onload: r => { (r.status >= 200 && r.status < 300) ? resolve(r.responseText) : resolve(null); },
- onerror: r => { resolve(null); },
- ontimeout: () => { resolve(null); }
- });
- });
- }
-
- function parseAndFindPost(html, postId) {
- try {
- const parser = new DOMParser();
- const doc = parser.parseFromString(html, 'text/html');
- const postElement = doc.querySelector(POST_SELECTOR_ID_FORMAT(postId));
- return postElement;
- } catch (error) {
- return null;
- }
- }
-
- function isElementInViewportStrict(el) {
- if (!el) return false;
- const rect = el.getBoundingClientRect();
- return rect.top < window.innerHeight && rect.bottom > 0 && rect.left < window.innerWidth && rect.right > 0;
- }
-
- function temporaryHighlight(el) {
- if (!el) return;
- $(el).addClass(TEMP_HIGHLIGHT_CLASS);
- setTimeout(() => { $(el).removeClass(TEMP_HIGHLIGHT_CLASS); }, 500);
- }
-
- function processLinks(parentElement) {
- if (!parentElement) return;
- const links = parentElement.querySelectorAll(POTENTIAL_QUOTE_LINK_SELECTOR);
- links.forEach(link => {
- if (!link.hasAttribute(PROCESSED_ATTR)) {
- const onclickValue = link.getAttribute('onclick');
- if (onclickValue && onclickValue.includes('highlightReply')) {
- link.removeAttribute('onclick');
- }
- link.setAttribute(PROCESSED_ATTR, 'true');
- }
- });
- }
-
- async function handleInlineQuoteClick(linkElement, postId) {
- const $link = $(linkElement);
- const $parentPost = $link.closest('div.post');
- const $insertionTarget = $parentPost;
- const $nextElement = $insertionTarget.next();
- const isAlreadyInlined = $nextElement.hasClass(INLINE_CONTAINER_CLASS) && $nextElement.attr(INLINED_ID_ATTR) === postId;
-
- if (isAlreadyInlined) {
- $nextElement.remove();
- $link.removeClass(INLINE_ACTIVE_LINK_CLASS).removeAttr(LOADING_DATA_ATTR).removeAttr(ERROR_DATA_ATTR);
- } else {
- closeOtherInlinePosts($link);
- const ancestorContainers = $link.parents(`.${INLINE_CONTAINER_CLASS}`);
- if (ancestorContainers.length > 0 && ancestorContainers.is(`[${INLINED_ID_ATTR}="${postId}"]`)) {
- temporaryHighlight(ancestorContainers.filter(`[${INLINED_ID_ATTR}="${postId}"]`).first()[0]);
- return;
- }
- const originalPostElement = document.querySelector(POST_SELECTOR_ID_FORMAT(postId));
- if (originalPostElement && isElementInViewportStrict(originalPostElement)) {
- temporaryHighlight(originalPostElement);
- return;
- }
-
- $link.addClass(INLINE_ACTIVE_LINK_CLASS).attr(LOADING_DATA_ATTR, "true");
- let targetPostElement = originalPostElement;
- let boardValue = null;
- const $linkContext = $link.closest(BOARD_CONTEXT_SELECTOR);
- if ($linkContext.length > 0) boardValue = $linkContext.data('board');
- else if (originalPostElement) {
- const $targetContext = $(originalPostElement).closest(BOARD_CONTEXT_SELECTOR);
- if ($targetContext.length > 0) boardValue = $targetContext.data('board');
- }
-
- if (!targetPostElement && linkElement.href) {
- const postHtml = await fetchPostHtml(linkElement.href);
- if (postHtml) {
- const parsed = parseAndFindPost(postHtml, postId);
- if (parsed) targetPostElement = parsed;
- }
- }
-
- $link.removeAttr(LOADING_DATA_ATTR);
-
- if (targetPostElement) {
- const $container = $('<div>')
- .addClass(INLINE_CONTAINER_CLASS)
- .attr(INLINED_ID_ATTR, postId);
- $insertionTarget.after($container);
- let handled = false;
- if (window.g?.posts) {
- const boardID = boardValue;
- const postKey = `${boardID}.${postId}`;
- const postObj = g.posts.get(postKey);
- if (postObj && typeof postObj.addClone === 'function') {
- const cloneObj = postObj.addClone($container[0], /*contractThumb=*/false);
- $(cloneObj.nodes.root)
- .addClass(CLONED_POST_CLASS)
- .attr(PROCESSED_ATTR, 'true');
- handled = true;
- }
- }
- if (!handled) {
- const cloned = targetPostElement.cloneNode(true);
- cloned.removeAttribute('id');
- cloned.querySelectorAll('[id]').forEach(el => el.removeAttribute('id'));
- cloned.classList.add(CLONED_POST_CLASS);
- if (boardValue) cloned.setAttribute('data-board', boardValue);
- else cloned.setAttribute('data-board-missing', 'true');
- $container.append(cloned);
- initializeInlineHover(cloned);
- initializeInlineImageHover(cloned);
- }
- } else {
- $link.attr(ERROR_DATA_ATTR, "true").removeClass(INLINE_ACTIVE_LINK_CLASS);
- setTimeout(() => { $link.removeAttr(ERROR_DATA_ATTR); }, 3000);
- }
- }
- }
-
- function closeOtherInlinePosts($triggerLink) {
- $(`.${INLINE_CONTAINER_CLASS}`).each(function() {
- const $container = $(this);
- const isAncestor = $triggerLink.closest($container).length > 0;
- if (isAncestor) return;
- const $parentPost = $container.prev('div.post');
- if ($parentPost.length === 0) return;
- const $possibleLink = $parentPost.find(`a.${INLINE_ACTIVE_LINK_CLASS}`);
- if ($possibleLink.length > 0 && !$possibleLink.is($triggerLink)) {
- $container.remove();
- $possibleLink.removeClass(INLINE_ACTIVE_LINK_CLASS).removeAttr(LOADING_DATA_ATTR).removeAttr(ERROR_DATA_ATTR);
- }
- });
- }
-
- function initializeInlineHover(parentElement) {
- $(parentElement).find(SITE_HOVER_TARGET_SELECTOR).each(function() {
- const $link = $(this);
- if ($link.attr(HOVER_INITIALIZED_ATTR)) return;
-
- let $preview = null;
- let targetPostId = null;
- let currentBoard = $(parentElement).closest(BOARD_CONTEXT_SELECTOR).data('board') || null;
- let fetchController = null;
- let mouseMoveTimer = null;
-
- function updatePreviewPosition(e) {
- if (!$preview) return;
- let top = e.pageY + 10; let left = e.pageX + 10;
- const win = $(window); const winHeight = win.height(); const winWidth = win.width();
- const previewHeight = $preview.outerHeight(); const previewWidth = $preview.outerWidth();
- const scrollTop = win.scrollTop(); const scrollLeft = win.scrollLeft();
- if (previewHeight > 0 && top + previewHeight > scrollTop + winHeight) top = e.pageY - previewHeight - 10;
- if (top < scrollTop) top = scrollTop + 5;
- if (previewWidth > 0 && left + previewWidth > scrollLeft + winWidth) left = e.pageX - previewWidth - 10;
- if (left < scrollLeft) left = scrollLeft + 5;
- $preview.css({ top: top, left: left });
- }
-
- function preparePreviewContent(sourceElement) {
- const clonedContent = sourceElement.cloneNode(true);
- clonedContent.removeAttribute('id'); clonedContent.querySelectorAll('[id]').forEach(el => el.removeAttribute('id'));
- return clonedContent;
- }
-
- function createPreviewDiv(sourceElement, postId) {
- $(`div[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"]`).remove();
- const $previewContainer = $('<div>')
- .addClass(SITE_PREVIEW_BASE_CLASSES)
- .attr('id', CLONED_HOVER_PREVIEW_ID_PREFIX + postId)
- .appendTo('body');
- if (sourceElement) {
- if (sourceElement.classList.contains(SITE_PREVIEW_REPLY_CLASS)) $previewContainer.addClass(SITE_PREVIEW_REPLY_CLASS);
- else if (sourceElement.classList.contains(SITE_PREVIEW_OP_CLASS)) $previewContainer.addClass(SITE_PREVIEW_OP_CLASS);
- $previewContainer.append(preparePreviewContent(sourceElement));
- }
- return $previewContainer;
- }
-
- $link.on('mouseenter.iqhover', function(e) {
- if ($link.hasClass(INLINE_ACTIVE_LINK_CLASS)) return;
- targetPostId = getPostIdFromLink(this);
- if (!targetPostId || !currentBoard) return;
- $(`div[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"]`).remove();
- const originalPostElement = document.querySelector(POST_SELECTOR_ID_FORMAT(targetPostId));
- if (originalPostElement && $(originalPostElement).closest(BOARD_CONTEXT_SELECTOR).data('board') === currentBoard) {
- $preview = createPreviewDiv(originalPostElement, targetPostId);
- updatePreviewPosition(e);
- } else {
- const url = $link.attr('href'); if (!url) return;
- $preview = createPreviewDiv(null, targetPostId);
- $preview.addClass('loading-preview');
- updatePreviewPosition(e);
- if (fetchController) fetchController.abort();
- const controller = new AbortController(); fetchController = controller;
- const fetchUrl = url.split('#')[0];
- GM_xmlhttpRequest({
- method: "GET", url: fetchUrl, signal: controller.signal,
- onload: function(response) {
- if (controller.signal.aborted) return; fetchController = null;
- if (response.status >= 200 && response.status < 300) {
- const fetchedPostElement = parseAndFindPost(response.responseText, targetPostId);
- 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); } }
- else { if ($preview) $preview.empty().removeClass('loading-preview').addClass('error-preview'); }
- } else { if ($preview) $preview.empty().removeClass('loading-preview').addClass('error-preview'); }
- },
- onerror: function(response) { if (controller.signal.aborted) return; fetchController = null; if ($preview) $preview.empty().removeClass('loading-preview').addClass('error-preview'); },
- ontimeout: function() { if (controller.signal.aborted) return; fetchController = null; if ($preview) $preview.empty().removeClass('loading-preview').addClass('error-preview'); }
- });
- }
- }).on('mouseleave.iqhover', function(e) {
- if (fetchController) { fetchController.abort(); fetchController = null; }
- if ($preview) { $preview.remove(); $preview = null; }
- targetPostId = null;
- }).on('mousemove.iqhover', function(e) {
- clearTimeout(mouseMoveTimer);
- mouseMoveTimer = setTimeout(() => { updatePreviewPosition(e); }, 20);
- }).attr(HOVER_INITIALIZED_ATTR, 'true');
- });
- }
-
- function initializeInlineImageHover(parentElement) {
- const $container = $(parentElement);
- $container.find('a').each(function() {
- let href = this.href;
- if (!href) return;
-
- let realHref = href;
- const urlObj = new URL(href, window.location.origin);
- if (urlObj.pathname.endsWith('player.php') && urlObj.searchParams.has('v')) {
- realHref = urlObj.searchParams.get('v');
- if (!/^(https?:)?\/\//i.test(realHref)) {
- realHref = window.location.origin + realHref;
- }
- }
-
- if (!/\.(jpe?g|png|gif|webm|mp4)(?:\?.*)?$/i.test(realHref)) return;
-
- const $link = $(this);
- if ($link.data('iq-image-hover')) return;
- $link.data('iq-image-hover', true);
-
- let $preview;
- function position(e) {
- if (!$preview) return;
-
- const winW = window.innerWidth;
- const winH = window.innerHeight;
- const el = $preview[0];
-
- let naturalW = el.naturalWidth || el.videoWidth || 0;
- let naturalH = el.naturalHeight || el.videoHeight || 0;
- if (!naturalW || !naturalH) return;
-
- let scale = Math.min(1, (winW * 0.97) / naturalW, (winH * 0.97) / naturalH);
- let width = naturalW * scale;
- let height = naturalH * scale;
-
- let left = e.clientX + 45;
- let top = e.clientY - 45;
-
- if (left + width > winW) left = e.clientX - width - 45;
- if (top + height > winH) top = e.clientY - height;
- if (left < 0) left = 0;
- if (top < 0) top = 0;
-
- $preview.css({ width, height, left, top });
- }
-
- $link
- .on('mouseenter.iqimagehover', function(e) {
- if (/\.(webm|mp4)$/i.test(realHref)) {
- $preview = $('<video>', { src: realHref, autoplay: true, muted: true, loop: true });
- $preview.on('loadedmetadata', function() {
- position(e);
- });
- } else {
- $preview = $('<img>', { src: realHref });
- $preview.on('load', function() {
- position(e);
- });
- }
- $preview
- .css({
- position: 'fixed',
- zIndex: 9999,
- pointerEvents: 'none',
- maxWidth: '97vw',
- maxHeight: '97vh'
- })
- .appendTo('body');
- })
- .on('mousemove.iqimagehover', position)
- .on('mouseleave.iqimagehover', function() {
- if ($preview) { $preview.remove(); $preview = null; }
- });
- });
- }
-
- document.documentElement.addEventListener('click', function(event) {
- const linkElement = event.target.closest('a');
- if (!linkElement) return;
- const postId = getPostIdFromLink(linkElement);
- if (!postId || !QUOTE_LINK_REGEX.test(linkElement.textContent?.trim() || '')) return;
- event.preventDefault(); event.stopImmediatePropagation();
- handleInlineQuoteClick(linkElement, postId);
- }, true);
-
- $(document).on('mouseenter', SITE_HOVER_TARGET_SELECTOR, function(event) {
- const linkElement = event.currentTarget;
- if ($(linkElement).hasClass(INLINE_ACTIVE_LINK_CLASS)) {
- if ($(event.target).closest(`div[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"]`).length > 0) return;
- event.stopImmediatePropagation();
- }
- });
- $(document).on('mouseleave', SITE_HOVER_TARGET_SELECTOR, function(event) {
- const linkElement = event.currentTarget;
- if ($(linkElement).hasClass(INLINE_ACTIVE_LINK_CLASS)) {
- if ($(event.relatedTarget).closest(`div[id^="${CLONED_HOVER_PREVIEW_ID_PREFIX}"]`).length > 0) return;
- event.stopImmediatePropagation();
- }
- });
-
- function runInitialProcessing() {
- if (!document.body) return;
- processLinks(document.body);
- }
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', runInitialProcessing);
- } else {
- runInitialProcessing();
- }
-
- const observer = new MutationObserver(mutations => {
- if (!document.body) return;
- mutations.forEach(mutation => {
- if (mutation.addedNodes.length) {
- mutation.addedNodes.forEach(node => {
- if (node.nodeType === Node.ELEMENT_NODE) {
- if (node.matches(POTENTIAL_QUOTE_LINK_SELECTOR) || node.querySelector(POTENTIAL_QUOTE_LINK_SELECTOR)) {
- processLinks(node);
- }
- let containerNode = null;
- if (node.matches && node.matches(`.${INLINE_CONTAINER_CLASS}`)) containerNode = node;
- else if (node.querySelector) containerNode = node.querySelector(`.${INLINE_CONTAINER_CLASS}`);
- if(containerNode) {
- const clonedPostElement = containerNode.querySelector(`.${CLONED_POST_CLASS}`);
- if (clonedPostElement) {
- initializeInlineHover(clonedPostElement);
- initializeInlineImageHover(clonedPostElement);
- }
- }
- }
- });
- }
- });
- });
- observer.observe(document.documentElement, { childList: true, subtree: true });
-
- })();