Holotower Inline Quoting

Inline Quoting for holotower.org

目前為 2025-05-08 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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 });

})();