User:Fred Gandt/aceEditorOptions.js

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.
var fg_aceEditorOptions_debugging = false; /* NOTE: available if needed */

// TODO: have it initialize on Modules during preview

$( document ).ready( () => {
	"use strict";
	
	// TODO: figure out why and fix very rare non existence of ace
	
	if ( mw.config.get( "wgAction" ) === "edit" && window.hasOwnProperty( "ace" ) ) {
		let changed_options = {},
				ace_default_options,
				ace_editor;
		const USER_NAME = mw.config.get( "wgUserName" ),
					OPTIONS_FORM = document.createElement( "form" ),
					USER_OPTIONS_NAME = "userjs-fg-ace-editor-options",
					WIKIEDITOR_TEXT = document.querySelector( "#editform .wikiEditor-ui-text" ),
					USER_OPTIONS = JSON.parse( mw.user.options.values[ USER_OPTIONS_NAME ] || {} ),
					DEFAULT_OPTIONS = {},
					BUILT_OPTIONS = {},
					STYLES = {},
			
			debugMsg = ( msg, force_type ) => {
				if ( fg_aceEditorOptions_debugging || force_type ) {
					console[ force_type || "log" ]( "AEO", msg );
				}
			},
			
			errorNotification = ( specifics, console_object ) => {
				debugMsg( console_object, "error" );
				mw.notify( `${specifics}; take a look at your browser's console [ctrl+shift+j] for some possibly helpful information`, { tag: "aceEditorOptions", type: "error", autoHide: false } );
			},
			
			api = ( dt, fnc ) => {
				dt.format = "json";
				$.ajax( {
					type: "POST",
					dataType: dt.format,
					url: "/w/api.php",
					data: dt,
					success: data => fnc( data ),
					error: ( type, status, thrown ) => errorNotification( "HTTP request error", { "api": { "dt": dt, "fnc": fnc, "error": { "type": type, "status": status, "thrown": thrown } } } )
				} );
			},
			
			unsavedChanges = are_there_any => {
				const UC = Object.entries( changed_options ).filter( ( [ key, val ] ) => BUILT_OPTIONS[ key ] !== val );
				debugMsg( { "unsavedChanges": { "UC": UC, "are_there_any": are_there_any } } );
				return are_there_any ? !!UC.length : Object.fromEntries( UO );
			},
			
			userOptions = objectified => {
				const UOA = Object.entries( Object.assign( {}, BUILT_OPTIONS, changed_options ) ).filter( ( [ key, val ] ) => DEFAULT_OPTIONS[ key ] !== val );
				debugMsg( { "userOptions": { "UOA": UOA } } );
				return objectified ? Object.fromEntries( UOA ) : UOA;
			},
			
			saveUserOptions = resetting => {
				let options = {};
				if ( !resetting ) {
					options = userOptions( true );
				}
				api( {
					action: "options",
					optionname: USER_OPTIONS_NAME,
					optionvalue: JSON.stringify( options ),
					token: mw.user.tokens.values.csrfToken
				}, data => {
					if ( data.options && data.options === "success" ) {
						OPTIONS_FORM.classList.add( "hide" );
						SETTINGS.setLabel( "Ace editor options" ).setFlags( { destructive: false } );
						mw.notify( "Ace editor options settings saved", { tag: "aceEditorOptions", type: "success" } );
						if ( resetting ) {
							changed_options = {};
							setUserOptions( userOptions().reduce( ( result, [ key, val ] ) => {
								const INPUT = OPTIONS_FORM[ key ];
								result[ key ] = INPUT[ INPUT.type === "checkbox" ? "checked" : "value" ] = DEFAULT_OPTIONS[ key ];
								return result;
							}, {} ) );
						}
					} else {
						errorNotification( "Failure to save Ace editor options settings", { "saveUserOptions": { "resetting": resetting, "options": options, "data": data } } );
					}
				} );
			},
			
			handleFGStyleSheets = ( name, value, text ) => {
				debugMsg( { "handleFGStyleSheets": { "name": name, "value": value, "text": text } } );
				let style_sheet = STYLES[ name ];
				if ( !style_sheet ) {
					style_sheet = new CSSStyleSheet();
					STYLES[ name ] = style_sheet;
					document.adoptedStyleSheets = [ ...document.adoptedStyleSheets, style_sheet ];
				}
				style_sheet.disabled = !value;
				if ( value && text ) {
					style_sheet.replaceSync( text );
				}
			},
			
			setUserOptions = options => {
				debugMsg( { "setUserOptions": { "options": options } } );
				Object.entries( options ).forEach( ( [ key, val ] ) => {
					debugMsg( { "setUserOptions": { "key": key, "val": val } } );
					if ( /^fg_/.test( key ) ) {
						/* NOTE: it's all very important */
						switch ( key ) {
							case "fg_pinkProtectedPages": {
								handleFGStyleSheets( key, !val, `#wpTextbox1.mw-textarea-protected + .ui-resizable {
	border-width: 1em 0 1em 1em !important;
	border-color: #c14848 !important;
	border-style: solid !important;
}
#wpTextbox1.mw-textarea-protected + .ui-resizable .ace_content { background-color: unset !important }` );
								break;
							}
							case "fg_containEditorOverscroll": { /* TODO: something less janky */
								handleFGStyleSheets( key, val, "body { overflow: hidden !important; }" );
								break;
							}
							case "fg_hidePageNotices": {
								handleFGStyleSheets( key, val, "#mw-content-text > div:not( #wikiPreview, #wikiDiff, .printfooter ) { display: none !important; }" );
								break;
							}
							case "fg_hidePrintMargin": {
								handleFGStyleSheets( key, val, ".ace_editor .ace_print-margin { background-color: transparent !important; }"  );
								break;
							}
							case "fg_selectedWordBorderColor": {
								handleFGStyleSheets( key, true, `.ace_editor .ace_selected-word { border-color: ${val} !important; }` );
								break;
							}
							case "fg_selectionColor": {
								handleFGStyleSheets( key, true, `.ace_editor .ace_selection { background-color: ${val} !important; }` );
								break;
							}
						}
					} else {
						ace_editor.setOption( key, val );
					}
				} );
			},
			
			appendFormButton = ( value, fnc ) => {
				const INPUT = document.createElement( "input" );
				INPUT.type = "button";
				INPUT.value = value;
				INPUT.addEventListener( "click", fnc, { passive: true } );
				OPTIONS_FORM.append( INPUT );
			},
			
			labelledInput = ( option_name, input_object, option_default, option_value ) => {
				debugMsg( { "labelledInput": { "option_name": option_name, "input_object": input_object, "option_default": option_default, "option_value": option_value } } );
				const INPUT = document.createElement( "input" ),
							LABEL = document.createElement( "label" ),
							ATTRIBUTES = input_object.attributes;
				INPUT.name = option_name; /* NOTE: allowed to be overwritten */
				Object.entries( ATTRIBUTES ).forEach( ( [ key, val ] ) => INPUT[ key ] = val );
				if ( option_default === "fg_" ) {
					DEFAULT_OPTIONS[ INPUT.name ] = ATTRIBUTES.checked ?? ATTRIBUTES.value;
				}
				if ( option_value === "fg_" ) {
					BUILT_OPTIONS[ INPUT.name ] = ATTRIBUTES.checked ?? ATTRIBUTES.value;
				} else {
					if ( INPUT.type === "radio" ) {
						if ( INPUT.checked = ATTRIBUTES.value === ( option_value ?? "" ) ) {
							BUILT_OPTIONS[ INPUT.name ] = option_value;
						}
					} else {
						if ( INPUT.type === "checkbox" ) {
							INPUT.checked = option_value;
						} else {
							INPUT.value = option_value;
						}
						BUILT_OPTIONS[ INPUT.name ] = option_value;
					}
				}
				LABEL.textContent = input_object.label;
				LABEL.append( INPUT );
				return LABEL;
			},
			
			optionsForm = () => {
				if ( OPTIONS_FORM.id ) {
					const UC = unsavedChanges( "?" );
					OPTIONS_FORM.classList.toggle( "hide" );
					SETTINGS.setLabel( UC ? "Unsaved changes" : "Ace editor options" ).setFlags( { destructive: UC } );
				} else {
					api( {
						action: "query",
						prop: "revisions",
						rvprop: "content",
						rvslots: "main",
						titles: "User:Fred Gandt/aceEditorOptions.json"
					}, data => {
						if ( data.hasOwnProperty( "batchcomplete" ) ) {
							const CONFIG = JSON.parse( data.query.pages[ Object.keys( data.query.pages )[ 0 ] ].revisions[ 0 ].slots.main[ "*" ] );
							if ( CONFIG ) {
								/* NOTE: so much slicker than loading from source */
								handleFGStyleSheets( "fg_aceEditorOptionsForm", true, `#fgAceEditorOptionsForm {
	border-radius: 0.2em 0px 0px 0.2em;
	height: calc(100% - 1px - 10.8em);
	overscroll-behavior: contain;
	contain: layout style paint;
	border: 1px solid #a7d7f9;
	background-color: white;
	position: absolute;
	overflow: auto;
	font-size: 85%;
	padding: 1em;
	right: 1.5em;
	top: 4.1em;
}
#fgAceEditorOptionsForm > fieldset {
	padding-bottom: 0.5em;
	border-radius: .2em;
	margin: 0.2em 0;
}
#fgAceEditorOptionsForm.hide { display: none }
#fgAceEditorOptionsForm label { display: block }
#fgAceEditorOptionsForm label input { margin-left: 0.4em }
#fgAceEditorOptionsForm > label + fieldset { margin-top: 0 }
#fgAceEditorOptionsForm > fieldset > legend { padding: 0 .4em .3em }
#fgAceEditorOptionsForm > label, #fgAceEditorOptionsForm > input { margin-top: 0.3em }
#fgAceEditorOptionsForm > label[for], #fgAceEditorOptionsForm > select > optgroup { text-transform: capitalize }
#fgAceEditorOptionsForm > label > input[type="color"] { vertical-align: middle }
#fgAceEditorOptionsForm > label > input[type="number"] { width: 8ch }
#fgAceEditorOptionsForm > label > input[type="text"] { width: 20ch }
#fgAceEditorOptionsForm > input[type="button"] {
	margin-top: .7em;
	cursor: pointer;
	display: block;
}` );
								OPTIONS_FORM.id = "fgAceEditorOptionsForm";
								CONFIG.build.forEach( option_name => {
									const OPTION_DEFAULT = /^fg_/.test( option_name ) ? "fg_" : ( ace_default_options[ option_name ] ),
												OPTION_VALUE = USER_OPTIONS[ option_name ] ?? OPTION_DEFAULT,
												CONFIG_OPTION = CONFIG.options[ option_name ],
												CONFIG_OPTION_TYPE = CONFIG_OPTION.type;
									if ( CONFIG_OPTION_TYPE ) {
										if ( CONFIG_OPTION_TYPE === "select" ) {
											const SELECT = document.createElement( "select" ),
														LABEL = document.createElement( "label" );
											LABEL.setAttribute( "for", SELECT.id = `${OPTIONS_FORM.id}-${option_name}` );
											LABEL.textContent = SELECT.name = option_name;
											OPTIONS_FORM.append( LABEL );
											CONFIG_OPTION.optgroups.forEach( group => {
												const OPTGROUP = document.createElement( "optgroup" );
												OPTGROUP.label = group.label;
												group.options.forEach( groupie => {
													const OPTION = document.createElement( "option" );
													OPTION.textContent = groupie.label;
													OPTION.selected = ( OPTION.value = groupie.value ) === OPTION_VALUE;
													OPTGROUP.append( OPTION );
												} );
												SELECT.append( OPTGROUP );
											} );
											OPTIONS_FORM.append( SELECT );
											BUILT_OPTIONS[ option_name ] = OPTION_VALUE;
										} else if ( CONFIG_OPTION_TYPE === "fieldset" ) {
											const FIELDSET = document.createElement( "fieldset" ),
														LEGEND = document.createElement( "legend" );
											LEGEND.textContent = CONFIG_OPTION.legend;
											FIELDSET.name = option_name;
											FIELDSET.append( LEGEND );
											CONFIG_OPTION.members.forEach( member => FIELDSET.append( labelledInput( option_name, member, OPTION_DEFAULT, OPTION_VALUE ) ) );
											OPTIONS_FORM.append( FIELDSET );
										}
									} else {
										OPTIONS_FORM.append( labelledInput( option_name, CONFIG_OPTION, OPTION_DEFAULT, OPTION_VALUE ) );
									}
								} );
								Object.assign( DEFAULT_OPTIONS, ace_default_options );
								debugMsg( { "optionsForm": { "BUILT_OPTIONS": BUILT_OPTIONS, "DEFAULT_OPTIONS": DEFAULT_OPTIONS } } );
								OPTIONS_FORM.addEventListener( "input", evt => {
									const TARGET = evt.target,
												TYPE = TARGET.type;
									let value = TARGET.value,
											name = TARGET.name;
									if ( TYPE === "checkbox" ) {
										value = TARGET.checked;
									} else if ( !isNaN( +value ) ) {
										value = +value;
									}
									changed_options[ name ] = value;
									setUserOptions( { [ name ]: value } );
								} );
								appendFormButton( "Save these options", () => saveUserOptions() );
								appendFormButton( "Reset to default", () => saveUserOptions( true ) );
								WIKIEDITOR_TEXT.append( OPTIONS_FORM );
							}
						}
					} );
				}
			},
			
			initAEO = () => {
				const ACE_EDITOR_CONTAINER = WIKIEDITOR_TEXT.querySelector( "div.editor.ace_editor" );
				if ( ACE_EDITOR_CONTAINER ) {
					ace_editor = ace.edit( ACE_EDITOR_CONTAINER );
					ace_default_options = ace_editor.getOptions();
					setUserOptions( USER_OPTIONS );
					debugMsg( { "initAEO": { "ace_default_options": ace_default_options, "ace_editor": ace_editor } }, "log" );
				}
			},
			
			SETTINGS = new OO.ui.ToggleButtonWidget( { label: "Ace editor options", icon: "settings", framed: false } ),
			
			OBSERVER = new MutationObserver( mutants => {
				const ADDED_NODE = mutants[ 0 ].addedNodes[ 0 ];
				if ( ADDED_NODE?.classList.contains( "ui-resizable" ) ) {
					SETTINGS.setDisabled( false );
					initAEO();
				} else if ( ADDED_NODE !== OPTIONS_FORM ) {
					OPTIONS_FORM.classList?.add( "hide" ); /* TODO: unsaved changes indicator color gets switched if the form is open when toggling away and back */
					SETTINGS.setDisabled( true );
				}
			} );
		
		initAEO();
		SETTINGS.onChange = optionsForm;
		SETTINGS.on( "change", SETTINGS.onChange );
		document.querySelector( '#wikiEditor-section-main span[rel="lineWrapping"]' )?.remove(); /* NOTE: removing as potentially conflicted */
		document.querySelector( '#wikiEditor-section-main span[rel="invisibleChars"]' )?.remove(); /* NOTE: removing as potentially conflicted */
		$( "#wikiEditor-section-secondary > div" ).removeClass( "empty" ).append( SETTINGS.$element );
		OBSERVER.observe( WIKIEDITOR_TEXT, { childList: true } );
		debugMsg( { "USER_OPTIONS": USER_OPTIONS }, "log" );
	}
} );