Last active
September 2, 2026 23:13
-
-
Save Oratorian/b1faaa6d80d43693c014bdeebb374e58 to your computer and use it in GitHub Desktop.
AI Dungeon Content Filter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name AI Dungeon Content Filter | |
| // @namespace https://alpha.aidungeon.com/profile/Jedziah | |
| // @version 5.7 | |
| // @description Hide NSFW / smut / sexual content cards on AI Dungeon, with an in-page settings panel | |
| // @author Jedziah | |
| // @copyright 2026, Jedziah (https://alpha.aidungeon.com/profile/Jedziah) | |
| // @homepageURL https://alpha.aidungeon.com/profile/Jedziah | |
| // @supportURL https://alpha.aidungeon.com/profile/Jedziah | |
| // @license MIT | |
| // @updateURL https://gist.githubusercontent.com/Oratorian/b1faaa6d80d43693c014bdeebb374e58/raw/gistfile1.txt | |
| // @downloadURL https://gist.githubusercontent.com/Oratorian/b1faaa6d80d43693c014bdeebb374e58/raw/gistfile1.txt | |
| // @match *://*.aidungeon.com/* | |
| // @exclude *://*.aidungeon.com/adventure/* | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| /* ---------------------------------------------------------------- defaults */ | |
| // Shown on the floating button and as the settings panel heading. | |
| const TITLE = "Jedziah's Custom Filters"; | |
| // Everything below is only the starting point — edit the lists in the | |
| // settings panel (the gear button in the bottom right corner). | |
| // Explicit only — words with no realistic innocent use. Anything that | |
| // doubles as ordinary English, mythology or a setting lives in the loose | |
| // list below. Whole-word matching, case insensitive, alphabetical. | |
| const DEFAULT_TERMS = [ | |
| '18+', 'adults only', 'ahegao', 'anal', 'anus', 'bdsm', 'bestiality', | |
| 'blowjob', 'blowjobs', 'bondage', 'boobs', 'breasts', 'bukkake', | |
| 'busty', 'clit', 'clitoris', 'cock', 'creampie', 'cucked', 'cuckold', | |
| 'cum', 'cumming', 'cumshot', 'cunnilingus', 'deepthroat', 'dildo', | |
| 'dilf', 'dominatrix', 'dub-con', 'dubcon', 'ecchi', 'erotic', | |
| 'erotica', 'erotics', 'fellatio', 'femdom', 'feminization', 'fetish', | |
| 'fetishes', 'footjob', 'foreplay', 'foursome', 'fuck', 'fucked', | |
| 'fucking', 'futa', 'futanari', 'gangbang', 'genitals', 'handjob', | |
| 'hentai', 'horny', 'hucow', 'impregnate', 'impregnation', 'incest', | |
| 'intercourse', 'kinky', 'lingerie', 'loli', 'lolicon', 'maledom', | |
| 'masochism', 'masturbate', 'masturbation', 'mature content', 'milf', | |
| 'molest', 'molestation', 'molested', 'netorare', 'nipples', 'non-con', | |
| 'non-consensual', 'noncon', 'nonconsensual', 'nsfl', 'nsfw', 'ntr', | |
| 'nude', 'nudes', 'nudity', 'nymphomaniac', 'orgasm', 'orgasms', | |
| 'panties', 'penis', 'porn', 'porno', 'pornographic', 'pussy', 'r18', | |
| 'rape', 'raped', 'rapist', 'sadism', 'sex', 'sex scene', 'sex slave', | |
| 'sex toy', 'sexting', 'sextoy', 'sexual', 'sexually', 'sexy', 'shota', | |
| 'shotacon', 'slut', 'sluts', 'slutty', 'smut', 'smutty', 'stepbro', | |
| 'stepsis', 'strap-on', 'strapon', 'testicles', 'threesome', 'tits', | |
| 'titty', 'topless', 'vagina', 'vibrator', 'xxx', 'yaoi', 'zoophilia' | |
| ]; | |
| // Softer signals, only used while "loose terms" is switched on: ordinary | |
| // words that often mark adult content but frequently do not (mythology, | |
| // romance, infidelity tropes, adult settings). Expect false positives. | |
| const DEFAULT_LOOSE_TERMS = [ | |
| 'adulterous', 'adultery', 'affair', 'affairs', 'aphrodisiac', | |
| 'arousal', 'aroused', 'best friends wife', 'bikini', 'bimbo', | |
| 'boyfriend', 'breeding', 'brothel', 'bunny girl', 'catgirl', 'cheated', | |
| 'cheating', 'cleavage', 'curvy', 'dating sim', 'degenerate', 'dick', | |
| 'doujin', 'escort service', 'femboy', 'forbidden love', 'friends mom', | |
| 'friends sister', 'girlfriend', 'gooning', 'harem', 'hooker', | |
| 'husband', 'husbands', 'hypno', 'hypnosis', 'incubus', 'infidelity', | |
| 'intimacy', 'intimate', 'kink', 'kinks', 'lust', 'lustful', 'maid', | |
| 'mind control', 'mistress', 'moan', 'moaning', 'naked', 'nymph', | |
| 'obedience', 'one night stand', 'orgies', 'orgy', 'passionate', | |
| 'polyamory', 'prostitute', 'prostitution', 'romance', 'romantic', | |
| 'seduce', 'seduced', 'seduces', 'seducing', 'seduction', 'seductive', | |
| 'sensual', 'sexuality', 'sissy', 'slave', 'slavery', 'steamy', | |
| 'stepdad', 'stepmom', 'strip club', 'stripper', 'submissive', | |
| 'succubus', 'sultry', 'swimsuit', 'taboo', 'temptation', 'tentacle', | |
| 'tentacles', 'thicc', 'thong', 'undress', 'undressing', 'unfaithful', | |
| 'voluptuous', 'waifu', 'whore', 'whores', 'wife', 'wifes', 'wives', | |
| 'yuri' | |
| ]; | |
| /* ------------------------------------------------------------------ storage */ | |
| const KEY = { | |
| enabled: 'aid-content-filter:enabled', | |
| loose: 'aid-content-filter:loose', | |
| mode: 'aid-content-filter:mode', | |
| terms: 'aid-content-filter:terms', | |
| looseTerms: 'aid-content-filter:looseTerms', | |
| position: 'aid-content-filter:position' | |
| }; | |
| function read(key, fallback) { | |
| try { | |
| const raw = localStorage.getItem(key); | |
| return raw === null ? fallback : JSON.parse(raw); | |
| } catch (err) { | |
| return fallback; | |
| } | |
| } | |
| function write(key, value) { | |
| try { | |
| localStorage.setItem(key, JSON.stringify(value)); | |
| } catch (err) { | |
| /* storage full or blocked — settings just will not persist */ | |
| } | |
| } | |
| const settings = { | |
| enabled: read(KEY.enabled, true), | |
| loose: read(KEY.loose, true), | |
| mode: read(KEY.mode, 'hide'), // 'hide' | 'blur' | |
| terms: read(KEY.terms, DEFAULT_TERMS), | |
| looseTerms: read(KEY.looseTerms, DEFAULT_LOOSE_TERMS) | |
| }; | |
| function save() { | |
| write(KEY.enabled, settings.enabled); | |
| write(KEY.loose, settings.loose); | |
| write(KEY.mode, settings.mode); | |
| write(KEY.terms, settings.terms); | |
| write(KEY.looseTerms, settings.looseTerms); | |
| } | |
| /* ------------------------------------------------------------------- terms */ | |
| // One term per line (commas work too), trimmed, lowercased, no doubles. | |
| function parseTerms(text) { | |
| const seen = new Set(); | |
| for (const raw of text.split(/[\n,]/)) { | |
| const term = raw.trim().toLowerCase(); | |
| if (term) seen.add(term); | |
| } | |
| return [...seen]; | |
| } | |
| const formatTerms = list => list.join('\n'); | |
| const escapeRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| let pattern = null; | |
| // Whole-word matching, so "analysis" is not caught by "anal" and "Essex" | |
| // is not caught by "sex". Hyphens count as part of a word here. | |
| function buildPattern() { | |
| const list = settings.loose | |
| ? settings.terms.concat(settings.looseTerms) | |
| : settings.terms; | |
| const cleaned = list.filter(Boolean); | |
| pattern = cleaned.length | |
| ? new RegExp('(?<![\\w-])(?:' + cleaned.map(escapeRe).join('|') + ')(?![\\w-])', 'i') | |
| : null; | |
| } | |
| /* -------------------------------------------------------------------- state */ | |
| // Both AI Dungeon layouts — the grid card (flex 0 0 33%, column) and the | |
| // wide banner row (flex 0 0 100%, row, inside a data-carousel-id list) — | |
| // use these same attributes, so one pair of selectors covers both. The | |
| // wrapper is flex-sized, so display:none collapses it without a gap. | |
| const CARD_SELECTOR = '[data-content-id][data-content-type]'; | |
| const WRAPPER_SELECTOR = '[data-fast-list-item-index], [data-fast-list-item-key]'; | |
| // Stay out of the way while a story is being played. @exclude covers a | |
| // direct load of /adventure/<id>, but AI Dungeon routes client side, so | |
| // walking into one from the library never reloads the script — hence the | |
| // same check at runtime. | |
| const PLAY_PATH = /^\/adventure\/[^/]+/; | |
| const onPlayPage = () => PLAY_PATH.test(location.pathname); | |
| let active = true; | |
| let scheduled = false; | |
| // Cards are recycled by the virtualised list, so everything is keyed by | |
| // content id rather than by DOM node — that is also what keeps the tally | |
| // free of doubles when the same card scrolls past twice. | |
| const hiddenIds = new Set(); | |
| const revealedIds = new Set(); | |
| /* -------------------------------------------------------------------- theme */ | |
| // Pulls AI Dungeon's own theme variables where they exist, with the site's | |
| // dark palette as the fallback, so the panel matches whatever theme is on. | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| .aidcf-fab { | |
| position: fixed; | |
| right: 16px; | |
| bottom: 16px; | |
| z-index: 2147483646; | |
| display: flex; | |
| align-items: center; | |
| box-sizing: border-box; | |
| height: 44px; | |
| padding: 0 13px; | |
| border: 1px solid var(--coreA1, rgba(199, 231, 255, 0.16)); | |
| border-radius: 999px; | |
| background: var(--background, #1b1f22); | |
| color: var(--color, #e6edf3); | |
| font: 600 13px/1 var(--f-family, "Inter", system-ui, Arial, sans-serif); | |
| cursor: grab; | |
| user-select: none; | |
| touch-action: none; | |
| opacity: 0.92; | |
| box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); | |
| transition: opacity 150ms, background-color 150ms, box-shadow 150ms; | |
| } | |
| .aidcf-fab:hover { | |
| opacity: 1; | |
| background: var(--backgroundHover, #23282c); | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6); | |
| } | |
| .aidcf-fab[data-off="1"] { opacity: 0.5; } | |
| .aidcf-fab[data-dragging="1"] { cursor: grabbing; } | |
| .aidcf-fab:focus-visible { outline: 2px solid #c7e7ff; outline-offset: 3px; } | |
| .aidcf-gear { | |
| flex: none; | |
| font-size: 17px; | |
| line-height: 1; | |
| /* the gear leans a little without this */ | |
| transform: translateY(-0.5px); | |
| } | |
| .aidcf-fab[data-off="1"] .aidcf-gear { opacity: 0.7; } | |
| .aidcf-name { | |
| margin-left: 9px; | |
| white-space: nowrap; | |
| } | |
| .aidcf-chip { | |
| margin-left: 9px; | |
| padding: 3px 9px; | |
| border-radius: 999px; | |
| background: rgba(199, 231, 255, 0.16); | |
| font-size: 12px; | |
| font-weight: 600; | |
| line-height: 1; | |
| white-space: nowrap; | |
| } | |
| .aidcf-fab[data-off="1"] .aidcf-chip { background: rgba(255, 255, 255, 0.08); } | |
| .aidcf-backdrop { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 2147483647; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 24px; | |
| background: rgba(0, 0, 0, 0.6); | |
| backdrop-filter: blur(3px); | |
| } | |
| .aidcf-panel { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| width: min(720px, 100%); | |
| max-height: 100%; | |
| overflow-y: auto; | |
| padding: 20px; | |
| border: 1px solid var(--coreA1, rgba(199, 231, 255, 0.16)); | |
| border-radius: 12px; | |
| background: var(--background, #1b1f22); | |
| color: var(--color, #e6edf3); | |
| font: 400 14px/1.5 var(--f-family, "Inter", system-ui, Arial, sans-serif); | |
| box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); | |
| } | |
| .aidcf-head { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 12px; | |
| } | |
| .aidcf-title { | |
| margin: 0; | |
| font-size: 20px; | |
| font-weight: 600; | |
| letter-spacing: 0.01em; | |
| } | |
| .aidcf-sub { | |
| margin: 4px 0 0; | |
| color: var(--coreA8, #9fb4c7); | |
| font-size: 13px; | |
| } | |
| .aidcf-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| gap: 16px; | |
| padding: 12px; | |
| border: 1px solid var(--coreA05, rgba(199, 231, 255, 0.08)); | |
| border-radius: 8px; | |
| background: rgba(255, 255, 255, 0.03); | |
| } | |
| .aidcf-check { | |
| position: relative; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| cursor: pointer; | |
| } | |
| /* AI Dungeon's Tailwind reset strips appearance and borders from bare | |
| inputs, which renders a native checkbox as an invisible box. So the | |
| real input is hidden and the switch below is drawn by hand — every | |
| property is set explicitly, nothing is left to the site's cascade. */ | |
| .aidcf-check input { | |
| position: absolute; | |
| width: 1px; | |
| height: 1px; | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| .aidcf-switch { | |
| position: relative; | |
| display: inline-block; | |
| box-sizing: border-box; | |
| flex: none; | |
| width: 38px; | |
| height: 22px; | |
| border: 1px solid var(--coreA1, rgba(199, 231, 255, 0.16)); | |
| border-radius: 999px; | |
| background: rgba(255, 255, 255, 0.12); | |
| transition: background-color 150ms, border-color 150ms; | |
| } | |
| .aidcf-switch::after { | |
| content: ""; | |
| position: absolute; | |
| top: 2px; | |
| left: 2px; | |
| box-sizing: border-box; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 999px; | |
| background: #e6edf3; | |
| transition: transform 150ms, background-color 150ms; | |
| } | |
| .aidcf-check input:checked + .aidcf-switch { | |
| background: #c7e7ff; | |
| border-color: #c7e7ff; | |
| } | |
| .aidcf-check input:checked + .aidcf-switch::after { | |
| transform: translateX(16px); | |
| background: #10161a; | |
| } | |
| .aidcf-check input:focus-visible + .aidcf-switch { | |
| outline: 2px solid #c7e7ff; | |
| outline-offset: 2px; | |
| } | |
| .aidcf-field { display: flex; flex-direction: column; gap: 6px; } | |
| .aidcf-label { | |
| display: flex; | |
| align-items: baseline; | |
| justify-content: space-between; | |
| gap: 8px; | |
| font-size: 13px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| color: var(--coreA8, #9fb4c7); | |
| } | |
| .aidcf-count { font-weight: 400; text-transform: none; letter-spacing: 0; } | |
| .aidcf-panel textarea, .aidcf-panel select { | |
| width: 100%; | |
| box-sizing: border-box; | |
| padding: 10px; | |
| border: 1px solid var(--coreA1, rgba(199, 231, 255, 0.16)); | |
| border-radius: 8px; | |
| background: rgba(0, 0, 0, 0.35); | |
| color: var(--color, #e6edf3); | |
| font: 400 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| resize: vertical; | |
| } | |
| .aidcf-panel select { | |
| width: auto; | |
| font-family: var(--f-family, "Inter", system-ui, Arial, sans-serif); | |
| padding: 6px 10px; | |
| } | |
| .aidcf-panel textarea { min-height: 130px; } | |
| .aidcf-panel textarea:focus, .aidcf-panel select:focus { | |
| outline: none; | |
| border-color: #c7e7ff; | |
| } | |
| .aidcf-actions { | |
| display: flex; | |
| flex-wrap: wrap; | |
| justify-content: flex-end; | |
| gap: 8px; | |
| } | |
| .aidcf-btn { | |
| padding: 8px 16px; | |
| border: 1px solid var(--coreA1, rgba(199, 231, 255, 0.16)); | |
| border-radius: 8px; | |
| background: transparent; | |
| color: var(--color, #e6edf3); | |
| font: 600 13px/1 var(--f-family, "Inter", system-ui, Arial, sans-serif); | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| cursor: pointer; | |
| transition: background-color 150ms, opacity 150ms; | |
| } | |
| .aidcf-btn:hover { background: rgba(255, 255, 255, 0.07); } | |
| .aidcf-btn--primary { | |
| background: #c7e7ff; | |
| border-color: #c7e7ff; | |
| color: #10161a; | |
| } | |
| .aidcf-btn--primary:hover { background: #dcf0ff; } | |
| .aidcf-btn--ghost { color: var(--coreA8, #9fb4c7); border-color: transparent; } | |
| .aidcf-note { margin: 0; color: var(--coreA8, #9fb4c7); font-size: 12px; } | |
| `; | |
| document.documentElement.appendChild(style); | |
| /* ------------------------------------------------------------ floating button */ | |
| const fab = document.createElement('div'); | |
| fab.className = 'aidcf-fab'; | |
| fab.setAttribute('role', 'button'); | |
| fab.setAttribute('tabindex', '0'); | |
| fab.title = `${TITLE} — click for settings, drag to move`; | |
| fab.innerHTML = | |
| '<span class="aidcf-gear" aria-hidden="true">⚙</span>' + | |
| '<span class="aidcf-name"></span>' + | |
| '<span class="aidcf-chip"></span>'; | |
| fab.querySelector('.aidcf-name').textContent = TITLE; | |
| const fabChip = fab.querySelector('.aidcf-chip'); | |
| document.documentElement.appendChild(fab); | |
| // Landing straight on a story: keep the button out of the first paint | |
| // rather than letting the first scan blink it away. | |
| if (onPlayPage()) { | |
| active = false; | |
| fab.style.display = 'none'; | |
| } | |
| function updateFab() { | |
| fab.dataset.off = settings.enabled ? '0' : '1'; | |
| fabChip.textContent = settings.enabled ? String(hiddenIds.size) : 'off'; | |
| fab.setAttribute('aria-label', settings.enabled | |
| ? `${TITLE} settings. ${hiddenIds.size} hidden.` | |
| : `${TITLE} settings. Filter is off, ${hiddenIds.size} hidden.`); | |
| } | |
| /* Dragging. The button keeps its CSS corner until it is moved, and only | |
| then switches to stored left/top coordinates, clamped into view. */ | |
| let position = read(KEY.position, null); | |
| let drag = null; | |
| let dragged = false; | |
| function applyPosition() { | |
| if (!position) return; | |
| const width = fab.offsetWidth || 44; | |
| const height = fab.offsetHeight || 44; | |
| const x = Math.min(Math.max(0, position.x), Math.max(0, window.innerWidth - width)); | |
| const y = Math.min(Math.max(0, position.y), Math.max(0, window.innerHeight - height)); | |
| fab.style.left = `${x}px`; | |
| fab.style.top = `${y}px`; | |
| fab.style.right = 'auto'; | |
| fab.style.bottom = 'auto'; | |
| } | |
| fab.addEventListener('pointerdown', event => { | |
| if (event.button !== 0) return; | |
| const rect = fab.getBoundingClientRect(); | |
| drag = { | |
| startX: event.clientX, | |
| startY: event.clientY, | |
| offsetX: event.clientX - rect.left, | |
| offsetY: event.clientY - rect.top | |
| }; | |
| fab.setPointerCapture(event.pointerId); | |
| }); | |
| fab.addEventListener('pointermove', event => { | |
| if (!drag) return; | |
| // A few pixels of slop, so a slightly shaky click is still a click | |
| if (!dragged) { | |
| if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) < 4) return; | |
| dragged = true; | |
| fab.dataset.dragging = '1'; | |
| } | |
| position = { x: event.clientX - drag.offsetX, y: event.clientY - drag.offsetY }; | |
| applyPosition(); | |
| }); | |
| function endDrag(event) { | |
| if (!drag) return; | |
| drag = null; | |
| delete fab.dataset.dragging; | |
| if (fab.hasPointerCapture(event.pointerId)) fab.releasePointerCapture(event.pointerId); | |
| if (dragged) { | |
| write(KEY.position, position); | |
| // The click event fires right after this one; clear the flag after | |
| // it has been swallowed so a drag never opens the panel. | |
| setTimeout(() => { dragged = false; }, 0); | |
| } | |
| } | |
| fab.addEventListener('pointerup', endDrag); | |
| fab.addEventListener('pointercancel', endDrag); | |
| fab.addEventListener('click', event => { | |
| if (dragged) { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| return; | |
| } | |
| openPanel(); | |
| }); | |
| fab.addEventListener('keydown', event => { | |
| if (event.key === 'Enter' || event.key === ' ') { | |
| event.preventDefault(); | |
| openPanel(); | |
| } | |
| }); | |
| window.addEventListener('resize', applyPosition); | |
| applyPosition(); | |
| /* ----------------------------------------------------------- settings panel */ | |
| let backdrop = null; | |
| function buildPanel() { | |
| backdrop = document.createElement('div'); | |
| backdrop.className = 'aidcf-backdrop'; | |
| backdrop.innerHTML = ` | |
| <div class="aidcf-panel" role="dialog" aria-modal="true" aria-label="${TITLE} settings"> | |
| <div class="aidcf-head"> | |
| <div> | |
| <h2 class="aidcf-title">${TITLE}</h2> | |
| <p class="aidcf-sub">Hides adventures and scenarios whose title, description or tags match your word lists.</p> | |
| </div> | |
| <button class="aidcf-btn aidcf-btn--ghost" data-act="close" type="button">Close</button> | |
| </div> | |
| <div class="aidcf-row"> | |
| <label class="aidcf-check"> | |
| <input type="checkbox" data-field="enabled"> | |
| <span class="aidcf-switch"></span> | |
| <span>Filter enabled</span> | |
| </label> | |
| <label class="aidcf-check"> | |
| <input type="checkbox" data-field="loose"> | |
| <span class="aidcf-switch"></span> | |
| <span>Use loose terms</span> | |
| </label> | |
| <label class="aidcf-check"> | |
| When matched: | |
| <select data-field="mode"> | |
| <option value="hide">Hide the card</option> | |
| <option value="blur">Blur it (click to reveal)</option> | |
| </select> | |
| </label> | |
| </div> | |
| <div class="aidcf-field"> | |
| <span class="aidcf-label"> | |
| Blocked terms | |
| <span class="aidcf-count"> | |
| one per line | |
| <button class="aidcf-btn aidcf-btn--ghost" data-act="reset-terms" type="button">Restore defaults</button> | |
| </span> | |
| </span> | |
| <textarea data-field="terms" spellcheck="false"></textarea> | |
| </div> | |
| <div class="aidcf-field"> | |
| <span class="aidcf-label"> | |
| Loose terms | |
| <span class="aidcf-count"> | |
| milder signals, more false positives | |
| <button class="aidcf-btn aidcf-btn--ghost" data-act="reset-loose" type="button">Restore defaults</button> | |
| </span> | |
| </span> | |
| <textarea data-field="looseTerms" spellcheck="false"></textarea> | |
| </div> | |
| <p class="aidcf-note">Terms match whole words only, so "anal" will not hit "analysis". Multi-word terms like "sex scene" are fine.</p> | |
| <div class="aidcf-actions"> | |
| <button class="aidcf-btn" data-act="close" type="button">Cancel</button> | |
| <button class="aidcf-btn aidcf-btn--primary" data-act="save" type="button">Save & apply</button> | |
| </div> | |
| </div> | |
| `; | |
| const field = name => backdrop.querySelector(`[data-field="${name}"]`); | |
| backdrop.addEventListener('click', event => { | |
| if (event.target === backdrop) closePanel(); | |
| const act = event.target.dataset && event.target.dataset.act; | |
| if (!act) return; | |
| if (act === 'close') closePanel(); | |
| if (act === 'reset-terms') field('terms').value = formatTerms(DEFAULT_TERMS); | |
| if (act === 'reset-loose') field('looseTerms').value = formatTerms(DEFAULT_LOOSE_TERMS); | |
| if (act === 'save') { | |
| settings.enabled = field('enabled').checked; | |
| settings.loose = field('loose').checked; | |
| settings.mode = field('mode').value; | |
| settings.terms = parseTerms(field('terms').value); | |
| settings.looseTerms = parseTerms(field('looseTerms').value); | |
| save(); | |
| buildPattern(); | |
| closePanel(); | |
| scan(); | |
| } | |
| }); | |
| return { field }; | |
| } | |
| let panelFields = null; | |
| function openPanel() { | |
| if (!backdrop) panelFields = buildPanel(); | |
| panelFields.field('enabled').checked = settings.enabled; | |
| panelFields.field('loose').checked = settings.loose; | |
| panelFields.field('mode').value = settings.mode; | |
| panelFields.field('terms').value = formatTerms(settings.terms); | |
| panelFields.field('looseTerms').value = formatTerms(settings.looseTerms); | |
| document.documentElement.appendChild(backdrop); | |
| document.addEventListener('keydown', onKeydown, true); | |
| } | |
| function closePanel() { | |
| backdrop.remove(); | |
| document.removeEventListener('keydown', onKeydown, true); | |
| } | |
| function onKeydown(event) { | |
| if (event.key === 'Escape') { | |
| event.stopPropagation(); | |
| closePanel(); | |
| } | |
| } | |
| /* ------------------------------------------------------------------- filter */ | |
| function wrapperFor(card) { | |
| return card.closest(WRAPPER_SELECTOR) || card; | |
| } | |
| function matchOf(card) { | |
| if (!pattern) return null; | |
| const hit = pattern.exec(card.textContent || ''); | |
| return hit ? hit[0] : null; | |
| } | |
| function conceal(card, term) { | |
| const wrapper = wrapperFor(card); | |
| wrapper.dataset.aidFilterHit = term; | |
| if (settings.mode === 'blur') { | |
| wrapper.style.removeProperty('display'); | |
| if (!card.dataset.aidFilterBlurred) { | |
| card.style.setProperty('filter', 'blur(14px)'); | |
| card.style.setProperty('opacity', '0.5'); | |
| card.dataset.aidFilterBlurred = '1'; | |
| } | |
| } else { | |
| unblur(card); | |
| if (wrapper.style.display !== 'none') { | |
| wrapper.style.setProperty('display', 'none', 'important'); | |
| } | |
| } | |
| } | |
| function unblur(card) { | |
| if (!card.dataset.aidFilterBlurred) return; | |
| card.style.removeProperty('filter'); | |
| card.style.removeProperty('opacity'); | |
| delete card.dataset.aidFilterBlurred; | |
| } | |
| function restore(card) { | |
| const wrapper = wrapperFor(card); | |
| if (wrapper.dataset.aidFilterHit) delete wrapper.dataset.aidFilterHit; | |
| if (wrapper.style.display === 'none') wrapper.style.removeProperty('display'); | |
| unblur(card); | |
| } | |
| function applyTo(card) { | |
| const id = card.dataset.contentId; | |
| const term = settings.enabled ? matchOf(card) : null; | |
| if (term && !(id && revealedIds.has(id))) { | |
| if (id) hiddenIds.add(id); | |
| conceal(card, term); | |
| } else { | |
| restore(card); | |
| } | |
| } | |
| // Everything this script put on the page comes back off, and the button | |
| // goes away, for as long as a story is open. | |
| function deactivate() { | |
| if (!active) return; | |
| active = false; | |
| for (const card of document.querySelectorAll(CARD_SELECTOR)) { | |
| restore(card); | |
| } | |
| if (backdrop && backdrop.isConnected) closePanel(); | |
| fab.style.display = 'none'; | |
| } | |
| function reactivate() { | |
| if (active) return; | |
| active = true; | |
| fab.style.removeProperty('display'); | |
| } | |
| function scan() { | |
| if (onPlayPage()) { | |
| deactivate(); | |
| return; | |
| } | |
| reactivate(); | |
| for (const card of document.querySelectorAll(CARD_SELECTOR)) { | |
| applyTo(card); | |
| } | |
| updateFab(); | |
| } | |
| function scheduleScan() { | |
| if (scheduled) return; | |
| scheduled = true; | |
| setTimeout(() => { | |
| scheduled = false; | |
| scan(); | |
| }, 120); | |
| } | |
| /* --------------------------------------------------- click-to-reveal (blur) */ | |
| document.addEventListener('click', event => { | |
| const target = event.target; | |
| const card = target.closest && target.closest('[data-aid-filter-blurred]'); | |
| if (!card) return; | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| const id = card.dataset.contentId; | |
| if (id) revealedIds.add(id); | |
| restore(card); | |
| }, true); | |
| /* ------------------------------------------------------------------ observe */ | |
| // Cards stream in on scroll and are recycled in place, so a debounced sweep | |
| // of the whole list is both simpler and more reliable than tracking nodes. | |
| const observer = new MutationObserver(scheduleScan); | |
| function start() { | |
| buildPattern(); | |
| scan(); | |
| // Client-side routing fires no load event. Back/forward gives us | |
| // popstate; a forward navigation is caught by the observer below, | |
| // since swapping the page out is itself a mutation. | |
| window.addEventListener('popstate', scheduleScan); | |
| observer.observe(document.body, { | |
| childList: true, | |
| subtree: true, | |
| characterData: true, | |
| attributes: true, | |
| attributeFilter: ['data-content-id'] | |
| }); | |
| } | |
| if (document.body) { | |
| start(); | |
| } else { | |
| document.addEventListener('DOMContentLoaded', start, { once: true }); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment