User:Jack who built the house/editHere.js

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/** Edit Here **/

// Edit sections of a page without leaving the article
// Based on [[en:w:User:BrandonXLF/QuickEdit]] by [[en:w:User:BrandonXLF]]

(function () {
	let mobile = mw.config.get('skin') === 'minerva';
	let titleRegexp = new RegExp(
		mw.config.get('wgArticlePath').replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\$1/, '([^?]+)') +
		'|[?&]title=([^&#]*)'
	);

	let apiSingleton;

	function api(func, params) {
		if (!apiSingleton) {
			apiSingleton = new mw.Api();
		}

		$.extend(params, {
			errorformat: 'html',
			errorlang: mw.config.get('wgUserLanguage'),
			errorsuselocal: true
		});

		return apiSingleton[func](params).fail(function (_, data) {
			mw.notify(apiSingleton.getErrorMessage(data), {
				type: 'error',
				tag: 'edithere'
			});
		});
	}

	function getPageInfo(title, sectionID) {
		return api('get', {
			action: 'query',
			curtimestamp: 1,
			prop: 'revisions',
			indexpageids: 1,
			titles: title,
			rvprop: ['timestamp', 'content'],
			rvslots: 'main',
			rvsection: sectionID
		}).then(function (res) {
			let rev = res.query.pages[res.query.pageids[0]].revisions[0];

			return {
				start: res.curtimestamp,
				base: rev.timestamp,
				full: rev.slots.main['*']
			};
		});
	}

	function getPreviewCallback(editor) {
		editor.children('.preview').remove();

		new OO.ui.ProgressBarWidget().$element.css({
			maxWidth: '100%',
			borderRadius: '0',
			boxShadow: 'none',
			margin: '8px 0'
		}).addClass('preview').appendTo(editor);

		return function (html) {
			editor.children('.preview').remove();

			const $preview = $('<div>')
				.html(html)
				.css({
					margin: '8px 0',
					border: '1px solid #a2a9b1',
					padding: '8px',
					overflowX: 'hidden'
				})
				.addClass('preview')
				.appendTo(editor);
			mw.hook('wikipage.content').fire($preview);
		};
	}

	function showCompare(editor, title, from, to) {
		mw.loader.load('mediawiki.diff.styles');

		api('post', {
			action: 'compare',
			fromslots: 'main',
			'fromtext-main': from,
			fromtitle: title,
			frompst: 'true',
			toslots: 'main',
			'totext-main': to,
			totitle: title,
			topst: 'true'
		}).then(function (r) {
			return r.compare['*'] ? $('<table>').addClass('diff').append(
				$('<colgroup>').append(
					$('<col>').addClass('diff-marker'),
					$('<col>').addClass('diff-content'),
					$('<col>').addClass('diff-marker'),
					$('<col>').addClass('diff-content')
				)
			).append(r.compare['*']) : 'No differences.';
		}).then(getPreviewCallback(editor));
	}

	// Parts taken from EditPage::extractSectionTitle and Parser::stripSectionName
	function getSectionSummary(text) {
		let match = text.match(/^(=+)(.+)\1\s*(\n|$)/);

		return !match ? '' : '/* ' + match[2].trim()
			// Strip internal link markup
			.replace(/\[\[:?([^[|]+)\|([^[]+)\]\]/g, '$2')
			.replace(/\[\[:?([^[]+)\|?\]\]/g, '$1')
			// Strip external link markup
			.replace(new RegExp('\\[(?:' + mw.config.get('wgUrlProtocols') + ')([^ ]+?) ([^\\[]+)\\]', 'ig'), '$2')
			// Remove wikitext quotes
			.replace(/(''|'''|''''')(?!')/g, '')
			// Strip HTML tags
			.replace(/<[^>]+?>/g, '') + ' */ ';
	}

	function showEditor($element) {
		let progress = new OO.ui.ProgressBarWidget();

		// https://www.mediawiki.org/wiki/Heading_HTML_changes
		// Cannot use .closest() because DiscussionTools nests an h2 within a .mw-heading
		let heading = $element.parents(':header, .mw-heading').last();

		let matcher = heading.nextUntil.bind(heading);
		let inserter = heading.after.bind(heading);
		let targetEl = $element.siblings('.edithere-target').last();
		let titleMatch = targetEl.attr('href').match(titleRegexp);
		let title = decodeURIComponent(titleMatch[1] || titleMatch[2]);
		let sectionID = /[?&]v?e?section=T?-?(\d*)/.exec(targetEl.attr('href'))[1];

		if (!heading.closest('.mw-parser-output').length) {
			let $articleContent = $('#mw-content-text .mw-parser-output');

			matcher = function (selector) {
				let $child = $articleContent.children(selector).first();

				if ($child.length) {
					return $child.prevAll();
				}
				return $articleContent.children();
			};
			inserter = $articleContent.prepend.bind($articleContent);
		}

		inserter(progress.$element.css({
			maxWidth: '100%',
			borderRadius: '0',
			boxShadow: 'none'
		}));

		$element.addClass('edithere-loading');
		$('.edithere-hide').removeClass('edithere-hide');
		$('.edithere-heading').removeClass('edithere-heading');
		$('#edithere-editor').remove();

		getPageInfo(title, sectionID).then(function (r) {
			var start = r.start,
				base = r.base,
				full = r.full,
				saving = false,
				expanded = false,
				remainderStart = full.match(/\n=+.+=+(?:\n|$)/),
				part = remainderStart ? full.substring(0, remainderStart.index) : full,
				remainder = remainderStart ? full.substring(remainderStart.index) : '',
				level = 0,
				editor;

			full.replace(/^(=+).+?(=+)(?:\n|$)/, function (m, a, b) {
				level = Math.min(a.length, b.length);
				return m;
			});

			var levelMatch = 'h1';
			for (var i = 2; i <= level; i++)
				levelMatch += ', h' + i + ':has(*), .mw-heading' + i;

			var partSection = matcher(':header:has(*), .mw-heading'),
				fullSection = matcher(levelMatch),
				textarea = new OO.ui.MultilineTextInputWidget({
					rows: 1,
					maxRows: 20,
					autosize: true,
					value: part
				}),
				summary = new OO.ui.TextInputWidget({
					value: getSectionSummary(part)
				}),
				minor = new OO.ui.CheckboxInputWidget(),
				save = new OO.ui.ButtonInputWidget({
					label: 'Save',
					title: 'Save your changes',
					flags: ['primary', 'progressive'],
					accessKey: 's'
				}),
				preview = new OO.ui.ButtonInputWidget({
					label: 'Preview',
					title: 'Preview the new wikitext'
				}),
				compare = new OO.ui.ButtonInputWidget({
					label: 'Compare',
					title: 'View the difference between the current revision and your revision'
				}),
				cancel = new OO.ui.ButtonInputWidget({
					useInputTag: true,
					label: 'Cancel',
					title: 'Close the edit form and discard changes',
					flags: ['secondary', 'destructive']
				}),
				more = new OO.ui.ButtonInputWidget({
					label: '+',
					title: 'Edit the entire section (including subsections)'
				}),
				buttons = new OO.ui.HorizontalLayout({
					items: [save, preview, compare, cancel]
				});

			if (part != full) {
				buttons.addItems([more], 3);
			}

			partSection.addClass('edithere-hide');
			heading.addClass('edithere-heading');
			$element.removeClass('edithere-loading');
			progress.$element.remove();
			textarea.$input.css({
				borderRadius: '0'
			});

			summary.on('enter', function () {
				save.emit('click');
			});

			save.on('click', function () {
				if (saving) return;

				var fullText = textarea.getValue() + (expanded ? '' : remainder);
				saving = true;
				save.setLabel('Saving...');
				compare.setDisabled(true);
				preview.setDisabled(true);
				cancel.setDisabled(true);
				more.setDisabled(true);

				api('postWithEditToken', {
					action: 'edit',
					title: title,
					section: sectionID,
					summary: summary.getValue(),
					text: fullText,
					minor: minor.isSelected() ? true : undefined,
					notminor: minor.isSelected() ? undefined : true,
					starttimestamp: start,
					basetimestamp: base
				}).then(function () {
					api('get', {
						action: 'parse',
						page: mw.config.get('wgPageName'),
						prop: ['text', 'categorieshtml']
					}).then(function (r) {
						var contentText = $('#mw-content-text'),
							catLinks = $('#catlinks');

						contentText.find('.mw-parser-output').replaceWith(r.parse.text['*']);
						mw.hook('wikipage.content').fire(contentText);

						catLinks.replaceWith(r.parse.categorieshtml['*']);
						mw.hook('wikipage.categories').fire(catLinks);

						saving = false;
					});
				}, function (code) {
					if (code == 'editconflict') {
						showEditConflict(editor, title, sectionID, fullText).then(function (r) {
							start = r.start;
							base = r.base;
							textarea = r.textarea;
							expanded = true;
						});
					}

					compare.setDisabled(false);
					preview.setDisabled(false);
					cancel.setDisabled(false);
					more.setDisabled(expanded);
					saving = false;
					save.setLabel('Save');
				});
			});

			preview.on('click', function () {
				api('post', {
					action: 'parse',
					title: title,
					prop: 'text',
					pst: 'true',
					disablelimitreport: 'true',
					disableeditsection: 'true',
					sectionpreview: 'true',
					disabletoc: 'true',
					text: textarea.getValue()
				}).then(function (r) {
					return r.parse.text['*'] + '<div style="clear:both;"></div>';
				}).then(getPreviewCallback(editor));
			});

			compare.on('click', function () {
				showCompare(editor, title, part + (expanded ? remainder : ''), textarea.getValue());
			});

			cancel.on('click', function () {
				editor.remove();
				heading.removeClass('edithere-heading');
				fullSection.removeClass('edithere-hide');
			});

			more.on('click', function () {
				expanded = true;
				textarea.setValue(textarea.getValue() + remainder);
				fullSection.addClass('edithere-hide');
				more.setDisabled(true);
			});

			editor = $('<div id="edithere-editor">').css({
				overflowX: 'hidden'
			}).append(
				$('<div>').css({
					backgroundColor: '#eaecf0',
					borderBottom: '1px solid #a2a9b1',
					marginBottom: '8px'
				}).append(
					textarea.$element.css({
						width: '100%',
						maxWidth: '100%',
						fontFamily: 'monospace, monospace'
					}).addClass('edithere-textarea'),
					$('<div>').css({
						border: '1px solid #a2a9b1',
						borderWidth: '0 1px'
					}).append(
						$('<div>').css({
							padding: '8px 4px 8px 8px',
							display: 'table-cell',
							verticalAlign: 'middle'
						}).html('Edit&nbsp;summary:'),
						summary.$element.css({
							width: '100%',
							maxWidth: '100%',
							padding: '8px 0px',
							display: 'table-cell',
							verticalAlign: 'middle'
						}),
						new OO.ui.FieldLayout(minor, {
							label: new OO.ui.HtmlSnippet('Minor&nbsp;edit?'),
							align: 'inline'
						}).$element.css({
							padding: '8px 8px 8px 4px',
							display: 'table-cell',
							verticalAlign: 'middle'
						})
					),
					buttons.$element.css({
						border: '1px solid #a2a9b1',
						borderWidth: '0 1px',
						padding: '0px 8px 0'
					}),
					title !== mw.config.get('wgPageName') ? $('<div>').css({
						border: '1px solid #a2a9b1',
						borderWidth: '0 1px',
						padding: '0px 8px 8px'
					}).append(
						'Editing page: ',
						$('<a>').attr('href', mw.config.get('wgArticlePath').replace('$1', title)).css({
							fontWeight: 'bold'
						}).text(title.replace(/_/g, ' '))
					) : undefined
				)
			);

			inserter(editor);

			// Fix cases when editing a section next to two floating images like at
			// https://en.wikipedia.org/w/index.php?title=Elevation&oldid=1235703526#Aviation
			textarea.updatePosition();
		}, function () {
			$element.removeClass('edithere-loading');
			progress.$element.remove();
		});
	}

	function showEditConflict(editor, title, sectionID, text) {
		return getPageInfo(title, sectionID).then(function (r) {
			var textarea = new OO.ui.MultilineTextInputWidget({
				rows: 1,
				maxRows: 20,
				autosize: true,
				value: r.full
			}),
				textarea2 = new OO.ui.MultilineTextInputWidget({
					rows: 1,
					maxRows: 20,
					autosize: true,
					value: text,
				});

			function syncSize() {
				textarea.styleHeight = -1;
				textarea.adjustSize(true);

				textarea2.styleHeight = -1;
				textarea2.adjustSize(true);

				var height = Math.max(textarea.$input.height(), textarea2.$input.height());
				textarea.$input.height(height);
				textarea2.$input.height(height);
			}

			textarea.$input.css({
				borderRadius: '0'
			});
			editor.find('> :first-child > :first-child').remove();

			$('<table>').css({
				width: '100%',
				border: '1px solid #a2a9b1',
				borderBottom: 'none',
				borderSpacing: '0',
				margin: '0 !important'
			}).append(
				$('<tr>').append(
					$('<th>').css({
						width: '50%',
						paddingTop: '4px'
					}).text('Their version (to be saved)'),
					$('<th>').css({
						width: '50%',
						paddingTop: '4px'
					}).text('Your version')
				),
				$('<tr>').append(
					$('<td>').css({
						width: '50%',
						padding: '4px 4px 0 8px'
					}).append(
						textarea.$element.css({
							width: '100%',
							maxWidth: '100%',
							fontFamily: 'monospace, monospace'
						})
					),
					$('<td>').css({
						width: '50%',
						padding: '4px 8px 0 4px'
					}).append(
						textarea2.$element.css({
							width: '100%',
							maxWidth: '100%',
							fontFamily: 'monospace, monospace'
						})
					)
				)
			).prependTo(editor.find('> :first-child'));

			textarea.on('change', syncSize);
			textarea2.on('change', syncSize);
			syncSize();
			showCompare(editor, title, text, r.full);

			r.textarea = textarea;
			return r;
		});
	}

	function clickHandler(event) {
		const $element = $(event.target).closest('.edithere-editlink');

		if (!$element.length || $element.hasClass('edithere-loading')) return;

		event.preventDefault();

		showEditor($element);
	}

	function addLinksToChildren($element) {
		$element.find('#edithere-editor, .edithere-section').remove();

		const $editHereLink = $('<a>')
			.addClass('edithere-section edithere-editlink')
			.attr('href', '#');
		if (mobile) {
			$editHereLink
				.addClass('edithere-icon edithere-minerva-icon')
				.attr('role', 'button')
				.attr('tabindex', '0')
				.append(
					createSvg(20, 20)
						.html('<path d="M20 14.4286L16.4 14.4286L16.4 7L14.6 7L14.6 14.4286L11 14.4286L15.5 20L20 14.4286Z" /><path fill-rule="evenodd" clip-rule="evenodd" d="M16.4 7.63119V7H15.7688L16.4 7.63119ZM14.6 10.1498L15.7094 9.04044L14.6 7.93109V10.1498ZM13.7688 5L18.4 5V6.31934L18.7092 6.00057C18.8955 5.81322 19 5.55978 19 5.2956C19 5.03143 18.8955 4.77799 18.7092 4.59063L15.3694 1.29078C15.182 1.10454 14.9286 1 14.6644 1C14.4002 1 14.1468 1.10454 13.9594 1.29078L11.9995 3.23069L13.7688 5ZM9.27453 15.4753L5.74979 19H1V14.2502L10.9596 4.29065L12.6 5.93109V12.1498L12.3212 12.4286H6.81374L9.27453 15.4753Z" />')
				);
		} else {
			$editHereLink.text('edit here');
		}
		$element.find('.mw-editsection').each(function () {
			$('[href*="section="]', this)
				.last()
				.after(
					mobile ? '' : '<span class="edithere-section"> | </span>',
					$editHereLink.clone(true)
				)
				.addClass('edithere-target');
		});
	}

	function createSvg(
		width,
		height,
		viewBoxWidth = width,
		viewBoxHeight = height
	) {
		return $(document.createElementNS('http://www.w3.org/2000/svg', 'svg'))
			.attr('width', width)
			.attr('height', height)
			.attr('viewBox', `0 0 ${viewBoxWidth} ${viewBoxHeight}`)
			.attr('aria-hidden', 'true')

			// https://en.wikipedia.org/wiki/Project:Dark_mode_(gadget)
			.addClass('mw-invert');
	}

	$.when(mw.loader.using(['oojs-ui-core']), $.ready).done(function () {
		const $body = $(document.body);

		$body.on('click', clickHandler);
		addLinksToChildren($body);
		mw.hook('wikipage.content').add(addLinksToChildren);
	});

	mw.loader.addStyleTag(`
.skin-minerva .mw-editsection {
	white-space: nowrap;
}

.skin-minerva .content .collapsible-heading .edithere-section {
	visibility: hidden;
}

.skin-minerva .content .collapsible-heading.open-block .edithere-section {
	visibility: visible;
}

.edithere-hide {
	display: none !important;
}

.edithere-loading,
.edithere-heading {
	color: #777;
}

.edithere-icon {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	background-image: none;

	svg {
		fill: currentcolor;
	}
}

.edithere-minerva-icon.edithere-minerva-icon {
	color: var(--color-subtle, #54595d);
	min-width: 44px;
	min-height: 44px;
	box-sizing: border-box;
	border: 1px solid transparent;
	border-radius: 2px;
}

.edithere-minerva-icon.edithere-minerva-icon:hover {
	background-color: var(--background-color-button-quiet--hover,rgba(0, 24, 73, .027));
}

.edithere-minerva-icon.edithere-minerva-icon:active {
	background-color: var(--background-color-button-quiet--active,rgba(0, 24, 73, .082));
	border-color: var(--border-color-interactive, #72777d);
}

.edithere-minerva-icon.edithere-minerva-icon:focus {
	outline: 1px solid transparent;
}

.edithere-minerva-icon.edithere-minerva-icon:focus:not(:active) {
	border-color: var(--border-color-progressive--focus, #36c);
	box-shadow: inset 0 0 0 1px var(--box-shadow-color-progressive--focus, #36c);
}`);
})();