阅图标记 (Visited Image Marker)

帮助你分辨哪些图片已经点击过;已读标识可自定义样式(五角星\点\标签);支持黑白名单功能;可自定义调整(px)生效范围以取消过小图片内容应用该标记效果。建议配合【阅图标记 (边框标记版)】使用,安装脚本后页面右下角添加齿轮悬浮按钮点击打开功能界面

当前为 2025-07-27 提交的版本,查看 最新版本

// ==UserScript==
// @name         阅图标记 (Visited Image Marker)
// @namespace    RANRAN
// @version      1.0.26
// @description  帮助你分辨哪些图片已经点击过;已读标识可自定义样式(五角星\点\标签);支持黑白名单功能;可自定义调整(px)生效范围以取消过小图片内容应用该标记效果。建议配合【阅图标记 (边框标记版)】使用,安装脚本后页面右下角添加齿轮悬浮按钮点击打开功能界面
// @match        http://*/*
// @match        https://*/*
// @exclude      *://tieba.baidu.com/*
// @exclude      *://hi.baidu.com/*
// @exclude      *://blog.sina.com.cn/*
// @exclude      *://*.blog.sina.com.cn/*
// @exclude      *://www.51.la/*
// @exclude      *://bbs.aicbbs.com/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // 默认配置
    const DEFAULTS = {
        style: 'tag',
        position: 'top-left',
        size: '24',
        offsetX: '5',
        offsetY: '5',
        unreadColor: '#FFFFFF',
        readColor: '#FF0000',
        shadow: true,
        minWidth: '40',
        minHeight: '40',
        siteListMode: 'blacklist',
        siteList: [],
        buttonPos: { x: '15px', y: '15px' },
        showFloatingButton: true,
    };

    // --- 1. 配置与存储管理 ---
    let config = {};
    let processImagesTimeout;
    let visitedLinks = new Set();
    const VISITED_LINKS_KEY = 'readimage_visited_links';
    const VISITED_LINKS_CAP = 2000;
    const SYNC_SAVE_KEY = 'readimage_sync_save';

    function loadConfig() {
        const savedConfig = GM_getValue('config', {});
        config = { ...DEFAULTS, ...savedConfig };
    }

    function shouldScriptRun() {
        const currentHost = window.location.hostname;
        if (!config.siteList || config.siteList.length === 0) {
            return config.siteListMode === 'blacklist';
        }
        const isOnList = config.siteList.some(site => currentHost.endsWith(site));
        return config.siteListMode === 'blacklist' ? !isOnList : isOnList;
    }

    loadConfig();

    GM_registerMenuCommand('设置标记样式 (UI)', showSettingsPanel);
    GM_registerMenuCommand('重置设置并清空记录', resetConfigAndClearData);

    if (config.showFloatingButton) {
        createSettingsButton();
    }

    if (!shouldScriptRun()) {
        return;
    }

    function saveConfig() {
        const panel = document.getElementById('readimage-settings-panel');
        if (panel) {
            const siteListText = panel.querySelector('#siteListArea').value;
            config.siteList = siteListText.split('\n').map(s => s.trim()).filter(Boolean);
            config.showFloatingButton = panel.querySelector('#showFloatingButton').checked;
        }
        GM_setValue('config', config);
        alert('设置已保存!请刷新页面以应用站点列表的更改。');
    }

    function loadVisitedDb() {
        const storedLinks = GM_getValue(VISITED_LINKS_KEY, []);
        visitedLinks = new Set(storedLinks);
        try {
            const syncSavedUrl = localStorage.getItem(SYNC_SAVE_KEY);
            if (syncSavedUrl) {
                visitedLinks.add(syncSavedUrl);
                localStorage.removeItem(SYNC_SAVE_KEY);
                saveVisitedDb();
            }
        } catch (e) { console.error('[readimage] Error accessing localStorage:', e); }
    }

    function canonicalizeUrl(href) { if (typeof href !== 'string' || href.length === 0) return null; try { const url = new URL(href); return url.origin + url.pathname; } catch (e) { return href.split('?')[0].split('#')[0]; } }
    function saveVisitedDb() { let linksToSave = Array.from(visitedLinks); if (linksToSave.length > VISITED_LINKS_CAP) { linksToSave = linksToSave.slice(linksToSave.length - VISITED_LINKS_CAP); } GM_setValue(VISITED_LINKS_KEY, linksToSave); }
    function addLinkToVisited(href) { const canonicalUrl = canonicalizeUrl(href); if (!canonicalUrl || visitedLinks.has(canonicalUrl)) { return; } try { localStorage.setItem(SYNC_SAVE_KEY, canonicalUrl); } catch (e) { console.error('[readimage] Error writing to localStorage:', e); } visitedLinks.add(canonicalUrl); saveVisitedDb(); }

    function resetConfigAndClearData() {
        if (confirm('确定要重置所有设置并清空已读记录吗?此操作不可恢复。')) {
            config = { ...DEFAULTS };
            GM_setValue('config', config);
            visitedLinks.clear();
            GM_setValue(VISITED_LINKS_KEY, []);
            try { localStorage.removeItem(SYNC_SAVE_KEY); } catch (e) {}
            document.querySelectorAll('.readimage-marker').forEach(m => m.remove());
            document.querySelectorAll('.readimage-processed, .is-read').forEach(el => el.classList.remove('readimage-processed', 'is-read'));
            updateStyles();
            processImages();
            let button = document.getElementById('readimage-settings-button');
            if (DEFAULTS.showFloatingButton) {
                if (!button) createSettingsButton();
                button = document.getElementById('readimage-settings-button');
                button.style.right = DEFAULTS.buttonPos.x;
                button.style.bottom = DEFAULTS.buttonPos.y;
            } else {
                if (button) button.remove();
            }
            if (document.getElementById('readimage-settings-panel')) {
                closeSettingsPanel();
                showSettingsPanel();
            }
            alert('已重置所有设置和已读记录!请刷新页面。');
        }
    }

    function debounceProcessImages() { clearTimeout(processImagesTimeout); processImagesTimeout = setTimeout(processImages, 250); }

    function applyMarker(link) {
        if (link.querySelector('.readimage-marker')) {
            const marker = link.querySelector('.readimage-marker');
            const canonicalUrl = canonicalizeUrl(link.href);
            const isRead = canonicalUrl && visitedLinks.has(canonicalUrl);
            marker.classList.toggle('is-read', isRead);
            return;
        }
        const marker = document.createElement('span');
        marker.className = `readimage-marker style-${config.style}`;
        link.appendChild(marker);
        const canonicalUrl = canonicalizeUrl(link.href);
        if (canonicalUrl && visitedLinks.has(canonicalUrl)) {
            marker.classList.add('is-read');
        }
    }

    function processImages() {
        const links = document.querySelectorAll('a:has(img):not(.readimage-processed)');
        links.forEach(link => {
            link.classList.add('readimage-processed');
            const img = link.querySelector('img');
            if (img) {
                const checkAndApply = (targetImg) => {
                    if (targetImg.naturalWidth >= config.minWidth && targetImg.naturalHeight >= config.minHeight) {
                        applyMarker(link);
                    }
                };
                img.addEventListener('load', () => checkAndApply(img), { once: true });
                if (img.complete) {
                    checkAndApply(img);
                }
            }
        });
    }

    function updateStyles() {
        const root = document.documentElement;
        root.style.setProperty('--marker-size', `${config.size}px`);
        root.style.setProperty('--marker-offset-x', `${config.offsetX}px`);
        root.style.setProperty('--marker-offset-y', `${config.offsetY}px`);
        root.style.setProperty('--marker-unread-color', config.unreadColor);
        root.style.setProperty('--marker-read-color', config.readColor);
        let positionCSS = '';
        switch (config.position) {
            case 'top-right': positionCSS = `top: var(--marker-offset-y); right: var(--marker-offset-x);`; break;
            case 'bottom-left': positionCSS = `bottom: var(--marker-offset-y); left: var(--marker-offset-x);`; break;
            case 'bottom-right': positionCSS = `bottom: var(--marker-offset-y); right: var(--marker-offset-x);`; break;
            case 'center': positionCSS = `top: 50%; left: 50%; transform: translate(-50%, -50%);`; break;
            case 'top-left': default: positionCSS = `top: var(--marker-offset-y); left: var(--marker-offset-x);`; break;
        }
        const shadowStyle = config.shadow ? 'text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;' : 'text-shadow: none;';
        const finalCSS = ` a:has(> .readimage-marker) { position: relative !important; display: inherit !important; } .readimage-marker { position: absolute; ${positionCSS} z-index: 999; pointer-events: none; transition: all 0.2s ease-in-out; line-height: 1; display: grid; place-items: center; font-weight: bold; ${shadowStyle} } .readimage-marker.style-star::before { content: '★'; font-size: var(--marker-size); color: var(--marker-unread-color); } .readimage-marker.style-star.is-read::before { color: var(--marker-read-color); } .readimage-marker.style-circle::before { content: ''; display: block; width: var(--marker-size); height: var(--marker-size); background-color: var(--marker-unread-color); border-radius: 50%; } .readimage-marker.style-circle.is-read::before { background-color: var(--marker-read-color); } .readimage-marker.style-tag { background-color: rgba(0, 0, 0, 0.6); color: white; font-size: calc(var(--marker-size) / 2); padding: 0.2em 0.5em; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.2); } .readimage-marker.style-tag::before { content: '未看'; } .readimage-marker.style-tag.is-read { background-color: var(--marker-read-color); color: var(--marker-unread-color); } .readimage-marker.style-tag.is-read::before { content: '已看'; } `;
        let styleElement = document.getElementById('readimage-style');
        if (!styleElement) { styleElement = document.createElement('style'); styleElement.id = 'readimage-style'; document.head.appendChild(styleElement); }
        styleElement.textContent = finalCSS;
        document.querySelectorAll('.readimage-marker').forEach(marker => {
            marker.className = 'readimage-marker';
            marker.classList.add(`style-${config.style}`);
            const link = marker.parentElement;
            const canonicalUrl = canonicalizeUrl(link.href);
            if (link.classList.contains('is-read') || (canonicalUrl && visitedLinks.has(canonicalUrl))) {
                marker.classList.add('is-read');
            }
        });
    }

    function showSettingsPanel() {
        if (document.getElementById('readimage-settings-panel')) return;
        const panel = document.createElement('div');
        panel.id = 'readimage-settings-panel';
        panel.innerHTML = ` <div id="readimage-settings-header"><span>标记样式设置</span><button id="readimage-close-btn" title="关闭">✖</button></div> <div id="readimage-settings-body"> <label>样式:</label> <select id="style"> <option value="star" ${config.style === 'star' ? 'selected' : ''}>五角星 ★</option> <option value="circle" ${config.style === 'circle' ? 'selected' : ''}>圆形 ●</option> <option value="tag" ${config.style === 'tag' ? 'selected' : ''}>标签</option> </select> <label>位置:</label> <select id="position"> <option value="top-left" ${config.position === 'top-left' ? 'selected' : ''}>左上</option> <option value="top-right" ${config.position === 'top-right' ? 'selected' : ''}>右上</option> <option value="bottom-left" ${config.position === 'bottom-left' ? 'selected' : ''}>左下</option> <option value="bottom-right" ${config.position === 'bottom-right' ? 'selected' : ''}>右下</option> <option value="center" ${config.position === 'center' ? 'selected' : ''}>居中</option> </select> <label id="size-label">大小 (px):</label> <input type="range" id="size" min="10" max="50" value="${config.size}"><span class="value-display">${config.size}px</span> <label>水平偏移 (px):</label> <input type="range" id="offsetX" min="-20" max="20" value="${config.offsetX}"><span class="value-display">${config.offsetX}px</span> <label>垂直偏移 (px):</label> <input type="range" id="offsetY" min="-20" max="20" value="${config.offsetY}"><span class="value-display">${config.offsetY}px</span> <label>未读颜色/标签文字:</label> <input type="color" id="unreadColor" value="${config.unreadColor}"> <label>已读颜色:</label> <input type="color" id="readColor" value="${config.readColor}"> <label>为星星/标签加描边:</label> <input type="checkbox" id="shadow" ${config.shadow ? 'checked' : ''}> <hr> <label>最小宽度 (px):</label> <input type="range" id="minWidth" min="10" max="200" value="${config.minWidth}"><span class="value-display">${config.minWidth}px</span> <label>最小高度 (px):</label> <input type="range" id="minHeight" min="10" max="200" value="${config.minHeight}"><span class="value-display">${config.minHeight}px</span> <hr> <label>站点管理:</label> <div class="radio-group"> <input type="radio" id="blacklist" name="siteListMode" value="blacklist" ${config.siteListMode === 'blacklist' ? 'checked' : ''}> <label for="blacklist">黑名单模式</label> <input type="radio" id="whitelist" name="siteListMode" value="whitelist" ${config.siteListMode === 'whitelist' ? 'checked' : ''}> <label for="whitelist">白名单模式</label> </div> <label for="siteListArea" style="align-self: start; padding-top: 5px;">网站列表:</label> <textarea id="siteListArea" rows="5" placeholder="每行一个域名,例如&#10;google.com&#10;example.org">${config.siteList.join('\n')}</textarea> <label></label> <p class="settings-help">黑名单:脚本在此列表网站上**禁用**。<br>白名单:脚本**仅**在此列表网站上生效。</p> <hr> <label>界面选项:</label> <div><input type="checkbox" id="showFloatingButton" ${config.showFloatingButton ? 'checked' : ''}> <label for="showFloatingButton" style="margin: 0 0 0 4px;">显示悬浮设置按钮</label></div> </div> <div id="readimage-settings-footer"><button id="readimage-save-btn">保存</button><button id="readimage-reset-btn">重置并清空记录</button></div> `;
        document.body.appendChild(panel);
        GM_addStyle(` #readimage-settings-panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 99999; background: #f0f0f0; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); font-family: sans-serif; width: 340px; color: #333; } #readimage-settings-body hr { grid-column: 1 / -1; border: none; border-top: 1px solid #ccc; margin: 5px 0; } #readimage-settings-header { padding: 10px; background: #e0e0e0; border-bottom: 1px solid #ccc; cursor: move; display: flex; justify-content: space-between; align-items: center; border-top-left-radius: 8px; border-top-right-radius: 8px; } #readimage-settings-header span { font-weight: bold; } #readimage-close-btn { background: none; border: none; font-size: 16px; cursor: pointer; } #readimage-settings-body { padding: 15px; display: grid; grid-template-columns: auto 1fr; gap: 10px 5px; align-items: center; } #readimage-settings-body label { font-size: 14px; grid-column: 1 / 2; } #readimage-settings-body > *:not(label):not(hr) { grid-column: 2 / 3; } #readimage-settings-body select, #readimage-settings-body textarea { width: 100%; padding: 4px; box-sizing: border-box; } #readimage-settings-body .value-display { font-family: monospace; } #readimage-settings-body div, #readimage-settings-body .radio-group { display: flex; align-items: center; } #readimage-settings-body input[type="range"] { flex: 1; } #readimage-settings-body input[type="color"] { width: 100%; height: 25px; } #readimage-settings-footer { padding: 10px; background: #e0e0e0; text-align: right; border-top: 1px solid #ccc; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; } #readimage-settings-footer button { margin-left: 10px; padding: 5px 15px; border: 1px solid #999; border-radius: 4px; cursor: pointer; } #readimage-save-btn { background: #4CAF50; color: white; border-color: #4CAF50; } #readimage-reset-btn { background: #f44336; color: white; border-color: #f44336; } input:disabled, select:disabled { opacity: 0.5; cursor: not-allowed; } .radio-group label { margin: 0 10px 0 2px; } .settings-help { font-size: 12px; color: #666; margin: 0; } `);
        panel.querySelectorAll('input[type="range"]').forEach(range => { const display = range.nextElementSibling; const container = document.createElement('div'); range.parentNode.insertBefore(container, range); container.appendChild(range); container.appendChild(display); });
        const inputs = panel.querySelectorAll('input, select, textarea');
        inputs.forEach(input => {
            input.addEventListener('input', () => {
                const key = input.id || input.name;
                const value = input.type === 'checkbox' ? input.checked : input.value;
                if (key) config[key] = value;
                if (input.type === 'range') { input.nextElementSibling.textContent = `${value}px`; }
                if (key === 'showFloatingButton') { const button = document.getElementById('readimage-settings-button'); if (config.showFloatingButton) { if (!button) createSettingsButton(); } else { if (button) button.remove(); } return; }
                if (input.id.includes('siteList')) return;
                updateStyles();
                if (key === 'minWidth' || key === 'minHeight' || key === 'style') { document.querySelectorAll('.readimage-processed').forEach(el => { el.classList.remove('readimage-processed'); const marker = el.querySelector('.readimage-marker'); if (marker) marker.remove(); }); debounceProcessImages(); }
                updatePanelState();
            });
        });
        panel.querySelector('#readimage-save-btn').addEventListener('click', () => { saveConfig(); closeSettingsPanel(); });
        panel.querySelector('#readimage-reset-btn').addEventListener('click', resetConfigAndClearData);
        // [关键修复] 简化关闭按钮的逻辑,只负责关闭
        panel.querySelector('#readimage-close-btn').addEventListener('click', closeSettingsPanel);
        updatePanelState();
        makeDraggable(panel.querySelector('#readimage-settings-header'), panel);
    }
    
    function updatePanelState() { const panel = document.getElementById('readimage-settings-panel'); if (!panel) return; const currentStyle = panel.querySelector('#style').value; const unreadColorInput = panel.querySelector('#unreadColor'); const shadowCheckbox = panel.querySelector('#shadow'); const sizeLabel = panel.querySelector('#size-label'); const unreadColorLabel = unreadColorInput.previousElementSibling; unreadColorInput.disabled = false; shadowCheckbox.disabled = (currentStyle === 'circle'); if (currentStyle === 'tag') { sizeLabel.textContent = '字号基准 (px):'; unreadColorLabel.textContent = '已读标签文字颜色:'; } else if (currentStyle === 'circle') { sizeLabel.textContent = '直径 (px):'; unreadColorLabel.textContent = '未读颜色:'; } else { sizeLabel.textContent = '大小 (px):'; unreadColorLabel.textContent = '未读颜色:'; } }
    function closeSettingsPanel() { const panel = document.getElementById('readimage-settings-panel'); if (panel) panel.remove(); }
    
    function makeDraggable(header, panel) {
        let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
        header.onmousedown = (e) => {
            // [关键修复] 判断点击目标,如果点的是关闭按钮,则不启动拖动
            if (e.target.id === 'readimage-close-btn') {
                return;
            }
            panel.style.transform = 'none';
            e.preventDefault();
            pos3 = e.clientX;
            pos4 = e.clientY;
            document.onmouseup = () => {
                document.onmouseup = null;
                document.onmousemove = null;
            };
            document.onmousemove = (e) => {
                e.preventDefault();
                pos1 = pos3 - e.clientX;
                pos2 = pos4 - e.clientY;
                pos3 = e.clientX;
                pos4 = e.clientY;
                panel.style.top = (panel.offsetTop - pos2) + "px";
                panel.style.left = (panel.offsetLeft - pos1) + "px";
            };
        };
    }

    function createSettingsButton() {
        if (document.getElementById('readimage-settings-button')) return;
        const button = document.createElement('div');
        button.id = 'readimage-settings-button';
        button.innerHTML = '⚙️';
        document.body.appendChild(button);
        GM_addStyle(` #readimage-settings-button { position: fixed; z-index: 99998; width: 40px; height: 40px; background-color: rgba(0, 0, 0, 0.5); color: white; border-radius: 50%; display: flex; justify-content: center; align-items: center; font-size: 24px; cursor: pointer; transition: background-color 0.2s, transform 0.2s; user-select: none; } #readimage-settings-button:hover { background-color: rgba(0, 0, 0, 0.7); transform: rotate(45deg); } `);
        button.style.right = config.buttonPos.x;
        button.style.bottom = config.buttonPos.y;
        let dragState = {};
        button.addEventListener('mousedown', (e) => {
            if (e.button !== 0) return;
            dragState = { isDragging: false, startX: e.clientX, startY: e.clientY, btnStartX: parseFloat(button.style.right), btnStartY: parseFloat(button.style.bottom) };
            document.addEventListener('mousemove', onMouseMove);
            document.addEventListener('mouseup', onMouseUp);
        });
        function onMouseMove(e) {
            const dx = e.clientX - dragState.startX;
            const dy = e.clientY - dragState.startY;
            if (!dragState.isDragging && Math.sqrt(dx*dx + dy*dy) > 5) { dragState.isDragging = true; }
            if (dragState.isDragging) {
                let newX = dragState.btnStartX - dx;
                let newY = dragState.btnStartY - dy;
                newX = Math.max(0, Math.min(newX, window.innerWidth - button.offsetWidth));
                newY = Math.max(0, Math.min(newY, window.innerHeight - button.offsetHeight));
                button.style.right = `${newX}px`;
                button.style.bottom = `${newY}px`;
            }
        }
        function onMouseUp() {
            document.removeEventListener('mousemove', onMouseMove);
            document.removeEventListener('mouseup', onMouseUp);
            if (dragState.isDragging) {
                config.buttonPos = { x: button.style.right, y: button.style.bottom };
                const currentConfig = GM_getValue('config', DEFAULTS);
                currentConfig.buttonPos = config.buttonPos;
                GM_setValue('config', currentConfig);
            } else {
                const panel = document.getElementById('readimage-settings-panel');
                if (panel) { closeSettingsPanel(); } else { showSettingsPanel(); }
            }
        }
    }

    // --- 4. 脚本初始化 ---
    loadVisitedDb();
    updateStyles();
    debounceProcessImages();

    const observer = new MutationObserver(debounceProcessImages);
    observer.observe(document.body, { childList: true, subtree: true });

    document.body.addEventListener("mousedown", function(event) {
        if (event.target.closest('#readimage-settings-button')) return;
        const link = event.target.closest('a');
        if (!link || !link.querySelector('.readimage-marker')) return;
        addLinkToVisited(link.href);
        const marker = link.querySelector('.readimage-marker');
        if(marker) marker.classList.add('is-read');
    }, true);
})();