您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Hides titles of posts marked as spoilers and/or NSFW on Reddit. Includes a toggle button to show/hide titles, and settings to 1). choose between blur and censor bar styles and 2). hide spoiler and/or NSFW titles. Settings are persistent across sessions. Script mostly made using AI.
// ==UserScript== // @name Hide Spoiler/NSFW Titles on Reddit // @namespace https://greasyfork.org/en/users/1438773-thezealot // @version 2.0.2 // @description Hides titles of posts marked as spoilers and/or NSFW on Reddit. Includes a toggle button to show/hide titles, and settings to 1). choose between blur and censor bar styles and 2). hide spoiler and/or NSFW titles. Settings are persistent across sessions. Script mostly made using AI. // @author TheZealot // @license MIT // @icon https://cdn-icons-png.flaticon.com/512/11695/11695651.png // @supportURL https://greasyfork.org/en/users/1438773-thezealot // @include https://www.reddit.com/* // @grant GM_registerMenuCommand // @grant GM_getValue // @grant GM_setValue // ==/UserScript== (function () { 'use strict'; // ==================== CONFIGURATION ==================== // Cached DOM selectors for better performance const SELECTORS = { POST: 'shreddit-post[spoiler], shreddit-post[nsfw]', POST_TITLE: '[id^="post-title-t3_"]', SEARCH_POST: 'div[data-testid="search-post-unit"]', SEARCH_TITLE: 'a[data-testid="post-title-text"]', SPOILER_MARKER: '.text-category-spoiler', NSFW_MARKER: '.text-category-nsfw', RELATED_POSTS: 'ul.list-none.p-0.m-0 li', RELATED_TITLE: '.i18n-list-item-post-title', HEADER: [ '.items-center.flex.h-header-large', 'header', 'div[data-testid="header-container"]' ] }; // Icons for the toggle button const ICONS = { ON: 'https://cdn-icons-png.flaticon.com/512/11860/11860394.png', OFF: 'https://cdn-icons-png.flaticon.com/512/11860/11860256.png' }; // Classes for styling const CLASSES = { BLUR: 'btr-blurred', CENSOR: 'btr-censor-bar', BUTTON: 'btr-toggle-btn', MODAL: 'btr-settings-modal', NO_TRANSITION: 'btr-no-transition', BACKDROP: 'btr-modal-backdrop' // Added class for modal backdrop }; // ==================== STATE MANAGEMENT ==================== // Retrieve last saved settings from GM_getValue (persistent across page loads) let state = { coverEnabled: GM_getValue('coverEnabled', true), useBlurMode: GM_getValue('useBlurMode', true), hideSpoilers: GM_getValue('hideSpoilers', true), hideNsfw: GM_getValue('hideNsfw', false), isStyleTransitionDisabled: false, blurIntensity: GM_getValue('blurIntensity', 4), // Added setting for blur intensity lastSettingsUpdate: Date.now() // Track when settings were last changed }; function updateFilterSettings(newSpoilerSetting, newNsfwSetting) { const previousSpoilerSetting = state.hideSpoilers; const previousNsfwSetting = state.hideNsfw; state.hideSpoilers = newSpoilerSetting; state.hideNsfw = newNsfwSetting; state.lastSettingsUpdate = Date.now(); GM_setValue('hideSpoilers', state.hideSpoilers); GM_setValue('hideNsfw', state.hideNsfw); // Force removal of concealment if either setting was turned off if (previousSpoilerSetting && !newSpoilerSetting) { removeSpoilerConcealment(); } if (previousNsfwSetting && !newNsfwSetting) { removeNsfwConcealment(); } // If both settings are now disabled, do an additional cleanup if (!newSpoilerSetting && !newNsfwSetting) { // Extra thorough removal of all concealment classes from any element document.querySelectorAll(`.${CLASSES.BLUR}, .${CLASSES.CENSOR}`).forEach(el => { el.classList.remove(CLASSES.BLUR, CLASSES.CENSOR); }); } // Update UI updateToggleButton(); updateSpoilerTitles(); } // Cache for DOM elements let cache = { button: null, modal: null, styleElement: null, postFeed: null, sidebar: null, searchResults: null, modalBackdrop: null // Added cache for modal backdrop }; // ==================== UI SETUP ==================== /** * Injects all CSS styles at once for better performance */ function setupStyles() { const style = document.createElement('style'); style.textContent = ` :root { --btr-censor-bar-color: black; /* Default color for censor bar in light mode */ --btr-primary-color: #ff4500; /* Reddit's primary orange color */ --btr-primary-hover: #ff6a33; /* Lighter shade for hover state */ --btr-primary-active: #cc3700; /* Darker shade for active state */ --btr-blur-intensity: ${state.blurIntensity}px; /* Configurable blur intensity */ } /* Apply effects only to spoiler and NSFW titles */ shreddit-post[spoiler] [id^="post-title-t3_"], shreddit-post[nsfw] [id^="post-title-t3_"], .text-category-spoiler + h3, .text-category-spoiler + a, .text-category-nsfw + h3, .text-category-nsfw + a, div[data-testid="search-post-unit"] a[data-testid="post-title-text"] { transition: filter 0.3s ease, opacity 0.3s ease; position: relative; white-space: normal !important; overflow-wrap: anywhere !important; display: inline !important; will-change: filter, opacity; /* GPU optimization hint */ } /* Disable transitions when switching styles in settings */ .${CLASSES.NO_TRANSITION} { transition: none !important; } /* Blur effect for spoiler and NSFW titles */ .${CLASSES.BLUR} { filter: blur(var(--btr-blur-intensity)) !important; white-space: normal !important; overflow-wrap: anywhere !important; } /* Blur hover effect - apply only to the element being hovered */ .${CLASSES.BLUR}:hover { filter: none !important; transition: filter 0.3s ease !important; } /* Censor bar effect for spoiler and NSFW titles */ .${CLASSES.CENSOR} { display: inline !important; width: 100% !important; position: relative; white-space: normal !important; overflow-wrap: anywhere !important; background-color: var(--btr-censor-bar-color) !important; color: transparent !important; transition: background-color 0.3s ease, color 0.3s ease !important; } /* Censor bar hover effect */ .${CLASSES.CENSOR}:hover { background-color: transparent !important; color: inherit !important; } /* Censor bar hover effect removal */ .${CLASSES.CENSOR}::after { display: none; } /* Fix for search results - remove parent hover detection, only apply to direct hover */ div[data-testid="search-post-unit"] a[data-testid="post-title-text"].${CLASSES.BLUR}:hover, div[data-testid="search-post-unit"] a[data-testid="post-title-text"].${CLASSES.CENSOR}:hover::after { pointer-events: auto !important; } /* Remove the parent hover triggering - only direct hover should work */ div[data-testid="search-post-unit"]:hover a[data-testid="post-title-text"].${CLASSES.BLUR}:not(:hover) { filter: blur(var(--btr-blur-intensity)) !important; } div[data-testid="search-post-unit"]:hover a[data-testid="post-title-text"].${CLASSES.CENSOR}:not(:hover)::after { opacity: 1 !important; } /* Toggle button styles with enhanced hover and active states */ .${CLASSES.BUTTON} { position: relative; cursor: pointer; margin-left: 8px; z-index: 1000; width: 36px; height: 36px; display: flex; justify-content: center; align-items: center; background-size: 75%; background-repeat: no-repeat; background-position: center; border: none; border-radius: 50%; background-color: var(--btr-primary-color); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); transition: all 0.2s ease; will-change: transform, background-color, box-shadow; } /* Enhanced hover state */ .${CLASSES.BUTTON}:not(:disabled):hover { background-color: var(--btr-primary-hover); transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); } /* Enhanced active/pressed state */ .${CLASSES.BUTTON}:not(:disabled):active { background-color: var(--btr-primary-active); transform: translateY(1px) scale(0.95); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); } /* Disabled state */ .${CLASSES.BUTTON}:disabled { background-color: #3D494E; opacity: 0.7; cursor: not-allowed; } /* Modal backdrop */ .${CLASSES.BACKDROP} { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 1999; opacity: 0; transition: opacity 0.3s ease; backdrop-filter: blur(2px); display: none; pointer-events: none; } .${CLASSES.BACKDROP}.active { opacity: 1; display: block; pointer-events: auto; } /* Settings modal styles with improved animation */ .${CLASSES.MODAL} { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.9); background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); z-index: 2000; display: none; min-width: 340px; text-align: center; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; opacity: 0; transition: transform 0.3s ease, opacity 0.3s ease; } .${CLASSES.MODAL}.active { opacity: 1; transform: translate(-50%, -50%) scale(1); } /* Dark mode support for modal */ :root.theme-dark .${CLASSES.MODAL} { background: #1a1a1b; color: #d7dadc; border: 1px solid #343536; } .${CLASSES.MODAL} h3 { font-size: 20px; font-weight: bold; color: var(--btr-primary-color); margin-bottom: 10px; } .btr-settings-container { display: flex; justify-content: space-between; column-gap: 12px; margin: 0 auto; max-width: 300px; } .btr-settings-column { display: flex; flex-direction: column; gap: 10px; text-align: left; } .btr-settings-column h4 { font-size: 16px; font-weight: bold; color: #444; text-decoration: underline; margin-bottom: 6px; } :root.theme-dark .btr-settings-column h4 { color: #d7dadc; } .${CLASSES.MODAL} label { font-size: 14px; display: flex; align-items: center; gap: 8px; color: #666; margin: 4px 0; } :root.theme-dark .${CLASSES.MODAL} label { color: #c5c6c7; } /* Improved slider styles */ .btr-range-slider { width: 100%; margin: 8px 0; display: flex; flex-direction: column; } .btr-range-slider label { margin-bottom: 5px; } .btr-range-slider-value { margin-top: 5px; text-align: center; font-weight: bold; color: var(--btr-primary-color); } /* Firefox-specific fix for settings modal UI */ @-moz-document url-prefix() { /* Make the columns perfectly equal and aligned */ .btr-settings-container { display: flex; justify-content: space-between; width: 100%; max-width: 300px; margin: 0 auto; } /* Ensure consistent column widths */ .btr-settings-column { flex: 1; display: flex; flex-direction: column; align-items: flex-start; padding: 0 5px; min-height: 160px; } /* Center the column headings */ .btr-settings-column h4 { text-align: left; width: 100%; margin: 0 0 10px 0; } /* Align radio buttons and checkboxes */ .btr-option { display: flex; align-items: center; width: 100%; margin: 4px 0; } /* Create a consistent container for inputs */ .btr-input-wrapper { display: inline-block; width: 16px; height: 16px; flex-shrink: 0; position: relative; margin-right: 8px; } /* Position the actual input elements */ .btr-input-wrapper input[type="radio"], .btr-input-wrapper input[type="checkbox"] { position: absolute; left: 0; top: 0; margin: 0; padding: 0; } /* Ensure label text is aligned */ .btr-label-text { display: inline-block; vertical-align: middle; line-height: 16px; } /* Consistent slider spacing */ .btr-range-slider { width: 100%; margin: 15px 0; } /* Maintain consistent spacing when blur intensity is hidden */ .btr-range-slider[style*="display: none"] { display: block !important; visibility: hidden; pointer-events: none; height: 0; margin: 0; padding: 0; } /* Fix specific slider elements */ #btr-blur-intensity { width: 100%; margin: 8px 0; } } /* Improved buttons */ .btr-button { background-color: var(--btr-primary-color); color: white; border: none; padding: 8px 16px; cursor: pointer; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: bold; transition: background-color 0.2s ease, transform 0.2s ease; } .btr-button:hover { background-color: var(--btr-primary-hover); transform: translateY(-1px); } .btr-button:active { background-color: var(--btr-primary-active); transform: translateY(1px); } .btr-buttons-container { margin-top: 20px; display: flex; justify-content: center; gap: 10px; } /* Reset button style */ .btr-reset-btn { background-color: #666; } .btr-reset-btn:hover { background-color: #777; } .btr-reset-btn:active { background-color: #555; } `; document.head.appendChild(style); cache.styleElement = style; } /** * Updates the censor bar color to match Reddit's theme (light/dark mode) */ function updateCensorBarColor() { const isDarkMode = document.documentElement.classList.contains('theme-dark'); const censorbarColor = isDarkMode ? 'white' : 'black'; document.documentElement.style.setProperty('--btr-censor-bar-color', censorbarColor); } /** * Creates or updates the toggle button in Reddit's header */ function setupToggleButton() { // Check if button already exists in DOM const existingButton = document.querySelector(`.${CLASSES.BUTTON}`); if (existingButton) { cache.button = existingButton; updateToggleButton(); return; } // Create new button cache.button = document.createElement('button'); cache.button.classList.add(CLASSES.BUTTON); cache.button.setAttribute('aria-label', 'Toggle Spoiler/NSFW Title Hiding'); cache.button.style.backgroundImage = `url('${state.coverEnabled ? ICONS.ON : ICONS.OFF}')`; // Add click event listener cache.button.addEventListener('click', function() { // Only toggle if at least one filter is enabled const atLeastOneFilterEnabled = state.hideSpoilers || state.hideNsfw; if (atLeastOneFilterEnabled) { state.coverEnabled = !state.coverEnabled; GM_setValue('coverEnabled', state.coverEnabled); // Add a subtle animation effect when toggling this.style.transform = 'scale(0.8)'; setTimeout(() => { this.style.transform = ''; updateToggleButton(); updateSpoilerTitles(); }, 150); } else { // Provide visual feedback that click does nothing when filters are disabled this.style.transform = 'scale(0.95)'; setTimeout(() => { this.style.transform = ''; }, 100); } }); // Add right-click event listener for settings cache.button.addEventListener('contextmenu', function(e) { e.preventDefault(); // Prevent context menu showModal(); return false; }); // Try to insert button in header let headerFound = false; for (const selector of SELECTORS.HEADER) { const headerContainer = document.querySelector(selector); if (headerContainer) { headerContainer.appendChild(cache.button); headerFound = true; break; } } // Fallback if header not found if (!headerFound) { console.warn('Header container not found. Creating floating button.'); cache.button.style.position = 'fixed'; cache.button.style.bottom = '20px'; cache.button.style.right = '20px'; cache.button.style.zIndex = '9999'; document.body.appendChild(cache.button); } updateToggleButton(); } /** * Updates the toggle button's appearance and tooltip based on current settings */ /** * Updates the toggle button's appearance and tooltip based on current settings */ function updateToggleButton() { if (!cache.button) return; // Check if at least one filter is enabled const atLeastOneFilterEnabled = state.hideSpoilers || state.hideNsfw; // Update button's appearance and functionality if (atLeastOneFilterEnabled) { cache.button.disabled = false; cache.button.style.backgroundColor = 'var(--btr-primary-color)'; cache.button.style.opacity = '1'; // Update tooltip based on the state of the checkboxes if (state.hideSpoilers && !state.hideNsfw) { cache.button.title = state.coverEnabled ? 'Show Spoilers' : 'Hide Spoilers'; } else if (!state.hideSpoilers && state.hideNsfw) { cache.button.title = state.coverEnabled ? 'Show NSFW' : 'Hide NSFW'; } else if (state.hideSpoilers && state.hideNsfw) { cache.button.title = state.coverEnabled ? 'Show Spoilers/NSFW' : 'Hide Spoilers/NSFW'; } } else { // Instead of disabling the button, we'll just make left-click do nothing cache.button.disabled = false; // Keep it enabled for right-click cache.button.style.backgroundColor = '#666'; // A more interactive gray color cache.button.title = 'No filters selected (Right-click for settings)'; cache.button.style.opacity = '0.8'; // Slightly more visible than before } // Update button icon cache.button.style.backgroundImage = `url('${state.coverEnabled ? ICONS.ON : ICONS.OFF}')`; } /** * Creates settings modal with improved UI and functionality */ function createSettingsModal() { if (document.querySelector(`.${CLASSES.MODAL}`)) return; // Create modal backdrop (initially hidden) const backdrop = document.createElement('div'); backdrop.classList.add(CLASSES.BACKDROP); backdrop.addEventListener('click', hideModal); backdrop.style.display = 'none'; // Initially hidden document.body.appendChild(backdrop); cache.modalBackdrop = backdrop; // Create modal const modal = document.createElement('div'); modal.classList.add(CLASSES.MODAL); modal.innerHTML = ` <h3>Reddit Spoiler/NSFW Title Settings</h3> <div class="btr-settings-container"> <div class="btr-settings-column"> <h4>Style</h4> <label><input type="radio" name="btr-mode" value="blur"> Blurred Titles</label> <label><input type="radio" name="btr-mode" value="censor-bar"> Censor Bar</label> <div class="btr-range-slider"> <label for="btr-blur-intensity">Blur Intensity</label> <input type="range" id="btr-blur-intensity" min="1" max="10" value="${state.blurIntensity}"> <div class="btr-range-slider-value" id="btr-blur-value">${state.blurIntensity}px</div> </div> </div> <div class="btr-settings-column"> <h4>Filters</h4> <label><input type="checkbox" id="btr-hide-spoilers"> Hide Spoilers</label> <label><input type="checkbox" id="btr-hide-nsfw"> Hide NSFW</label> </div> </div> <div class="btr-buttons-container"> <button id="btr-reset-settings" class="btr-button btr-reset-btn">Reset</button> <button id="btr-close-settings" class="btr-button">Save & Close</button> </div> `; document.body.appendChild(modal); cache.modal = modal; // Set initial values const blurOption = modal.querySelector('input[value="blur"]'); const censorOption = modal.querySelector('input[value="censor-bar"]'); const spoilersOption = modal.querySelector('#btr-hide-spoilers'); const nsfwOption = modal.querySelector('#btr-hide-nsfw'); const blurIntensitySlider = modal.querySelector('#btr-blur-intensity'); const blurValueDisplay = modal.querySelector('#btr-blur-value'); if (blurOption && censorOption) { blurOption.checked = state.useBlurMode; censorOption.checked = !state.useBlurMode; modal.querySelectorAll('input[name="btr-mode"]').forEach(input => { input.addEventListener('change', (e) => { // Check if we're switching from censor bar to blur const switchingFromCensorToBlur = !state.useBlurMode && e.target.value === 'blur'; // Update state state.useBlurMode = e.target.value === 'blur'; GM_setValue('useBlurMode', state.useBlurMode); // Toggle blur intensity control visibility document.querySelector('.btr-range-slider').style.display = state.useBlurMode ? 'flex' : 'none'; // Disable transitions when switching from censor to blur if (switchingFromCensorToBlur) { disableTransitions(); } // Update the titles with the new style updateSpoilerTitles(); // If we disabled transitions, re-enable them after a short delay if (switchingFromCensorToBlur) { setTimeout(enableTransitions, 50); } }); }); // Set initial display of blur intensity control document.querySelector('.btr-range-slider').style.display = state.useBlurMode ? 'flex' : 'none'; } if (spoilersOption) { spoilersOption.checked = state.hideSpoilers; spoilersOption.addEventListener('change', (e) => { const wasEnabled = state.hideSpoilers; state.hideSpoilers = e.target.checked; GM_setValue('hideSpoilers', state.hideSpoilers); if (wasEnabled && !state.hideSpoilers) { // Do an aggressive removal of all blurred/censored elements removeSpoilerConcealment(); document.querySelectorAll(`.${CLASSES.BLUR}, .${CLASSES.CENSOR}`).forEach(el => { el.classList.remove(CLASSES.BLUR, CLASSES.CENSOR); }); } updateSpoilerTitles(); updateToggleButton(); }); } if (nsfwOption) { nsfwOption.checked = state.hideNsfw; nsfwOption.addEventListener('change', (e) => { const wasEnabled = state.hideNsfw; state.hideNsfw = e.target.checked; GM_setValue('hideNsfw', state.hideNsfw); if (wasEnabled && !state.hideNsfw) { // Do an aggressive removal of all blurred/censored elements removeNsfwConcealment(); document.querySelectorAll(`.${CLASSES.BLUR}, .${CLASSES.CENSOR}`).forEach(el => { el.classList.remove(CLASSES.BLUR, CLASSES.CENSOR); }); } updateSpoilerTitles(); updateToggleButton(); }); } // Blur intensity slider if (blurIntensitySlider && blurValueDisplay) { blurIntensitySlider.addEventListener('input', (e) => { const value = parseInt(e.target.value); state.blurIntensity = value; blurValueDisplay.textContent = `${value}px`; document.documentElement.style.setProperty('--btr-blur-intensity', `${value}px`); // Debounce saving to avoid excessive writes clearTimeout(blurIntensitySlider.saveTimeout); blurIntensitySlider.saveTimeout = setTimeout(() => { GM_setValue('blurIntensity', value); updateSpoilerTitles(); }, 300); }); } // Close button event listener const closeButton = document.getElementById('btr-close-settings'); if (closeButton) { closeButton.addEventListener('click', hideModal); } // Reset button event listener const resetButton = document.getElementById('btr-reset-settings'); if (resetButton) { resetButton.addEventListener('click', resetSettings); } // Right click on toggle button opens settings cache.button.addEventListener('contextmenu', function(e) { e.preventDefault(); // Prevent context menu showModal(); return false; }); // Add keyboard event listener to close modal on Escape document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.classList.contains('active')) { hideModal(); } }); } /** * Shows the settings modal with animation */ function showModal() { if (!cache.modal || !cache.modalBackdrop) return; // First make elements visible but not active cache.modal.style.display = 'block'; cache.modalBackdrop.style.display = 'block'; // Trigger reflow to enable animation void cache.modal.offsetWidth; // Then add active class for animation cache.modal.classList.add('active'); cache.modalBackdrop.classList.add('active'); } /** * Hides the settings modal with animation */ function hideModal() { if (!cache.modal || !cache.modalBackdrop) return; // Remove active classes first (starts animation) cache.modal.classList.remove('active'); cache.modalBackdrop.classList.remove('active'); // Wait for animation to complete before hiding completely setTimeout(() => { cache.modal.style.display = 'none'; cache.modalBackdrop.style.display = 'none'; }, 300); } /** * Resets all settings to defaults */ function resetSettings() { // Default values const defaults = { coverEnabled: true, useBlurMode: true, hideSpoilers: true, hideNsfw: false, blurIntensity: 4 }; // Update state with defaults Object.assign(state, defaults); // Save to storage GM_setValue('coverEnabled', defaults.coverEnabled); GM_setValue('useBlurMode', defaults.useBlurMode); GM_setValue('hideSpoilers', defaults.hideSpoilers); GM_setValue('hideNsfw', defaults.hideNsfw); GM_setValue('blurIntensity', defaults.blurIntensity); // Update UI elements const modal = cache.modal; if (modal) { modal.querySelector('input[value="blur"]').checked = defaults.useBlurMode; modal.querySelector('input[value="censor-bar"]').checked = !defaults.useBlurMode; modal.querySelector('#btr-hide-spoilers').checked = defaults.hideSpoilers; modal.querySelector('#btr-hide-nsfw').checked = defaults.hideNsfw; const blurSlider = modal.querySelector('#btr-blur-intensity'); const blurValue = modal.querySelector('#btr-blur-value'); if (blurSlider && blurValue) { blurSlider.value = defaults.blurIntensity; blurValue.textContent = `${defaults.blurIntensity}px`; } document.querySelector('.btr-range-slider').style.display = 'flex'; } // Update CSS variable document.documentElement.style.setProperty('--btr-blur-intensity', `${defaults.blurIntensity}px`); // Update page content updateToggleButton(); updateSpoilerTitles(); // Feedback animation const resetBtn = document.getElementById('btr-reset-settings'); if (resetBtn) { resetBtn.textContent = 'Done!'; setTimeout(() => { resetBtn.textContent = 'Reset'; }, 1000); } } /** * Temporarily disables transitions for all title elements */ function disableTransitions() { // Get all elements that might have transitions const elements = document.querySelectorAll( `${SELECTORS.POST_TITLE}, ${SELECTORS.SEARCH_TITLE}, ${SELECTORS.RELATED_TITLE}` ); // Add no-transition class to all elements elements.forEach(el => { el.classList.add(CLASSES.NO_TRANSITION); }); state.isStyleTransitionDisabled = true; } /** * Re-enables transitions for all title elements */ function enableTransitions() { // Get all elements with no-transition class const elements = document.querySelectorAll(`.${CLASSES.NO_TRANSITION}`); // Remove no-transition class from all elements elements.forEach(el => { el.classList.remove(CLASSES.NO_TRANSITION); }); state.isStyleTransitionDisabled = false; } // ==================== CONTENT PROCESSING ==================== function updateSpoilerTitles() { // Skip processing if no filter is active if (!state.hideSpoilers && !state.hideNsfw) { // Make sure to remove any existing effects when both filters are disabled removeSpoilerConcealment(); removeNsfwConcealment(); return; } // Process standard posts (spoilers and NSFW) document.querySelectorAll(SELECTORS.POST).forEach(post => { const title = post.querySelector(SELECTORS.POST_TITLE); if (!title) return; // Determine if title should be hidden const isSpoiler = post.hasAttribute('spoiler') && state.hideSpoilers; const isNsfw = post.hasAttribute('nsfw') && state.hideNsfw; const shouldHide = state.coverEnabled && (isSpoiler || isNsfw); // Skip processing if hovering - prevents flickering if (title.matches(':hover')) return; // Update classes efficiently by removing both first title.classList.remove(CLASSES.BLUR, CLASSES.CENSOR); // Apply appropriate effect if needed if (shouldHide) { title.classList.add(state.useBlurMode ? CLASSES.BLUR : CLASSES.CENSOR); } }); // Process search results (spoilers and NSFW) document.querySelectorAll(SELECTORS.SEARCH_POST).forEach(postContainer => { const spoilerMarker = postContainer.querySelector(SELECTORS.SPOILER_MARKER); const nsfwMarker = postContainer.querySelector(SELECTORS.NSFW_MARKER); const titleLink = postContainer.querySelector(SELECTORS.SEARCH_TITLE); if (!titleLink) return; // Skip processing ONLY if directly hovering over the title - fixes hover issue if (titleLink.matches(':hover')) return; // Determine if title should be hidden const isSpoiler = spoilerMarker && state.hideSpoilers; const isNsfw = nsfwMarker && state.hideNsfw; const shouldHide = state.coverEnabled && (isSpoiler || isNsfw); // Update classes efficiently titleLink.classList.remove(CLASSES.BLUR, CLASSES.CENSOR); if (shouldHide) { titleLink.classList.add(state.useBlurMode ? CLASSES.BLUR : CLASSES.CENSOR); } }); // Process related posts in sidebar (spoilers and NSFW) document.querySelectorAll(SELECTORS.RELATED_POSTS).forEach(post => { const spoilerMarker = post.querySelector(SELECTORS.SPOILER_MARKER); const nsfwMarker = post.querySelector(SELECTORS.NSFW_MARKER); const title = post.querySelector(SELECTORS.RELATED_TITLE); if (!title) return; // Skip processing if hovering - prevents flickering if (title.matches(':hover')) return; // Determine if title should be hidden const isSpoiler = spoilerMarker && state.hideSpoilers; const isNsfw = nsfwMarker && state.hideNsfw; const shouldHide = state.coverEnabled && (isSpoiler || isNsfw); // Update classes efficiently title.classList.remove(CLASSES.BLUR, CLASSES.CENSOR); if (shouldHide) { title.classList.add(state.useBlurMode ? CLASSES.BLUR : CLASSES.CENSOR); } }); } /** * Explicitly removes concealment effects from all spoiler titles * when the hideSpoilers setting is toggled off */ function removeSpoilerConcealment() { // Bundle all selector operations for better performance // Add a more generic selector to catch all elements with concealment classes const selectors = [ `shreddit-post[spoiler] ${SELECTORS.POST_TITLE}`, `${SELECTORS.SEARCH_POST} ${SELECTORS.SPOILER_MARKER} ~ ${SELECTORS.SEARCH_TITLE}`, `${SELECTORS.RELATED_POSTS} ${SELECTORS.SPOILER_MARKER} ~ ${SELECTORS.RELATED_TITLE}`, // More direct selectors for any element with the classes `.${CLASSES.BLUR}, .${CLASSES.CENSOR}` ]; // Process all matching elements document.querySelectorAll(selectors.join(', ')).forEach(element => { element.classList.remove(CLASSES.BLUR, CLASSES.CENSOR); }); } /** * Explicitly removes concealment effects from all NSFW titles * when the hideNsfw setting is toggled off */ function removeNsfwConcealment() { // Bundle all selector operations for better performance // Add a more generic selector to catch all elements with concealment classes const selectors = [ `shreddit-post[nsfw] ${SELECTORS.POST_TITLE}`, `${SELECTORS.SEARCH_POST} ${SELECTORS.SPOILER_MARKER} ~ ${SELECTORS.SEARCH_TITLE}`, `${SELECTORS.RELATED_POSTS} ${SELECTORS.SPOILER_MARKER} ~ ${SELECTORS.RELATED_TITLE}`, // More direct selectors for any element with the classes `.${CLASSES.BLUR}, .${CLASSES.CENSOR}` ]; // Process all matching elements document.querySelectorAll(selectors.join(', ')).forEach(element => { element.classList.remove(CLASSES.BLUR, CLASSES.CENSOR); }); } // ==================== OBSERVERS ==================== /** * Optimized function to handle all observation setup for better performance */ function setupObservers() { // 1. Theme changes observer (light/dark mode) const themeObserver = new MutationObserver(() => { updateCensorBarColor(); updateSpoilerTitles(); }); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); // 2. Header changes observer (for button persistence) const headerObserver = new MutationObserver(throttle(() => { if (!document.contains(cache.button)) { setupToggleButton(); } }, 500)); headerObserver.observe(document.body, { childList: true, subtree: true }); // 3. Content changes observer (for updating spoiler/NSFW titles) // Find the main content container cache.postFeed = document.querySelector('[data-scroller-first]') || document.querySelector('div[data-testid="post-container"]') || document.body; cache.sidebar = document.querySelector('ul.list-none.p-0.m-0'); cache.searchResults = document.querySelector('div[data-testid="search-results-container"]'); // Create a single content observer for all relevant areas const contentObserver = new MutationObserver(throttle(() => { updateSpoilerTitles(); }, 300)); // Observe the main content area contentObserver.observe(cache.postFeed, { childList: true, subtree: true }); // Observe sidebar if present if (cache.sidebar) { contentObserver.observe(cache.sidebar, { childList: true, subtree: true }); } // Observe search results if present if (cache.searchResults) { contentObserver.observe(cache.searchResults, { childList: true, subtree: true }); } // 4. Dynamic content observer (for sidebar and search results that appear later) const dynamicContentObserver = new MutationObserver(throttle((mutations) => { // Check for new sidebar or search results const newSidebar = !cache.sidebar && document.querySelector('ul.list-none.p-0.m-0'); const newSearchResults = !cache.searchResults && document.querySelector('div[data-testid="search-results-container"]'); if (newSidebar) { cache.sidebar = newSidebar; contentObserver.observe(cache.sidebar, { childList: true, subtree: true }); } if (newSearchResults) { cache.searchResults = newSearchResults; contentObserver.observe(cache.searchResults, { childList: true, subtree: true }); } // Run update after detecting new content areas if (newSidebar || newSearchResults) { updateSpoilerTitles(); } }, 500)); dynamicContentObserver.observe(document.body, { childList: true, subtree: true }); } /** * Improved throttle function with leading and trailing options */ function throttle(func, wait, options = {}) { let context, args, result; let timeout = null; let previous = 0; const later = function() { previous = options.leading === false ? 0 : Date.now(); timeout = null; result = func.apply(context, args); if (!timeout) context = args = null; }; return function() { const now = Date.now(); if (!previous && options.leading === false) previous = now; const remaining = wait - (now - previous); context = this; args = arguments; if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout); timeout = null; } previous = now; result = func.apply(context, args); if (!timeout) context = args = null; } else if (!timeout && options.trailing !== false) { timeout = setTimeout(later, remaining); } return result; }; } /** * Handles navigation events (page changes within Reddit SPA) */ function setupNavigationHandlers() { // Handle pushState and replaceState const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = function() { originalPushState.apply(this, arguments); handleNavigation(); }; history.replaceState = function() { originalReplaceState.apply(this, arguments); handleNavigation(); }; // Handle popstate event (back/forward browser navigation) window.addEventListener('popstate', handleNavigation); // Store last URL to detect actual navigation let lastUrl = location.href; function handleNavigation() { // Check if URL actually changed (avoid unnecessary processing) if (lastUrl === location.href) return; lastUrl = location.href; // Slight delay to allow Reddit to update the DOM setTimeout(() => { // Re-check sidebar and search results cache.sidebar = document.querySelector('ul.list-none.p-0.m-0'); cache.searchResults = document.querySelector('div[data-testid="search-results-container"]'); // Ensure button is present after navigation setupToggleButton(); // Force clean all spoiler/NSFW concealment removeSpoilerConcealment(); removeNsfwConcealment(); // Determine if we need a full reset based on settings changes const pageLoadTime = Date.parse(performance.getEntriesByType("navigation")[0]?.startTime || Date.now()); const settingsChangedSincePageLoad = state.lastSettingsUpdate > pageLoadTime; if (settingsChangedSincePageLoad) { // Force clean and reapply removeSpoilerConcealment(); removeNsfwConcealment(); } // Always update for consistency updateSpoilerTitles(); }, 300); } } // ==================== INITIALIZATION ==================== function init() { // Setup UI components setupStyles(); updateCensorBarColor(); setupToggleButton(); createSettingsModal(); // Register menu command GM_registerMenuCommand('Reddit Spoiler/NSFW Settings', showModal); // Setup observers and event handlers setupObservers(); setupNavigationHandlers(); // Initial update updateSpoilerTitles(); console.log('Reddit Spoiler/NSFW Hider initialized'); } // Start the script after a slight delay to ensure Reddit is fully loaded setTimeout(init, 100); })();