// ==UserScript==
// @name 阅图标记 (Visited Image Marker)
// @namespace RANRAN
// @version 1.0.25
// @description 帮助你分辨哪些图片已经点击过;已读标识可自定义样式,所有功能已集成在可视化UI中,安装脚本后页面右下角添加齿轮悬浮按钮点击打开功能界面
// @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="每行一个域名,例如 google.com 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);
// [关键修复] 使用纯CSS进行居中
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', () => { loadConfig(); updateStyles(); if (!document.getElementById('readimage-settings-panel')) processImages(); 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) => {
// [关键修复] 开始拖动时,清除transform,防止位置计算错误
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);
})();