mediawiki-extensions-CodeMi.../resources/codemirror.preferences.js

336 lines
9.3 KiB
JavaScript

const {
Compartment,
EditorView,
Extension,
StateEffect,
StateEffectType,
StateField,
keymap,
showPanel
} = require( 'ext.CodeMirror.v6.lib' );
const CodeMirrorPanel = require( './codemirror.panel.js' );
require( './ext.CodeMirror.data.js' );
/**
* CodeMirrorPreferences is a panel that allows users to configure CodeMirror preferences.
* It is toggled by pressing `Mod`-`Shift`-`,` (or `Command`+`Shift`+`,` on macOS).
*
* Note that this code, like MediaWiki Core, refers to the user's preferences as "options".
* In this class, "preferences" refer to the user's preferences for CodeMirror, which
* are stored as a single user 'option' in the database.
*/
class CodeMirrorPreferences extends CodeMirrorPanel {
/**
* @param {Object} extensionRegistry Key-value pairs of CodeMirror Extensions.
* @param {boolean} [isVisualEditor=false] Whether the VE 2017 editor is being used.
*/
constructor( extensionRegistry, isVisualEditor = false ) {
super();
/** @type {string} */
this.optionName = 'codemirror-preferences';
/** @type {boolean} */
this.isVisualEditor = isVisualEditor;
// VisualEditor only supports a subset of Extensions.
const veSupportedExtensions = [
'bracketMatching',
'lineWrapping',
'lineNumbering'
];
/**
* Registry of CodeMirror Extensions that are made available to CodeMirrorPreferences.
*
* @type {Object<Extension>}
*/
this.extensionRegistry = extensionRegistry;
/** @type {mw.Api} */
this.api = new mw.Api();
/**
* Registry of CodeMirror Compartments that are made available for
* reconfiguration in CodeMirrorPreferences.
*
* @type {Object<Compartment>}
*/
this.compartmentRegistry = {};
for ( const extName of Object.keys( extensionRegistry ) ) {
if ( isVisualEditor && !veSupportedExtensions.includes( extName ) ) {
delete this.extensionRegistry[ extName ];
continue;
}
this.compartmentRegistry[ extName ] = new Compartment();
}
/** @type {StateEffectType} */
this.prefsToggleEffect = StateEffect.define();
/** @type {StateField} */
this.panelStateField = StateField.define( {
create: () => true,
update: ( value, transaction ) => {
for ( const e of transaction.effects ) {
if ( e.is( this.prefsToggleEffect ) ) {
value = e.value;
}
}
return value;
},
// eslint-disable-next-line arrow-body-style
provide: ( stateField ) => {
// eslint-disable-next-line arrow-body-style
return showPanel.from( stateField, ( on ) => {
return on ? () => this.panel : null;
} );
}
} );
/**
* The user's CodeMirror preferences.
*
* @type {Object}
*/
this.preferences = this.fetchPreferences();
}
/**
* The default CodeMirror preferences, as defined by `$wgCodeMirrorPreferences`.
*
* @return {Object}
*/
get defaultPreferences() {
return mw.config.get( 'extCodeMirrorConfig' ).defaultPreferences;
}
/**
* Fetch the user's CodeMirror preferences from the user options API,
* or clientside storage for unnamed users.
*
* @return {Object}
*/
fetchPreferences() {
let storageObj = this.defaultPreferences;
if ( mw.user.isNamed() ) {
try {
storageObj = JSON.parse( mw.user.options.get( this.optionName ) );
} catch ( e ) {
// Invalid JSON, or no preferences set.
}
} else {
storageObj = mw.storage.getObject( this.optionName ) || this.defaultPreferences;
}
storageObj = Object.assign( {}, this.defaultPreferences, storageObj );
// Convert binary representation to boolean.
const preferences = {};
for ( const prefName in storageObj ) {
preferences[ prefName ] = !!storageObj[ prefName ];
}
return preferences;
}
/**
* Set the given CodeMirror preference and update the user option in the database,
* or clientside storage for unnamed users.
*
* @param {string} key
* @param {Mixed} value
*/
setPreference( key, value ) {
this.preferences[ key ] = value;
// Only save the preferences that differ from the defaults,
// and use a binary representation for storage. This is to prevent
// bloat of the user_properties table (T54777).
const storageObj = {};
for ( const prefName in this.preferences ) {
if ( !!this.preferences[ prefName ] !== !!this.defaultPreferences[ prefName ] ) {
storageObj[ prefName ] = this.preferences[ prefName ] ? 1 : 0;
}
}
mw.user.options.set( this.optionName, JSON.stringify( storageObj ) );
// Save the preferences to the database or clientside storage.
if ( mw.user.isNamed() ) {
this.api.saveOption( this.optionName, JSON.stringify( storageObj ) );
} else {
mw.storage.setObject( this.optionName, storageObj );
}
}
/**
* Get the value of the given CodeMirror preference.
*
* @param {string} prefName
* @return {boolean}
*/
getPreference( prefName ) {
// First check the preference explicitly set by the user.
// For now, we don't allow CodeMirror preferences to override
// config settings in the 2017 editor, since there's no UI to set them.
if ( !this.isVisualEditor && this.preferences[ prefName ] !== undefined ) {
return this.preferences[ prefName ];
}
// Otherwise, go by the defaults.
// Some preferences can be set per-namespace through wiki configuration.
// Values are an array of namespace IDs, [] to disable everywhere,
// or null to enable everywhere.
const namespacePrefs = [ 'lineNumbering', 'templateFolding', 'autocomplete' ];
if ( namespacePrefs.includes( prefName ) ) {
const namespaces = mw.config.get( 'extCodeMirrorConfig' )[ prefName + 'Namespaces' ];
return !namespaces || namespaces.includes( mw.config.get( 'wgNamespaceNumber' ) );
}
// These preferences do not have configuration settings.
return this.defaultPreferences[ prefName ];
}
/**
* Register an {@link Extension} with CodeMirrorPreferences, along with a
* corresponding {@link Compartment} so that the Extension can be reconfigured.
*
* @param {string} name
* @param {Extension} extension
* @param {EditorView} view
* @internal
*/
registerExtension( name, extension, view ) {
this.extensionRegistry[ name ] = extension;
this.compartmentRegistry[ name ] = new Compartment();
view.dispatch( {
effects: StateEffect.appendConfig.of(
this.compartmentRegistry[ name ].of(
this.getPreference( name ) ? this.extensionRegistry[ name ] : []
)
)
} );
}
/**
* @inheritDoc
*/
get extension() {
return [
// Keymap for toggling the preferences panel.
keymap.of( [
{ key: 'Mod-Shift-,', run: ( view ) => this.toggle( view, true ) }
] ),
// Compartmentalized extensions
Object.keys( this.extensionRegistry ).map(
( name ) => this.compartmentRegistry[ name ].of(
// Only apply the extension if the preference (or default pref) is enabled.
this.getPreference( name ) ? this.extensionRegistry[ name ] : []
)
)
];
}
/**
* @inheritDoc
*/
get panel() {
const container = document.createElement( 'div' );
container.className = 'cm-mw-preferences-panel cm-mw-panel';
container.addEventListener( 'keydown', this.onKeydown.bind( this ) );
const wrappers = [];
for ( const prefName in this.extensionRegistry ) {
const [ wrapper ] = this.getCheckbox(
prefName,
`codemirror-prefs-${ prefName.toLowerCase() }`,
this.getPreference( prefName )
);
wrappers.push( wrapper );
}
const fieldset = this.getFieldset( mw.msg( 'codemirror-prefs-title' ), ...wrappers );
container.appendChild( fieldset );
const closeBtn = this.getButton( 'codemirror-close', 'close', true );
closeBtn.classList.add( 'cdx-button--weight-quiet', 'cm-mw-panel-close' );
container.appendChild( closeBtn );
closeBtn.addEventListener( 'click', () => {
this.toggle( this.view, false );
} );
return {
dom: container,
top: true
};
}
/**
* Toggle display of the preferences panel.
*
* @param {EditorView} view
* @param {boolean} [force] Force the panel to open or close.
* @return {boolean}
*/
toggle( view, force ) {
this.view = view;
const effects = [];
let bool;
// Add the panel state field to the state if it doesn't exist.
if ( !this.view.state.field( this.panelStateField, false ) ) {
effects.push( StateEffect.appendConfig.of( [ this.panelStateField ] ) );
bool = true;
} else {
bool = !this.view.state.field( this.panelStateField );
}
if ( typeof force === 'boolean' ) {
bool = force;
}
effects.push( this.prefsToggleEffect.of( bool ) );
this.view.dispatch( { effects } );
// If the panel is being opened, focus the first input.
if ( bool ) {
this.view.dom.querySelector(
'.cm-mw-preferences-panel input:first-child'
).focus();
}
return true;
}
/**
* Handle keydown events on the preferences panel.
*
* @param {KeyboardEvent} event
*/
onKeydown( event ) {
if ( event.key === 'Escape' ) {
event.preventDefault();
this.toggle( this.view, false );
this.view.focus();
} else if ( event.key === 'Enter' ) {
event.preventDefault();
}
}
/**
* @inheritDoc
*/
getCheckbox( name, label, checked ) {
const compartment = this.compartmentRegistry[ name ];
const extension = this.extensionRegistry[ name ];
const [ wrapper, input ] = super.getCheckbox( name, label, checked );
input.addEventListener( 'change', () => {
this.view.dispatch( {
effects: compartment.reconfigure( input.checked ? extension : [] )
} );
this.setPreference( name, input.checked );
} );
return [ wrapper, input ];
}
}
module.exports = CodeMirrorPreferences;