Holotower Inline Quoting

Inline Quoting for holotower.org

当前为 2025-06-23 提交的版本,查看 最新版本

// ==UserScript==
// @name         Holotower Inline Quoting
// @namespace    http://tampermonkey.net/
// @version      2.3
// @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 $, setting */

    // SETTINGS
    const IQ_SETTINGS_KEY = "InlineQuote Settings";
    const iqDefaultSettings = {
        enableVideoHoverPreview: false,
    };

    let iqSettings = {};
        try {
            iqSettings = JSON.parse(localStorage.getItem(IQ_SETTINGS_KEY)) || {};
        } catch(e) { iqSettings = {}; }
    for (const k in iqDefaultSettings) if (!(k in iqSettings)) iqSettings[k] = iqDefaultSettings[k];

    function saveIQ() {
      localStorage.setItem(IQ_SETTINGS_KEY, JSON.stringify(iqSettings));
    }

    // Small helper: current video volume slider (from webm-settings.js)
    function getGlobalDefaultVolume() {
      try {
            if (typeof setting === "function") {
                const v = parseFloat(setting("videovolume"));
                if (!isNaN(v)) return Math.min(Math.max(v,0),1);
            }
        } catch {}
        return 1;
    }

    // OPTIONS TAB
    $(function () {
        if (typeof Options === "undefined") return;

        const tab = Options.add_tab("inline-quote", "quote-right", "Inline Quotes");
        const $content = $("<div></div>");

        // Checkbox: play videos on hover
        const $videoHoverEntry = $(
            `<div id="enableVideoHoverPreview-container">
                <label style="text-decoration: underline; cursor: pointer;">
                    <input type="checkbox" id="enableVideoHoverPreview">Play videos on hover</label>
                <span class="description">: Show/Hide previews when hovering videos inside inline quotes</span>
            </div>`);
        $content.append($videoHoverEntry);
        $(tab.content).append($content);

        // Init state
        $("#enableVideoHoverPreview").prop("checked", iqSettings.enableVideoHoverPreview);

        // Persist changes
        $("#enableVideoHoverPreview").on("change", function () {
            iqSettings.enableVideoHoverPreview = this.checked;
            saveIQ();
        });
    });

(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 QUOTE_LINK_REGEX = /^>>(\d+)/;
    const SITE_HOVER_TARGET_SELECTOR = 'div.body a:not([rel="nofollow"])';
    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,.05)); padding:5px; margin-top:5px; margin-left:20px; border-radius:4px; }
        .${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:.85; text-decoration:underline dotted!important; }
        a.${INLINE_ACTIVE_LINK_CLASS}:hover { opacity:1 }
        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 .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, winH = window.innerHeight, el = $preview[0];
                const naturalW = el.naturalWidth || el.videoWidth || 0, naturalH = el.naturalHeight || el.videoHeight || 0; if (!naturalW||!naturalH) return;
                const scale = Math.min(1, (winW*0.97)/naturalW, (winH*0.97)/naturalH);
                const width = naturalW*scale, height = naturalH*scale;
                let left = e.clientX + 45, 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) {
                const isVideo = /\.(webm|mp4)$/i.test(realHref);
                if (isVideo && !iqSettings.enableVideoHoverPreview) return;

                if (isVideo) {
                    $preview = $('<video>', {src: realHref, autoplay: true, muted: true, loop: true});
                    const vol = getGlobalDefaultVolume();
                    $preview.prop('volume', vol);
                    $preview.on('loadedmetadata', () => position(e));
                } else {
                    $preview = $('<img>', {src: realHref}).on('load', () => 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 });

})();