Sleazy Fork is available in English.
Fixes Anime GF's chat formatting, mainly for Plain RP and Text, which still show RP-style *asterisk* narration in bold italics. Shows narration as plain text, keeps real emphasis and extra spaces, fixes code blocks, colors curly-quote dialogue, adds bold and code colors, and has a Roleplay option.
// ==UserScript==
// @name Anime GF Format Fix
// @namespace animegf-plain-narration
// @version 1.2.2
// @description Fixes Anime GF's chat formatting, mainly for Plain RP and Text, which still show RP-style *asterisk* narration in bold italics. Shows narration as plain text, keeps real emphasis and extra spaces, fixes code blocks, colors curly-quote dialogue, adds bold and code colors, and has a Roleplay option.
// @author .moonsight.
// @copyright 2026, .moonsight.
// @license MIT
// @match https://www.anime.gf/*
// @match https://anime.gf/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @run-at document-start
// @noframes
// ==/UserScript==
/*
* Anime GF Format Fix
*
* Fixes how Anime GF's chat formats messages. It is made mainly for the
* Plain RP and Text formats in Response Settings: replies are saved with
* plain narration, but the chat display wraps narration and actions back in
* *asterisks* and shows them in bold italics, RP style, unlike the settings
* preview. It also drops emphasis inside dialogue, squashes runs of spaces,
* adds a stray space after italic words ("her ." for "*her*."), breaks
* ```code blocks```, and only colors dialogue in straight quotes.
*
* With Plain RP or Text, narration and actions show as plain text, real
* emphasis (a stressed word in dialogue or narration) stays italic, and
* bold, dialogue and everything else keep the site's look unless you change
* them in the menu. With Roleplay, turn on Italics as written: italics stay
* where the reply has asterisks, and only the lines the site italicized on
* its own, like status lines, show plain.
*
* It only changes how messages look. The text, the saved chat and what is
* sent to the AI are never touched.
*
* Menu toggles (open your userscript manager's menu on the site):
* Format fix ON/OFF master switch: off turns everything
* below off too, without losing them
* Keep emphasis ON/OFF stressed words stay italic
* Italics as written ON/OFF off by default, for Roleplay: italics
* (for Roleplay) exactly where the reply has asterisks,
* so lines the site italicized on its
* own, like status lines, show plain
* Dialogue emphasis color italics/dialogue
* color of stressed words inside dialogue
* Dialogue emphasis ON/OFF experimental, off by default: brings
* (experimental) back the emphasis the site drops from
* dialogue, *italic* in the italics
* color and **bold** in bold, also in
* dialogue with `code` in it
* Keep spacing ON/OFF shows runs of spaces and tabs as
* written, so status boxes and other
* aligned layouts line up
* Curly quote dialogue ON/OFF gives dialogue in curly quotes, double
* or single, the quotes color the site
* only gives to straight quotes
* Colors... pick colors for bold and for code
* (text in single or triple backticks),
* yours and the character's, with a
* picker laid out like the site's
* Changes apply at once and are remembered.
*
* Not affiliated with Anime GF.
*/
// Matches the whole site because opening a chat from inside the site may not
// reload the page, and a script limited to /chat would then never load. The
// rules only affect chat messages, so other pages are unchanged.
(function () {
'use strict';
const STYLE_ID = 'agf-plain-narration';
// The site renders Markdown, so *narration* and *emphasis* both arrive as
// <em class="font-semibold italic text-chat-character-message-text-italics">.
// The substring match also covers the user-message variant,
// text-chat-user-message-text-italics. <strong> from **text** keeps its
// weight; only its color can change, and only through Colors.
const EM_SEL = 'em[class*="-message-text-italics"]';
// The site wraps dialogue in AI replies in its own tag.
const QUOTES_SEL = '[class*="-message-text-quotes"]';
const BLOCK_SEL = 'p, li, blockquote, td, th, h1, h2, h3, h4, h5, h6';
const MARK = 'data-agf-emphasis';
// Above this, a span between two plain words reads as narration, not emphasis.
const MAX_EMPHASIS_WORDS = 4;
const settings = {
enabled: GM_getValue('enabled', true),
keepEmphasis: GM_getValue('keepEmphasis', true),
dialogueEmphasisPlainColor: GM_getValue('dialogueEmphasisPlainColor', false),
recoverEmphasis: GM_getValue('recoverEmphasis', false),
keepSpacing: GM_getValue('keepSpacing', true),
curlyDialogue: GM_getValue('curlyDialogue', true),
italicsAsWritten: GM_getValue('italicsAsWritten', false),
boldColorCharacter: GM_getValue('boldColorCharacter', ''),
boldColorUser: GM_getValue('boldColorUser', ''),
codeColorCharacter: GM_getValue('codeColorCharacter', ''),
codeColorUser: GM_getValue('codeColorUser', '')
};
// The site lets you color base text, italics, quotes and OOC, but not bold.
// An empty value means bold keeps whatever color the site gives it. The
// order matches the site's Chat Appearance: user first, then character.
const BOLD_COLORS = [
{ key: 'boldColorUser', who: 'user', label: 'Your bold' },
{ key: 'boldColorCharacter', who: 'character', label: 'Character bold' }
];
// Code: text in single backticks, and code blocks in triple backticks. The
// site's monospace look stays; only the text color changes. Unset, the
// site's color stays.
const CODE_COLORS = [
{ key: 'codeColorUser', who: 'user', label: 'Your code' },
{ key: 'codeColorCharacter', who: 'character', label: 'Character code' }
];
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
// Stored values are checked before they reach the style sheet.
function savedColor(key) {
return HEX_COLOR.test(settings[key]) ? settings[key] : '';
}
// Tags that stay italic are marked; unmarked ones show as plain text.
// Keep emphasis marks stressed words found by the text rules, and Italics
// as written marks words the reply put in asterisks. Each works on its own.
function wantMarks() {
return settings.enabled && (settings.keepEmphasis || settings.italicsAsWritten);
}
// Experimental. The site removes *emphasis* inside dialogue before display,
// but the original reply text still sits in the message component's props.
// For a dialogue line that had emphasis, the site's own text is hidden (left
// in place, never edited) and the script shows its own copy of the line with
// the emphasized words in the site's italics tag. React never sees its text
// change, and the copy is rebuilt before paint whenever the line updates.
const COPY_CLASS = 'agf-dialogue-copy';
const COPY_ATTR = 'data-agf-copy';
// The site's italics tag for the side that wrote the message.
function italicsClasses(who) {
return 'font-semibold italic text-chat-' + (who || 'character') + '-message-text-italics';
}
function wantRecovery() {
return wantMarks() && settings.recoverEmphasis;
}
// Two fixes to the white space of messages, both made by rewriting the
// value of the site's text nodes in place, since CSS can do neither.
//
// Runs of spaces (Keep spacing). Browsers squash a run of spaces into one,
// and show a tab as one space, so a status box or other layout lined up
// with spaces falls apart. The site pads its own tags with line breaks, so
// telling the browser to keep all white space (white-space: pre-wrap) would
// show that padding too. Instead, each run of two or more spaces or tabs
// between visible characters is rewritten with characters the browser does
// not squash: spaces alternate with no-break spaces, which keeps the width
// and still lets long lines wrap, and a tab becomes an em space, about four
// spaces wide. Runs touching a line break are left alone, which is where
// the site's padding is. Indentation at the start of a line never reaches
// the page: Markdown drops it.
//
// Stray spaces (always, with Format fix on). The site writes each italics
// and dialogue tag as a line break, the text, and another line break. A
// line break shows as a space, so "*her*." shows as "her ." and "*un*real"
// as "un real". Where a padding line break touches a visible character
// outside its tag, it becomes a zero-width space, which shows nothing.
// Where the reply had a real space, the padding sits next to it and is
// left alone.
//
// Each character maps to exactly one, so the text keeps its length. React
// only rewrites a text node when its own text changes, and then the fix is
// made again before paint. The original is kept and put back when a
// setting or Format fix is turned off.
const MESSAGE_SEL = '[class*="-message-text"]';
const MESSAGE_SCHEME_SEL = '[class*="text-chat-character-message-text"], [class*="text-chat-user-message-text"]';
// Skipped: edit boxes, code, and anything that already keeps its spaces,
// such as the hidden copy of the text the site sizes its edit box with.
const SPACING_SKIP = 'textarea, input, pre, code, script, style, [class*="whitespace-pre"], ' +
'[contenteditable]:not([contenteditable="false"])';
// The character before the run is captured and put back rather than checked
// with a lookbehind, which older Safari cannot parse at all.
const SPACE_RUN = /(^|[^ \t\r\n])([ \t]{2,}|\t)(?![ \t\r\n])/g;
const HAS_RUN = /[ \t]{2}|\t/;
const ZERO_WIDTH = '\u200B';
const textFixes = new WeakMap();
let textFixApplied = false;
function wantTextFix() {
return settings.enabled;
}
function spacedRun(run) {
return Array.from(run, (ch, i) => {
if (ch === '\t') return '\u2003';
return i % 2 ? ' ' : '\u00A0';
}).join('');
}
// True when this text node holds the script's own rewrite.
function isOwnSpacing(node) {
const known = textFixes.get(node);
return !!known && known.fixed === node.nodeValue;
}
// Tags the site pads with line breaks, plus the script's dialogue copy,
// which is built from the padded text.
function padded(el) {
return el.matches(EM_SEL + ', ' + QUOTES_SEL + ', .' + COPY_CLASS);
}
// The nearest character outside a tag, before or after it, skipping line
// breaks and zero-width spaces, which are padding. Null at a <br> or at
// the edge of the paragraph, where nothing is glued on.
function charOutside(el, forward) {
const block = el.closest(BLOCK_SEL) || messageBox(el);
if (!block) return null;
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
if (forward) {
let last = el;
while (last.lastChild) last = last.lastChild;
walker.currentNode = last;
} else {
walker.currentNode = el;
}
let node;
while ((node = forward ? walker.nextNode() : walker.previousNode())) {
if (node.nodeType === 1) {
if (node.tagName === 'BR') return null;
continue;
}
if (!forward && el.contains(node)) continue;
const text = node.nodeValue;
for (let k = 0; k < text.length; k++) {
const ch = forward ? text[k] : text[text.length - 1 - k];
if (ch !== '\n' && ch !== ZERO_WIDTH) return ch;
}
}
return null;
}
function glued(ch) {
return ch !== null && !/\s/.test(ch);
}
// The text a node should show, worked out from the site's own value.
function wantedText(node, orig) {
if (fenceHidden.has(node)) return '';
const cut = lineCuts.get(node);
let text = cut ? orig.slice(cut[0], cut[1]) : orig;
if (settings.keepSpacing && HAS_RUN.test(text)) {
text = text.replace(SPACE_RUN, (match, before, run) => before + spacedRun(run));
}
const parent = node.parentElement;
if (padded(parent)) {
// The padding can be its own one-character node or part of the text.
// A node that is the only child holds both ends, so a lone line break
// there is the opening one. In a dialogue tag holding the script's
// copy, the site's text ends where the copy starts, and the copy looks
// outside the dialogue tag, so the hidden line and the copy always get
// the same fix.
const kids = Array.from(parent.childNodes).filter(n => !(n.nodeType === 1 && n.classList.contains(COPY_CLASS)));
const host = parent.classList.contains(COPY_CLASS) ? parent.parentElement : parent;
const opening = node === kids[0];
const closing = node === kids[kids.length - 1];
if (opening && text[0] === '\n' && glued(charOutside(host, false))) {
text = ZERO_WIDTH + text.slice(1);
}
const end = text.length - 1;
if (closing && (end > 0 || !opening) && text[end] === '\n' && glued(charOutside(host, true))) {
text = text.slice(0, end) + ZERO_WIDTH;
}
}
return text;
}
function fixText(node) {
const value = node.nodeValue;
const known = textFixes.get(node);
// Empty text is skipped, unless it is text the script emptied itself.
if (!value && !known) return;
if (!known && !fenceHidden.has(node) && !lineCuts.has(node) && !HAS_RUN.test(value) &&
value[0] !== '\n' && value[value.length - 1] !== '\n') return;
const parent = node.parentElement;
if (!parent || !parent.closest(MESSAGE_SEL) || parent.closest(SPACING_SKIP)) return;
const orig = known && known.fixed === value ? known.orig : value;
const wanted = wantedText(node, orig);
if (wanted === orig) {
if (known) textFixes.delete(node);
if (value !== orig) node.nodeValue = orig;
return;
}
textFixes.set(node, { orig, fixed: wanted });
textFixApplied = true;
if (value !== wanted) node.nodeValue = wanted;
}
function fixTextWithin(root) {
if (root.nodeType === 3) {
fixText(root);
return;
}
if (root.nodeType !== 1) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) fixText(node);
}
// Puts back the original text wherever a rewrite is still in place.
function restoreText() {
if (!textFixApplied || !document.body) return;
textFixApplied = false;
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const known = textFixes.get(node);
if (!known) continue;
textFixes.delete(node);
if (known.fixed === node.nodeValue) node.nodeValue = known.orig;
}
}
// Code blocks. The site's display puts its italics asterisks around a
// ```-fenced block like around any other text, so the fence breaks: the
// opening line shows as "*```", the code shows as plain text, and the
// closing line, now "```*", opens a new code box with nothing in it. When
// a reply ends with a fenced block, its content is read from the original
// reply (see rawMessageText) and shown in that empty box, and the broken
// lines above it are hidden: the site's tags by an attribute, its bare text
// by emptying the text node through the text fix above, which puts the
// text back when this stops. A block with more text after it is left as
// the site shows it, since the broken box takes in the rest of the reply.
const FENCE_COPY_CLASS = 'agf-code-copy';
const HIDE_ATTR = 'data-agf-hide';
const fenceHidden = new Set();
const fenceState = new WeakMap();
function wantFences() {
return settings.enabled;
}
// The content of a fenced block that ends the reply, or null.
function finalFence(raw) {
const lines = raw.replace(/\s+$/, '').split('\n');
if (lines.length < 2 || !/^[ \t]*```[ \t]*$/.test(lines[lines.length - 1])) return null;
for (let i = lines.length - 2; i >= 0; i--) {
if (/^[ \t]*```/.test(lines[i])) return lines.slice(i + 1, lines.length - 1).join('\n');
}
return null;
}
// What to show and hide in one message, or null when it has no broken
// block at its end.
function fencePlan(box) {
const pre = Array.from(box.querySelectorAll('pre')).pop();
if (!pre || pre.nextElementSibling) return null;
const p = pre.previousElementSibling;
if (!p || p.tagName !== 'P') return null;
const code = pre.querySelector('code') || pre;
const siteText = Array.from(code.childNodes)
.filter(n => !(n.nodeType === 1 && n.classList.contains(FENCE_COPY_CLASS)))
.map(n => n.textContent).join('');
if (siteText.trim()) return null;
let marker = null;
const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT);
let n;
while ((n = walker.nextNode())) {
const shown = fenceHidden.has(n) && textFixes.has(n) ? textFixes.get(n).orig : n.nodeValue;
if (shown.includes('```')) marker = n;
}
if (!marker) return null;
let raw = null;
try {
raw = rawMessageText(pre);
} catch (_) {
// a prop getter on the site's side threw; leave the block as it is
}
const content = raw && finalFence(raw);
if (content === null || content === undefined) return null;
// Everything from the marker's line to the end of the paragraph, plus the
// blank line before it, which would otherwise end the paragraph early.
let top = marker;
while (top.parentNode !== p) top = top.parentNode;
const hide = [];
for (let k = top; k; k = k.nextSibling) hide.push(k);
for (let k = top.previousSibling; k; k = k.previousSibling) {
const blank = k.nodeType === 1 ? k.tagName === 'BR' : !visible(k.nodeValue.replace(/\u00A0/g, ' '));
if (!blank) break;
hide.push(k);
}
return { code, content, hide: new Set(hide) };
}
function hideNode(node) {
if (node.nodeType === 1) {
if (!node.hasAttribute(HIDE_ATTR)) node.setAttribute(HIDE_ATTR, '');
} else if (node.nodeType === 3 && !fenceHidden.has(node)) {
fenceHidden.add(node);
fixText(node);
}
}
function showNode(node) {
if (node.nodeType === 1) {
if (node.hasAttribute(HIDE_ATTR)) node.removeAttribute(HIDE_ATTR);
} else if (fenceHidden.delete(node)) {
fixText(node);
}
}
// Shows, updates or undoes the rebuilt block for one message. It writes
// only what differs, so the records its own writes cause find nothing to do.
function syncFence(box) {
const plan = wantFences() ? fencePlan(box) : null;
const before = fenceState.get(box);
if (before) {
for (const node of before.hide) if (!plan || !plan.hide.has(node)) showNode(node);
if (before.copy && (!plan || before.copy.parentNode !== plan.code)) before.copy.remove();
}
if (!plan) {
fenceState.delete(box);
return;
}
plan.hide.forEach(hideNode);
let copy = before && before.copy && before.copy.parentNode === plan.code ? before.copy : null;
if (!copy) {
copy = document.createElement('span');
copy.className = FENCE_COPY_CLASS;
}
if (copy.textContent !== plan.content) copy.textContent = plan.content;
if (copy.parentNode !== plan.code) plan.code.appendChild(copy);
fenceState.set(box, { hide: plan.hide, copy });
}
function messageBoxes(root) {
const sel = 'div[class*="text-chat-character-message-text"], div[class*="text-chat-user-message-text"]';
const el = root.nodeType === 1 ? root : root.parentElement;
if (!el) return [];
const own = el.closest(sel);
if (own) return [own];
return Array.from(el.querySelectorAll(sel));
}
function syncFences() {
if (!document.body) return;
messageBoxes(document.body).forEach(syncFence);
// Text emptied in messages that have since left the page is forgotten,
// and anything left over from a switched-off fix is put back.
for (const node of Array.from(fenceHidden)) {
if (!node.isConnected) fenceHidden.delete(node);
else if (!wantFences()) showNode(node);
}
if (!wantFences()) {
document.querySelectorAll('[' + HIDE_ATTR + ']').forEach(el => el.removeAttribute(HIDE_ATTR));
document.querySelectorAll('.' + FENCE_COPY_CLASS).forEach(el => el.remove());
}
}
// Comparisons with the original reply read the rewritten characters as the
// spaces, tabs and line breaks they stand for.
function plainSpacing(text) {
return text.replace(/\u00A0/g, ' ').replace(/\u2003/g, '\t').replace(/\u200B/g, '\n');
}
// Trimmed text, with zero-width spaces counted as the padding they replace.
function visible(text) {
return text.replace(/\u200B/g, '').trim();
}
// The site puts dialogue in its quotes tag, with the quotes color, only when
// it uses straight quotes ("). Dialogue in curly quotes (\u201C \u201D, or
// single \u2018 \u2019) is left as plain text in the base color. That text is
// painted with the CSS Custom Highlight API, which colors text without
// touching the page. It can change the color, not the weight, so this
// dialogue is not semibold like the site's own.
const CURLY_SIDES = ['character', 'user'];
const canHighlight = typeof Highlight === 'function' &&
typeof CSS !== 'undefined' && !!CSS.highlights;
const curlyColors = { character: '', user: '' };
const curlyBlocks = new Map();
function wantCurly() {
return settings.enabled && settings.curlyDialogue && canHighlight;
}
function curlyName(who) {
return 'agf-curly-' + who;
}
function sideOf(el) {
return CURLY_SIDES.find(who => el.closest('[class*="text-chat-' + who + '-message-text"]')) || null;
}
// Reads the quotes color the site gives each side, which follows the
// person's Chat Appearance settings, from a hidden tag that exists for a
// moment inside a message of that side. Returns true if a color changed.
function readSiteColors() {
let changed = false;
const probes = new Set();
const read = (host, className, store, who) => {
const probe = document.createElement('span');
probe.className = className;
probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none';
probes.add(probe);
host.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
if (color && color !== store[who]) {
store[who] = color;
changed = true;
}
};
for (const who of CURLY_SIDES) {
const host = document.querySelector('[class*="text-chat-' + who + '-message-text"]:not(em):not(span)');
if (!host) continue;
read(host, 'text-chat-' + who + '-message-text-quotes', curlyColors, who);
}
// The probe's own records are dropped; anything else is handled as usual.
if (bodyObserver && probes.size) {
const isProbe = n => probes.has(n);
const others = bodyObserver.takeRecords().filter(r => !(r.type === 'childList' &&
Array.from(r.addedNodes).concat(Array.from(r.removedNodes)).every(isProbe)));
if (others.length) onBodyMutations(others);
}
return changed;
}
function isLetter(ch) {
return !!ch && /[\p{L}\p{N}]/u.test(ch);
}
// Ranges covering the curly-quoted dialogue in one block, quote marks
// included, as the site's own dialogue tag includes them. A line break ends
// any quote left open, so a missing closing mark colors one line at most.
// Inside double quotes, single quotes are only quoted words. A closing
// single quote is the same character as an apostrophe, so it only closes
// when no letter follows it ("Mel\u2019s" does not close).
function curlyRanges(block, who) {
const ranges = [];
const keepBold = !!savedColor(who === 'user' ? 'boldColorUser' : 'boldColorCharacter');
// Stressed words keep the italics color when that is what they show in.
// With the dialogue color picked, or emphasis off, they get the dialogue
// color like the words around them.
const keepEmphasisColor = wantMarks() && !settings.dialogueEmphasisPlainColor;
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
acceptNode: n => n.nodeType === 1 && (n.matches(QUOTES_SEL) || n.classList.contains(COPY_CLASS))
? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT
});
let open = null;
let node;
while ((node = walker.nextNode())) {
if (node.nodeType === 1) {
if (node.tagName === 'BR') open = null;
continue;
}
const parent = node.parentElement;
const paint = !((keepEmphasisColor && parent.closest(EM_SEL + '[' + MARK + ']')) ||
(keepBold && parent.closest('strong')));
const text = node.nodeValue;
let start = open ? 0 : -1;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
let closesHere = false;
if (!open) {
if (ch === '\u201C') open = 'double';
else if (ch === '\u2018' && !isLetter(text[i - 1])) open = 'single';
if (open) start = i;
} else if (open === 'double' && ch === '\u201D') {
closesHere = true;
} else if (open === 'single' && ch === '\u2019' && !isLetter(text[i + 1])) {
closesHere = true;
}
if (closesHere) {
if (paint) ranges.push([node, start, i + 1]);
open = null;
start = -1;
}
}
if (open && paint && start < text.length) ranges.push([node, start, text.length]);
}
return ranges.map(([n, a, b]) => {
const range = document.createRange();
range.setStart(n, a);
range.setEnd(n, b);
return range;
});
}
// Chrome does not always repaint when ranges are added to a highlight that
// is already registered, so each update registers a fresh one per side.
function commitCurly() {
for (const who of CURLY_SIDES) {
const ranges = [];
curlyBlocks.forEach(entry => {
if (entry.who === who) ranges.push(...entry.ranges);
});
if (ranges.length) CSS.highlights.set(curlyName(who), new Highlight(...ranges));
else CSS.highlights.delete(curlyName(who));
}
}
// Works out one block's ranges; commitCurly puts them on screen.
function paintCurlyBlock(block) {
curlyBlocks.delete(block);
if (!block.isConnected) return;
const text = block.textContent;
if (text.indexOf('\u201C') === -1 && text.indexOf('\u2018') === -1) return;
const who = sideOf(block);
if (!who) return;
const ranges = curlyRanges(block, who);
if (!ranges.length) return;
curlyBlocks.set(block, { who, ranges });
if (!curlyColors[who]) scheduleRecovery();
}
function blocksWithin(root) {
const el = root.nodeType === 1 ? root : root.parentElement;
if (!el) return [];
const own = el.closest(BLOCK_SEL);
if (own) return [own];
return Array.from(el.querySelectorAll(BLOCK_SEL)).filter(b => !b.parentElement.closest(BLOCK_SEL));
}
// Full pass: works out every block again, so blocks that left the page drop
// out, and picks up a changed quotes color.
function paintCurlyDialogue() {
if (!canHighlight) return;
if (!wantCurly()) {
curlyBlocks.clear();
commitCurly();
return;
}
if (!document.body) return;
curlyBlocks.clear();
blocksWithin(document.body).forEach(paintCurlyBlock);
commitCurly();
}
// The site color the curly-quote coloring follows.
function refreshSiteColors() {
if (!document.body || !wantCurly()) return;
if (readSiteColors()) ensureStyle();
}
// Every declaration is !important inside a layer that is declared before
// any of the site's CSS. For important declarations the first declared
// layer wins over later layers and over unlayered rules, so the site cannot
// outrank these with specificity, !important, or its own layers. Only an
// inline style="... !important" on the tag itself could.
function buildCss() {
if (!settings.enabled) return '';
const marks = wantMarks();
const narration = marks ? EM_SEL + ':not([' + MARK + '])' : EM_SEL;
const rules = [
// A browser's dark mode for websites lightens dark text as it paints it,
// so a dark italics, quotes or bold color shows up pink or near white,
// while the page still reports the chosen color. The chat is always
// dark, and saying so makes Chromium leave its text alone; it also keeps
// the browser's own controls inside messages, like the edit box, dark.
// Chromium honors this while the device is in dark theme. A browser
// that forces dark mode with the device in light theme cannot be
// stopped from CSS.
' ' + MESSAGE_SCHEME_SEL + ' {',
' color-scheme: dark !important;',
' }',
// The broken lines of a code block the script rebuilt.
' [' + HIDE_ATTR + '] {',
' display: none !important;',
' }',
' ' + EM_SEL + ' {',
' font-style: normal !important;',
' font-weight: inherit !important;',
' }',
' ' + narration + ' {',
' color: inherit !important;',
' }'
];
if (marks) {
// No color here, so emphasis keeps the site's italics color, in dialogue
// and in narration alike.
rules.push(
' ' + EM_SEL + '[' + MARK + '] {',
' font-style: italic !important;',
' }'
);
if (settings.dialogueEmphasisPlainColor) {
rules.push(
' ' + EM_SEL + '[' + MARK + '="dialogue"] {',
' color: inherit !important;',
' }'
);
}
}
if (wantRecovery()) {
// The site's text in a copied line shrinks to nothing and cannot be
// selected; the copy carries the size, so a selection copies one line.
rules.push(
' [' + COPY_ATTR + '] {',
' font-size: 0 !important;',
' user-select: none !important;',
' }',
' .' + COPY_CLASS + ' {',
' font-size: var(--agf-copy-size) !important;',
' user-select: text !important;',
' }'
);
}
// curlyColors come from getComputedStyle, so they are plain color values.
if (wantCurly()) {
for (const who of CURLY_SIDES) {
if (!curlyColors[who]) continue;
rules.push(
' ::highlight(' + curlyName(who) + ') {',
' color: ' + curlyColors[who] + ' !important;',
' -webkit-text-fill-color: ' + curlyColors[who] + ' !important;',
' }'
);
}
}
// Code in single and triple backticks, matched by who wrote it like bold.
// The rebuilt code block sits inside the site's code tag and follows it.
for (const { key, who } of CODE_COLORS) {
const color = savedColor(key);
if (!color) continue;
rules.push(
' [class*="text-chat-' + who + '-message-text"] code {',
' color: ' + color + ' !important;',
' -webkit-text-fill-color: ' + color + ' !important;',
' }'
);
}
// The text fill is set too: when a page sets -webkit-text-fill-color, that
// is what gets painted, and "color" alone changes nothing on screen.
// Each message's text classes carry "text-chat-character-message-text" or
// "text-chat-user-message-text", so bold inside them, including bold the
// dialogue copy brings back, is matched by who wrote it. Italics nested in
// bold keep the italics color, since their own class sets it.
for (const { key, who } of BOLD_COLORS) {
const color = savedColor(key);
if (!color) continue;
rules.push(
' [class*="text-chat-' + who + '-message-text"] strong {',
' color: ' + color + ' !important;',
' -webkit-text-fill-color: ' + color + ' !important;',
' }'
);
}
return '@layer ' + STYLE_ID + ' {\n' + rules.join('\n') + '\n}';
}
// Inserted as the first child so this layer is declared before the site's.
function ensureStyle() {
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
const parent = document.head || document.documentElement;
parent.insertBefore(style, parent.firstChild);
}
const css = buildCss();
if (style.textContent !== css) style.textContent = css;
}
// Straight quotes toggle; curly and angle quotes say which side they are on.
// Single quotes are ignored because apostrophes use the same character.
function scanQuotes(text, inside) {
for (const ch of text) {
if (ch === '"') inside = !inside;
else if (ch === '\u201C' || ch === '\u00AB') inside = true;
else if (ch === '\u201D' || ch === '\u00BB') inside = false;
}
return inside;
}
// Counts quotes only in plain text before the tag, so quotes written inside
// narration (a sign being read, a height in inches) cannot flip the state.
// The site often puts a whole reply in one paragraph with <br> between
// lines, so a line break ends a quote left open: a missing closing mark
// cannot turn the rest of the reply into dialogue.
function insideDialogue(em, block) {
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
let inside = false;
let node;
while ((node = walker.nextNode())) {
if (!(em.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_PRECEDING)) break;
if (node.nodeType === 1) {
if (node.tagName === 'BR') inside = false;
continue;
}
const parent = node.parentElement;
if (parent && parent.closest('.' + COPY_CLASS)) continue;
const owner = parent && parent.closest(EM_SEL);
if (owner && block.contains(owner)) continue;
inside = scanQuotes(node.nodeValue, inside);
}
return inside;
}
// Emphasis outside quotes, as in: He was *so* tired. Needs plain text on both
// sides, a word right before, and a lowercase word right after. The site pads
// tags with newlines, which display as spaces, so any whitespace counts as a
// space here. A real line break is a <br>, which is not text and fails the test.
function midSentence(em) {
const prev = em.previousSibling;
const next = em.nextSibling;
if (!prev || prev.nodeType !== 3 || !next || next.nodeType !== 3) return false;
const words = em.textContent.trim().split(/\s+/).filter(Boolean).length;
if (words === 0 || words > MAX_EMPHASIS_WORDS) return false;
return /[\p{L}\p{N}]\s*$/u.test(prev.nodeValue) && /^\s*\p{Ll}/u.test(next.nodeValue);
}
function hasOwnText(el) {
return Array.from(el.childNodes).some(n => n.nodeType === 3 && visible(n.nodeValue));
}
// Returns "dialogue" for emphasis inside dialogue, "text" for emphasis
// anywhere else, and null for narration. A tag inside the site's dialogue
// tag is emphasis by structure. A tag inside a narration tag is emphasis
// only when that narration has words of its own around it: when the AI
// already wrapped narration in asterisks and a quote splits it, the site
// adds its own on top, and the result is a narration tag holding nothing
// but the narration pieces, which are not emphasis. The text rules are the
// fallback for the rest.
function emphasisKind(em) {
const block = em.closest(BLOCK_SEL) || em.parentElement;
if (!block) return null;
if (!em.closest(QUOTES_SEL) && wantWritten()) {
const written = writtenItalic(em);
if (written === false) return null;
if (written === true) return insideDialogue(em, block) ? 'dialogue' : 'text';
}
// From here on the text rules guess, which is what Keep emphasis is for.
// A tag inside the site's dialogue tag is not a guess, so it always counts.
if (em.closest(QUOTES_SEL)) return 'dialogue';
if (!settings.keepEmphasis) return null;
if (insideDialogue(em, block)) return 'dialogue';
const parent = em.parentElement;
const outer = parent && parent.closest(EM_SEL);
if ((outer && hasOwnText(outer)) || midSentence(em)) return 'text';
return null;
}
// Italics as written. The site's display adds asterisks around every piece
// of a line outside quotes that does not already have them, so in every
// format text the reply never put in asterisks shows up italic: narration
// in Plain RP and Text, and status lines like "**HP:** 10" in Roleplay.
// The reply as written is in the message component's props (see
// rawMessageText). It is read to find which words really had asterisks,
// and only those stay italic. When the original cannot be read, or a piece
// of text cannot be found in it, the text rules above decide instead.
// Off by default: in Plain RP and Text the AI sometimes wraps narration in
// asterisks anyway, and there the narration should still show plain.
function wantWritten() {
return settings.enabled && settings.italicsAsWritten;
}
const writtenCache = new Map();
function squashSpace(text) {
return plainSpacing(text).replace(/\s+/g, ' ');
}
// The reply with its asterisk markers removed, white space squashed as the
// page shows it, and for each character whether it sat inside italics.
// Asterisk runs pair up as Markdown pairs them, closely enough for this:
// a run can open when a non-space follows it and close when a non-space
// comes before it, and each closer takes the nearest open run. One
// asterisk on each side is italic, two bold, three both. Runs that never
// pair stay in the text, as the page shows them.
function writtenItalics(raw) {
let entry = writtenCache.get(raw);
if (entry) return entry;
const marker = new Uint8Array(raw.length);
const depth = new Int32Array(raw.length + 1);
const open = [];
for (const m of raw.matchAll(/\*+/g)) {
const run = { start: m.index, count: m[0].length, left: m[0].length };
const before = raw[m.index - 1];
const after = raw[m.index + m[0].length];
const canOpen = !!after && !/\s/.test(after);
const canClose = !!before && !/\s/.test(before);
let closedFrom = 0;
while (canClose && run.left > 0 && open.length) {
const opener = open[open.length - 1];
const use = run.left >= 2 && opener.left >= 2 ? 2 : 1;
// The opener gives up its innermost asterisks, the closer its first.
for (let k = 0; k < use; k++) {
marker[opener.start + opener.left - 1 - k] = 1;
marker[run.start + closedFrom + k] = 1;
}
if (use === 1) {
depth[opener.start + opener.count] += 1;
depth[run.start] -= 1;
}
opener.left -= use;
run.left -= use;
closedFrom += use;
if (opener.left === 0) open.pop();
}
if (run.left > 0 && canOpen) {
run.start += closedFrom;
run.count -= closedFrom;
open.push(run);
}
}
let plain = '';
const italic = [];
let level = 0;
let lastSpace = false;
for (let i = 0; i < raw.length; i++) {
level += depth[i];
if (marker[i]) continue;
const ch = raw[i];
if (/\s/.test(ch)) {
if (lastSpace) continue;
plain += ' ';
italic.push(level > 0);
lastSpace = true;
} else {
plain += ch;
italic.push(level > 0);
lastSpace = false;
}
}
entry = { plain, italic };
if (writtenCache.size >= MAX_CACHED_TEXTS) writtenCache.clear();
writtenCache.set(raw, entry);
return entry;
}
// The message box an element sits in, for placing text within the reply.
function messageBox(el) {
return el.closest('div[class*="text-chat-character-message-text"], div[class*="text-chat-user-message-text"]');
}
// How far into its message each text node starts, in squashed characters.
// Worked out once per message for each batch of updates: the page does not
// change while a batch is handled, and the table is dropped right after.
let offsetTables = null;
function offsetsFor(box) {
if (!offsetTables) {
offsetTables = new Map();
queueMicrotask(() => {
offsetTables = null;
});
}
let table = offsetTables.get(box);
if (table) return table;
table = new Map();
const walker = document.createTreeWalker(box, NodeFilter.SHOW_TEXT);
let length = 0;
let n;
while ((n = walker.nextNode())) {
table.set(n, length);
length += squashSpace(n.nodeValue).length;
}
offsetTables.set(box, table);
return table;
}
// Where a piece of page text sits in the plain reply. When it appears more
// than once, the copy nearest to how far into the message the piece is
// on the page wins.
function findInWritten(written, needle, node) {
const hits = [];
let at = written.plain.indexOf(needle);
while (at !== -1 && hits.length < 64) {
hits.push(at);
at = written.plain.indexOf(needle, at + 1);
}
if (hits.length <= 1) return hits.length ? hits[0] : -1;
const box = messageBox(node.parentElement);
const expected = box ? offsetsFor(box).get(node) : undefined;
if (expected === undefined) return hits[0];
return hits.reduce((best, hit) => Math.abs(hit - expected) < Math.abs(best - expected) ? hit : best);
}
// True when most of the tag's own words had asterisks in the reply, false
// when they did not, and null when that cannot be told.
function writtenItalic(em) {
let raw = null;
try {
raw = rawMessageText(em);
} catch (_) {
// a prop getter on the site's side threw; let the text rules decide
}
if (!raw) return null;
const written = writtenItalics(raw);
// The tag's own text, not the text of tags inside it, which are judged
// on their own. A tag with no words of its own that wraps other italics
// tags or dialogue is only a wrapper: italic, it would slant the dialogue
// inside it too, so it stays plain and the tags inside decide. Any other
// tag with no words of its own, such as one holding only bold, is judged
// by all of its text.
let nodes = Array.from(em.childNodes).filter(n => n.nodeType === 3 && visible(n.nodeValue));
if (!nodes.length && em.querySelector(EM_SEL + ', ' + QUOTES_SEL)) return false;
if (!nodes.length) {
const walker = document.createTreeWalker(em, NodeFilter.SHOW_TEXT);
let n;
while ((n = walker.nextNode())) if (visible(n.nodeValue)) nodes.push(n);
}
let found = 0;
let italicChars = 0;
let chars = 0;
for (const node of nodes) {
const needle = squashSpace(node.nodeValue).trim();
if (!needle) continue;
const at = findInWritten(written, needle, node);
if (at === -1) continue;
found++;
for (let i = at; i < at + needle.length; i++) {
if (written.plain[i] === ' ') continue;
chars++;
if (written.italic[i]) italicChars++;
}
}
if (!found || !chars) return null;
return italicChars * 2 >= chars;
}
// Sets or removes one data attribute. React ignores attributes it did not
// set, and the text and structure of the message are never changed.
function classify(em) {
const kind = emphasisKind(em);
if (kind === em.getAttribute(MARK)) return;
if (kind) em.setAttribute(MARK, kind);
else em.removeAttribute(MARK);
}
function classifyWithin(root) {
if (root.matches(EM_SEL)) classify(root);
root.querySelectorAll(EM_SEL).forEach(classify);
}
// The message component's props hold the reply as written, with the
// asterisks inside dialogue that the display drops. In Firefox, a userscript
// manager can run the script behind an Xray wrapper that hides properties
// the page's own code set on elements; wrappedJSObject reaches past it.
// Chromium has no such wrapper, and the element is read directly.
function rawMessageText(el) {
const host = el.wrappedJSObject || el;
const key = Object.keys(host).find(k => k.startsWith('__reactFiber$'));
let fiber = key ? host[key] : null;
for (let depth = 0; fiber && depth < 30; depth++, fiber = fiber.return) {
const props = fiber.memoizedProps;
if (!props || typeof props !== 'object') continue;
if (props.message && typeof props.message.text === 'string') return props.message.text;
if (typeof props.displayText === 'string') return props.displayText;
}
return null;
}
// For each quote in the raw text that had *emphasis* or `code`, the quote
// as displayed (asterisks and backticks removed) and where the emphasized
// words and the code sit inside it.
const quoteCache = new Map();
const MAX_CACHED_TEXTS = 200;
function emphasizedQuotes(raw) {
let list = quoteCache.get(raw);
if (list) return list;
list = [];
for (const m of raw.matchAll(/"[^"]{1,2000}"|\u201C[^\u201D]{1,2000}\u201D/g)) {
const quote = m[0];
if (!quote.includes('*') && !quote.includes('`')) continue;
const ranges = [];
let plain = '';
let last = 0;
// Code in paired backticks comes first, as in Markdown, and is marked
// 'code'. Emphasis runs must match: one asterisk is italic, two bold,
// three both. Unpaired asterisks are dropped, as the site does.
for (const e of quote.matchAll(/`([^`\n]{1,500})`|(\*{1,3})([^*`]{1,500}?)\2/g)) {
plain += quote.slice(last, e.index).replace(/\*/g, '');
const start = plain.length;
plain += e[1] !== undefined ? e[1] : e[3];
ranges.push([start, plain.length, e[1] !== undefined ? 'code' : e[2].length]);
last = e.index + e[0].length;
}
plain += quote.slice(last).replace(/\*/g, '');
if (ranges.length) list.push({ plain, ranges });
}
if (quoteCache.size >= MAX_CACHED_TEXTS) quoteCache.clear();
quoteCache.set(raw, list);
return list;
}
// The site's own text inside a dialogue tag, leaving out the script's copy.
function siteText(span) {
let text = '';
for (const node of span.childNodes) {
if (node.nodeType === 3) text += node.nodeValue;
else if (node.nodeType === 1 && !node.classList.contains(COPY_CLASS)) text += node.textContent;
}
return text;
}
// Where the emphasized words sit in the line as displayed, or null when
// the line had no emphasis or the original text cannot be read.
function copyPlan(span) {
let raw = null;
try {
raw = rawMessageText(span);
} catch (_) {
// a prop getter on the site's side threw; leave this line alone
}
if (!raw) return null;
const quotes = emphasizedQuotes(raw);
if (!quotes.length) return null;
const text = siteText(span);
const plainText = plainSpacing(text);
const ranges = [];
for (const quote of quotes) {
const at = plainText.indexOf(plainSpacing(quote.plain));
if (at === -1) continue;
for (const [start, end, stars] of quote.ranges) ranges.push([at + start, at + end, stars]);
}
if (!ranges.length) return null;
ranges.sort((a, b) => a[0] - b[0]);
return { text, ranges, who: sideOf(span) };
}
// Italic words use the site's italics tag, so its color and the toggles
// apply. Bold uses a plain <strong>, which is one step heavier than the
// dialogue around it and keeps the dialogue color. Code uses the site's
// own code look.
const CODE_CLASSES = 'bg-container-secondary inline-block w-fit rounded px-2 py-1.5 font-medium';
function emphasisNode(text, stars, who) {
let node = document.createTextNode(text);
if (stars === 'code') {
const code = document.createElement('code');
code.className = CODE_CLASSES;
code.appendChild(node);
return code;
}
if (stars !== 2) {
const em = document.createElement('em');
em.className = italicsClasses(who);
em.setAttribute(MARK, 'dialogue');
em.appendChild(node);
node = em;
}
if (stars >= 2) {
const strong = document.createElement('strong');
strong.appendChild(node);
node = strong;
}
return node;
}
function fillCopy(copy, plan) {
copy.replaceChildren(...copyParts(plan));
}
function copyParts(plan) {
const parts = [];
let last = 0;
for (const [start, end, stars] of plan.ranges) {
if (start < last) continue;
if (start > last) parts.push(document.createTextNode(plan.text.slice(last, start)));
parts.push(emphasisNode(plan.text.slice(start, end), stars, plan.who));
last = end;
}
parts.push(document.createTextNode(plan.text.slice(last)));
return parts;
}
// Shows, updates or removes the copy for one dialogue tag. It writes only
// when the wanted copy differs from what is there, so the mutation records
// its own writes cause find nothing to do, and it cannot loop.
function syncCopy(span) {
const copy = Array.from(span.children).find(c => c.classList.contains(COPY_CLASS)) || null;
const plan = wantRecovery() ? copyPlan(span) : null;
if (!plan) {
if (copy) copy.remove();
if (span.hasAttribute(COPY_ATTR)) span.removeAttribute(COPY_ATTR);
return;
}
const signature = JSON.stringify([plan.text, plan.ranges]);
if (copy && copy.dataset.agfSignature === signature && span.hasAttribute(COPY_ATTR)) return;
// Read only when building, since a style read can force layout. The copy
// takes the size the line had, measured on its parent, which is not hidden.
const size = getComputedStyle(span.parentElement || span).fontSize;
const target = copy || document.createElement('span');
if (!copy) {
target.className = COPY_CLASS;
// Screen readers keep reading the site's own text, so nothing is read twice.
target.setAttribute('aria-hidden', 'true');
}
target.dataset.agfSignature = signature;
target.style.setProperty('--agf-copy-size', size);
fillCopy(target, plan);
if (!copy) span.appendChild(target);
if (!span.hasAttribute(COPY_ATTR)) span.setAttribute(COPY_ATTR, '');
}
// The script's own rebuilt lines hold a dialogue tag too; that one is not
// the site's and is left alone.
function innermostQuoteSpans(root) {
const list = root.matches(QUOTES_SEL) ? [root] : [];
list.push(...root.querySelectorAll(QUOTES_SEL));
return list.filter(s => !s.querySelector(QUOTES_SEL) && !s.closest('.' + LINE_CLASS));
}
// Dialogue the site never put in its dialogue tag. The site finds dialogue
// one piece of text at a time, so a quote with `code` in it, which splits
// the quote around a code tag, gets no tag: no dialogue color, and no line
// for the copy above to work from. Its asterisks are gone too, removed
// before display like in any straight-quoted dialogue. For such a quote
// the script shows the whole line rebuilt from the original reply, in the
// site's dialogue tag with emphasis and code back in place, and hides the
// site's pieces of it: its tags by an attribute, its text by trimming the
// text nodes through the text fix, which puts the text back when this
// stops. Only straight quotes, which the site colors; curly quotes have
// their own coloring above.
const LINE_CLASS = 'agf-line-copy';
const lineCuts = new Map();
const lineState = new Map();
// The block's text as the site wrote it, piece by piece, leaving out the
// script's own copies.
function blockPieces(block) {
const pieces = [];
let text = '';
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let n;
while ((n = walker.nextNode())) {
if (n.parentElement.closest('.' + COPY_CLASS + ', .' + LINE_CLASS)) continue;
const known = textFixes.get(n);
const value = known && known.fixed === n.nodeValue ? known.orig : n.nodeValue;
pieces.push({ node: n, start: text.length, value });
text += value;
}
return { pieces, text };
}
function topChild(node, block) {
let top = node;
while (top.parentNode !== block) top = top.parentNode;
return top;
}
// What to show, trim and hide in one block, or an empty plan.
function linePlan(block) {
const plan = { copies: [], cuts: new Map(), hidden: new Set() };
let raw = null;
try {
raw = rawMessageText(block);
} catch (_) {
// a prop getter on the site's side threw; leave the block as it is
}
if (!raw) return plan;
const quotes = emphasizedQuotes(raw).filter(q => q.plain[0] === '"');
if (!quotes.length) return plan;
const who = sideOf(block);
if (!who) return plan;
const { pieces, text } = blockPieces(block);
const flat = plainSpacing(text);
let cursor = 0;
for (const quote of quotes) {
const at = flat.indexOf(plainSpacing(quote.plain), cursor);
if (at === -1) continue;
const end = at + quote.plain.length;
cursor = end;
const covered = pieces.filter(pc => pc.start < end && pc.start + pc.value.length > at && pc.value.length);
if (covered.length < 2) continue;
// Inside the site's dialogue tag, the copy above handles it.
if (covered.some(pc => pc.node.parentElement.closest(QUOTES_SEL))) continue;
const tops = [];
for (const pc of covered) {
const top = topChild(pc.node, block);
if (!tops.includes(top)) tops.push(top);
}
// A tag the quote only partly covers cannot be hidden in part.
const partial = tops.some(top => {
if (top.nodeType === 3) return false;
const inside = pieces.filter(pc => top.contains(pc.node));
return inside.some(pc => pc.start < at || pc.start + pc.value.length > end);
});
if (partial) continue;
let before = null;
for (const top of tops) {
if (top.nodeType === 1) {
plan.hidden.add(top);
continue;
}
const pc = pieces.find(x => x.node === top);
const from = Math.max(0, at - pc.start);
const to = Math.min(pc.value.length, end - pc.start);
// The part outside the quote stays: text before it, or after it.
if (from > 0) {
plan.cuts.set(top, [0, from]);
before = top;
} else if (to < pc.value.length) {
plan.cuts.set(top, [to, pc.value.length]);
} else {
plan.cuts.set(top, [0, 0]);
}
}
plan.copies.push({
signature: JSON.stringify([who, quote.plain, quote.ranges]),
after: before,
first: tops[0],
build: () => {
const tag = document.createElement('span');
tag.className = 'font-semibold text-chat-' + who + '-message-text-quotes';
tag.append(...copyParts({ text: quote.plain, ranges: quote.ranges.slice().sort((a, b) => a[0] - b[0]), who }));
return tag;
}
});
}
return plan;
}
function setCut(node, cut) {
const old = lineCuts.get(node);
if (cut && old && old[0] === cut[0] && old[1] === cut[1]) return;
if (cut) lineCuts.set(node, cut);
else if (!lineCuts.delete(node)) return;
fixText(node);
}
// Shows, updates or undoes the rebuilt lines of one block, writing only
// what differs, so the records its own writes cause find nothing to do.
function syncLines(block) {
const plan = wantRecovery() && block.isConnected ? linePlan(block) : { copies: [], cuts: new Map(), hidden: new Set() };
const before = lineState.get(block);
if (before) {
for (const node of before.cuts) if (!plan.cuts.has(node)) setCut(node, null);
for (const el of before.hidden) if (!plan.hidden.has(el) && el.hasAttribute(HIDE_ATTR)) el.removeAttribute(HIDE_ATTR);
}
plan.cuts.forEach((cut, node) => setCut(node, cut));
plan.hidden.forEach(el => {
if (!el.hasAttribute(HIDE_ATTR)) el.setAttribute(HIDE_ATTR, '');
});
const existing = Array.from(block.children).filter(c => c.classList.contains(LINE_CLASS));
const kept = new Set();
for (const want of plan.copies) {
let copy = existing.find(c => !kept.has(c) && c.dataset.agfSignature === want.signature);
if (!copy) {
copy = document.createElement('span');
copy.className = LINE_CLASS;
copy.dataset.agfSignature = want.signature;
copy.append(want.build());
}
kept.add(copy);
const placed = want.after ? want.after.nextSibling === copy : copy.nextSibling === want.first;
if (!placed) block.insertBefore(copy, want.after ? want.after.nextSibling : want.first);
}
existing.forEach(c => {
if (!kept.has(c)) c.remove();
});
if (plan.copies.length || plan.cuts.size || plan.hidden.size) {
lineState.set(block, { cuts: new Set(plan.cuts.keys()), hidden: plan.hidden });
} else {
lineState.delete(block);
}
}
function syncAllLines() {
if (!document.body) return;
if (wantRecovery()) blocksWithin(document.body).forEach(syncLines);
for (const block of Array.from(lineState.keys())) {
if (!block.isConnected) {
lineState.get(block).cuts.forEach(node => lineCuts.delete(node));
lineState.delete(block);
} else if (!wantRecovery()) {
syncLines(block);
}
}
}
function recoverDialogueEmphasis() {
syncAllLines();
if (wantRecovery()) {
innermostQuoteSpans(document.body).forEach(syncCopy);
return;
}
document.querySelectorAll('.' + COPY_CLASS).forEach(el => el.remove());
document.querySelectorAll('[' + COPY_ATTR + ']').forEach(el => el.removeAttribute(COPY_ATTR));
}
// Coalesces a burst of updates, such as a streaming reply, into one pass.
const RECOVERY_DELAY_MS = 150;
let recoveryTimer = 0;
function scheduleRecovery() {
if (recoveryTimer) return;
recoveryTimer = setTimeout(() => {
recoveryTimer = 0;
syncFences();
recoverDialogueEmphasis();
paintCurlyDialogue();
refreshSiteColors();
}, RECOVERY_DELAY_MS);
}
// Runs before the browser paints, so a new tag never shows in the wrong
// style first. Attribute changes are not observed, so marking cannot loop.
// The record a text fix's own write causes is skipped, and a fix worked out
// again from the site's value comes out the same, so that cannot loop
// either.
function onBodyMutations(records) {
records = records.filter(r => !(r.type === 'characterData' && isOwnSpacing(r.target)));
if (!records.length) return;
// Text first, so marking and coloring see the fixed text. Whole
// paragraphs are redone, because a stray space depends on the text next
// to a tag, which can arrive after the tag while a reply streams in.
const boxes = new Set();
for (const r of records) {
messageBoxes(r.target).forEach(b => boxes.add(b));
for (const n of Array.from(r.addedNodes)) messageBoxes(n).forEach(b => boxes.add(b));
}
boxes.forEach(syncFence);
const dirtyBlocks = new Set();
for (const r of records) {
blocksWithin(r.target).forEach(b => dirtyBlocks.add(b));
for (const n of Array.from(r.addedNodes)) blocksWithin(n).forEach(b => dirtyBlocks.add(b));
}
if (wantRecovery() || lineState.size) dirtyBlocks.forEach(syncLines);
if (wantTextFix()) dirtyBlocks.forEach(fixTextWithin);
const curly = wantCurly();
const roots = new Set();
const curlyDirty = new Set();
for (const r of records) {
if (curly) {
blocksWithin(r.target).forEach(b => curlyDirty.add(b));
for (const n of Array.from(r.addedNodes)) blocksWithin(n).forEach(b => curlyDirty.add(b));
if (r.removedNodes.length) scheduleRecovery();
}
if (!wantMarks()) continue;
const el = r.target.nodeType === 1 ? r.target : r.target.parentElement;
if (!el) continue;
roots.add(el.closest(BLOCK_SEL) || el);
for (const n of Array.from(r.addedNodes)) {
if (n.nodeType === 1) roots.add(n);
}
}
roots.forEach(root => {
if (root.isConnected) classifyWithin(root);
});
// After marking, so emphasis marked just now keeps its own color.
if (curlyDirty.size) {
curlyDirty.forEach(paintCurlyBlock);
commitCurly();
}
// A message from a side whose color is still unread gets it read.
if (wantCurly()) {
for (const box of boxes) {
const who = sideOf(box);
if (who && !curlyColors[who]) {
scheduleRecovery();
break;
}
}
}
if (!wantRecovery()) return;
// Lines the site just changed are re-synced now, before paint, so a
// hidden line never shows without its copy. A full pass follows shortly
// to catch lines whose original text changed with no visible change.
const spans = new Set();
for (const r of records) {
const el = r.target.nodeType === 1 ? r.target : r.target.parentElement;
const span = el && el.closest(QUOTES_SEL);
if (span) spans.add(span);
for (const n of Array.from(r.addedNodes)) {
if (n.nodeType === 1) innermostQuoteSpans(n).forEach(s => spans.add(s));
}
}
spans.forEach(span => {
if (span.isConnected && !span.querySelector(QUOTES_SEL)) syncCopy(span);
});
scheduleRecovery();
}
let bodyObserver = null;
let observedBody = null;
function wantBodyWatch() {
return wantMarks() || wantTextFix() || wantCurly();
}
// The body watcher runs while Format fix is on, for the text fixes, marking
// and coloring. With Format fix off it stops, and the script does nothing.
function syncBodyObserver() {
const body = document.body;
if (bodyObserver && (!wantBodyWatch() || observedBody !== body)) {
bodyObserver.disconnect();
bodyObserver = null;
observedBody = null;
}
if (!bodyObserver && wantBodyWatch() && body) {
bodyObserver = new MutationObserver(onBodyMutations);
bodyObserver.observe(body, { childList: true, subtree: true, characterData: true });
observedBody = body;
}
if (!body) return;
syncFences();
if (wantTextFix()) fixTextWithin(body);
else restoreText();
if (wantMarks()) classifyWithin(body);
recoverDialogueEmphasis();
paintCurlyDialogue();
refreshSiteColors();
}
// Watches only where the style tag can live: <html>'s direct children, and
// <head> when it exists. React can replace <head> during hydration or
// navigation, so a new <head> gets observed in place of the old one.
let observedHead = null;
const headObserver = new MutationObserver(ensureStyle);
function watchHead() {
const head = document.head;
if (!head || head === observedHead) return;
headObserver.disconnect();
headObserver.observe(head, { childList: true });
observedHead = head;
}
function start() {
new MutationObserver(() => {
watchHead();
ensureStyle();
syncBodyObserver();
}).observe(document.documentElement, { childList: true });
watchHead();
ensureStyle();
syncBodyObserver();
}
// At document-start the browser may not have created <html> yet, and
// observing a missing element throws and stops the whole script.
if (document.documentElement) {
start();
} else {
const docObserver = new MutationObserver(() => {
if (!document.documentElement) return;
docObserver.disconnect();
start();
});
docObserver.observe(document, { childList: true });
}
const menuIds = [];
function buildMenu() {
while (menuIds.length) GM_unregisterMenuCommand(menuIds.pop());
// Format fix is the master switch. While it is off the other settings keep
// their values but do nothing, and the menu says so.
const paused = settings.enabled ? '' : ' (paused)';
menuIds.push(GM_registerMenuCommand(
'Format fix: ' + (settings.enabled ? 'ON' : 'OFF, everything off'),
() => toggle('enabled')
));
menuIds.push(GM_registerMenuCommand(
'Keep emphasis: ' + (settings.keepEmphasis ? 'ON' : 'OFF') + paused,
() => toggle('keepEmphasis')
));
menuIds.push(GM_registerMenuCommand(
'Italics as written (for Roleplay): ' + (settings.italicsAsWritten ? 'ON' : 'OFF') + paused,
() => toggle('italicsAsWritten')
));
menuIds.push(GM_registerMenuCommand(
'Dialogue emphasis color: ' + (settings.dialogueEmphasisPlainColor ? 'dialogue' : 'italics') + paused,
() => toggle('dialogueEmphasisPlainColor')
));
menuIds.push(GM_registerMenuCommand(
'Dialogue emphasis (experimental): ' + (settings.recoverEmphasis ? 'ON' : 'OFF') + paused,
() => toggle('recoverEmphasis')
));
menuIds.push(GM_registerMenuCommand(
'Keep spacing: ' + (settings.keepSpacing ? 'ON' : 'OFF') + paused,
() => toggle('keepSpacing')
));
menuIds.push(GM_registerMenuCommand(
'Curly quote dialogue: ' + (canHighlight ? (settings.curlyDialogue ? 'ON' : 'OFF') : 'not supported here') + paused,
() => toggle('curlyDialogue')
));
menuIds.push(GM_registerMenuCommand('Colors...' + paused, openColorPanel));
}
// syncBodyObserver also repaints curly-quoted dialogue, since Keep emphasis
// and the dialogue emphasis color change which words it paints.
function toggle(key) {
settings[key] = !settings[key];
GM_setValue(key, settings[key]);
ensureStyle();
syncBodyObserver();
buildMenu();
}
// While a picker is being dragged the color applies live but is saved only
// when the drag ends, so storage is not written on every pointer move.
function setColor(key, value, save = true) {
const had = !!settings[key];
settings[key] = HEX_COLOR.test(value) ? value : '';
if (save) GM_setValue(key, settings[key]);
ensureStyle();
// Bold in curly-quoted dialogue takes the bold color when one is set, and
// the dialogue color when not.
if (BOLD_COLORS.some(c => c.key === key) && had !== !!settings[key]) paintCurlyDialogue();
}
// A small sheet at the bottom of the screen, styled to sit with the site's
// dark settings panels. It is the script's own element, outside the chat.
const PANEL_ID = 'agf-colors';
const PANEL_COLORS = { sheet: '#1a1b26', line: '#2c2d3c', text: '#ececf4', muted: '#9a9bb0', button: '#272838' };
function make(tag, css, text) {
const el = document.createElement(tag);
if (css) el.style.cssText = css;
if (text) el.textContent = text;
return el;
}
// Chrome on Android flashes a tinted box on a tapped button. A swatch's flash
// would show through the picker that opens over it, so buttons here turn it off.
function panelButton(text) {
return make('button',
'min-height:44px;padding:0 16px;-webkit-tap-highlight-color:transparent;border:1px solid ' + PANEL_COLORS.line + ';border-radius:12px;' +
'background:' + PANEL_COLORS.button + ';color:' + PANEL_COLORS.text + ';font:inherit;font-size:15px;', text);
}
// The color picker copies the layout of Anime GF's own: a saturation and
// brightness area, a hue bar, Hex and RGB fields, and the same presets.
const PICKER_ID = 'agf-color-picker';
const PRESETS = ['#D0021B', '#F5A623', '#F8E71C', '#8B572A', '#7ED321', '#417505', '#BD10E0', '#9013FE',
'#4A90E2', '#50E3C2', '#B8E986', '#000000', '#4A4A4A', '#9B9B9B', '#FFFFFF'];
function hexToRgb(hex) {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbToHex(rgb) {
return '#' + rgb.map(c => c.toString(16).padStart(2, '0')).join('').toUpperCase();
}
function rgbToHsv(rgb) {
const [r, g, b] = rgb.map(c => c / 255);
const max = Math.max(r, g, b);
const d = max - Math.min(r, g, b);
let h = 0;
if (d) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h = (h * 60 + 360) % 360;
}
return { h, s: max ? d / max : 0, v: max };
}
function hsvToRgb({ h, s, v }) {
const channel = n => {
const k = (n + h / 60) % 6;
return v - v * s * Math.max(0, Math.min(k, 4 - k, 1));
};
return [channel(5), channel(3), channel(1)].map(x => Math.round(x * 255));
}
function clamp01(x) {
return Math.min(1, Math.max(0, x));
}
// onPick(hex, save) runs on every change; save is false mid-drag.
function openPicker(startHex, onPick) {
// Kept as hue, saturation and brightness so the hue survives dragging to
// black or gray, where the color alone no longer says what it was.
let hsv = rgbToHsv(hexToRgb(startHex));
const layer = make('div', 'position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;' +
'justify-content:center;font-family:system-ui,sans-serif;');
layer.id = PICKER_ID;
// On a short screen, such as a phone held sideways, the card scrolls. The
// dvh value follows the visible height on phones and is skipped where unsupported.
const card = make('div', 'width:min(320px,calc(100vw - 32px));box-sizing:border-box;padding:12px 12px 14px;' +
'max-height:calc(100vh - 24px);max-height:calc(100dvh - 24px);overflow-y:auto;' +
'background:#fff;color:#333;border-radius:6px;box-shadow:0 0 0 1px rgba(0,0,0,0.15),0 8px 24px rgba(0,0,0,0.35);');
card.setAttribute('role', 'dialog');
card.setAttribute('aria-label', 'Pick a color');
const area = make('div', 'position:relative;width:100%;aspect-ratio:4/3;border-radius:2px;' +
'touch-action:none;cursor:crosshair;');
const dot = make('div', 'position:absolute;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;' +
'box-shadow:0 0 0 2px #fff,0 0 1px 3px rgba(0,0,0,0.4);pointer-events:none;');
area.append(dot);
const hue = make('div', 'position:relative;height:14px;margin-top:12px;border-radius:2px;touch-action:none;' +
'cursor:pointer;background:linear-gradient(to right,#f00 0%,#ff0 17%,#0f0 33%,#0ff 50%,#00f 67%,#f0f 83%,#f00 100%);');
const knob = make('div', 'position:absolute;top:-2px;width:6px;height:18px;margin-left:-3px;background:#fff;' +
'border-radius:1px;box-shadow:0 0 2px rgba(0,0,0,0.6);pointer-events:none;');
hue.append(knob);
const fields = make('div', 'display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:8px;margin-top:12px;');
const field = label => {
const wrap = make('label', 'display:flex;flex-direction:column;align-items:center;gap:4px;' +
'font-size:12px;color:#666;');
const input = make('input', 'width:100%;box-sizing:border-box;padding:6px;border:0;border-radius:0;' +
'box-shadow:inset 0 0 0 1px #ccc;background:#fff;color:#333;font:14px system-ui,sans-serif;');
input.inputMode = label === 'Hex' ? 'text' : 'numeric';
input.autocomplete = 'off';
input.spellcheck = false;
wrap.append(input, document.createTextNode(label));
fields.append(wrap);
return input;
};
const hexField = field('Hex');
const rgbFields = [field('R'), field('G'), field('B')];
const presets = make('div', 'display:grid;grid-template-columns:repeat(8,1fr);gap:8px;' +
'margin:12px -12px 0;padding:12px 12px 0;border-top:1px solid #eee;');
card.append(area, hue, fields, presets);
layer.append(card);
// except: the field being typed in, which is not rewritten under the cursor.
// quiet: redraw only, for opening the picker and leaving a field, so neither
// applies a color the person did not pick.
const render = (save, except, quiet) => {
const rgb = hsvToRgb(hsv);
const hex = rgbToHex(rgb);
area.style.background = 'linear-gradient(to top,#000,transparent),' +
'linear-gradient(to right,#fff,transparent),hsl(' + hsv.h + ',100%,50%)';
dot.style.left = hsv.s * 100 + '%';
dot.style.top = (1 - hsv.v) * 100 + '%';
dot.style.background = hex;
knob.style.left = (hsv.h / 360) * 100 + '%';
if (except !== hexField) hexField.value = hex.slice(1);
rgbFields.forEach((input, i) => {
if (except !== input) input.value = rgb[i];
});
if (!quiet) onPick(hex, save);
};
PRESETS.forEach(hex => {
const swatch = make('button', 'aspect-ratio:1;padding:0;border:0;border-radius:3px;cursor:pointer;' +
'-webkit-tap-highlight-color:transparent;' +
'background:' + hex + ';box-shadow:inset 0 0 0 1px rgba(0,0,0,0.15);');
swatch.setAttribute('aria-label', hex);
swatch.addEventListener('click', () => {
hsv = rgbToHsv(hexToRgb(hex));
render(true);
});
presets.append(swatch);
});
// Pointer capture keeps a drag tracking after the finger leaves the area.
const drag = (el, move) => {
el.addEventListener('pointerdown', e => {
e.preventDefault();
el.setPointerCapture(e.pointerId);
move(e);
render(false);
const onMove = ev => {
move(ev);
render(false);
};
const onUp = () => {
el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp);
render(true);
};
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onUp);
el.addEventListener('pointercancel', onUp);
});
};
drag(area, e => {
const r = area.getBoundingClientRect();
hsv = { h: hsv.h, s: clamp01((e.clientX - r.left) / r.width), v: 1 - clamp01((e.clientY - r.top) / r.height) };
});
drag(hue, e => {
const r = hue.getBoundingClientRect();
hsv = { h: clamp01((e.clientX - r.left) / r.width) * 360, s: hsv.s, v: hsv.v };
});
hexField.addEventListener('input', () => {
const value = hexField.value.trim().replace(/^#/, '');
if (!/^[0-9a-f]{6}$/i.test(value)) return;
hsv = rgbToHsv(hexToRgb('#' + value));
render(true, hexField);
});
rgbFields.forEach(input => input.addEventListener('input', () => {
if (rgbFields.some(f => !/^\d{1,3}$/.test(f.value))) return;
const rgb = rgbFields.map(f => Number(f.value));
if (rgb.some(c => c > 255)) return;
hsv = rgbToHsv(rgb);
render(true, input);
}));
// A half-typed field snaps back to the current color when left.
[hexField, ...rgbFields].forEach(input => input.addEventListener('blur', () => render(false, null, true)));
const close = () => {
layer.remove();
document.removeEventListener('keydown', onKey);
};
const onKey = e => {
if (e.key === 'Escape') close();
};
// Closes on the click, so a tap's click is used up here and does not reach
// the panel underneath and close it too. The press must also start
// outside the card, so a drag that ends outside it does not close it.
let pressedOutside = false;
layer.addEventListener('pointerdown', e => {
pressedOutside = e.target === layer;
});
layer.addEventListener('click', e => {
if (e.target === layer && pressedOutside) close();
});
document.addEventListener('keydown', onKey);
document.body.append(layer);
render(false, null, true);
}
// unset: what an unset color shows as, since something else decides it.
function colorRow({ key, label }, unset) {
const row = make('div', 'display:flex;align-items:center;gap:12px;min-height:56px;');
const name = make('div', 'flex:1;');
name.append(make('div', 'font-size:16px;', label));
const status = make('div', 'font-size:13px;color:' + PANEL_COLORS.muted + ';');
name.append(status);
const swatch = make('button', 'width:52px;height:44px;padding:0;border-radius:12px;cursor:pointer;' +
'-webkit-tap-highlight-color:transparent;' +
'border:1px solid ' + PANEL_COLORS.line + ';');
swatch.setAttribute('aria-label', 'Pick ' + label.toLowerCase() + ' color');
const reset = panelButton('Default');
const show = () => {
const color = savedColor(key);
// An unset color shows as an empty dashed swatch, since the site decides it.
swatch.style.background = color || 'transparent';
swatch.style.borderStyle = color ? 'solid' : 'dashed';
swatch.style.borderColor = color ? PANEL_COLORS.line : PANEL_COLORS.muted;
status.textContent = color ? color.toUpperCase() : unset;
reset.disabled = !color;
reset.style.opacity = color ? '1' : '0.45';
};
swatch.addEventListener('click', () => {
// White is the starting point for an unset color, near the site's light text.
openPicker(savedColor(key) || '#FFFFFF', (hex, save) => {
setColor(key, hex, save);
show();
});
});
reset.addEventListener('click', () => {
setColor(key, '');
show();
});
show();
row.append(name, swatch, reset);
return row;
}
function openColorPanel() {
if (!document.body || document.getElementById(PANEL_ID)) return;
const backdrop = make('div', 'position:fixed;inset:0;z-index:2147483646;background:rgba(0,0,0,0.5);' +
'display:flex;align-items:flex-end;justify-content:center;font-family:system-ui,sans-serif;');
backdrop.id = PANEL_ID;
const sheet = make('div', 'width:min(480px,100%);box-sizing:border-box;padding:20px 20px 24px;' +
'max-height:calc(100vh - 24px);max-height:calc(100dvh - 24px);overflow-y:auto;' +
'border-radius:20px 20px 0 0;background:' + PANEL_COLORS.sheet + ';color:' + PANEL_COLORS.text + ';');
sheet.setAttribute('role', 'dialog');
sheet.setAttribute('aria-label', 'Colors');
// Focus goes to the sheet, not a button, so opening the panel shows no
// focus ring. A keyboard user tabs from here into the buttons, and the
// browser rings each one as usual.
sheet.tabIndex = -1;
sheet.style.outline = 'none';
sheet.append(
make('div', 'font-size:18px;font-weight:600;margin-bottom:4px;', 'Colors'),
make('div', 'font-size:14px;color:' + PANEL_COLORS.muted + ';margin-bottom:12px;',
'Anime GF has no color settings for bold text or text in `backticks`, so pick them here.')
);
if (!settings.enabled) {
sheet.append(make('div', 'font-size:14px;margin-bottom:12px;padding:10px 12px;border-radius:12px;' +
'background:' + PANEL_COLORS.button + ';',
'Format fix is off, so these colors are saved but not shown. Turn Format fix on to see them.'));
}
const heading = text => make('div', 'font-size:13px;font-weight:600;letter-spacing:0.04em;' +
'text-transform:uppercase;color:' + PANEL_COLORS.muted + ';margin-top:12px;', text);
sheet.append(heading('Bold'));
BOLD_COLORS.forEach(entry => sheet.append(colorRow(entry, "Site's color")));
sheet.append(heading('Code (text in backticks)'));
CODE_COLORS.forEach(entry => sheet.append(colorRow(entry, "Site's color")));
const done = panelButton('Done');
done.style.width = '100%';
done.style.marginTop = '16px';
sheet.append(done);
backdrop.append(sheet);
const close = () => {
backdrop.remove();
document.removeEventListener('keydown', onKey);
};
// With the color picker open, Escape closes only the picker.
const onKey = e => {
if (e.key === 'Escape' && !document.getElementById(PICKER_ID)) close();
};
done.addEventListener('click', close);
backdrop.addEventListener('click', e => {
if (e.target === backdrop) close();
});
document.addEventListener('keydown', onKey);
document.body.append(backdrop);
sheet.focus();
}
buildMenu();
})();