const {
Compartment,
EditorState,
EditorView,
Extension,
StateEffect
} = require( 'ext.CodeMirror.lib' );
/**
* Container class for housing CodeMirror {@link Extension Extensions}. Each Extension
* is wrapped in a {@link Compartment} so that it can be
* {@link CodeMirrorExtensionRegistry#reconfigure reconfigured}.
*
* If an Extension doesn't need to be reconfigured, it should instead be added during CodeMirror
* {@link CodeMirror#initialize initialization}, or by using
* {@link CodeMirror#applyExtension CodeMirror#applyExtension()}.
*
* The constructor is internal. The class can be accessed via {@link CodeMirror#extensionRegistry}.
*
* @example
* const require = await mw.loader.using( 'ext.CodeMirror' );
* mw.hook( 'ext.CodeMirror.ready' ).add( ( cm ) => {
* const { EditorView, Prec } = require( 'ext.CodeMirror.lib' );
* // Disable spellchecking. Use Prec.high() to override the
* // contentAttributesExtension which adds spellcheck="true".
* cm.extensionRegistry.register(
* 'spellcheck',
* Prec.high( EditorView.contentAttributes.of( {
* spellcheck: 'false'
* } ) ),
* cm.view
* );
*
* const toggleButton = document.querySelector( '#toggle-spellcheck' );
* toggleButton.addEventListener( 'click', () => {
* cm.extensionRegistry.toggle( 'spellcheck', cm.view );
* } );
* } );
*/
class CodeMirrorExtensionRegistry {
/**
* For use only by the {@link CodeMirror} class constructor.
*
* @param {Object<Extension>} extensions Keyed by a unique string identifier.
* These extensions will be included in the configuration during CodeMirror
* initialization via {@link CodeMirrorPreferences}.
* @param {boolean} [isVisualEditor=false] Whether the VE 2017 editor is being used.
* @hideconstructor
* @internal
*/
constructor( extensions = {}, isVisualEditor = false ) {
/**
* Registry of CodeMirror Extensions, keyed by a unique string identifier.
*
* @type {Object<Extension>}
* @private
*/
this.extensions = extensions;
/**
* @type {boolean}
* @private
*/
this.isVisualEditor = isVisualEditor;
/**
* Registry of CodeMirror Compartments for each Extension,
* keyed by the same unique string identifier.
*
* @type {Object<Compartment>}
* @private
*/
this.compartments = {};
/**
* Allowlist of names of CodeMirror extensions supported by the 2017 wikitext editor.
* Do *not* include Extensions that make changes to the document text, or visually
* change the placement of text.
*
* Note also that there is no UI to toggle or reconfigure CodeMirror Extensions in VE.
*
* @type {string[]}
*/
this.veSupportedExtensions = [
'bracketMatching',
'highlightRefs',
'lineWrapping',
'lineNumbering'
];
/**
* Map of reconfiguration values and the {@link Extension extensions} that should be
* applied when a compartmentalized extension is reconfigured with that value.
*
* Keyed by extension name, then by 'reconfig value' and then the implementing Extension.
*
* This is used when we need to pass around the CodeMirrorExtensionRegistry but keep
* track of the Extension values elsewhere.
*
* @see CodeMirrorExtensionRegistry#reconfigure
* @type {Map<string, Map>}
* @internal
*/
this.reconfigValueMap = new Map();
// Create a compartment for each extension.
for ( const extName of this.names ) {
// Skip if the extension is not supported by VE.
if ( this.isVisualEditor && !this.veSupportedExtensions.includes( extName ) ) {
delete this.extensions[ extName ];
continue;
}
// The compartmentalized extensions here are included during
// CodeMirror initialization via CodeMirrorPreferences#extension.
this.compartments[ extName ] = new Compartment();
}
}
/**
* Get the compartmentalized {@link Extension} with the given name.
*
* This should only be used when including registered extensions during
* CodeMirror initialization such as with {@link CodeMirrorPreferences#extension}.
*
* @param {string} name
* @return {Extension|undefined}
* @internal
*/
get( name ) {
if ( !this.compartments[ name ] ) {
return undefined;
}
return this.compartments[ name ].of( this.extensions[ name ] );
}
/**
* Get the `Compartment` for the extension with the given name.
*
* @param {string} name
* @return {Compartment|undefined}
*/
getCompartment( name ) {
return this.compartments[ name ];
}
/**
* The names of all registered Extensions.
*
* @type {string[]}
*/
get names() {
return Object.keys( this.extensions );
}
/**
* Register an {@link Extension}, creating a corresponding {@link Compartment}.
* The Extension can then be {@link CodeMirrorExtensionRegistry#reconfigure reconfigured}
* such as {@link CodeMirrorExtensionRegistry#toggle toggling} on and off.
*
* @param {string} name
* @param {Extension} extension
* @param {EditorView} view
* @param {boolean} [enable] `true` to enable the extension immediately.
*/
register( name, extension, view, enable ) {
if ( !this.veSupportedExtensions.includes( name ) && this.isVisualEditor ) {
// Unsupported.
return;
}
if ( this.isRegistered( name, view ) ) {
// Already registered, so toggle accordingly.
if ( enable !== undefined ) {
this.toggle( name, view, enable );
}
return;
}
this.extensions[ name ] = extension;
this.compartments[ name ] = new Compartment();
view.dispatch( {
effects: StateEffect.appendConfig.of(
this.compartments[ name ].of( enable ? extension : [] )
)
} );
}
/**
* Register an extension with an initial value from the
* {@link #reconfigValueMap reconfiguration value map}.
*
* @param {string} name
* @param {EditorView} view
* @param {string} reconfigValue
* @internal
*/
registerFromValueMap( name, view, reconfigValue ) {
this.register( name, this.reconfigValueMap.get( name ).get( reconfigValue ), view, true );
}
/**
* Reconfigure a compartmentalized extension with a new {@link Extension}.
*
* @example
* const cm = new CodeMirror( ... );
* // Register an Extension that sets the tab size to 5 spaces.
* cm.extensionRegistry.register( 'tabSize', EditorState.tabSize.of( 5 ), cm.view, true );
* // Reconfigure the tab size to 10 spaces.
* cm.extensionRegistry.reconfigure( 'tabSize', cm.view, EditorState.tabSize.of( 10 ) );
*
* @param {string} name
* @param {EditorView} view
* @param {Extension} extension
*/
reconfigure( name, view, extension ) {
if ( !this.isRegistered( name, view ) ) {
mw.log.warn( `[CodeMirror] Extension "${ name }" is not registered.` );
return;
}
view.dispatch( {
effects: this.getCompartment( name ).reconfigure( extension )
} );
}
/**
* Reconfigure a compartmentalized extension with a value from the
* {@link #reconfigValueMap reconfiguration value map}.
*
* @param {string} name
* @param {EditorView} view
* @param {string} reconfigValue
* @internal
*/
reconfigureFromValueMap( name, view, reconfigValue ) {
this.reconfigure( name, view, this.reconfigValueMap.get( name ).get( reconfigValue ) );
}
/**
* Toggle on or off an extension.
*
* @param {string} name
* @param {EditorView} view
* @param {PrefValue} [force] `true` to enable, `false` to disable, `undefined` to toggle,
* or a string value to force-enable with the given value from the
* {@link #reconfigValueMap reconfiguration value map}.
*/
toggle( name, view, force ) {
if ( !this.isRegistered( name, view ) ) {
mw.log.warn( `[CodeMirror] Extension "${ name }" is not registered.` );
return;
}
if ( typeof force === 'string' ) {
this.reconfigureFromValueMap( name, view, force );
} else {
const toEnable = force === undefined ? !this.isEnabled( name, view ) : force;
this.reconfigure( name, view, toEnable ? this.extensions[ name ] : [] );
}
}
/**
* Check if an extension is enabled.
*
* @param {string} name
* @param {EditorView} view
* @return {boolean}
*/
isEnabled( name, view ) {
if ( !this.isRegistered( name, view ) ) {
return false;
}
// An Extension can be of various types (FacetProvider, PrecExtension, etc.),
// but a "disabled" extension is always an empty array.
const contents = this.getCompartment( name ).get( view.state );
return !Array.isArray( contents ) || !!contents.length;
}
/**
* Check if the {@link Extension} with the given name has been appended to the
* {@link EditorState} configuration. In the context of CodeMirror, this means
* that the extension has been "registered", but not necessarily enabled.
*
* @param {string} name
* @param {EditorView} view
* @return {boolean}
*/
isRegistered( name, view ) {
const compartment = this.compartments[ name ];
return compartment && !!compartment.get( view.state );
}
}
module.exports = CodeMirrorExtensionRegistry;