CM6: Add jsdoc build step, fix JSDoc annotations, and add @stable tags

There is a known bug with JSDoc and using `export default`. These must
be separate statements for JSDoc to parse properly.
See https://github.com/jsdoc/jsdoc/issues/1132

Update README; change log now lives on the wiki.

Bug: T359986
Depends-On: I58a0766e35eddaf7bebe2c080757bb09963d8555
Change-Id: Ibc2212ef9eab512511b13a99ecc2ccbda8c52ece
This commit is contained in:
MusikAnimal 2024-03-18 23:10:11 -04:00
parent ca02360228
commit d652f3d2a2
14 changed files with 2209 additions and 3360 deletions

View file

@ -1,2 +1,3 @@
/resources/lib/ /resources/lib/
/vendor /vendor
/docs

1
.gitignore vendored
View file

@ -9,3 +9,4 @@
/composer.lock /composer.lock
.eslintcache .eslintcache
.env .env
docs/

View file

@ -1,6 +1,4 @@
# mediawiki/extensions/CodeMirror CodeMirror 6 homepage: [https://www.mediawiki.org/wiki/Extension:CodeMirror/6](https://www.mediawiki.org/wiki/Extension:CodeMirror/6)
Homepage: https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:CodeMirror
## Development ## Development
@ -24,6 +22,7 @@ _NOTE: Consider using [Fresh](https://gerrit.wikimedia.org/g/fresh/) to run thes
You'll want to keep this running in a separate terminal during development. You'll want to keep this running in a separate terminal during development.
* `npm run build` to compile the production assets. You *must* run this step before * `npm run build` to compile the production assets. You *must* run this step before
sending the patch or CI will fail (so that sources and built assets are in sync). sending the patch or CI will fail (so that sources and built assets are in sync).
* `npm run doc` to generate the API documentation.
* `npm test` to run the linting tools, JavaScript unit tests, and build checks. * `npm test` to run the linting tools, JavaScript unit tests, and build checks.
* `npm run test:lint` for linting of JS/LESS/CSS. * `npm run test:lint` for linting of JS/LESS/CSS.
* `npm run test:lint:js` for linting of just JavaScript. * `npm run test:lint:js` for linting of just JavaScript.
@ -31,34 +30,9 @@ _NOTE: Consider using [Fresh](https://gerrit.wikimedia.org/g/fresh/) to run thes
* `npm run test:i18n` for linting of i18n messages with banana-checker. * `npm run test:i18n` for linting of i18n messages with banana-checker.
* `npm run test:unit` for the new Jest unit tests. * `npm run test:unit` for the new Jest unit tests.
* `npm run selenium-test` for the Selenium tests. * `npm run selenium-test` for the Selenium tests.
* Older QUnit tests are in `resources/mode/mediawiki/tests/qunit/`. These have been
Older QUnit tests are in `resources/mode/mediawiki/tests/qunit/`. These will replaced and will be removed after the CodeMirror 6 upgrade.
eventually be moved over to `tests/qunit` and rewritten for CodeMirror 6.
## CodeMirror 6 change log ## CodeMirror 6 change log
This is a list of changes that either come by default with the CodeMirror 6 upgrade, * See [Extension:CodeMirror/6](https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:CodeMirror/6#Differences_from_CodeMirror_5)
or changes of our that we deem as reasonable improvements.
Some may be removed pending user feedback:
### Upstream changes
* Bracket matching now highlights unmatched brackets in red
### New MediaWiki mode features
* Closing HTML tags that highlighted as an error now also highlight the closing '>'
* Allow link titles to be both emboldened and italicized.
* Wikitext syntax highlighting is shown on protected pages
([T301615](https://phabricator.wikimedia.org/T301615))
### Deprecations and other changes
* The `.cm-mw-mnemonic` CSS class has been renamed to `.cm-mw-html-entity`
* The `.cm-mw-template-name-mnemonic` class has been removed.
Use `.cm-mw-template-ground.cm-html-entity` instead.
* Line-level styling for `<nowiki>`, `<pre>`, and any tag without an associated
TagMode has been removed.
* The browser's native search functionality (ala Ctrl+F) has been replaced with
search functionality built into CodeMirror. This is necessary to maintain
performance (see [T303664](https://phabricator.wikimedia.org/T303664)).

52
jsdoc.json Normal file
View file

@ -0,0 +1,52 @@
{
"opts": {
"encoding": "utf8",
"destination": "docs/js",
"package": "package.json",
"readme": "README.md",
"recurse": true,
"template": "node_modules/jsdoc-wmf-theme"
},
"plugins": [
"plugins/markdown"
],
"source": {
"include": [ "src" ],
"includePattern": ".+\\.js$"
},
"tags": {},
"templates": {
"cleverLinks": true,
"default": {
"useLongnameInNav": true
},
"wmf": {
"maintitle": "CodeMirror",
"repository": "https://gerrit.wikimedia.org/g/mediawiki/extensions/CodeMirror",
"linkMap": {
"Decoration": "https://codemirror.net/docs/ref/#view.Decoration",
"DecorationSet": "https://codemirror.net/docs/ref/#view.DecorationSet",
"EditorState": "https://codemirror.net/docs/ref/#state.EditorState",
"EditorView": "https://codemirror.net/docs/ref/#view.EditorView",
"Error": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error",
"Extension": "https://codemirror.net/docs/ref/#state.Extension",
"HTMLTextAreaElement": "https://developer.mozilla.org/en-US/docs/Web/API/HTMLTextAreaElement",
"jQuery": "https://api.jquery.com/Types/#jQuery",
"jQuery.fn.textSelection": "https://doc.wikimedia.org/mediawiki-core/master/js/jQueryPlugins.html#.textSelection",
"KeyBinding": "https://codemirror.net/docs/ref/#view.KeyBinding",
"LanguageSupport": "https://codemirror.net/docs/ref/#language.LanguageSupport",
"PluginSpec": "https://codemirror.net/docs/ref/#view.PluginSpec",
"Promise": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise",
"RangeSet": "https://codemirror.net/docs/ref/#state.RangeSet",
"StreamParser": "https://codemirror.net/docs/ref/#language.StreamParser",
"StringStream": "https://codemirror.net/docs/ref/#language.StringStream",
"SyntaxNode": "https://lezer.codemirror.net/docs/ref/#common.SyntaxNode",
"Tag": "https://lezer.codemirror.net/docs/ref/#highlight.Tag",
"TagStyle": "https://codemirror.net/docs/ref/#language.TagStyle",
"Tooltip": "https://codemirror.net/docs/ref/#view.Tooltip",
"Tree": "https://lezer.codemirror.net/docs/ref/#common.Tree",
"ViewUpdate": "https://codemirror.net/docs/ref/#view.ViewUpdate"
}
}
}
}

5145
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
{ {
"name": "codemirror", "name": "CodeMirror",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "rollup -c --watch", "start": "rollup -c --watch",
@ -12,7 +12,8 @@
"test:unit": "jest", "test:unit": "jest",
"check-built-assets": "{ git status src/ | grep \"nothing to commit, working tree clean\"; } && { echo 'CHECKING BUILD SOURCES ARE COMMITTED' && npm run build && git status resources/dist/ | grep \"nothing to commit, working tree clean\" || { npm run node-debug; false; }; }", "check-built-assets": "{ git status src/ | grep \"nothing to commit, working tree clean\"; } && { echo 'CHECKING BUILD SOURCES ARE COMMITTED' && npm run build && git status resources/dist/ | grep \"nothing to commit, working tree clean\" || { npm run node-debug; false; }; }",
"node-debug": "node -v && npm -v && echo 'ERROR: Please ensure that production assets have been built with `npm run build` and commited, and that you are using the correct version of Node/NPM.'", "node-debug": "node -v && npm -v && echo 'ERROR: Please ensure that production assets have been built with `npm run build` and commited, and that you are using the correct version of Node/NPM.'",
"selenium-test": "wdio tests/selenium/wdio.conf.js" "selenium-test": "wdio tests/selenium/wdio.conf.js",
"doc": "jsdoc -c jsdoc.json"
}, },
"engines": { "engines": {
"node": "18.17.0" "node": "18.17.0"
@ -41,6 +42,8 @@
"jest": "29.7.0", "jest": "29.7.0",
"jest-environment-jsdom": "29.7.0", "jest-environment-jsdom": "29.7.0",
"jquery": "3.7.1", "jquery": "3.7.1",
"jsdoc": "4.0.2",
"jsdoc-wmf-theme": "0.0.13",
"rollup": "4.13.0", "rollup": "4.13.0",
"rollup-plugin-copy": "3.5.0", "rollup-plugin-copy": "3.5.0",
"stylelint-config-wikimedia": "0.16.1", "stylelint-config-wikimedia": "0.16.1",

View file

@ -1 +1 @@
"use strict";var e=require("ext.CodeMirror.v6.lib"),t=require("ext.CodeMirror.v6"),r=require("ext.CodeMirror.v6.mode.mediawiki"),i=function(t){function r(t,i){var o;return e._classCallCheck(this,r),(o=e._callSuper(this,r,[t])).langExtension=i,o.useCodeMirror=mw.user.options.get("usecodemirror")>0,o}return e._inherits(r,t),e._createClass(r,[{key:"setCodeMirrorPreference",value:function(t){this.useCodeMirror=t,e._get(e._getPrototypeOf(r.prototype),"setCodeMirrorPreference",this).call(this,t)}},{key:"enableCodeMirror",value:function(){var t=this;if(!this.view){var r=this.$textarea.prop("selectionStart"),i=this.$textarea.prop("selectionEnd"),o=this.$textarea.scrollTop(),s=this.$textarea.is(":focus"),a=[].concat(e._toConsumableArray(this.defaultExtensions),[this.langExtension,e.EditorView.domEventHandlers({blur:function(){return t.$textarea.triggerHandler("blur")},focus:function(){return t.$textarea.triggerHandler("focus")}}),e.EditorView.lineWrapping]);if(this.initialize(a),requestAnimationFrame((function(){t.view.scrollDOM.scrollTop=o})),0!==r||0!==i){var n=e.EditorSelection.range(r,i),d=e.EditorView.scrollIntoView(n);d.value.isSnapshot=!0,this.view.dispatch({selection:e.EditorSelection.create([n]),effects:d})}s&&this.view.focus(),mw.hook("ext.CodeMirror.switch").fire(!0,$(this.view.dom))}}},{key:"addCodeMirrorToWikiEditor",value:function(){var e=this,t=this.$textarea.data("wikiEditor-context"),r=t&&t.modules&&t.modules.toolbar;r&&(this.$textarea.wikiEditor("addToToolbar",{section:"main",groups:{codemirror:{tools:{CodeMirror:{label:mw.msg("codemirror-toggle-label"),type:"toggle",oouiIcon:"highlight",action:{type:"callback",execute:function(){return e.switchCodeMirror()}}}}}}}),r.$toolbar.find(".tool[rel=CodeMirror]").attr("id","mw-editbutton-codemirror"),this.readOnly&&this.$textarea.data("wikiEditor-context").$ui.addClass("ext-codemirror-readonly"),this.useCodeMirror&&this.enableCodeMirror(),this.updateToolbarButton(),this.logUsage({editor:"wikitext",enabled:this.useCodeMirror,toggled:!1,edit_start_ts_ms:1e3*parseInt($('input[name="wpStarttime"]').val(),10)||0}))}},{key:"updateToolbarButton",value:function(){var e=$("#mw-editbutton-codemirror");e.toggleClass("mw-editbutton-codemirror-active",this.useCodeMirror),e.data("setActive")&&e.data("setActive")(this.useCodeMirror)}},{key:"switchCodeMirror",value:function(){if(this.view){this.setCodeMirrorPreference(!1);var e=this.view.scrollDOM.scrollTop,t=this.view.hasFocus,r=this.view.state.selection.ranges[0],i=r.from,o=r.to;$(this.view.dom).textSelection("unregister"),this.$textarea.textSelection("unregister"),this.$textarea.val(this.view.state.doc.toString()),this.view.destroy(),this.view=null,this.$textarea.show(),t&&this.$textarea.trigger("focus"),this.$textarea.prop("selectionStart",Math.min(i,o)).prop("selectionEnd",Math.max(o,i)),this.$textarea.scrollTop(e),mw.hook("ext.CodeMirror.switch").fire(!1,this.$textarea)}else this.enableCodeMirror(),this.setCodeMirrorPreference(!0);this.updateToolbarButton(),this.logUsage({editor:"wikitext",enabled:this.useCodeMirror,toggled:!0,edit_start_ts_ms:1e3*parseInt($('input[name="wpStarttime"]').val(),10)||0})}}]),r}(t);mw.loader.getState("ext.wikiEditor")&&mw.hook("wikiEditor.toolbarReady").add((function(e){new i(e,r({bidiIsolation:"rtl"===e.attr("dir")})).addCodeMirrorToWikiEditor()})); "use strict";var e=require("ext.CodeMirror.v6.lib"),t=require("ext.CodeMirror.v6"),r=require("ext.CodeMirror.v6.mode.mediawiki"),i=function(t){function r(t,i){var o;return e._classCallCheck(this,r),(o=e._callSuper(this,r,[t])).langExtension=i,o.useCodeMirror=mw.user.options.get("usecodemirror")>0,o}return e._inherits(r,t),e._createClass(r,[{key:"setCodeMirrorPreference",value:function(t){this.useCodeMirror=t,e._get(e._getPrototypeOf(r.prototype),"setCodeMirrorPreference",this).call(this,t)}},{key:"enableCodeMirror",value:function(){var t=this;if(!this.view){var r=this.$textarea.prop("selectionStart"),i=this.$textarea.prop("selectionEnd"),o=this.$textarea.scrollTop(),s=this.$textarea.is(":focus"),a=[this.defaultExtensions,this.langExtension,e.EditorView.domEventHandlers({blur:function(){return t.$textarea.triggerHandler("blur")},focus:function(){return t.$textarea.triggerHandler("focus")}}),e.EditorView.lineWrapping];if(this.initialize(a),requestAnimationFrame((function(){t.view.scrollDOM.scrollTop=o})),0!==r||0!==i){var n=e.EditorSelection.range(r,i),d=e.EditorView.scrollIntoView(n);d.value.isSnapshot=!0,this.view.dispatch({selection:e.EditorSelection.create([n]),effects:d})}s&&this.view.focus(),mw.hook("ext.CodeMirror.switch").fire(!0,$(this.view.dom))}}},{key:"addCodeMirrorToWikiEditor",value:function(){var e=this,t=this.$textarea.data("wikiEditor-context"),r=t&&t.modules&&t.modules.toolbar;r&&(this.$textarea.wikiEditor("addToToolbar",{section:"main",groups:{codemirror:{tools:{CodeMirror:{label:mw.msg("codemirror-toggle-label"),type:"toggle",oouiIcon:"highlight",action:{type:"callback",execute:function(){return e.switchCodeMirror()}}}}}}}),r.$toolbar.find(".tool[rel=CodeMirror]").attr("id","mw-editbutton-codemirror"),this.readOnly&&this.$textarea.data("wikiEditor-context").$ui.addClass("ext-codemirror-readonly"),this.useCodeMirror&&this.enableCodeMirror(),this.updateToolbarButton(),this.logUsage({editor:"wikitext",enabled:this.useCodeMirror,toggled:!1,edit_start_ts_ms:1e3*parseInt($('input[name="wpStarttime"]').val(),10)||0}))}},{key:"updateToolbarButton",value:function(){var e=$("#mw-editbutton-codemirror");e.toggleClass("mw-editbutton-codemirror-active",this.useCodeMirror),e.data("setActive")&&e.data("setActive")(this.useCodeMirror)}},{key:"switchCodeMirror",value:function(){if(this.view){this.setCodeMirrorPreference(!1);var e=this.view.scrollDOM.scrollTop,t=this.view.hasFocus,r=this.view.state.selection.ranges[0],i=r.from,o=r.to;$(this.view.dom).textSelection("unregister"),this.$textarea.textSelection("unregister"),this.$textarea.val(this.view.state.doc.toString()),this.view.destroy(),this.view=null,this.$textarea.show(),t&&this.$textarea.trigger("focus"),this.$textarea.prop("selectionStart",Math.min(i,o)).prop("selectionEnd",Math.max(o,i)),this.$textarea.scrollTop(e),mw.hook("ext.CodeMirror.switch").fire(!1,this.$textarea)}else this.enableCodeMirror(),this.setCodeMirrorPreference(!0);this.updateToolbarButton(),this.logUsage({editor:"wikitext",enabled:this.useCodeMirror,toggled:!0,edit_start_ts_ms:1e3*parseInt($('input[name="wpStarttime"]').val(),10)||0})}}]),r}(t);mw.loader.getState("ext.wikiEditor")&&mw.hook("wikiEditor.toolbarReady").add((function(e){new i(e,r({bidiIsolation:"rtl"===e.attr("dir")})).addCodeMirrorToWikiEditor()}));

View file

@ -13,6 +13,7 @@ import { mwModeConfig } from './codemirror.mode.mediawiki.config';
/** /**
* @type {Decoration} * @type {Decoration}
* @private
*/ */
const isolate = Decoration.mark( { const isolate = Decoration.mark( {
class: 'cm-bidi-isolate', class: 'cm-bidi-isolate',
@ -22,6 +23,7 @@ const isolate = Decoration.mark( {
/** /**
* @param {EditorView} view * @param {EditorView} view
* @return {RangeSet} * @return {RangeSet}
* @private
*/ */
function computeIsolates( view ) { function computeIsolates( view ) {
const set = new RangeSetBuilder(); const set = new RangeSetBuilder();
@ -55,17 +57,17 @@ function computeIsolates( view ) {
} }
/** /**
* @class * @private
* @property {DecorationSet} isolates
* @property {Tree} tree
*/ */
class CodeMirrorBidiIsolation { class CodeMirrorBidiIsolation {
/** /**
* @constructor * @constructor
* @param {EditorView} view * @param {EditorView} view The editor view.
*/ */
constructor( view ) { constructor( view ) {
/** @type {DecorationSet} */
this.isolates = computeIsolates( view ); this.isolates = computeIsolates( view );
/** @type {Tree} */
this.tree = syntaxTree( view.state ); this.tree = syntaxTree( view.state );
} }
@ -84,6 +86,7 @@ class CodeMirrorBidiIsolation {
/** /**
* @type {PluginSpec} * @type {PluginSpec}
* @private
*/ */
const bidiIsolationSpec = { const bidiIsolationSpec = {
provide: ( plugin ) => { provide: ( plugin ) => {
@ -106,4 +109,14 @@ const bidiIsolationSpec = {
} }
}; };
/**
* Bidirectional isolation plugin for CodeMirror for use on RTL pages.
* This ensures HTML and MediaWiki tags are always displayed left-to-right.
*
* Use this plugin by passing in `bidiIsolation: true` when instantiating
* a [CodeMirrorModeMediaWiki]{@link CodeMirrorModeMediaWiki} object.
*
* @module CodeMirrorBidiIsolation
* @see https://codemirror.net/examples/bidi/
*/
export default ViewPlugin.fromClass( CodeMirrorBidiIsolation, bidiIsolationSpec ); export default ViewPlugin.fromClass( CodeMirrorBidiIsolation, bidiIsolationSpec );

View file

@ -3,39 +3,71 @@ import { EditorView, drawSelection, lineNumbers, highlightSpecialChars, keymap }
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { searchKeymap } from '@codemirror/search'; import { searchKeymap } from '@codemirror/search';
import { bracketMatching } from '@codemirror/language'; import { bracketMatching } from '@codemirror/language';
import CodemirrorTextSelection from './codemirror.textSelection'; import CodeMirrorTextSelection from './codemirror.textSelection';
require( '../ext.CodeMirror.data.js' ); require( '../ext.CodeMirror.data.js' );
/** /**
* @class CodeMirror * Interface for the CodeMirror editor.
* @property {jQuery} $textarea *
* @property {EditorView} view * @example
* @property {EditorState} state * mw.loader.using( [
* @property {boolean} readOnly * 'ext.CodeMirror.v6',
* @property {Function|null} editRecoveryHandler * 'ext.CodeMirror.v6.mode.mediawiki'
* @property {CodemirrorTextSelection} textSelection * ] ).then( ( require ) => {
* const CodeMirror = require( 'ext.CodeMirror.v6' );
* const mediawikiLang = require( 'ext.CodeMirror.v6.mode.mediawiki' );
* const cm = new CodeMirror( myTextarea );
* cm.initialize( [ cm.defaultExtensions, mediawikiLang() ] );
* } );
*/ */
export default class CodeMirror { class CodeMirror {
/** /**
* @constructor * Instantiate a new CodeMirror instance.
*
* @param {HTMLTextAreaElement|jQuery|string} textarea Textarea to add syntax highlighting to. * @param {HTMLTextAreaElement|jQuery|string} textarea Textarea to add syntax highlighting to.
* @constructor
*/ */
constructor( textarea ) { constructor( textarea ) {
/**
* The textarea that CodeMirror is bound to.
* @type {jQuery}
*/
this.$textarea = $( textarea ); this.$textarea = $( textarea );
/**
* The editor user interface.
* @type {EditorView}
*/
this.view = null; this.view = null;
/**
* The editor state.
* @type {EditorState}
*/
this.state = null; this.state = null;
/**
* Whether the textarea is read-only.
* @type {boolean}
*/
this.readOnly = this.$textarea.prop( 'readonly' ); this.readOnly = this.$textarea.prop( 'readonly' );
/**
* The [edit recovery]{@link https://www.mediawiki.org/wiki/Manual:Edit_Recovery} handler.
* @type {Function|null}
*/
this.editRecoveryHandler = null; this.editRecoveryHandler = null;
/**
* jQuery.textSelection overrides for CodeMirror.
* @type {CodeMirrorTextSelection}
*/
this.textSelection = null; this.textSelection = null;
} }
/** /**
* Default extensions used by CodeMirror.
* Extensions here should be applicable to all theoretical uses of CodeMirror in MediaWiki. * Extensions here should be applicable to all theoretical uses of CodeMirror in MediaWiki.
* Subclasses are safe to override this method if needed.
* *
* @see https://codemirror.net/docs/ref/#state.Extension * @see https://codemirror.net/docs/ref/#state.Extension
* @return {Extension[]} * @type {Extension|Extension[]}
* @stable to call
*/ */
get defaultExtensions() { get defaultExtensions() {
const extensions = [ const extensions = [
@ -77,8 +109,8 @@ export default class CodeMirror {
* This extension sets the height of the CodeMirror editor to match the textarea. * This extension sets the height of the CodeMirror editor to match the textarea.
* Override this method to change the height of the editor. * Override this method to change the height of the editor.
* *
* @return {Extension} * @type {Extension}
* @stable * @stable to call and override
*/ */
get heightExtension() { get heightExtension() {
return EditorView.theme( { return EditorView.theme( {
@ -92,11 +124,12 @@ export default class CodeMirror {
} }
/** /**
* This specifies which attributes get added to the .cm-content and .cm-editor elements. * This specifies which attributes get added to the `.cm-content` and `.cm-editor` elements.
* Subclasses are safe to override this method, but attributes here are considered vital. * Subclasses are safe to override this method, but attributes here are considered vital.
* *
* @see https://codemirror.net/docs/ref/#view.EditorView^contentAttributes * @see https://codemirror.net/docs/ref/#view.EditorView^contentAttributes
* @return {Extension} * @type {Extension}
* @stable to call and override
*/ */
get contentAttributesExtension() { get contentAttributesExtension() {
const classList = []; const classList = [];
@ -140,7 +173,9 @@ export default class CodeMirror {
* and we don't want localization to be overlooked by CodeMirror clients and subclasses. * and we don't want localization to be overlooked by CodeMirror clients and subclasses.
* *
* @see https://codemirror.net/examples/translate/ * @see https://codemirror.net/examples/translate/
* @return {Extension} * @type {Extension}
* @stable to call. Instead of overriding, pass in an additional `EditorState.phrases.of()`
* when calling `initialize()`.
*/ */
get phrasesExtension() { get phrasesExtension() {
return EditorState.phrases.of( { return EditorState.phrases.of( {
@ -165,7 +200,8 @@ export default class CodeMirror {
* which is the localization of 'codemirror-control-character' followed by the Unicode number. * which is the localization of 'codemirror-control-character' followed by the Unicode number.
* *
* @see https://codemirror.net/docs/ref/#view.highlightSpecialChars * @see https://codemirror.net/docs/ref/#view.highlightSpecialChars
* @return {Extension} * @type {Extension}
* @stable to call
*/ */
get specialCharsExtension() { get specialCharsExtension() {
// Keys are the decimal unicode number, values are the messages. // Keys are the decimal unicode number, values are the messages.
@ -220,10 +256,20 @@ export default class CodeMirror {
/** /**
* Setup CodeMirror and add it to the DOM. This will hide the original textarea. * Setup CodeMirror and add it to the DOM. This will hide the original textarea.
* *
* @param {Extension[]} extensions * @param {Extension|Extension[]} [extensions=this.defaultExtensions] Extensions to use.
* @stable * @fires CodeMirror~'ext.CodeMirror.initialize'
* @stable to call and override
*/ */
initialize( extensions = this.defaultExtensions ) { initialize( extensions = this.defaultExtensions ) {
/**
* Called just before CodeMirror is initialized.
* This can be used to manipulate the DOM to suit CodeMirror
* (i.e. if you manipulate WikiEditor's DOM, you may need this).
*
* @event CodeMirror~'ext.CodeMirror.initialize'
* @param {jQuery} $textarea The textarea that CodeMirror is bound to.
* @stable to use
*/
mw.hook( 'ext.CodeMirror.initialize' ).fire( this.$textarea ); mw.hook( 'ext.CodeMirror.initialize' ).fire( this.$textarea );
mw.hook( 'editRecovery.loadEnd' ).add( ( data ) => { mw.hook( 'editRecovery.loadEnd' ).add( ( data ) => {
this.editRecoveryHandler = data.fieldChangeHandler; this.editRecoveryHandler = data.fieldChangeHandler;
@ -264,7 +310,7 @@ export default class CodeMirror {
* Log usage of CodeMirror. * Log usage of CodeMirror.
* *
* @param {Object} data * @param {Object} data
* @stable * @stable to call
*/ */
logUsage( data ) { logUsage( data ) {
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -284,7 +330,7 @@ export default class CodeMirror {
* Save CodeMirror enabled preference. * Save CodeMirror enabled preference.
* *
* @param {boolean} prefValue True, if CodeMirror should be enabled by default, otherwise false. * @param {boolean} prefValue True, if CodeMirror should be enabled by default, otherwise false.
* @stable * @stable to call and override
*/ */
setCodeMirrorPreference( prefValue ) { setCodeMirrorPreference( prefValue ) {
// Skip for unnamed users // Skip for unnamed users
@ -298,12 +344,13 @@ export default class CodeMirror {
/** /**
* jQuery.textSelection overrides for CodeMirror. * jQuery.textSelection overrides for CodeMirror.
* *
* @see https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/jQuery.plugin.textSelection * @see jQuery.fn.textSelection
* @return {Object} * @type {Object}
* @private
*/ */
get cmTextSelection() { get cmTextSelection() {
if ( !this.textSelection ) { if ( !this.textSelection ) {
this.textSelection = new CodemirrorTextSelection( this.view ); this.textSelection = new CodeMirrorTextSelection( this.view );
} }
return { return {
getContents: () => this.textSelection.getContents(), getContents: () => this.textSelection.getContents(),
@ -317,3 +364,5 @@ export default class CodeMirror {
}; };
} }
} }
export default CodeMirror;

View file

@ -4,12 +4,23 @@ import { TagStyle, StreamParser } from '@codemirror/language';
/** /**
* Configuration for the MediaWiki highlighting mode for CodeMirror. * Configuration for the MediaWiki highlighting mode for CodeMirror.
* This is a separate class mainly to keep static configuration out of * This is a separate class mainly to keep static configuration out of
* the logic in CodeMirrorModeMediaWiki. * the logic in {@link CodeMirrorModeMediaWiki}.
* *
* @class CodeMirrorModeMediaWikiConfig * @module CodeMirrorModeMediaWikiConfig
*
* @example
* // within MediaWiki:
* import { mwModeConfig } from 'ext.CodeMirror.v6.mode.mediawiki';
* // Reference tags by their constants in the tags property.
* if ( tag === mwModeConfig.tags.htmlTagBracket ) {
* // …
* }
*/ */
class CodeMirrorModeMediaWikiConfig { class CodeMirrorModeMediaWikiConfig {
/**
* @internal
*/
constructor() { constructor() {
this.extHighlightStyles = []; this.extHighlightStyles = [];
this.tokenTable = this.defaultTokenTable; this.tokenTable = this.defaultTokenTable;
@ -23,6 +34,7 @@ class CodeMirrorModeMediaWikiConfig {
* @see https://www.mediawiki.org/wiki/Extension:CodeMirror#Extension_integration * @see https://www.mediawiki.org/wiki/Extension:CodeMirror#Extension_integration
* @param {string} tag * @param {string} tag
* @param {Tag} [parent] * @param {Tag} [parent]
* @private
* @internal * @internal
*/ */
addTag( tag, parent = null ) { addTag( tag, parent = null ) {
@ -39,6 +51,7 @@ class CodeMirrorModeMediaWikiConfig {
* *
* @param {string} token * @param {string} token
* @param {Tag} [parent] * @param {Tag} [parent]
* @private
* @internal * @internal
*/ */
addToken( token, parent = null ) { addToken( token, parent = null ) {
@ -88,7 +101,8 @@ class CodeMirrorModeMediaWikiConfig {
} }
/** /**
* Mapping of MediaWiki-esque token identifiers to a standardized lezer highlighting tag. * Mapping of MediaWiki-esque token identifiers to
* [standardized lezer highlighting tags]{@link https://lezer.codemirror.net/docs/ref/#highlight.tags}.
* Values are one of the default highlighting tags. The idea is to use as many default tags as * Values are one of the default highlighting tags. The idea is to use as many default tags as
* possible so that theming (such as dark mode) can be applied with minimal effort. The * possible so that theming (such as dark mode) can be applied with minimal effort. The
* semantic meaning of the tag may not really match how it is used, but as per CodeMirror docs, * semantic meaning of the tag may not really match how it is used, but as per CodeMirror docs,
@ -99,8 +113,9 @@ class CodeMirrorModeMediaWikiConfig {
* in highlightStyle(). * in highlightStyle().
* *
* @see https://lezer.codemirror.net/docs/ref/#highlight.tags * @see https://lezer.codemirror.net/docs/ref/#highlight.tags
* @member CodeMirrorModeMediaWikiConfig
* @type {Object<string>}
* @return {Object<string>} * @return {Object<string>}
* @internal
*/ */
get tags() { get tags() {
return { return {
@ -195,6 +210,7 @@ class CodeMirrorModeMediaWikiConfig {
* @see https://codemirror.net/docs/ref/#language.StreamParser.tokenTable * @see https://codemirror.net/docs/ref/#language.StreamParser.tokenTable
* @see https://lezer.codemirror.net/docs/ref/#highlight.Tag%5Edefine * @see https://lezer.codemirror.net/docs/ref/#highlight.Tag%5Edefine
* @return {Object<Tag>} * @return {Object<Tag>}
* @internal
*/ */
get defaultTokenTable() { get defaultTokenTable() {
return { return {
@ -229,6 +245,7 @@ class CodeMirrorModeMediaWikiConfig {
* @see https://codemirror.net/docs/ref/#language.TagStyle * @see https://codemirror.net/docs/ref/#language.TagStyle
* @param {StreamParser} context * @param {StreamParser} context
* @return {TagStyle[]} * @return {TagStyle[]}
* @internal
*/ */
getTagStyles( context ) { getTagStyles( context ) {
return [ return [
@ -491,4 +508,8 @@ class CodeMirrorModeMediaWikiConfig {
} }
} }
/**
* @member CodeMirrorModeMediaWikiConfig
* @type {CodeMirrorModeMediaWikiConfig}
*/
export const mwModeConfig = new CodeMirrorModeMediaWikiConfig(); export const mwModeConfig = new CodeMirrorModeMediaWikiConfig();

View file

@ -12,13 +12,26 @@ import templateFoldingExtension from './codemirror.templateFolding';
import bidiIsolationExtension from './codemirror.bidiIsolation'; import bidiIsolationExtension from './codemirror.bidiIsolation';
/** /**
* Adapted from the original CodeMirror 5 stream parser by Pavel Astakhov * MediaWiki language support for CodeMirror 6.
* Adapted from the original CodeMirror 5 stream parser by Pavel Astakhov.
* *
* @class CodeMirrorModeMediaWiki * @module CodeMirrorModeMediaWiki
*
* @example
* mw.loader.using( [
* 'ext.CodeMirror.v6',
* 'ext.CodeMirror.v6.mode.mediawiki'
* ] ).then( ( require ) => {
* const CodeMirror = require( 'ext.CodeMirror.v6' );
* const mediawikiLang = require( 'ext.CodeMirror.v6.mode.mediawiki' );
* const cm = new CodeMirror( myTextarea );
* cm.initialize( [ cm.defaultExtensions, mediawikiLang() ] );
* } );
*/ */
class CodeMirrorModeMediaWiki { class CodeMirrorModeMediaWiki {
/** /**
* @param {Object} config * @param {Object} config MediaWiki configuration as generated by DataScript.php
* @internal
*/ */
constructor( config ) { constructor( config ) {
this.config = config; this.config = config;
@ -45,6 +58,8 @@ class CodeMirrorModeMediaWiki {
* Register the ground tokens. These aren't referenced directly in the StreamParser, nor do * Register the ground tokens. These aren't referenced directly in the StreamParser, nor do
* they have a parent Tag, so we don't need them as constants like we do for other tokens. * they have a parent Tag, so we don't need them as constants like we do for other tokens.
* See this.makeLocalStyle() for how these tokens are used. * See this.makeLocalStyle() for how these tokens are used.
*
* @private
*/ */
registerGroundTokens() { registerGroundTokens() {
[ [
@ -735,6 +750,7 @@ class CodeMirrorModeMediaWiki {
/** /**
* @param {string} style * @param {string} style
* @return {string|Function} * @return {string|Function}
* @private
*/ */
eatWikiText( style ) { eatWikiText( style ) {
return ( stream, state ) => { return ( stream, state ) => {
@ -1026,6 +1042,7 @@ class CodeMirrorModeMediaWiki {
* @see https://phabricator.wikimedia.org/T108455 * @see https://phabricator.wikimedia.org/T108455
* *
* @param {StringStream} stream * @param {StringStream} stream
* @private
*/ */
prepareItalicForCorrection( stream ) { prepareItalicForCorrection( stream ) {
// See Parser::doQuotes() in MediaWiki Core, it works similarly. // See Parser::doQuotes() in MediaWiki Core, it works similarly.
@ -1058,6 +1075,7 @@ class CodeMirrorModeMediaWiki {
/** /**
* @see https://codemirror.net/docs/ref/#language.StreamParser * @see https://codemirror.net/docs/ref/#language.StreamParser
* @return {StreamParser} * @return {StreamParser}
* @private
*/ */
get mediawiki() { get mediawiki() {
return { return {
@ -1067,6 +1085,7 @@ class CodeMirrorModeMediaWiki {
* Initial State for the parser. * Initial State for the parser.
* *
* @return {Object} * @return {Object}
* @private
*/ */
startState: () => { startState: () => {
return { return {
@ -1087,6 +1106,7 @@ class CodeMirrorModeMediaWiki {
* *
* @param {Object} state * @param {Object} state
* @return {Object} * @return {Object}
* @private
*/ */
copyState: ( state ) => { copyState: ( state ) => {
return { return {
@ -1109,6 +1129,7 @@ class CodeMirrorModeMediaWiki {
* @param {StringStream} stream * @param {StringStream} stream
* @param {Object} state * @param {Object} state
* @return {string|null} * @return {string|null}
* @private
*/ */
token: ( stream, state ) => { token: ( stream, state ) => {
let style, p, t, f, let style, p, t, f,
@ -1195,6 +1216,10 @@ class CodeMirrorModeMediaWiki {
return t.style; return t.style;
}, },
/**
* @param {Object} state
* @private
*/
blankLine: ( state ) => { blankLine: ( state ) => {
if ( state.extMode && state.extMode.blankLine ) { if ( state.extMode && state.extMode.blankLine ) {
state.extMode.blankLine( state.extState ); state.extMode.blankLine( state.extState );
@ -1206,30 +1231,24 @@ class CodeMirrorModeMediaWiki {
* *
* @see CodeMirrorModeMediaWikiConfig.defaultTokenTable * @see CodeMirrorModeMediaWikiConfig.defaultTokenTable
* @return {Object<Tag>} * @return {Object<Tag>}
* @private
*/ */
tokenTable: this.tokenTable tokenTable: this.tokenTable
}; };
} }
} }
/**
* @typedef {Object} mediaWikiLangConfig
* @property {boolean} [bidiIsolation=false] Enable bidi isolation around HTML tags.
* This should generally always be enabled on RTL pages, but it comes with a performance cost.
*/
/** /**
* Gets a LanguageSupport instance for the MediaWiki mode. * Gets a LanguageSupport instance for the MediaWiki mode.
* *
* @example * @member CodeMirrorModeMediaWiki
* import CodeMirror from './codemirror'; * @method
* import { mediaWikiLang } from './codemirror.mode.mediawiki'; * @param {Object} [config] Configuration options for the MediaWiki mode.
* const cm = new CodeMirror( textarea ); * @param {boolean} [config.bidiIsolation=false] Enable bidi isolation around HTML tags.
* cm.initialize( [ ...cm.defaultExtensions, mediaWikiLang() ] ); * This should generally always be enabled on RTL pages, but it comes with a performance cost.
*
* @param {mediaWikiLangConfig} [config] Configuration options for the MediaWiki mode.
* @param {Object|null} [mwConfig] Ignore; used only by unit tests. * @param {Object|null} [mwConfig] Ignore; used only by unit tests.
* @return {LanguageSupport} * @return {LanguageSupport}
* @stable to call
*/ */
export default ( config = { bidiIsolation: false }, mwConfig = null ) => { export default ( config = { bidiIsolation: false }, mwConfig = null ) => {
mwConfig = mwConfig || mw.config.get( 'extCodeMirrorConfig' ); mwConfig = mwConfig || mw.config.get( 'extCodeMirrorConfig' );

View file

@ -8,25 +8,29 @@ import { mwModeConfig as modeConfig } from './codemirror.mode.mediawiki.config';
* Check if a SyntaxNode is a template bracket (`{{` or `}}`) * Check if a SyntaxNode is a template bracket (`{{` or `}}`)
* @param {SyntaxNode} node The SyntaxNode to check * @param {SyntaxNode} node The SyntaxNode to check
* @return {boolean} * @return {boolean}
* @private
*/ */
const isBracket = ( node ) => node.name.split( '_' ).includes( modeConfig.tags.templateBracket ), const isBracket = ( node ) => node.name.split( '_' ).includes( modeConfig.tags.templateBracket ),
/** /**
* Check if a SyntaxNode is a template delimiter (`|`) * Check if a SyntaxNode is a template delimiter (`|`)
* @param {SyntaxNode} node The SyntaxNode to check * @param {SyntaxNode} node The SyntaxNode to check
* @return {boolean} * @return {boolean}
* @private
*/ */
isDelimiter = ( node ) => node.name.split( '_' ).includes( modeConfig.tags.templateDelimiter ), isDelimiter = ( node ) => node.name.split( '_' ).includes( modeConfig.tags.templateDelimiter ),
/** /**
* Check if a SyntaxNode is part of a template, except for the brackets * Check if a SyntaxNode is part of a template, except for the brackets
* @param {SyntaxNode} node The SyntaxNode to check * @param {SyntaxNode} node The SyntaxNode to check
* @return {boolean} * @return {boolean}
* @private
*/ */
isTemplate = ( node ) => /-template[a-z\d-]+ground/u.test( node.name ) && !isBracket( node ), isTemplate = ( node ) => /-template[a-z\d-]+ground/u.test( node.name ) && !isBracket( node ),
/** /**
* Update the stack of opening (+) or closing (-) brackets * Update the stack of opening (+) or closing (-) brackets
* @param {EditorState} state EditorState instance * @param {EditorState} state EditorState instance
* @param {SyntaxNode} node The SyntaxNode of the bracket * @param {SyntaxNode} node The SyntaxNode of the bracket
* @return {1|-1} * @return {number}
* @private
*/ */
stackUpdate = ( state, node ) => state.sliceDoc( node.from, node.from + 1 ) === '{' ? 1 : -1; stackUpdate = ( state, node ) => state.sliceDoc( node.from, node.from + 1 ) === '{' ? 1 : -1;
@ -36,6 +40,7 @@ const isBracket = ( node ) => node.name.split( '_' ).includes( modeConfig.tags.t
* @param {number|SyntaxNode} posOrNode Position or node * @param {number|SyntaxNode} posOrNode Position or node
* @param {Tree|null} [tree] Syntax tree * @param {Tree|null} [tree] Syntax tree
* @return {{from: number, to: number}|null} * @return {{from: number, to: number}|null}
* @private
*/ */
const foldable = ( state, posOrNode, tree ) => { const foldable = ( state, posOrNode, tree ) => {
if ( typeof posOrNode === 'number' ) { if ( typeof posOrNode === 'number' ) {
@ -109,6 +114,7 @@ const foldable = ( state, posOrNode, tree ) => {
* Create a tooltip for folding a template * Create a tooltip for folding a template
* @param {EditorState} state EditorState instance * @param {EditorState} state EditorState instance
* @return {Tooltip|null} * @return {Tooltip|null}
* @private
*/ */
const create = ( state ) => { const create = ( state ) => {
const { selection: { main: { head } } } = state, const { selection: { main: { head } } } = state,
@ -146,7 +152,10 @@ const create = ( state ) => {
return null; return null;
}; };
/** @type {KeyBinding[]} */ /**
* @type {KeyBinding[]}
* @private
*/
const foldKeymap = [ const foldKeymap = [
{ {
// Fold the template at the selection/cursor // Fold the template at the selection/cursor
@ -221,7 +230,14 @@ const foldKeymap = [
{ key: 'Ctrl-Alt-]', run: unfoldAll } { key: 'Ctrl-Alt-]', run: unfoldAll }
]; ];
/** @type {Extension} */ /**
* CodeMirror extension providing
* [template folding](https://www.mediawiki.org/wiki/Help:Extension:CodeMirror#Template_folding)
* for the MediaWiki mode. This automatically applied when using {@link CodeMirrorModeMediaWiki}.
*
* @module CodeMirrorTemplateFolding
* @type {Extension}
*/
export default [ export default [
codeFolding( { codeFolding( {
placeholderDOM( view ) { placeholderDOM( view ) {

View file

@ -2,20 +2,26 @@ import { EditorView } from '@codemirror/view';
import { EditorSelection } from '@codemirror/state'; import { EditorSelection } from '@codemirror/state';
/** /**
* jQuery.textSelection implementation for CodeMirror. * [jQuery.textSelection]{@link jQuery.fn.textSelection} implementation for CodeMirror.
* This is registered to both the textarea and the `.cm-editor` element.
* *
* @see jQuery.fn.textSelection * @see jQuery.fn.textSelection
* @class CodemirrorTextSelection
* @property {EditorView} view
* @property {jQuery} $cmDom
*/ */
export default class CodemirrorTextSelection { class CodeMirrorTextSelection {
/** /**
* @constructor * @constructor
* @param {EditorView} view * @param {EditorView} view
*/ */
constructor( view ) { constructor( view ) {
/**
* The CodeMirror view.
* @type {EditorView}
*/
this.view = view; this.view = view;
/**
* The CodeMirror DOM.
* @type {jQuery}
*/
this.$cmDom = $( view.dom ); this.$cmDom = $( view.dom );
} }
@ -23,6 +29,7 @@ export default class CodemirrorTextSelection {
* Get the contents of the editor. * Get the contents of the editor.
* *
* @return {string} * @return {string}
* @stable to call
*/ */
getContents() { getContents() {
return this.view.state.doc.toString(); return this.view.state.doc.toString();
@ -33,6 +40,7 @@ export default class CodemirrorTextSelection {
* *
* @param {string} content * @param {string} content
* @return {jQuery} * @return {jQuery}
* @stable to call
*/ */
setContents( content ) { setContents( content ) {
this.view.dispatch( { this.view.dispatch( {
@ -48,8 +56,11 @@ export default class CodemirrorTextSelection {
/** /**
* Get the current caret position. * Get the current caret position.
* *
* @param {Object} options * @param {Object} [options]
* @param {boolean} [options.startAndEnd] Whether to return the start and end of the selection
* instead of the caret position.
* @return {number[]|number} * @return {number[]|number}
* @stable to call
*/ */
getCaretPosition( options ) { getCaretPosition( options ) {
if ( !options.startAndEnd ) { if ( !options.startAndEnd ) {
@ -65,6 +76,7 @@ export default class CodemirrorTextSelection {
* Scroll the editor to the current caret position. * Scroll the editor to the current caret position.
* *
* @return {jQuery} * @return {jQuery}
* @stable to call
*/ */
scrollToCaretPosition() { scrollToCaretPosition() {
const scrollEffect = EditorView.scrollIntoView( this.view.state.selection.main.head ); const scrollEffect = EditorView.scrollIntoView( this.view.state.selection.main.head );
@ -79,6 +91,7 @@ export default class CodemirrorTextSelection {
* Get the selected text. * Get the selected text.
* *
* @return {string} * @return {string}
* @stable to call
*/ */
getSelection() { getSelection() {
return this.view.state.sliceDoc( return this.view.state.sliceDoc(
@ -91,7 +104,10 @@ export default class CodemirrorTextSelection {
* Set the selected text. * Set the selected text.
* *
* @param {Object} options * @param {Object} options
* @param {number} options.start The start of the selection.
* @param {number} [options.end=options.start] The end of the selection.
* @return {jQuery} * @return {jQuery}
* @stable to call
*/ */
setSelection( options ) { setSelection( options ) {
this.view.dispatch( { this.view.dispatch( {
@ -106,6 +122,7 @@ export default class CodemirrorTextSelection {
* *
* @param {string} value * @param {string} value
* @return {jQuery} * @return {jQuery}
* @stable to call
*/ */
replaceSelection( value ) { replaceSelection( value ) {
this.view.dispatch( this.view.dispatch(
@ -118,13 +135,23 @@ export default class CodemirrorTextSelection {
* Encapsulate the selected text with the given values. * Encapsulate the selected text with the given values.
* *
* This is intentionally a near-identical implementation to jQuery.textSelection, * This is intentionally a near-identical implementation to jQuery.textSelection,
* except it uses CodeMirror's EditorState.changeByRange when there are multiple selections. * except it uses CodeMirror's
* [EditorState.changeByRange](https://codemirror.net/docs/ref/#state.EditorState.changeByRange)
* when there are multiple selections.
* *
* @see jQuery.fn.textSelection.encapsulateSelection * @todo Add support for 'ownline' and 'splitlines' options.
* @todo Add support for 'ownline', 'selectPeri' and 'splitlines' options.
* *
* @param {Object} options * @param {Object} options
* @param {string} [options.pre] The text to insert before the cursor/selection.
* @param {string} [options.post] The text to insert after the cursor/selection.
* @param {string} [options.peri] Text to insert between pre and post and select afterwards.
* @param {boolean} [options.replace=false] If there is a selection, replace it with peri
* instead of leaving it alone.
* @param {boolean} [options.selectPeri=true] Select the peri text if it was inserted.
* @param {number} [options.selectionStart] Position to start selection at.
* @param {number} [options.selectionEnd=options.selectionStart] Position to end selection at.
* @return {jQuery} * @return {jQuery}
* @stable to call
*/ */
encapsulateSelection( options ) { encapsulateSelection( options ) {
let selectedText, let selectedText,
@ -200,3 +227,5 @@ export default class CodemirrorTextSelection {
return this.$cmDom; return this.$cmDom;
} }
} }
export default CodeMirrorTextSelection;

View file

@ -4,19 +4,32 @@ import { EditorView } from '@codemirror/view';
import { LanguageSupport } from '@codemirror/language'; import { LanguageSupport } from '@codemirror/language';
/** /**
* @class CodeMirrorWikiEditor * CodeMirror integration with
* @property {LanguageSupport|Extension} langExtension * [WikiEditor](https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:WikiEditor).
* @property {boolean} useCodeMirror *
* Use this class if you want WikiEditor's toolbar. If you don't need the toolbar,
* using {@link CodeMirror} directly will be considerably more efficient.
*
* @extends CodeMirror
*/ */
export default class CodeMirrorWikiEditor extends CodeMirror { class CodeMirrorWikiEditor extends CodeMirror {
/** /**
* @constructor * @constructor
* @param {jQuery} $textarea * @param {jQuery} $textarea The textarea to replace with CodeMirror.
* @param {LanguageSupport|Extension} langExtension * @param {LanguageSupport|Extension} langExtension Language support and its extension(s).
* @stable to call and override
*/ */
constructor( $textarea, langExtension ) { constructor( $textarea, langExtension ) {
super( $textarea ); super( $textarea );
/**
* Language support and its extension(s).
* @type {LanguageSupport|Extension}
*/
this.langExtension = langExtension; this.langExtension = langExtension;
/**
* Whether CodeMirror is currently enabled.
* @type {boolean}
*/
this.useCodeMirror = mw.user.options.get( 'usecodemirror' ) > 0; this.useCodeMirror = mw.user.options.get( 'usecodemirror' ) > 0;
} }
@ -30,7 +43,10 @@ export default class CodeMirrorWikiEditor extends CodeMirror {
} }
/** /**
* Replaces the default textarea with CodeMirror * Replaces the default textarea with CodeMirror.
*
* @fires CodeMirrorWikiEditor~'ext.CodeMirror.switch'
* @stable to call
*/ */
enableCodeMirror() { enableCodeMirror() {
// If CodeMirror is already loaded, abort. // If CodeMirror is already loaded, abort.
@ -48,7 +64,7 @@ export default class CodeMirrorWikiEditor extends CodeMirror {
* @see https://codemirror.net/docs/ref/#state.Extension * @see https://codemirror.net/docs/ref/#state.Extension
*/ */
const extensions = [ const extensions = [
...this.defaultExtensions, this.defaultExtensions,
this.langExtension, this.langExtension,
EditorView.domEventHandlers( { EditorView.domEventHandlers( {
blur: () => this.$textarea.triggerHandler( 'blur' ), blur: () => this.$textarea.triggerHandler( 'blur' ),
@ -76,11 +92,22 @@ export default class CodeMirrorWikiEditor extends CodeMirror {
this.view.focus(); this.view.focus();
} }
/**
* Called after CodeMirror is enabled or disabled in WikiEditor.
*
* @event CodeMirrorWikiEditor~'ext.CodeMirror.switch'
* @param {boolean} enabled Whether CodeMirror is enabled.
* @param {jQuery} $textarea The current "editor", either the
* original textarea or the `.cm-editor` element.
* @stable to use
*/
mw.hook( 'ext.CodeMirror.switch' ).fire( true, $( this.view.dom ) ); mw.hook( 'ext.CodeMirror.switch' ).fire( true, $( this.view.dom ) );
} }
/** /**
* Adds the CodeMirror button to WikiEditor * Adds the CodeMirror button to WikiEditor.
*
* @stable to call
*/ */
addCodeMirrorToWikiEditor() { addCodeMirrorToWikiEditor() {
const context = this.$textarea.data( 'wikiEditor-context' ); const context = this.$textarea.data( 'wikiEditor-context' );
@ -136,7 +163,9 @@ export default class CodeMirrorWikiEditor extends CodeMirror {
} }
/** /**
* Updates CodeMirror button on the toolbar according to the current state (on/off) * Updates CodeMirror button on the toolbar according to the current state (on/off).
*
* @private
*/ */
updateToolbarButton() { updateToolbarButton() {
// eslint-disable-next-line no-jquery/no-global-selector // eslint-disable-next-line no-jquery/no-global-selector
@ -150,7 +179,10 @@ export default class CodeMirrorWikiEditor extends CodeMirror {
} }
/** /**
* Enables or disables CodeMirror * Enables or disables CodeMirror.
*
* @fires CodeMirrorWikiEditor~'ext.CodeMirror.switch'
* @stable to call
*/ */
switchCodeMirror() { switchCodeMirror() {
if ( this.view ) { if ( this.view ) {
@ -186,3 +218,5 @@ export default class CodeMirrorWikiEditor extends CodeMirror {
} ); } );
} }
} }
export default CodeMirrorWikiEditor;