202408011.1 [油猴脚本] (新版) 像Arc一样,预览论坛内链接 适配:🍎移动端;匹配 Discourse 论坛: linux.do & 小众软件 (meta.appinn.net)

接上一版:

:warning: 注意:
本次更新可能与部分移动端设备不兼容,
故另起一新话题!

谨慎更新:
(论坛近期升级了,
许多链接需要排除,
早知道搞白名单了 :tieba_087:)

:fire: 持续更新…

:ladder: 施工进展:

  1. :white_check_mark: 加载动画;
  2. :white_check_mark: 适配黑暗模式;
  3. :white_check_mark: 自定义预览窗口 (宽度 + 高度)
  4. :white_check_mark: 预览窗口 锁定(防误触) 功能;
  5. :white_check_mark: 适配超高(宽)屏+超高分辨率

:spiral_notepad: 计划:

  1. 站内链接改用白名单判断;
  2. 阻止预览窗口下方的网页滚动;
  3. 加载动画 + 实用功能(提醒);
  4. 预览窗口的 “台前调度” 功能;

—分割线—

太长不看

碎碎念:
由于不喜欢在新标签页中打开网页,我一直在寻找优雅的预览方法:
(这也是我用arc浏览器的主要原因)

桌面端可以使用的预览插件有很多,但是想在移动设备上实现预览,基本没有可用的方法;
(注:仅折腾过iOS/iPadOS的浏览器);

在Discourse论坛点击帖子后,默认是改变直接改变当前页面的链接;
那么在回退后,原页面有可能会刷新;
(注:仅个人观察结果,未排除其他插件等因素)

某日,我在Greasy Fork上,发现了一个链接预览的脚本
试了一下,移动端也可以正常使用;

太长不看

碎碎念:
使用一段时间后,发现预览窗口与原页面之间的边界比较模糊;
于是我就在这个脚本的基础上,修修改改;
目前为止还没有折腾出啥好结果,bug有点多,留着自用了 :smiling_face_with_tear:
(具体折腾过程略)

下面分享的代码,我仅做了小小的修改:

  1. :white_check_mark: 匹配了linux.do 和meta.appinn.net(小众软件);
  2. :white_check_mark: 外观/功能 上:修改了iframe样式,效果类似Arc浏览器;
  3. :white_check_mark: 加载动画;
  4. :white_check_mark: 适配黑暗模式;
  5. 未完待续…欢迎留言建议 :smiling_face_with_three_hearts:

—分割线—

点击查看 (新版) 脚本
// ==UserScript==
// @name         [Discourse] 论坛内链接预览 "Arc"版 -20240811.1
// @version      20240811.1
// @description  更新:适配超高(宽)屏+超高分辨率
//
// @match        https://linux.do/*
// @match        https://meta.appinn.net/*
//
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
//
// @icon         https://www.svgrepo.com/show/330308/discourse.svg
// ==/UserScript==

(function() {
    'use strict';
    console.log("脚本初始化...");

    // 获取用户设置
    let previewWidth = GM_getValue('previewWidth', 90);
    let previewHeight = GM_getValue('previewHeight', 90);
    let currentLockMode = GM_getValue('lockMode', false);
    let closeButtonPosition = GM_getValue('closeButtonPosition', 'right');

    // 存储所有创建的模态窗口
    let allModals = [];

    // 添加样式到页面头部
    const style = document.createElement('style');
    style.type = 'text/css';
    style.innerHTML = `
        .modal {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-color: rgba(0, 0, 0, 0.5);
            display: none;
            align-items: center;
            justify-content: center;
            z-index: 1000;
        }
        .iframe-container {
            position: relative;
            width: ${previewWidth}%;
            height: ${previewHeight}%;
            display: grid;
        }
        iframe {
            position: absolute;
            min-width: 100%;
            min-height: 100%;
            border: 8px solid rgba(224, 224, 224, 0.9);
            border-radius: 16px;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
            display: none;
        }
        .loader {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            border: 5px solid #f3f3f3;
            border-top: 5px solid #3498db;
            border-radius: 50%;
            width: 50px;
            height: 50px;
            animation: spin 1s linear infinite;
            display: none;
        }
        @keyframes spin {
            0% { transform: translate(-50%, -50%) rotate(0deg); }
            100% { transform: translate(-50%, -50%) rotate(360deg); }
        }
        .close-button {
            position: absolute;
            top: calc(50% - ${previewHeight/2}% + 10px);
            ${closeButtonPosition === 'left' ? 'left: calc(50% - ' + previewWidth/2 + '% - 35px);' : 'right: calc(50% - ' + previewWidth/2 + '% - 35px);'}
            background-color: rgba(255, 255, 255, 0.7);
            color: #333;
            border: none;
            border-radius: 50%;
            width: 30px;
            height: 30px;
            font-size: 16px;
            line-height: 30px;
            text-align: center;
            cursor: pointer;
            opacity: 0.7;
            transition: all 0.3s;
            box-shadow: 0 2px 5px rgba(0,0,0,0.2);
            display: none;
            z-index: 1001;
        }
        .close-button:hover {
            opacity: 1;
            background-color: rgba(255, 255, 255, 0.9);
            transform: scale(1.1);
        }
        .close-button:active {
            transform: scale(0.95);
        }
        @keyframes shake {
            0%, 100% { transform: translateX(0); }
            10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); }
            20%, 40%, 60%, 80% { transform: translateX(10px); }
        }
        .shake {
            animation: shake 0.82s cubic-bezier(.36,.07,.19,.97) both;
        }
        /* 滚动条样式 */
        ::-webkit-scrollbar {
            width: 7px;
        }
        ::-webkit-scrollbar-track {
            background: #f1f1f1;
            border-radius: 10px;
        }
        ::-webkit-scrollbar-thumb {
            background: #888;
            border-radius: 10px;
        }
        ::-webkit-scrollbar-thumb:hover {
            background: #555;
        }
        /* 设置弹窗样式 */
        .settings-modal {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background-color: #fff;
            padding: 20px;
            border-radius: 10px;
            box-shadow: 0 0 10px rgba(0,0,0,0.1);
            z-index: 1001;
            display: none;
            width: 300px;
        }
        .settings-modal h2 {
            margin-top: 0;
            color: #333;
            font-size: 18px;
            text-align: center;
        }
        .settings-modal .input-group {
            display: flex;
            align-items: center;
            margin-bottom: 10px;
        }
        .settings-modal label {
            flex: 1;
            margin-right: 10px;
        }
        .settings-modal input[type="number"] {
            width: 50px;
            padding: 5px;
            margin-right: 5px;
        }
        .settings-modal .percent {
            margin-left: 5px;
        }
        .settings-modal .checkbox-group {
            display: flex;
            align-items: center;
            margin-bottom: 10px;
        }
        .settings-modal .checkbox-group label {
            margin-left: 5px;
        }
        .settings-modal .radio-group {
            display: flex;
            flex-direction: column;
            margin-bottom: 10px;
        }
        .settings-modal .radio-group label {
            margin-bottom: 5px;
        }
        .settings-modal .buttons {
            display: flex;
            justify-content: space-between;
            margin-top: 20px;
        }
        .settings-modal button {
            padding: 5px 10px;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            flex: 1;
            margin: 0 5px;
        }
        .settings-modal button.reset {
            background-color: #3498db;
            color: white;
        }
        .settings-modal button.cancel {
            background-color: #e74c3c;
            color: white;
        }
        .settings-modal button.confirm {
            background-color: #2ecc71;
            color: white;
        }
        /* 黑暗模式 */
        @media (prefers-color-scheme: dark) {
            iframe {
                border: 8px solid rgba(51, 51, 51, 0.9);
                box-shadow: 0 4px 12px rgba(255, 255, 255, 0.05);
            }
            ::-webkit-scrollbar-track {
                background: #333333;
            }
            ::-webkit-scrollbar-thumb {
                background: #666666;
            }
            ::-webkit-scrollbar-thumb:hover {
                background: #888888;
            }
            .loader {
                border: 5px solid #333;
                border-top: 5px solid #3498db;
            }
            .settings-modal {
                background-color: #333;
                color: #fff;
            }
            .settings-modal h2 {
                color: #fff;
            }
            .settings-modal input {
                background-color: #444;
                color: #fff;
                border: 1px solid #555;
            }
            .close-button {
                background-color: rgba(51, 51, 51, 0.7);
                color: #fff;
            }
            .close-button:hover {
                background-color: rgba(51, 51, 51, 0.9);
            }
        }
    `;
    document.head.appendChild(style);

    // 创建模态窗口、iframe容器和加载动画
    function createModal() {
        const modal = document.createElement('div');
        modal.className = 'modal';
        const iframeContainer = document.createElement('div');
        iframeContainer.className = 'iframe-container';
        const iframe = document.createElement('iframe');
        const loader = document.createElement('div');
        loader.className = 'loader';
        iframeContainer.appendChild(loader);
        iframeContainer.appendChild(iframe);
        modal.appendChild(iframeContainer);

        // 创建关闭按钮
        const closeButton = document.createElement('button');
        closeButton.className = 'close-button';
        closeButton.innerHTML = '✕';
        closeButton.style.display = 'none';
        modal.appendChild(closeButton);

        document.body.appendChild(modal);

        // 将新创建的模态窗口添加到数组中
        allModals.push(modal);

        return { modal, iframe, loader, closeButton };
    }

    // 创建设置弹窗
    const settingsModal = document.createElement('div');
    settingsModal.className = 'settings-modal';
    settingsModal.innerHTML = `
        <h2>预览窗口设置</h2>
        <div class="input-group">
            <label for="widthInput">宽度:</label>
            <input type="number" id="widthInput" min="10" max="100">
            <span class="percent">%</span>
        </div>
        <div class="input-group">
            <label for="heightInput">高度:</label>
            <input type="number" id="heightInput" min="10" max="100">
            <span class="percent">%</span>
        </div>
        <div class="checkbox-group">
            <input type="checkbox" id="lockModeCheckbox">
            <label for="lockModeCheckbox">启用锁定(防误触)模式</label>
        </div>
        <div class="radio-group">
            <label>关闭按钮位置:</label>
            <label>
                <input type="radio" name="closeButtonPosition" value="left"> 左上角
            </label>
            <label>
                <input type="radio" name="closeButtonPosition" value="right"> 右上角
            </label>
        </div>
        <div class="buttons">
            <button class="reset">重置</button>
            <button class="cancel">取消</button>
            <button class="confirm">确认</button>
        </div>
    `;
    document.body.appendChild(settingsModal);

    // 注册菜单命令
    GM_registerMenuCommand("设置预览窗口", openSettingsModal);

    // 打开设置弹窗
    function openSettingsModal() {
        document.getElementById('widthInput').value = previewWidth;
        document.getElementById('heightInput').value = previewHeight;
        document.getElementById('lockModeCheckbox').checked = currentLockMode;
        document.querySelector(`input[name="closeButtonPosition"][value="${closeButtonPosition}"]`).checked = true;
        settingsModal.style.display = 'block';
    }

    // 事件监听器
    settingsModal.querySelector('.reset').addEventListener('click', () => {
        document.getElementById('widthInput').value = 90;
        document.getElementById('heightInput').value = 90;
        document.getElementById('lockModeCheckbox').checked = false;
        document.querySelector('input[name="closeButtonPosition"][value="right"]').checked = true;
    });

    settingsModal.querySelector('.cancel').addEventListener('click', () => {
        settingsModal.style.display = 'none';
    });

    settingsModal.querySelector('.confirm').addEventListener('click', () => {
        const newWidth = parseInt(document.getElementById('widthInput').value);
        const newHeight = parseInt(document.getElementById('heightInput').value);
        if (newWidth && newHeight && newWidth >= 10 && newWidth <= 100 && newHeight >= 10 && newHeight <= 100) {
            previewWidth = newWidth;
            previewHeight = newHeight;
            GM_setValue('previewWidth', newWidth);
            GM_setValue('previewHeight', newHeight);
            const newLockMode = document.getElementById('lockModeCheckbox').checked;
            if (newLockMode !== currentLockMode) {
                currentLockMode = newLockMode;
                GM_setValue('lockMode', currentLockMode);
                updateAllModalsLockMode();
            }
            closeButtonPosition = document.querySelector('input[name="closeButtonPosition"]:checked').value;
            GM_setValue('closeButtonPosition', closeButtonPosition);
            updateStyles();
        } else {
            alert('请输入10到100之间的有效数值。');
            return;
        }
        settingsModal.style.display = 'none';
    });

    // 更新所有模态窗口的锁定模式
    function updateAllModalsLockMode() {
        allModals.forEach(modal => {
            updateModalLockMode(modal);
        });
        // 向所有iframe发送更新消息
        allModals.forEach(modal => {
            const iframe = modal.querySelector('iframe');
            if (iframe) {
                iframe.contentWindow.postMessage({ type: 'updateLockMode', lockMode: currentLockMode }, '*');
            }
        });
    }

    // 更新单个模态窗口的锁定模式
    function updateModalLockMode(modal) {
        const closeButton = modal.querySelector('.close-button');
        const iframe = modal.querySelector('iframe');
        if (iframe.style.display === 'inline-block') {
            closeButton.style.display = currentLockMode ? 'block' : 'none';
        }
        updateModalClickBehavior(modal);
    }

    // 更新模态窗口的点击行为
    function updateModalClickBehavior(modal) {
        modal.onclick = function(event) {
            if (event.target === modal) {
                if (currentLockMode) {
                    shakeModal(modal.querySelector('.iframe-container'));
                } else {
                    closeModal(modal);
                }
            }
        };
    }

    // 更新样式
    function updateStyles() {
        const newStyle = `
            .iframe-container {
                width: ${previewWidth}%;
                height: ${previewHeight}%;
            }
            .close-button {
                top: calc(50% - ${previewHeight/2}% + 10px);
                ${closeButtonPosition === 'left' ? 'left: calc(50% - ' + previewWidth/2 + '% - 35px); right: auto;' : 'right: calc(50% - ' + previewWidth/2 + '% - 35px); left: auto;'}
            }
        `;
        style.innerHTML += newStyle;

        allModals.forEach(modal => {
            const iframeContainer = modal.querySelector('.iframe-container');
            const closeButton = modal.querySelector('.close-button');
            iframeContainer.style.width = `${previewWidth}%`;
            iframeContainer.style.height = `${previewHeight}%`;
            closeButton.style.top = `calc(50% - ${previewHeight/2}% + 10px)`;
            if (closeButtonPosition === 'left') {
                closeButton.style.left = `calc(50% - ${previewWidth/2}% - 35px)`;
                closeButton.style.right = 'auto';
            } else {
                closeButton.style.right = `calc(50% - ${previewWidth/2}% - 35px)`;
                closeButton.style.left = 'auto';
            }
        });
    }

    // 打开模态窗口的函数
    function openModal(url) {
        const { modal, iframe, loader, closeButton } = createModal();
        modal.style.display = 'flex';
        loader.style.display = 'block';
        iframe.style.display = 'none';
        closeButton.style.display = 'none';
        iframe.src = url;
        iframe.onload = function() {
            loader.style.display = 'none';
            iframe.style.display = 'inline-block';
            if (currentLockMode) {
                closeButton.style.display = 'block';
            }
            setupIframeContentListener(iframe);
        };

        // 更新模态窗口的点击行为
        updateModalClickBehavior(modal);

        // 关闭按钮点击事件
        closeButton.addEventListener('click', () => closeModal(modal));
    }

    // 关闭模态窗口的函数
    function closeModal(modal) {
        const iframe = modal.querySelector('iframe');
        const loader = modal.querySelector('.loader');
        const closeButton = modal.querySelector('.close-button');

        iframe.src = '';
        modal.style.display = 'none';
        loader.style.display = 'none';
        iframe.style.display = 'none';
        closeButton.style.display = 'none';

        // 从数组中移除关闭的模态窗口
        const index = allModals.indexOf(modal);
        if (index > -1) {
            allModals.splice(index, 1);
        }

        // 移除模态窗口元素
        modal.remove();
    }

    // 抖动窗口的函数
    function shakeModal(element) {
        element.classList.add('shake');
        setTimeout(() => {
            element.classList.remove('shake');
        }, 820); // 抖动动画持续时间为 820ms
    }

    // 检查链接是否为内部话题链接
    function isInternalTopicLink(url) {
        const currentDomain = window.location.hostname;
        const urlObject = new URL(url, window.location.origin);

        // // 获取链接元素
        // let linkElement = document.querySelector(`a[href="${urlObject.pathname}"]`);

        // 获取当前页面的 topic ID
        const currentTopicMatch = window.location.pathname.match(/\/t\/topic\/(\d+)(\/|\b)/);
        const currentTopicId = currentTopicMatch ? currentTopicMatch[1] : null;

        // 获取链接中的 topic ID
        const linkTopicMatch = urlObject.pathname.match(/\/t\/topic\/(\d+)(\/|\b)/);
        const linkTopicId = linkTopicMatch ? linkTopicMatch[1] : null;

        // 检查是否为相同 domain 且不同 topic
        return (urlObject.hostname === currentDomain) &&
               linkTopicId !== null &&
               linkTopicId !== currentTopicId;
    }

    // 处理链接点击的函数
    function handleLinkClick(e) {
        console.log("Link clicked:", e.target);  // 调试日志
        let target = e.target.closest('a');  // 找到最近的 <a> 标签
        if (!target) return;  // 如果点击的不是链接或其子元素,直接返回

        // 检查是否是我们想要处理的链接类型
        if (target.classList.contains('raw-link') ||
            (target.closest('.fps-topic') && target.classList.contains('search-link')) ||
            isInternalTopicLink(target.href)) {

            // // 检查链接是否在导航栏或其他功能区域
            // if (target.closest('header') || target.closest('nav') || target.closest('.d-header')|| target.closest('.d-sidebar')|| target.closest('.user-menu')) {
            //     return;  // 如果在这些区域,不处理链接
            // }

            e.preventDefault();
            e.stopPropagation();  // 防止事件冒泡

            let url = target.href;
            if (url.startsWith('/')) {
                url = window.location.origin + url;
            }
            console.log("Opening URL:", url);  // 调试日志
            openModal(url);
        }
    }

    // 设置iframe内容的事件监听器
    function setupIframeContentListener(iframe) {
        iframe.addEventListener('load', function() {
            try {
                const iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
                iframeDocument.addEventListener('click', handleIframeLinkClick);

                // 向iframe发送当前的锁定模式
                iframe.contentWindow.postMessage({ type: 'updateLockMode', lockMode: currentLockMode }, '*');
            } catch (e) {
                console.error("无法访问iframe内容:", e);
            }
        });
    }

    // 处理iframe内链接点击的函数
    function handleIframeLinkClick(e) {
        let target = e.target.closest('a');
        if (!target) return;

        if (target.classList.contains('raw-link') ||
            (target.closest('.fps-topic') && target.classList.contains('search-link')) ||
            isInternalTopicLink(target.href)) {

            e.preventDefault();
            e.stopPropagation();
            let url = target.href;
            if (url.startsWith('/')) {
                url = new URL(url, e.target.baseURI).href;
            }
            openModal(url);
        }
    }

    // 监听来自iframe的消息
    window.addEventListener('message', function(event) {
        if (event.data.type === 'updateLockMode') {
            currentLockMode = event.data.lockMode;
            updateAllModalsLockMode();
        }
    });

    // 使用事件委托来监听整个文档的点击事件
    document.addEventListener('click', handleLinkClick, true);

    // 添加 MutationObserver 来处理动态加载的内容
    const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
            if (mutation.type === 'childList') {
                mutation.addedNodes.forEach((node) => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        const links = node.querySelectorAll('a');
                        links.forEach(link => {
                            if (link.classList.contains('raw-link') ||
                                (link.closest('.fps-topic') && link.classList.contains('search-link')) ||
                                isInternalTopicLink(link.href)) {
                                    link.addEventListener('click', handleLinkClick);
                                    }
                        });
                    }
                });
            }
        });
    });

    observer.observe(document.body, { childList: true, subtree: true });

    console.log("脚本加载完成");  // 调试日志
})();
脚本设置
  1. 先点这里 :arrow_down:
    脚本设置

  2. 设置弹窗如下:

点击查看 测试结果

经个人测试:

202408011.1 (新版)

浏览器 运行 功能
桌面端 ☐ 是否正常? ☐ 可嵌套不止1层?
Chrome + Violentmonkey :white_check_mark: :white_check_mark:
Firefox + Violentmonkey :white_check_mark: :white_check_mark:
Safari + Stay :x: :x:
Orion + Violentmonkey :white_check_mark: :white_check_mark:
移动端 ☐ 是否正常? ☐ 可嵌套不止1层?
Orion + Violentmonkey :white_check_mark: :x:
Safari + Stay :x: :x:
Safari + Addons :white_check_mark: :x:
Safari + Makeover
未完待续…
点击查看 桌面端
普通模式




黑暗模式




点击查看 移动端 -待更新
平板
竖屏
普通模式
黑暗模式
横屏
普通模式
黑暗模式
手机
竖屏
普通模式
黑暗模式
横屏
普通模式
黑暗模式

—分割线—

致谢

感谢论坛里的佬们,
你们经常分享自己编写的脚本,质量普遍很高 :yum:

—分割线—

其他:
如果有对 链接预览 感兴趣的佬,可以一起交流;

----202408011.1

14 个赞

感谢分享,大佬牛哇

3 个赞

大佬厉害!前来捧场

3 个赞

感谢大佬

3 个赞

感谢。

3 个赞

感谢大佬,我今天把arc无法注册的问题解决了,然后用上这个浏览器了,感觉还不错:wink:

3 个赞

预览加载有点慢

1 个赞

预览 ≈ 在新标签页打开话题,

网页资源需要重新加载 :melting_face:

1 个赞