Use it however, but it is fragile, unused features
// ==UserScript==
// @name Word Replacer Universal
// @match https://*/*
// @version 31.1
// @namespace WordReplacer for all sites
// @description Use it however, but it is fragile, unused features
// @grant none
// ==/UserScript==
(function () {
'use strict';
const STORAGE_KEY = 'wordReplacerKeys12345';
let data = loadData();
const mainButton = document.createElement('button');
mainButton.textContent = '☰';
Object.assign(mainButton.style, {
position: 'fixed',
bottom: '20px',
right: '20px',
zIndex: '100001',
padding: '8px 14px',
fontSize: '16px',
backgroundColor: '#333',
color: '#fff',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
});
document.body.appendChild(mainButton);
let popup = null;
mainButton.addEventListener('click', () => {
if (popup) {
closePopup();
} else {
openPopup();
replaceTextInChapter();
}
});
function loadData() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
try {
return JSON.parse(raw);
} catch {
return {};
}
}
function saveData(obj) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(obj));
}
function closePopup() {
if (popup) {
popup.remove();
popup = null;
}
}
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\",]/g, '\\$&');
function isStartOfSentence(index, fullText) {
if (index === 0) return true;
const before = fullText.slice(0, index);
if (/^\s*$/.test(before)) return true;
const trimmed = before.replace(/\s+$/, '');
if (/[.!?…]["”’')\]]*$/.test(trimmed)) return true;
if (/[\n\r]\s*$/.test(before)) return true;
if (/["“”'‘(\[]\s*$/.test(before)) return true;
if (/["“”]$/.test(before)) return true;
if (/Chapter\s+\d+:\s*,?\s*$/.test(before)) return true;
return false;
}
function isInsideDialogueAtIndex(text, index) {
const quoteChars = `"'""''`;
let quoteCount = 0;
for (let i = 0; i < index; i++) {
if (quoteChars.includes(text[i])) {
quoteCount++;
}
}
return quoteCount % 2 === 1;
}
function applyPreserveCapital(orig, replacement) {
if (!orig) return replacement;
if (orig[0] >= 'A' && orig[0] <= 'Z') {
return replacement.charAt(0).toUpperCase() + replacement.slice(1);
}
return replacement;
}
function buildIgnoreRegex(from, ignoreTerm, entry, wildcardSymbol) {
const flags = entry.ignoreCapital ? 'gi' : 'g';
let basePattern = escapeRegex(from).replace(new RegExp(`\\${wildcardSymbol}`, 'g'), '.');
if (entry.noTrailingSpace) {
basePattern = basePattern.trim();
}
if (ignoreTerm) {
return new RegExp(
basePattern + `(?![\\s"“”'’,.-]+${escapeRegex(ignoreTerm)})`,
flags
);
} else {
return new RegExp(basePattern, flags);
}
}
function applyReplacements(text, replacements) {
let replacedText = text;
const WILDCARD = '@';
const punctuationRegex = /^[\W_'"“”‘’„,;:!?~()\[\]{}<>【】「」『』()《》〈〉—–-]|[\W_'"“”‘’„,;:!?~()\[\]{}<>【】「」『』()《》〈〉—–-]$/;
const quoteChars = `"'“”‘’`;
for (const entry of replacements) {
if (!entry.from || !entry.to) continue;
const flags = entry.ignoreCapital ? 'gi' : 'g';
let searchTerm = entry.from;
if (entry.noTrailingSpace) searchTerm = searchTerm.trimEnd();
let ignoreTerm = null;
const prefixMatch = searchTerm.match(/^\|(.*?)\|\s*(.+)$/);
const suffixMatch = searchTerm.match(/^(.*?)\s*\|(.*?)\|$/);
if (prefixMatch) {
ignoreTerm = { type: 'before', value: prefixMatch[1] };
searchTerm = prefixMatch[2];
} else if (suffixMatch) {
ignoreTerm = { type: 'after', value: suffixMatch[2] };
searchTerm = suffixMatch[1];
}
if (quoteChars.includes(searchTerm.charAt(0))) {
searchTerm = `[${quoteChars}]` + escapeRegex(searchTerm.slice(1));
} else {
searchTerm = escapeRegex(searchTerm);
}
const caretCountFrom = (entry.from.match(/\^/g) || []).length;
const caretCountTo = (entry.to.match(/\^/g) || []).length;
const usePlaceholder = caretCountFrom === 1 && caretCountTo === 1;
let base = usePlaceholder ? searchTerm.replace('\\^', '([^\\s])') : searchTerm.replace(new RegExp(`\\${WILDCARD}`, 'g'), '.');
const firstChar = entry.from.charAt(0);
const lastChar = entry.from.charAt(entry.from.length - 1);
const skipBoundaries = punctuationRegex.test(firstChar) || punctuationRegex.test(lastChar);
let patternStr = (entry.allInstances || skipBoundaries)
? base
: `(?<=^|[^A-Za-z0-9])${base}(?=[^A-Za-z0-9]|$)`;
if (ignoreTerm && ignoreTerm.value) {
const escapedIgnore = escapeRegex(ignoreTerm.value);
if (ignoreTerm.type === 'before') {
patternStr = `(?<!${escapedIgnore})${patternStr}`;
} else if (ignoreTerm.type === 'after') {
patternStr = `${patternStr}(?!${escapedIgnore}\\s*)`;
} else {
patternStr = `(?<!${escapedIgnore})${patternStr}(?!${escapedIgnore}\\s*)`;
}
}
const regex = new RegExp(patternStr, flags);
let newText = '';
let lastIndex = 0;
let match;
while ((match = regex.exec(replacedText)) !== null) {
const idx = match.index;
const insideDialogue = isInsideDialogueAtIndex(replacedText, idx);
if ((entry.insideDialogueOnly && !insideDialogue) || (entry.outsideDialogueOnly && insideDialogue)) continue;
newText += replacedText.slice(lastIndex, idx);
let replacementBase = entry.noTrailingSpace ? entry.to.trimEnd() : entry.to;
if (usePlaceholder && match[1] !== undefined) replacementBase = replacementBase.replace('^', match[1]);
const startSentence = entry.startOfSentence && isStartOfSentence(idx, replacedText);
const finalReplacement = startSentence
? (entry.preserveFirstCapital
? applyPreserveCapital(match[0], replacementBase)
: replacementBase.charAt(0).toUpperCase() + replacementBase.slice(1))
: (entry.preserveFirstCapital
? applyPreserveCapital(match[0], replacementBase)
: replacementBase);
newText += finalReplacement;
lastIndex = idx + match[0].length;
}
if (lastIndex < replacedText.length) newText += replacedText.slice(lastIndex);
replacedText = newText;
}
return replacedText;
}
function replaceTextInChapter() {
const seriesId = (() => {
const urlMatch = location.href.match(/\/novel\/(\d+)\//i);
if (urlMatch) return urlMatch[1];
const crumb = document.querySelector('.breadcrumb li.breadcrumb-item a[href*="/novel/"]');
if (crumb) {
const crumbMatch = crumb.href.match(/\/novel\/(\d+)\//i);
if (crumbMatch) return crumbMatch[1];
}
return null;
})();
let replacements = [];
for (const key in data) {
if (key === 'global' || (seriesId && key === `series-${seriesId}`)) {
replacements = replacements.concat(
data[key].filter(e => e.enabled)
);
}
}
if (replacements.length === 0) return false;
const targets = document.querySelectorAll('*');
let replacedAny = false;
targets.forEach(container => {
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while ((node = walker.nextNode())) {
if (!node.nodeValue || !node.nodeValue.trim()) continue;
let newText = applyReplacements(node.nodeValue, replacements);
if (newText !== node.nodeValue) {
node.nodeValue = newText;
replacedAny = true;
}
}
});
if (replacedAny) console.log('Simple replacements done.');
return replacedAny;
}
function runReplacementMultiple(times = 1, delay = 100) {
let count = 0;
function nextPass() {
replaceTextInChapter();
count++;
if (count < times) {
setTimeout(nextPass, delay);
}
}
nextPass();
}
(function () {
let lastUrl = location.href;
function checkUrlChange() {
const currentUrl = location.href;
if (currentUrl !== lastUrl) {
lastUrl = currentUrl;
runReplacementMultiple(1, 100);
removeExtraPencils();
applyDataHashReplacements();
}
}
const originalPushState = history.pushState;
history.pushState = function () {
originalPushState.apply(this, arguments);
window.dispatchEvent(new Event("locationchange"));
};
const originalReplaceState = history.replaceState;
history.replaceState = function () {
originalReplaceState.apply(this, arguments);
window.dispatchEvent(new Event("locationchange"));
};
window.addEventListener("popstate", () => window.dispatchEvent(new Event("locationchange")));
window.addEventListener("locationchange", checkUrlChange);
runReplacementMultiple(1, 100);
applyDataHashReplacements(1,100);
})();
function openPopup() {
if (popup) return;
popup = document.createElement('div');
Object.assign(popup.style, {
position: 'fixed',
bottom: '70px',
right: '10px',
width: '100vw',
maxWidth: '370px',
height: 'auto',
maxHeight: 'none',
backgroundColor: '#fff',
border: '1px solid #aaa',
padding: '15px',
boxShadow: '0 0 15px rgba(0,0,0,0.2)',
overflow: 'visible',
zIndex: '100000',
fontFamily: 'Arial, sans-serif',
fontSize: '14px',
});
document.body.appendChild(popup);
const toggleListBtn = document.createElement('button');
toggleListBtn.textContent = 'List';
toggleListBtn.style.marginBottom = '1px';
toggleListBtn.style.display = 'block';
toggleListBtn.style.color = 'black'
styleButton(toggleListBtn);
popup.appendChild(toggleListBtn);
const infoBtn = document.createElement('button');
infoBtn.textContent = 'Info';
infoBtn.style.marginLeft = '6px';
infoBtn.style.padding = '5px 10px';
infoBtn.style.alignSelf = 'flex-start';
infoBtn.style.color = 'black';
styleButton(infoBtn);
const topBtnContainer = document.createElement('div');
topBtnContainer.style.display = 'flex';
topBtnContainer.style.alignItems = 'center';
topBtnContainer.appendChild(toggleListBtn);
topBtnContainer.appendChild(infoBtn);
popup.appendChild(topBtnContainer);
const infoBox = document.createElement('div');
Object.assign(infoBox.style, {
maxHeight: '0',
overflow: 'hidden',
backgroundColor: '#fff',
color: 'black',
border: '1px solid #000',
padding: '0 10px',
marginTop: '10px',
fontSize: '13px',
maxHeightWhenOpen: '200px',
lineHeight: '1.4',
overflowY: 'auto',
transition: 'max-height 0.3s ease, padding 0.3s ease',
});
infoBox.innerHTML = `
<div style="padding:10px 0;">
<strong>Replacement System Info:</strong>
<ul style="margin:5px 0; padding-left:18px;">
<li><strong>Ignore Capital:</strong> Match case-insensitively.</li>
<li><strong>Start of Sentence:</strong> Only capitalize if the word starts a sentence.</li>
<li><strong>Fuzzy Match:</strong> Ignore boundaries, match anywhere.</li>
<li><strong>Preserve Capital:</strong> Keep first letter capitalized if original was capitalized.</li>
<li><strong>No Trailing Space:</strong> Trim trailing space in replacement.</li>
<li><strong>Inside Dialogue Only:</strong> Replace only inside quotation marks.</li>
<li><strong>Outside Dialogue Only:</strong> Replace only outside quotation marks.</li>
<li><strong>Global:</strong> Makes the entry apply to all novels.</li>
<li><strong>|ignore this|:</strong> Use before or after a word to ignore specific matches. Example: <code>|ignore |term</code> or <code>term| ignore|</code>. Spaces must be inside the <code>||</code>.</li>
<li><strong>@ wildcard:</strong> Any character substitution. Example: <code>fr@t</code> replaces fret, frat, frit, etc.</li>
<li><strong>^ special placeholder:</strong> Use <code>^</code> in Find like <code>Th^t</code> and in Replace like <code>Br^</code>. The character at <code>^</code> in Find will be preserved in the replacement.</li>
<li><strong>Edit Entries:</strong> Use 'Show List', tap an entry to make edits and change the series ID. By default, it will be applied only to whatever novel you're on currently. If you entered a term while in Library, it will default to an empty series ID, which is global.</li>
<li>The Show Note requires you have go to the term editor in the Show List, but this is a fragile feature, I don't recommend it.<li>
<li><strong>Raws:</strong> Match the raw text. You can copy the raw from popover, only works on the clickable terms.</li>
</ul>
</div>
`;
popup.appendChild(infoBox);
infoBtn.addEventListener('click', (e) => {
if (infoBox.style.maxHeight && infoBox.style.maxHeight !== '0px') {
infoBox.style.maxHeight = '0';
infoBox.style.padding = '0 10px';
} else {
infoBox.style.maxHeight = '200px';
infoBox.style.padding = '10px';
}
e.stopPropagation();
});
document.addEventListener('click', (e) => {
if (!infoBox.contains(e.target) && e.target !== infoBtn) {
infoBox.style.maxHeight = '0';
infoBox.style.padding = '0 10px';
}
});
const invertBtn = document.createElement('button');
invertBtn.textContent = 'Invert';
invertBtn.style.marginLeft = '6px';
invertBtn.style.padding = '5px 10px';
invertBtn.style.alignSelf = 'flex-start';
invertBtn.style.color = '#000';
styleButton(invertBtn);
topBtnContainer.appendChild(invertBtn);
let isInverted = localStorage.getItem('replacementUIInverted') === 'true';
function applyInversion(state) {
isInverted = state;
localStorage.setItem('replacementUIInverted', String(state));
const colour = isInverted ? '#fff' : '#000';
const bg = isInverted ? '#000' : '#fff';
if (!popup) return;
popup.style.backgroundColor = bg;
popup.style.color = colour;infoBox && (infoBox.style.color = colour);
popup.querySelectorAll('*').forEach(el => {
const tag = el.tagName;
if (tag === 'BUTTON' || tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') {
el.style.backgroundColor = bg;
el.style.color = colour;} else {
el.style.color = colour;
}
});
topBtnContainer.querySelectorAll('button').forEach(btn => {
btn.style.backgroundColor = bg;
btn.style.color = colour;});
}
applyInversion(isInverted);
invertBtn.addEventListener('click', () => {
applyInversion(!isInverted);
});
const rulesContainer = document.createElement('div');
rulesContainer.style.display = 'flex';
rulesContainer.style.flexWrap = 'wrap';
rulesContainer.style.gap = '10px';
rulesContainer.style.alignItems = 'center';
rulesContainer.style.marginBottom = '10px';
const currentFlags = {
ignoreCapital: false,
startOfSentence: false,
allInstances: false,
preserveFirstCapital: false,
global: false,
noTrailingSpace: false,
insideDialogueOnly: false,
outsideDialogueOnly: false,
};
function createCheckbox(flagKey, labelText) {
const label = document.createElement('label');
label.style.userSelect = 'none';
label.style.fontSize = '13px';
label.style.display = 'flex';
label.style.alignItems = 'center';
label.style.gap = '4px';
label.style.whiteSpace = 'nowrap';
label.style.flex = '0 1 auto';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = currentFlags[flagKey];
input.style.cursor = 'pointer';
input.addEventListener('change', () => {
currentFlags[flagKey] = input.checked;
});
label.appendChild(input);
label.appendChild(document.createTextNode(labelText));
return label;
}
rulesContainer.appendChild(createCheckbox('ignoreCapital', 'Ignore Capital'));
rulesContainer.appendChild(createCheckbox('startOfSentence', 'Start of Sentence'));
rulesContainer.appendChild(createCheckbox('allInstances', 'Fuzzy Match'));
rulesContainer.appendChild(createCheckbox('preserveFirstCapital', 'Preserve Capital'));
rulesContainer.appendChild(createCheckbox('global', 'Global'));
rulesContainer.appendChild(createCheckbox('noTrailingSpace', 'No Trailing Space'));
rulesContainer.appendChild(createCheckbox('insideDialogueOnly', 'Edit Inside Dialogue'));
rulesContainer.appendChild(createCheckbox('outsideDialogueOnly', 'Edit Outside Dialogue'));
popup.appendChild(rulesContainer);
const listUIContainer = document.createElement('div');
listUIContainer.style.display = 'none';
popup.appendChild(listUIContainer);
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.placeholder = 'Search terms...';
searchInput.style.width = '100%';
searchInput.style.marginBottom = '10px';
listUIContainer.appendChild(searchInput);
const toggleFilter = document.createElement('select');
['Global'].forEach(optText => {
const option = document.createElement('option');
option.textContent = optText;
toggleFilter.appendChild(option);
});
toggleFilter.style.width = '100%';
toggleFilter.style.marginBottom = '10px';
listUIContainer.appendChild(toggleFilter);
const btnContainer = document.createElement('div');
btnContainer.style.marginBottom = '10px';
btnContainer.style.textAlign = 'right';
const listContainer = document.createElement('div');
listContainer.style.maxHeight = '260px';
listContainer.style.overflowY = 'auto';
listContainer.style.borderTop = '1px solid #ddd';
listContainer.style.paddingTop = '8px';
listUIContainer.appendChild(listContainer);
function styleButton(btn) {
btn.style.padding = '5px 12px';
btn.style.fontSize = '13px';
btn.style.cursor = 'pointer';
btn.style.border = '1px solid #888';
btn.style.borderRadius = '4px';
btn.style.backgroundColor = '#eee';
btn.style.userSelect = 'none';
}
toggleListBtn.addEventListener('click', () => {
const isShowing = listUIContainer.style.display !== 'none';
if (!isShowing) {
listUIContainer.style.display = 'block';
rulesContainer.style.display = 'none';
toggleListBtn.textContent = 'List';
renderList();
} else {
listUIContainer.style.display = 'none';
rulesContainer.style.display = 'flex';
toggleListBtn.textContent = 'List';
}
});
function getCurrentSeriesId() {
const urlMatch = location.href.match(/\/novel\/(\d+)\//i);
if (urlMatch) return urlMatch[1];
const crumb = document.querySelector('.breadcrumb li.breadcrumb-item a[href*="/novel/"]');
if (crumb) {
const crumbMatch = crumb.href.match(/\/novel\/(\d+)\//i);
if (crumbMatch) return crumbMatch[1];
}
return null;
}
function renderList() {
listContainer.innerHTML = '';
const seriesId = getCurrentSeriesId();
let keysToShow = [];
if (toggleFilter.value === 'All') {
if (seriesId) keysToShow = [`series-${seriesId}`];
else keysToShow = [];
} else if (toggleFilter.value === 'Global + Others') {
keysToShow = Object.keys(data).filter(k => k !== `series-${seriesId}`);
} else {
keysToShow = Object.keys(data);
}
let allEntries = [];
keysToShow.forEach(key => {
if (data[key]) allEntries = allEntries.concat(data[key]);
});
const searchLower = searchInput.value.trim().toLowerCase();
if (searchLower) {
allEntries = allEntries.filter(e =>
(e.from && e.from.toLowerCase().includes(searchLower)) ||
(e.to && e.to.toLowerCase().includes(searchLower))
);
}
if (allEntries.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.textContent = 'No terms found.';
emptyMsg.style.fontStyle = 'italic';
listContainer.appendChild(emptyMsg);
return;
}
allEntries.forEach((entry) => {
const row = document.createElement('div');
row.style.display = 'flex';
row.style.alignItems = 'flex-start';
row.style.justifyContent = 'space-between';
row.style.marginBottom = '6px';
row.style.width = '100%';
const textContainer = document.createElement('div');
textContainer.style.display = 'flex';
textContainer.style.flexDirection = 'row';
textContainer.style.flexWrap = 'wrap';
textContainer.style.flexGrow = '1';
textContainer.style.minWidth = '0';
const fromSpan = document.createElement('span');
fromSpan.textContent = entry.from;
fromSpan.style.cursor = 'pointer';
fromSpan.style.userSelect = 'none';
fromSpan.style.color = '#007bff';
fromSpan.style.wordBreak = 'break-word';
fromSpan.style.overflowWrap = 'anywhere';
fromSpan.addEventListener('click', () => {
openEditDialog(entry);
});
const toSpan = document.createElement('span');
toSpan.textContent = ' → ' + entry.to;
toSpan.style.marginLeft = '8px';
toSpan.style.wordBreak = 'break-word';
toSpan.style.overflowWrap = 'anywhere';
textContainer.appendChild(fromSpan);
textContainer.appendChild(toSpan);
const controls = document.createElement('div');
controls.style.display = 'flex';
controls.style.alignItems = 'center';
controls.style.flexShrink = '0';
controls.style.marginLeft = '12px';
const enabledCheckbox = document.createElement('input');
enabledCheckbox.type = 'checkbox';
enabledCheckbox.checked = entry.enabled ?? true;
enabledCheckbox.title = 'Enable / Disable this replacement';
enabledCheckbox.style.marginRight = '8px';
enabledCheckbox.addEventListener('change', () => {
entry.enabled = enabledCheckbox.checked;
saveData(data);
replaceTextInChapter();
});
const deleteBtn = document.createElement('button');
deleteBtn.textContent = '✕';
styleButton(deleteBtn);
deleteBtn.title = 'Delete this replacement';
deleteBtn.addEventListener('click', () => {
deleteEntry(entry);
});
controls.appendChild(enabledCheckbox);
controls.appendChild(deleteBtn);
row.appendChild(textContainer);
row.appendChild(controls);
listContainer.appendChild(row);
});
}
function deleteEntry(entry) {
for (const key in data) {
const arr = data[key];
const idx = arr.findIndex(e => e.from === entry.from);
if (idx >= 0) {
arr.splice(idx, 1);
if (arr.length === 0 && key !== 'global') {
delete data[key];
}
saveData(data);
renderList();
replaceTextInChapter();
break;
}
}
}
function openEditDialog(entry) {
const modalBg = document.createElement('div');
Object.assign(modalBg.style, {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
zIndex: 100001,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
const modal = document.createElement('div');
Object.assign(modal.style, {
backgroundColor: isInverted ? '#000' : 'white',
color: isInverted ? '#fff' : '#000',
padding: '20px',
borderRadius: '8px',
width: '320px',
boxShadow: '0 0 15px rgba(0,0,0,0.3)',
fontSize: '14px',
});
modalBg.appendChild(modal);
const title = document.createElement('h3');
title.textContent = 'Edit Replacement';
title.style.marginTop = '0';
modal.appendChild(title);
const fromLabel = document.createElement('label');
fromLabel.textContent = 'Find: ';
const fromInput = document.createElement('input');
fromInput.type = 'text';
fromInput.value = entry.from;
fromInput.style.width = '100%';
fromInput.required = true;
fromLabel.appendChild(fromInput);
modal.appendChild(fromLabel);
modal.appendChild(document.createElement('br'));
const toLabel = document.createElement('label');
toLabel.textContent = 'Replace with: ';
const toInput = document.createElement('input');
toInput.type = 'text';
toInput.value = entry.to;
toInput.style.width = '100%';
toLabel.appendChild(toInput);
modal.appendChild(toLabel);
modal.appendChild(document.createElement('br'));
function openNoteModal(entry, buttonRef) {
const noteModalBg = document.createElement('div');
Object.assign(noteModalBg.style, {
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
backgroundColor: 'rgba(0,0,0,0.4)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 999999,
});
const noteModal = document.createElement('div');
Object.assign(noteModal.style, {
backgroundColor: 'black',
color: 'black',
padding: '20px',
borderRadius: '8px',
width: '280px',
boxShadow: '0 0 15px rgba(0,0,0,0.3)',
fontSize: '14px',
});
noteModalBg.appendChild(noteModal);
const noteTitle = document.createElement('h3');
noteTitle.textContent = 'Add Note';
noteTitle.style.marginTop = '0';
noteModal.appendChild(noteTitle);
noteTitle.style.color = 'white';
const noteInput = document.createElement('textarea');
noteInput.rows = 3;
noteInput.maxLength = 30;
noteInput.value = entry.note || '';
noteInput.style.width = '100%';
noteInput.placeholder = 'Enter a short note';
noteModal.appendChild(noteInput);
const noteSave = document.createElement('button');
noteSave.textContent = 'Save';
noteSave.style.marginRight = '10px';
const noteCancel = document.createElement('button');
noteCancel.textContent = 'Cancel';
noteModal.appendChild(noteSave);
noteModal.appendChild(noteCancel);
document.body.appendChild(noteModalBg);
noteSave.addEventListener('click', () => {
entry.note = noteInput.value.trim().slice(0, 30);
if (buttonRef) buttonRef.textContent = entry.note ? 'Edit Note' : 'Add Note';
document.body.removeChild(noteModalBg);
});
noteCancel.addEventListener('click', () => {
document.body.removeChild(noteModalBg);
});
}
const enabledLabel = document.createElement('label');
const enabledInput = document.createElement('input');
enabledInput.type = 'checkbox';
enabledInput.checked = entry.enabled ?? true;
enabledLabel.appendChild(enabledInput);
enabledLabel.append(' Enabled');
enabledLabel.style.userSelect = 'none';
modal.appendChild(enabledLabel);
modal.appendChild(document.createElement('br'));
const flags = [
{ key: 'ignoreCapital', label: 'Ignore Capitalization' },
{ key: 'startOfSentence', label: 'Match Whether Start of Sentence' },
{ key: 'allInstances', label: 'Fuzzy Match' },
{ key: 'preserveFirstCapital', label: 'Preserve First Capital Letter' },
{ key: 'noTrailingSpace', label: 'No Trailing Space' },
{ key: 'insideDialogueOnly', label: 'Edit Only Inside Dialogue' },
{ key: 'outsideDialogueOnly', label: 'Edit Only Outside Dialogue' },
];
flags.forEach(f => {
const flagLabel = document.createElement('label');
const flagInput = document.createElement('input');
flagInput.type = 'checkbox';
flagInput.checked = entry[f.key] ?? false;
flagLabel.appendChild(flagInput);
flagLabel.append(' ' + f.label);
flagLabel.style.display = 'block';
flagLabel.style.userSelect = 'none';
modal.appendChild(flagLabel);
flagInput.addEventListener('change', () => {
entry[f.key] = flagInput.checked;
});
});
modal.appendChild(document.createElement('br'));
const seriesLabel = document.createElement('label');
seriesLabel.textContent = 'Series ID (empty = global): ';
const seriesInput = document.createElement('input');
seriesInput.type = 'text';
seriesInput.value = entry.series || '';
seriesInput.style.width = '100%';
seriesLabel.appendChild(seriesInput);
modal.appendChild(seriesLabel);
modal.appendChild(document.createElement('br'));
const btnSave = document.createElement('button');
btnSave.textContent = 'Save';
btnSave.style.marginRight = '10px';
const btnCancel = document.createElement('button');
btnCancel.textContent = 'Cancel';
modal.appendChild(btnSave);
modal.appendChild(btnCancel);
btnSave.addEventListener('click', () => {
let f = fromInput.value;
const t = toInput.value;
if (entry.noTrailingSpace) {
f = f.trim();
}
const oldSeriesKey = entry.series ? `series-${entry.series}` : 'global';
const newSeriesKey = seriesInput.value ? `series-${seriesInput.value}` : 'global';
if (oldSeriesKey !== newSeriesKey) {
if (data[oldSeriesKey]) {
const idx = data[oldSeriesKey].indexOf(entry);
if (idx >= 0) data[oldSeriesKey].splice(idx, 1);
if (data[oldSeriesKey].length === 0 && oldSeriesKey !== 'global') {
delete data[oldSeriesKey];
}
}
if (!data[newSeriesKey]) data[newSeriesKey] = [];
data[newSeriesKey].push(entry);
entry.series = seriesInput.value.trim();
}
entry.from = f;
entry.to = t;
entry.enabled = enabledInput.checked;
saveData(data);
renderList();
replaceTextInChapter();
closeEditModal();
});
btnCancel.addEventListener('click', () => {
closeEditModal();
});
function closeEditModal() {
modalBg.remove();
}
document.body.appendChild(modalBg);
}
const addNewLabel = document.createElement('div');
addNewLabel.textContent = 'Add New Replacement:';
addNewLabel.style.marginTop = '15px';
addNewLabel.style.fontWeight = 'bold';
popup.appendChild(addNewLabel);
const inputContainer = document.createElement('div');
inputContainer.style.display = 'flex';
inputContainer.style.gap = '6px';
inputContainer.style.marginTop = '6px';
inputContainer.style.flexWrap = 'nowrap';
inputContainer.style.alignItems = 'center';
const fromInputNew = document.createElement('input');
fromInputNew.placeholder = 'Find';
fromInputNew.style.flex = '1';
fromInputNew.style.minWidth = '60px';
inputContainer.appendChild(fromInputNew);
const toInputNew = document.createElement('input');
toInputNew.placeholder = 'Replace with';
toInputNew.style.flex = '1';
toInputNew.style.minWidth = '60px';
inputContainer.appendChild(toInputNew);
const replaceSuggestionBox = document.createElement('ul');
Object.assign(replaceSuggestionBox.style, {
position: 'absolute',
zIndex: 9999,
border: '1px solid #ccc',
background: '#000',
color: '#fff',
listStyle: 'none',
margin: 0,
padding: 0,
maxHeight: '120px',
overflowY: 'auto',
display: 'none',
opacity: '1',
});
inputContainer.appendChild(replaceSuggestionBox);
function positionReplaceBox() {
replaceSuggestionBox.style.display = 'block';
const rect = toInputNew.getBoundingClientRect();
const containerRect = inputContainer.getBoundingClientRect();
replaceSuggestionBox.style.left = (toInputNew.offsetLeft) + 'px';
replaceSuggestionBox.style.top = (toInputNew.offsetTop - replaceSuggestionBox.offsetHeight) + 'px';
}
toInputNew.addEventListener('input', () => {
const val = toInputNew.value.trim().toLowerCase();
replaceSuggestionBox.innerHTML = '';
if (val.length < 2) {
replaceSuggestionBox.style.display = 'none';
return;
}
const allTerms = Object.values(data)
.flat()
.map(entry => entry.to)
.filter((v, i, self) => v && self.indexOf(v) === i);
const matches = allTerms.filter(term => term.toLowerCase().includes(val));
if (!matches.length) {
replaceSuggestionBox.style.display = 'none';
return;
}
matches.forEach(term => {
const li = document.createElement('li');
li.textContent = term;
li.style.padding = '4px 6px';
li.style.cursor = 'pointer';
li.style.background = '#000';
li.style.color = '#fff';
li.addEventListener('mousedown', (e) => {
e.preventDefault();
toInputNew.value = term;
replaceSuggestionBox.style.display = 'none';
});
li.addEventListener('mouseover', () => {
li.style.background = '#111';
});
li.addEventListener('mouseout', () => {
li.style.background = '#000';
});
replaceSuggestionBox.appendChild(li);
});
positionReplaceBox();
replaceSuggestionBox.style.display = 'block';
});
document.addEventListener('click', (e) => {
if (!inputContainer.contains(e.target)) {
replaceSuggestionBox.style.display = 'none';
}
});
const addBtn = document.createElement('button');
addBtn.textContent = 'Add';
styleButton(addBtn);
addBtn.addEventListener('click', () => {
let f = fromInputNew.value;
const t = toInputNew.value;
const noTrailingSpaceChecked = document.querySelector('#noTrailingSpaceCheckboxId')?.checked;
if (noTrailingSpaceChecked) {
f = f.trim();
}
if (!f) {
alert('Find term cannot be empty');
return;
}
const seriesId = currentFlags.global ? '' : getCurrentSeriesId();
const seriesKey = seriesId ? `series-${seriesId}` : 'global';
if (!data[seriesKey]) data[seriesKey] = [];
if (data[seriesKey].some(e => e.from.toLowerCase() === f.toLowerCase())) {
alert('This find term already exists in this series/global.');
return;
}
data[seriesKey].push({
from: f,
to: t,
note: '',
enabled: true,
ignoreCapital: currentFlags.ignoreCapital,
startOfSentence: currentFlags.startOfSentence,
allInstances: currentFlags.allInstances,
preserveFirstCapital: currentFlags.preserveFirstCapital,
series: seriesId || '',
noTrailingSpace: currentFlags.noTrailingSpace,
insideDialogueOnly: currentFlags.insideDialogueOnly,
outsideDialogueOnly: currentFlags.outsideDialogueOnly,
});
saveData(data);
fromInputNew.value = '';
toInputNew.value = '';
renderList();
replaceTextInChapter();
});
inputContainer.appendChild(fromInputNew);
inputContainer.appendChild(toInputNew);
inputContainer.appendChild(addBtn);
popup.appendChild(inputContainer);
exportBtn.addEventListener('click', () => {
const dataStr = JSON.stringify(data, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'word-replacer-data.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
importBtn.addEventListener('click', () => {
const inputFile = document.createElement('input');
inputFile.type = 'file';
inputFile.accept = '.json,.txt';
inputFile.addEventListener('change', (e) => {
if (!e.target.files.length) return;
const file = e.target.files[0];
const reader = new FileReader();
function importData(parsed) {
if (typeof parsed === 'object' && !Array.isArray(parsed)) {
for (const key in parsed) {
if (!data[key]) data[key] = [];
parsed[key].forEach(newEntry => data[key].push(newEntry));
}
} else if (Array.isArray(parsed)) {
if (!data.global) data.global = [];
const newPairs = parsed.map(pair => {
if (!Array.isArray(pair) || pair.length < 2) return null;
return {
from: pair[0],
to: pair[1],
enabled: true,
startOfSentence: false,
ignoreCapital: false,
allInstances: false,
preserveFirstCapital: false,
global: true,
seriesId: ''
};
}).filter(Boolean);
data.global.push(...newPairs);
} else {
alert('Import failed: unsupported format.');
return;
}
saveData(data);
renderList();
replaceTextInChapter();
}
reader.onload = (e) => {
try {
const parsed = JSON.parse(e.target.result);
importData(parsed);
alert('Import successful!');
} catch (err) {
alert('Invalid JSON: ' + err.message);
}
};
reader.readAsText(file);
});
inputFile.click();
});
searchInput.addEventListener('input', renderList);
toggleFilter.addEventListener('change', renderList);
renderList();
document.body.appendChild(popup);
}
startReplaceLoop();
})();