Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
['edit', 'submit'].includes(mw.config.get('wgAction')) &&
$(async function () {
	let section = $('input[name="wpSection"]').val();
	if (section === 'new') return;
	if (section?.startsWith('T-')) {
		section = section.slice(2);
	}
	if (section === '0') {
		section = null;
	}
	let dependencies = [
		'jquery.textSelection', 'oojs-ui-core',
		'oojs-ui.styles.icons-editing-core'
	];
	if (section) {
		dependencies.push('mediawiki.api');
	}
	await mw.loader.using(dependencies);
	mw.loader.addStyleTag('.diff > tbody > tr{position:relative} .diffundo{position:absolute;inset-inline-end:0;bottom:0} tr:not(:hover) > td > .diffundo:not(:focus-within){opacity:0} .diffundo-undone{text-decoration:line-through;opacity:0.5}');
	let idxMap = new WeakMap(), offset = 0, rev;
	let handler = button => {
		let $row = button.$element.closest('tr');
		let numRow = $row.prevAll().get().find(row => idxMap.has(row));
		if (!numRow) {
			mw.notify(`Couldn't get the line number.`, {
				tag: 'diffundo',
				type: 'error'
			});
			return;
		}
		let isUndone = $row.hasClass('diffundo-undone');
		let $toReplace = $row.children(isUndone ? '.diff-deletedline' : '.diff-addedline');
		let $toRestore = $row.children(isUndone ? '.diff-addedline' : '.diff-deletedline');
		let isInsert = !$toReplace.length;
		let isRemove = !$toRestore.length;
		let $midLines = $row.prevUntil(numRow).map(function () {
			return this.querySelector(
				this.classList.contains('diffundo-undone')
					? ':scope > .diff-deletedline'
					: ':scope > .diff-context, :scope > .diff-addedline'
			);
		});
		let lineIdx = idxMap.get(numRow) + $midLines.length;
		let $textarea = $('#wpTextbox1');
		let lines = $textarea.textSelection('getContents').split('\n');
		let canUndo;
		if (isInsert) {
			canUndo = !$midLines.length ||
				lines[lineIdx - 1] === $midLines[0].textContent;
		} else {
			canUndo = lines[lineIdx] === $toReplace.text();
		}
		if (!canUndo) {
			mw.notify('The line has been modified since the diff.', {
				tag: 'diffundo',
				type: 'warn'
			});
			return;
		}
		let coords = [window.scrollX, window.scrollY];
		let [start, end] = $textarea.textSelection('getCaretPosition', { startAndEnd: true });
		let beforeLen = lines.slice(0, lineIdx).join('').length + lineIdx;
		if (isRemove) {
			let toReplaceLen = lines[lineIdx].length;
			lines.splice(lineIdx, 1);
			[start, end] = [start, end].map(idx => {
				if (idx > beforeLen + toReplaceLen) {
					return idx - toReplaceLen - 1;
				} else if (idx > beforeLen) {
					return beforeLen;
				}
				return idx;
			});
			$row.nextAll().each(function () {
				if (idxMap.has(this)) {
					idxMap.set(this, idxMap.get(this) - 1);
				}
			});
		} else if (isInsert) {
			let text = $toRestore.text();
			lines.splice(lineIdx, 0, text);
			[start, end] = [start, end].map(idx => {
				if (idx > beforeLen) {
					return idx + text.length + 1;
				}
				return idx;
			});
			$row.nextAll().each(function () {
				if (idxMap.has(this)) {
					idxMap.set(this, idxMap.get(this) + 1);
				}
			});
		} else {
			let toReplaceLen = lines[lineIdx].length;
			let text = $toRestore.text();
			lines.splice(lineIdx, 1, text);
			[start, end] = [start, end].map(idx => {
				if (idx > beforeLen + toReplaceLen) {
					return idx - (toReplaceLen - text.length);
				} else if (idx > beforeLen) {
					return beforeLen;
				}
				return idx;
			});
		}
		$textarea.textSelection('setContents', lines.join('\n'));
		$textarea.textSelection('setSelection', { start, end })
			.textSelection('scrollToCaretPosition');
		$row.toggleClass('diffundo-undone', !isUndone);
		window.scrollTo(...coords);
		setTimeout(() => {
			button.focus();
		});
	};
	let updateOffset = async () => {
		if (rev) {
			let { query } = await new mw.Api().get({
				action: 'query',
				titles: mw.config.get('wgPageName'),
				prop: 'info',
				formatversion: 2
			});
			if (query.pages[0].lastrevid === rev) return;
		}
		let { parse } = await new mw.Api().get({
			action: 'parse',
			page: mw.config.get('wgPageName'),
			prop: 'revid|sections|wikitext',
			formatversion: 2
		});
		let charOffset = parse.sections.find(s => s.index === section)?.byteoffset;
		if (!charOffset && charOffset !== 0) {
			mw.notify(`Couldn't get the section offset.`, {
				tag: 'diffundo',
				type: 'error'
			});
			return false;
		}
		offset = charOffset
			? [...parse.wikitext].slice(0, charOffset - 1).join('').split('\n').length
			: 0;
		rev = parse.revid;
	};
	mw.hook('wikipage.diff').add(async $diff => {
		let $lineNums = $diff.find('.diff-lineno:last-child');
		if (!$lineNums.length ||
			section && (await updateOffset() === false || !$diff[0].isConnected)
		) {
			return;
		}
		$lineNums.each(function () {
			let num = this.textContent.replace(/\D/g, '');
			if (!num) return;
			idxMap.set(this.parentElement, num - 1 - offset);
		});
		$diff.find('.diff-addedline, .diff-empty.diff-side-added').append(() => {
			let button = new OO.ui.ButtonWidget({
				classes: ['diffundo'],
				framed: false,
				icon: 'undo',
				title: 'Undo this line'
			});
			return button.on('click', handler, [button]).$element;
		});
	});
});