Holotower Inline Quoting

Inline Quoting for holotower.org

当前为 2025-05-08 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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 });

})();