跳转到内容

User:Addonian1123/QuickEdit-core.js

维基百科,自由的百科全书
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
// Quick Edit
// Edit sections of a page without leaving the article
// Based on [[mw:User:QuickEdit]] by [[meta:Iniquity]]
// Documentation: [[User:Addonian1123/QuickEdit+]]
// Main: [[User:Addonian1123/QuickEdit.js]]

var _addText="{{工具警告|QuickEdit+}}";
var _addText="{{Notice|<big>'''文檔請見:[[User:Addonian1123/QuickEdit+]]'''</big>}}";
var _addText="{{Notice|<big>'''【QuickEdit+】組件'''</big><br>主JavaScript:[[User:Addonian1123/QuickEdit.js]]<br>Core核心:[[User:Addonian1123/QuickEdit-core.js]]<br>CSS樣式表:[[User:Addonian1123/QuickEdit-core.css]]<br><!--Config-未啟用:[[User:Addonian1123/QuickEdit-config.js]]<br>-->GUI設定:[[User:Addonian1123/QuickEdit-settings.js]]}}";

;(function () {
	const isMultiEdit = localStorage.getItem('qe_multi_edit') === 'true';
	const isEditNoticeEnabled = localStorage.getItem('qe_edit_notice') !== 'false';
	const isDebugEnabled = localStorage.getItem('qeDebug') === 'true';

	// 繁簡
	const userLang = mw.config.get('wgUserLanguage') || mw.config.get('wgUserVariant') || 'zh-hans';
	const isHans = ['zh-hans', 'zh-cn', 'zh-my', 'zh-sg'].some(l => userLang.toLowerCase().startsWith(l));

	const msg = {
		errorInitCodeMirror: isHans ? '[QuickEdit+] 初始化WikiEditor或CodeMirror失败:' : '[QuickEdit+] 初始化WikiEditor或CodeMirror失敗:',
		errorLoadModules: isHans ? '[QuickEdit+] 加载WikiEditor、CodeMirror或Wikificator失败:' : '[QuickEdit+] 載入WikiEditor、CodeMirror或Wikificator失敗:',
		warnNoIntegrations: isHans ? '[QuickEdit+] 未找到qeIntegrations或applyExternalIntegrations。' : '[QuickEdit+] 未找到qeIntegrations或applyExternalIntegrations。',
		errorWhenClose: isHans ? '关闭WikiEditor或CodeMirror时发生错误:' : '關閉WikiEditor或CodeMirror時發生錯誤:',
		errorCloseAfterSave: isHans ? '在保存更改后关闭WikiEditor或CodeMirror时发生错误:' : '在儲存變更後關閉WikiEditor或CodeMirror時發生錯誤:',
		editAllSection: isHans ? '编辑整个段落(包含子段落)' : '編輯整個段落(包含子段落)',
		cancelButtonTitle: isHans ? '关闭QuickEdit且不保存更改' : '關閉QuickEdit並捨棄所有變更',
		pageEditNotice: isHans ? '正在编辑页面:' : '正在編輯頁面:',
		saving: isHans ? '正在保存更改...' : '正在發布變更...',
		save: isHans ? '保存更改' : '發布變更',
		cancelNotice: isHans ? '已取消更改。' : '已捨棄變更。',
		saveNotice: isHans ? '已保存更改。' :'已發布變更。',
		editSummary: isHans ? '透过[[User:Addonian1123/QuickEdit|QuickEdit+]]快速编辑' : '透過[[User:Addonian1123/QuickEdit|QuickEdit+]]快速編輯',
	};

	var strings = {
		cancelNotice: msg.cancelNotice,
		saveNotice: msg.saveNotice,
	};
	
	const 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: 'qe'
			})
		})
	}

	function getPageInfo ( title , sectionID ) {
		qeLog ( 'getPageInfo:request' , { title : title , sectionID : sectionID });
	
		const params = {
			action : 'query' ,
			curtimestamp : 1 ,
			prop : 'revisions' ,
			indexpageids : 1 ,
			titles : title ,
			rvprop : [ 'timestamp' , 'content' ],
			rvslots : 'main'
		};
	
		if ( sectionID !== null && sectionID !== undefined && sectionID !== '' ) {
			params.rvsection = sectionID;
		}
	
		return api ( 'get' , params ).then( function ( res ) {
			const pageId = res && res.query && res.query.pageids && res.query.pageids[0]
			const page = pageId && res.query.pages && res.query.pages[pageId]
			const rev = page && page.revisions && page.revisions[0]
			const exists = !!(page && !page.missing && page.revisions && page.revisions.length)

			const full = rev && rev.slots && rev.slots.main ? rev.slots.main['*'] : ''
			const base = (rev && rev.timestamp) || res.curtimestamp

			const result = {
				start: res.curtimestamp,
				base: base,
				full: full,
				exists: exists
			}
			qeLog('getPageInfo:response', {
				exists: exists,
				hasRev: !!rev,
				fullLength: full ? full.length : 0
			})
			return result
		})
	}

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

		new OO.ui.ProgressBarWidget().$element.addClass('qe-progress-bar').appendTo(editor)

		return function (html) {
			editor.children('.preview').remove()
			editor.children('.qe-progress-bar').remove()

			const $preview = $('<div>')
				.html(html)
				.addClass('qe-border-color-base qe-preview-block')
				.addClass('preview')
				.appendTo(editor)
			mw.hook('wikipage.content').fire($preview)
		}
	}

	function qeLog() {
		if (!isDebugEnabled) return;
		try {
			console.log.apply(console, ['[QuickEdit+]'].concat([].slice.call(arguments)));
		} catch (_e) {}
	}

	function smoothScrollToElement(el, duration) {
		const $el = el && el.jquery ? el : $(el)

		if (!$el || !$el.length) return

		const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0
		const elementHeight = $el.outerHeight()
		const offsetTop = $el.offset().top
		const scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0
		const visibleTop = scrollTop
		const visibleBottom = scrollTop + viewportHeight
		const elementBottom = offsetTop + elementHeight
		const fits = elementHeight <= viewportHeight
		const fullyVisible = offsetTop >= visibleTop && elementBottom <= visibleBottom

		if (fits && fullyVisible) {
			return Promise.resolve(false)
		}

		const targetTop = Math.max(
			0,
			Math.round(offsetTop - (viewportHeight - Math.min(elementHeight, viewportHeight)) / 2)
		)
		const animDuration = typeof duration === 'number' ? duration : 400

		try {
			const $doc = $('html, body')
			if (typeof $doc.stop === 'function' && typeof $doc.animate === 'function') {
				return new Promise(function (resolve) {
					$doc.stop(true).animate({ scrollTop: targetTop }, animDuration, 'swing', function () {
						resolve(true)
					})
				})
			}
		} catch (_e) {}

		try {
			window.scrollTo({ top: targetTop, behavior: 'smooth' })
			return new Promise(function (resolve) {
				setTimeout(function () {
					resolve(true)
				}, animDuration)
			})
		} catch (_e) {}

		return Promise.resolve(true)
	}

	function focusWithoutScroll(element) {
		if (!element) return

		try {
			const x = window.pageXOffset || document.documentElement.scrollLeft || 0
			const y = window.pageYOffset || document.documentElement.scrollTop || 0

			try {
				element.focus({ preventScroll: true })
			} catch (_e) {
				element.focus()
			}

			window.scrollTo(x, y)
		} catch (_e) {}
	}

	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['*'])
					: mw.msg('diff-empty')
			})
			.then(getPreviewCallback(editor))
	}

	function getSectionSummary(text) {
		const match = text.match(/^(=+)(.+)\1\s*(\n|$)/)

		return !match
			? ''
			: '/* ' +
					match[2]
						.trim()
						.replace(/\[\[:?([^[|]+)\|([^[]+)\]\]/g, '$2')
						.replace(/\[\[:?([^[]+)\|?\]\]/g, '$1')
						.replace(new RegExp('\\[(?:' + mw.config.get('wgUrlProtocols') + ')([^ ]+?) ([^\\[]+)\\]', 'ig'), '$2')
						.replace(/(''|'''|''''')(?!')/g, '')
						.replace(/<[^>]+?>/g, '') +
					' */ '
	}

	async function showEditor($element, customTitle, customSectionID) {
		const progress = new OO.ui.ProgressBarWidget()

		let title = customTitle
		let sectionID = customSectionID

		const heading = ($element && $element.length) ? $element.parents(':header, .mw-heading').last() : $()

		let matcher
		let inserter

		if (heading.length) {
			matcher = heading.nextUntil.bind(heading)
			inserter = heading.after.bind(heading)
			const targetEl = $element.siblings('.qe-target').last()
			const href = (targetEl && targetEl.attr && targetEl.attr('href')) || ''
			const titleMatch = (href && href.match && href.match(titleRegexp)) || []
			const fallbackTitle = mw.config.get('wgPageName')
			if (!title) title = decodeURIComponent(titleMatch[1] || titleMatch[2] || fallbackTitle)
			if (sectionID === undefined) {
				const sectionMatch = /[?&]v?e?section=T?-?(\d*)/.exec(href) || []
				sectionID = sectionMatch[1] !== undefined && sectionMatch[1] !== '' ? sectionMatch[1] : '0'
			}
		} else {
			const $articleContent = ($element && $element.length && $element.is('#mw-content-text')) ? $element : $('#mw-content-text')
			if (!title) title = mw.config.get('wgPageName')
			if (sectionID === undefined) sectionID = null

			matcher = function (selector) {
				return $articleContent.find(selector)
			}
			inserter = function ($el) {
				$articleContent.prepend($el)
			}
		}

		qeLog('showEditor:target', { title: title, sectionID: sectionID })

		if (heading.length && !heading.closest('.mw-parser-output').length) {
			const $articleContent = $('#mw-content-text')
			matcher = function (selector) {
				const $child = $articleContent.children(selector).first()
				return $child.length ? $child.prevAll() : $articleContent.children()
			}
			inserter = $articleContent.prepend.bind($articleContent)
		}

		inserter(progress.$element.addClass('qe-progress-bar'))

		if ($element && $element.length) $element.addClass('qe-loading')
		if (!isMultiEdit) {
			$('.qe-hide').removeClass('qe-hide')
			$('.qe-heading').removeClass('qe-heading')
			$('#qe-editor').remove()
		}

		getPageInfo(title, sectionID).then(
			async function (r) {
				await new mw.Api().loadMessages([
					'publishchanges',
					'tooltip-publish',
					'accesskey-publish',
					'showpreview',
					'tooltip-preview',
					'accesskey-preview',
					'showdiff',
					'tooltip-diff',
					'accesskey-diff',
					'cancel',
					'word-separator',
					'brackets',
					'diff-empty',
					'summary',
					'minoredit',
					'summary-preview'
				])

				const start = r.start
				const base = r.base
				const full = r.full
				const isNewPage = !r.exists
				let saving = false
				let expanded = (sectionID === null)
				const remainderStart = (sectionID !== null && sectionID !== undefined && sectionID !== '') ? full.match(/\n=+.+=+(?:\n|$)/) : null
				const part = isNewPage ? '' : remainderStart ? full.substring(0, remainderStart.index) : full
				const remainder = isNewPage ? '' : remainderStart ? full.substring(remainderStart.index) : ''
				let level = 0
				let editor

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

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

				const partSection = heading.length ? matcher(':header:has(*), .mw-heading') : $()
				const fullSection = heading.length ? matcher(levelMatch) : $()

				;((textarea = new OO.ui.MultilineTextInputWidget({
					rows: 1,
					maxRows: 20,
					autosize: true,
					value: part
				})),
					(summary = new OO.ui.TextInputWidget({
						value: (isNewPage || sectionID === null) ? '' : getSectionSummary(part)
					})),
					(minor = new OO.ui.CheckboxInputWidget({
						accessKey: 'i',
						selected: mw.user.options.get('minoredit') === 1 || mw.user.options.get('minoredit') === '1'
					})),
					(save = new OO.ui.ButtonInputWidget({
						label: mw.msg('publishchanges'),
						flags: ['primary', 'progressive'],
						accessKey: 's'
					})),
					(preview = new OO.ui.ButtonInputWidget({
						label: mw.msg('showpreview')
					})),
					(compare = new OO.ui.ButtonInputWidget({
						label: mw.msg('showdiff')
					})),
					(cancel = new OO.ui.ButtonInputWidget({
						useInputTag: true,
						label: mw.msg('cancel'),
						title: msg.cancelButtonTitle,
						flags: ['secondary', 'destructive']
					})),
					(more = new OO.ui.ButtonInputWidget({
						label: '+',
						title: msg.editAllSection
					})),
					(buttons = new OO.ui.HorizontalLayout({
						items: [save, preview, compare, cancel]
					})))

				if (!isNewPage && part != full && sectionID !== null) {
					buttons.addItems([more], 3)
				}

				if (partSection.length) partSection.addClass('qe-hide')
				if (heading.length) heading.addClass('qe-heading')
				if ($element && $element.length) $element.removeClass('qe-loading')
				progress.$element.remove()
				textarea.$input.addClass('qe-textarea')

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

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

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

					const editParams = {
						action: 'edit',
						title: title,
						summary: (function () {
							var userSummary = summary.getValue().trim();
							return userSummary ? userSummary + ' // ' + msg.editSummary : msg.editSummary;
						})(),
						text: fullText
					};

					if (minor.isSelected()) {
						editParams.minor = true;
					} else {
						editParams.notminor = true;
					}
					
					if ( sectionID !== null && sectionID !== undefined && sectionID !== '' ) {
						editParams.section = sectionID;
					}
					
					api('postWithEditToken', editParams).then(
						function () {
							api('get', {
								action: 'parse',
								page: mw.config.get('wgPageName'),
								prop: ['text', 'categorieshtml']
							}).then(function (r) {
								const contentText = $('#mw-content-text'),
									catLinks = $('#catlinks')

								const $parserOutput = contentText.find('.mw-parser-output')
								if ($parserOutput.length) {
									$parserOutput.replaceWith(r.parse.text['*'])
								} else {
									contentText.html(r.parse.text['*'])
								}
								mw.hook('wikipage.content').fire(contentText)

								if (catLinks.length) {
									catLinks.replaceWith(r.parse.categorieshtml['*'])
									mw.hook('wikipage.categories').fire($('#catlinks'))
								}

								if (typeof textarea.$input.wikiEditor === 'function') {
									try {
										const $cmWrapper = textarea.$input.closest('.ext-codemirror-wrapper')
										if ($cmWrapper.length) {
											const cmInstance = $cmWrapper.data('cmInstance')
											if (cmInstance && typeof cmInstance.destroy === 'function') {
												cmInstance.destroy()
											}
										}
										textarea.$input.wikiEditor('destroy')
									} catch (e) {
										console.error(msg.errorCloseAfterSave, e)
									}
								}

								editor.remove()
								if (heading.length) heading.removeClass('qe-heading')
								if (fullSection.length) fullSection.removeClass('qe-hide')

								if (window.qeIntegrations && typeof window.qeIntegrations.onEditorClose === 'function') {
									window.qeIntegrations.onEditorClose()
								}

								saving = false
								
								if (isEditNoticeEnabled) {
									mw.notify( strings.saveNotice, {
										type: 'success',
										tag: 'saved'
									} );
								}
							})
						},
						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(msg.save)
						}
					)
				})

				preview.on('click', function () {
					const summaryValue = summary.getValue()
					const sectionMatch = summaryValue.match(/^\/\*\s*([^*]+?)\s*\*\/(.*)$/)
					let summaryHtml = ''
					if (sectionMatch) {
						const section = sectionMatch[1].trim()
						const rest = sectionMatch[2] ? sectionMatch[2].trimStart() : ''
						const anchor = '#' + section.replace(/ /g, '_')
						summaryHtml =
							'<span class="comment"><span class="autocomment">&#8594;<a href="' +
							anchor +
							'"><bdi dir="ltr">' +
							section +
							'</bdi></a>: </span>'
						const restParsePromise = api('post', {
							action: 'parse',
							title,
							prop: 'text',
							pst: 'true',
							disablelimitreport: 'true',
							disableeditsection: 'true',
							sectionpreview: 'true',
							disabletoc: 'true',
							text: rest
						})
						const mainPreviewPromise = api('post', {
							action: 'parse',
							title,
							prop: 'text',
							pst: 'true',
							disablelimitreport: 'true',
							disableeditsection: 'true',
							sectionpreview: 'true',
							disabletoc: 'true',
							text: textarea.getValue()
						})
						Promise.all([restParsePromise, mainPreviewPromise])
							.then(function ([restRes, mainRes]) {
								let restHtml = restRes.parse.text['*']
								if (restHtml.startsWith('<div')) {
									try {
										restHtml = $(restHtml).html()
									} catch (_e) {
										restHtml = restHtml
											.replace(/^<div[^>]*>[\s\n\r]*<p>/, '<div>')
											.replace(/<\/p>[\s\n\r]*<\/div>$/, '</div>')
									}
								}
								restHtml = restHtml.replace(/^<p>([\s\S]*)<\/p>$/i, '$1')

								const summaryPreviewHtml =
									'<div class="qe-summary-preview qe-border-color-base">' +
									'<b>' +
									mw.msg('summary-preview') +
									'</b><br>' +
									summaryHtml +
									restHtml +
									'</span>' +
									'</div>'
								const mainPreviewHtml = mainRes.parse.text['*'] + '<div style="clear:both;"></div>'
								return summaryPreviewHtml + mainPreviewHtml
							})
							.then(getPreviewCallback(editor))
					} else {
						const summaryParsePromise = api('post', {
							action: 'parse',
							title,
							prop: 'text',
							pst: 'true',
							disablelimitreport: 'true',
							disableeditsection: 'true',
							sectionpreview: 'true',
							disabletoc: 'true',
							text: summaryValue
						})
						const mainPreviewPromise = api('post', {
							action: 'parse',
							title,
							prop: 'text',
							pst: 'true',
							disablelimitreport: 'true',
							disableeditsection: 'true',
							sectionpreview: 'true',
							disabletoc: 'true',
							text: textarea.getValue()
						})
						Promise.all([summaryParsePromise, mainPreviewPromise])
							.then(function ([summaryRes, mainRes]) {
								const summaryPreviewHtml =
									'<div class="qe-summary-preview qe-border-color-base">' +
									'<b>' +
									mw.msg('summary-preview') +
									'</b><br>' +
									'<span class="comment">' +
									summaryRes.parse.text['*'] +
									'</span>' +
									'</div>'
								const mainPreviewHtml = mainRes.parse.text['*'] + '<div style="clear:both;"></div>'
								return summaryPreviewHtml + mainPreviewHtml
							})
							.then(getPreviewCallback(editor))
					}
				})

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

				cancel.on('click', function () {
					if (typeof textarea.$input.wikiEditor === 'function') {
						try {
							const $cmWrapper = textarea.$input.closest('.ext-codemirror-wrapper')
							if ($cmWrapper.length) {
								const cmInstance = $cmWrapper.data('cmInstance')
								if (cmInstance && typeof cmInstance.destroy === 'function') {
									cmInstance.destroy()
								}
							}
							textarea.$input.wikiEditor('destroy')
						} catch (e) {
							console.error(msg.errorWhenClose, e)
						}
					}

					editor.remove()
					if (heading.length) heading.removeClass('qe-heading')
					if (fullSection.length) fullSection.removeClass('qe-hide')
					
					if (window.qeIntegrations && typeof window.qeIntegrations.onEditorClose === 'function') {
						window.qeIntegrations.onEditorClose()
					}
					if (isEditNoticeEnabled) {
						mw.notify( strings.cancelNotice, {
							type: 'warn',
							tag: 'canceled'
						} );
					}
				})

				more.on('click', function () {
					expanded = true
					textarea.setValue(textarea.getValue() + remainder)

					if (typeof textarea.adjustSize === 'function') {
						textarea.adjustSize(true)
					}

					const $wrapper = $('.ext-codemirror-wrapper').first()
					setTimeout(function () {
						const $textarea = $wrapper.find('textarea')
						if ($textarea.length) {
							const height = $textarea.outerHeight()
							const $nextDiv = $textarea.next('div')
							if ($nextDiv.length) {
								$nextDiv.height(height)
								$nextDiv.css('height', height + 'px')
								$nextDiv[0].style.setProperty('height', height + 'px', 'important')
							}
						}
					}, 5)

					if (fullSection.length) fullSection.addClass('qe-hide')
					more.setDisabled(true)
				})

				editor = $('<div id="qe-editor">').append(
					$('<div>')
						.addClass('qe-editor-block qe-background-color-interactive qe-color-base qe-border-color-base')
						.append(
							textarea.$element.addClass('qe-textarea'),
							$('<div>')
								.addClass('qe-edit-options')
								.append(
									$('<div id="wpSummaryWidget">')
										.addClass('qe-border-color-base')
										.append(
											$('<div>')
												.html(
													mw.message('summary').parse()
														.replace(/^(&lt;|<)span[^>]*(&gt;|>)/i, '')
														.replace(/(&lt;|<)\/span(&gt;|>)$/i, '')
												)
												.on('click', 'a', function (e) {
													e.preventDefault();
													var text = $(this).text().trim();
													if (!text) return;
													var cur = summary.getValue().trim();
													summary.setValue(cur ? cur + ' ' + text : text);
												}),
											summary.$element.addClass('qe-summary-input')
										),
									new OO.ui.FieldLayout(minor, {
										label: new OO.ui.HtmlSnippet(mw.message('minoredit').parse()),
										align: 'inline'
									}).$element.addClass('qe-minoredit-block'),
									buttons.$element.addClass('qe-buttons qe-border-color-base'),
									title !== mw.config.get('wgPageName')
										? $('<div>')
												.addClass('qe-not-current-page qe-border-color-base')
												.append(
													msg.pageEditNotice,
													$('<a>')
														.attr('href', mw.config.get('wgArticlePath').replace('$1', title))
														.addClass('qe-not-current-page-link')
														.text(title.replace(/_/g, ' '))
												)
										: undefined
								)
						)
				)

				inserter(editor)

				let didSmoothScroll = false
				function maybeSmoothScroll() {
					try {
						const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0
						const elementHeight = editor && editor.outerHeight ? editor.outerHeight() : 0
						const offsetTop = editor && editor.offset ? editor.offset().top : 0
						const scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0
						const visibleTop = scrollTop
						const visibleBottom = scrollTop + viewportHeight
						const elementBottom = offsetTop + elementHeight
						const fits = elementHeight <= viewportHeight
						const fullyVisible = offsetTop >= visibleTop && elementBottom <= visibleBottom
						const needScroll = !(fits && fullyVisible)

						if (!didSmoothScroll && needScroll) {
							didSmoothScroll = true
							smoothScrollToElement(editor, 450).then(function () {
								if (textarea && textarea.$input && textarea.$input[0]) {
									focusWithoutScroll(textarea.$input[0])
								}
							})
						}
					} catch (_e) {}
				}

				requestAnimationFrame(function () {
					setTimeout(maybeSmoothScroll, 0)
				})
				setTimeout(function () {
					if (textarea && textarea.$input && textarea.$input[0]) {
						focusWithoutScroll(textarea.$input[0])
					}
				}, 0)

				mw.loader
					.using(['ext.wikiEditor', 'ext.CodeMirror.WikiEditor', 'ext.CodeMirror.mode.mediawiki'])
					.then(function (require) {
						if (typeof mw.addWikiEditor === 'function') {
							try {
								mw.addWikiEditor(textarea.$input)
								textarea.$input.wikiEditor({
									toolbar: {
										format: {},
										insert: {},
										advanced: {}
									}
								})
								const CodeMirrorWikiEditor = require('ext.CodeMirror.WikiEditor')
								const mediawikiLang = require('ext.CodeMirror.mode.mediawiki')
								const cmWe = new CodeMirrorWikiEditor(textarea.$input[0], mediawikiLang())
								cmWe.initialize()
								try {
									if (cmWe && cmWe.view && typeof cmWe.view.focus === 'function') {
										const x = window.pageXOffset || document.documentElement.scrollLeft || 0
										const y = window.pageYOffset || document.documentElement.scrollTop || 0
										cmWe.view.focus()
										window.scrollTo(x, y)
									} else if (textarea && textarea.$input && textarea.$input[0]) {
										focusWithoutScroll(textarea.$input[0])
									}
								} catch (_e) {}
								maybeSmoothScroll()
							} catch (e) {
								console.error(msg.errorInitCodeMirror, e);
							}
						}

						setTimeout(function () {
							if (window.qeIntegrations && typeof window.qeIntegrations.applyExternalIntegrations === 'function') {
								window.qeIntegrations.applyExternalIntegrations(textarea, summary)
							} else {
								console.warn(msg.warnNoIntegrations);
							}
						}, 50)
					})
					.catch(function (error) {
						console.error(msg.errorLoadModules, error);
					})

				textarea.$input.on('wikiEditor-toolbar-done', function () {
					if (window.qeIntegrations && typeof window.qeIntegrations.applyExternalIntegrations === 'function') {
						window.qeIntegrations.applyExternalIntegrations(textarea, summary)
					} else {
						console.warn(msg.warnNoIntegrations);
					}
				})

				textarea.updatePosition()
			},
			function () {
				if ($element && $element.length) $element.removeClass('qe-loading')
				progress.$element.remove()
			}
		)
	}

	function showEditConflict(editor, title, sectionID, text) {
		return getPageInfo(title, sectionID).then(function (r) {
			const 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)

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

			textarea.$input.addClass('qe-textarea')
			editor.find('> :first-child > :first-child').remove()

			$('<table>')
				.addClass('qe-border-color-base')
				.css({
					width: '100%',
					borderWidth: '1px',
					borderStyle: 'solid',
					borderBottom: 'none',
					borderSpacing: '0',
					margin: '0 !important'
				})
				.append(
					$('<tr>').append(
						$('<th>').addClass('qe-table-th').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('.qe-editlink')

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

		event.preventDefault()

		showEditor($element)
	}

	// 匯出全域 API
	function qeEditSection(title, sectionID, $mountTarget) {
		showEditor($mountTarget || $('#mw-content-text'), title, sectionID)
	}

	window.qeEditSection = qeEditSection

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

		$body.on('click', clickHandler)
	})
})()