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/
/vendor
/docs

1
.gitignore vendored
View file

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

View file

@ -1,6 +1,4 @@
# mediawiki/extensions/CodeMirror
Homepage: https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:CodeMirror
CodeMirror 6 homepage: [https://www.mediawiki.org/wiki/Extension:CodeMirror/6](https://www.mediawiki.org/wiki/Extension:CodeMirror/6)
## 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.
* `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).
* `npm run doc` to generate the API documentation.
* `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: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:unit` for the new Jest unit tests.
* `npm run selenium-test` for the Selenium tests.
Older QUnit tests are in `resources/mode/mediawiki/tests/qunit/`. These will
eventually be moved over to `tests/qunit` and rewritten for CodeMirror 6.
* Older QUnit tests are in `resources/mode/mediawiki/tests/qunit/`. These have been
replaced and will be removed after the CodeMirror 6 upgrade.
## CodeMirror 6 change log
This is a list of changes that either come by default with the CodeMirror 6 upgrade,
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)).
* See [Extension:CodeMirror/6](https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:CodeMirror/6#Differences_from_CodeMirror_5)

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,
"scripts": {
"start": "rollup -c --watch",
@ -12,7 +12,8 @@
"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; }; }",
"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": {
"node": "18.17.0"
@ -41,6 +42,8 @@
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jquery": "3.7.1",
"jsdoc": "4.0.2",
"jsdoc-wmf-theme": "0.0.13",
"rollup": "4.13.0",
"rollup-plugin-copy": "3.5.0",
"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}
* @private
*/
const isolate = Decoration.mark( {
class: 'cm-bidi-isolate',
@ -22,6 +23,7 @@ const isolate = Decoration.mark( {
/**
* @param {EditorView} view
* @return {RangeSet}
* @private
*/
function computeIsolates( view ) {
const set = new RangeSetBuilder();
@ -55,17 +57,17 @@ function computeIsolates( view ) {
}
/**
* @class
* @property {DecorationSet} isolates
* @property {Tree} tree
* @private
*/
class CodeMirrorBidiIsolation {
/**
* @constructor
* @param {EditorView} view
* @param {EditorView} view The editor view.
*/
constructor( view ) {
/** @type {DecorationSet} */
this.isolates = computeIsolates( view );
/** @type {Tree} */
this.tree = syntaxTree( view.state );
}
@ -84,6 +86,7 @@ class CodeMirrorBidiIsolation {
/**
* @type {PluginSpec}
* @private
*/
const bidiIsolationSpec = {
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 );

View file

@ -3,39 +3,71 @@ import { EditorView, drawSelection, lineNumbers, highlightSpecialChars, keymap }
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { searchKeymap } from '@codemirror/search';
import { bracketMatching } from '@codemirror/language';
import CodemirrorTextSelection from './codemirror.textSelection';
import CodeMirrorTextSelection from './codemirror.textSelection';
require( '../ext.CodeMirror.data.js' );
/**
* @class CodeMirror
* @property {jQuery} $textarea
* @property {EditorView} view
* @property {EditorState} state
* @property {boolean} readOnly
* @property {Function|null} editRecoveryHandler
* @property {CodemirrorTextSelection} textSelection
* Interface for the CodeMirror editor.
*
* @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() ] );
* } );
*/
export default class CodeMirror {
class CodeMirror {
/**
* @constructor
* Instantiate a new CodeMirror instance.
*
* @param {HTMLTextAreaElement|jQuery|string} textarea Textarea to add syntax highlighting to.
* @constructor
*/
constructor( textarea ) {
/**
* The textarea that CodeMirror is bound to.
* @type {jQuery}
*/
this.$textarea = $( textarea );
/**
* The editor user interface.
* @type {EditorView}
*/
this.view = null;
/**
* The editor state.
* @type {EditorState}
*/
this.state = null;
/**
* Whether the textarea is read-only.
* @type {boolean}
*/
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;
/**
* jQuery.textSelection overrides for CodeMirror.
* @type {CodeMirrorTextSelection}
*/
this.textSelection = null;
}
/**
* Default extensions used by CodeMirror.
* 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
* @return {Extension[]}
* @type {Extension|Extension[]}
* @stable to call
*/
get defaultExtensions() {
const extensions = [
@ -77,8 +109,8 @@ export default class CodeMirror {
* This extension sets the height of the CodeMirror editor to match the textarea.
* Override this method to change the height of the editor.
*
* @return {Extension}
* @stable
* @type {Extension}
* @stable to call and override
*/
get heightExtension() {
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.
*
* @see https://codemirror.net/docs/ref/#view.EditorView^contentAttributes
* @return {Extension}
* @type {Extension}
* @stable to call and override
*/
get contentAttributesExtension() {
const classList = [];
@ -140,7 +173,9 @@ export default class CodeMirror {
* and we don't want localization to be overlooked by CodeMirror clients and subclasses.
*
* @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() {
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.
*
* @see https://codemirror.net/docs/ref/#view.highlightSpecialChars
* @return {Extension}
* @type {Extension}
* @stable to call
*/
get specialCharsExtension() {
// 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.
*
* @param {Extension[]} extensions
* @stable
* @param {Extension|Extension[]} [extensions=this.defaultExtensions] Extensions to use.
* @fires CodeMirror~'ext.CodeMirror.initialize'
* @stable to call and override
*/
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( 'editRecovery.loadEnd' ).add( ( data ) => {
this.editRecoveryHandler = data.fieldChangeHandler;
@ -264,7 +310,7 @@ export default class CodeMirror {
* Log usage of CodeMirror.
*
* @param {Object} data
* @stable
* @stable to call
*/
logUsage( data ) {
/* eslint-disable camelcase */
@ -284,7 +330,7 @@ export default class CodeMirror {
* Save CodeMirror enabled preference.
*
* @param {boolean} prefValue True, if CodeMirror should be enabled by default, otherwise false.
* @stable
* @stable to call and override
*/
setCodeMirrorPreference( prefValue ) {
// Skip for unnamed users
@ -298,12 +344,13 @@ export default class CodeMirror {
/**
* jQuery.textSelection overrides for CodeMirror.
*
* @see https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/jQuery.plugin.textSelection
* @return {Object}
* @see jQuery.fn.textSelection
* @type {Object}
* @private
*/
get cmTextSelection() {
if ( !this.textSelection ) {
this.textSelection = new CodemirrorTextSelection( this.view );
this.textSelection = new CodeMirrorTextSelection( this.view );
}
return {
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.
* 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 {
/**
* @internal
*/
constructor() {
this.extHighlightStyles = [];
this.tokenTable = this.defaultTokenTable;
@ -23,6 +34,7 @@ class CodeMirrorModeMediaWikiConfig {
* @see https://www.mediawiki.org/wiki/Extension:CodeMirror#Extension_integration
* @param {string} tag
* @param {Tag} [parent]
* @private
* @internal
*/
addTag( tag, parent = null ) {
@ -39,6 +51,7 @@ class CodeMirrorModeMediaWikiConfig {
*
* @param {string} token
* @param {Tag} [parent]
* @private
* @internal
*/
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
* 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,
@ -99,8 +113,9 @@ class CodeMirrorModeMediaWikiConfig {
* in highlightStyle().
*
* @see https://lezer.codemirror.net/docs/ref/#highlight.tags
* @member CodeMirrorModeMediaWikiConfig
* @type {Object<string>}
* @return {Object<string>}
* @internal
*/
get tags() {
return {
@ -195,6 +210,7 @@ class CodeMirrorModeMediaWikiConfig {
* @see https://codemirror.net/docs/ref/#language.StreamParser.tokenTable
* @see https://lezer.codemirror.net/docs/ref/#highlight.Tag%5Edefine
* @return {Object<Tag>}
* @internal
*/
get defaultTokenTable() {
return {
@ -229,6 +245,7 @@ class CodeMirrorModeMediaWikiConfig {
* @see https://codemirror.net/docs/ref/#language.TagStyle
* @param {StreamParser} context
* @return {TagStyle[]}
* @internal
*/
getTagStyles( context ) {
return [
@ -491,4 +508,8 @@ class CodeMirrorModeMediaWikiConfig {
}
}
/**
* @member CodeMirrorModeMediaWikiConfig
* @type {CodeMirrorModeMediaWikiConfig}
*/
export const mwModeConfig = new CodeMirrorModeMediaWikiConfig();

View file

@ -12,13 +12,26 @@ import templateFoldingExtension from './codemirror.templateFolding';
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 {
/**
* @param {Object} config
* @param {Object} config MediaWiki configuration as generated by DataScript.php
* @internal
*/
constructor( config ) {
this.config = config;
@ -45,6 +58,8 @@ class CodeMirrorModeMediaWiki {
* 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.
* See this.makeLocalStyle() for how these tokens are used.
*
* @private
*/
registerGroundTokens() {
[
@ -735,6 +750,7 @@ class CodeMirrorModeMediaWiki {
/**
* @param {string} style
* @return {string|Function}
* @private
*/
eatWikiText( style ) {
return ( stream, state ) => {
@ -1026,6 +1042,7 @@ class CodeMirrorModeMediaWiki {
* @see https://phabricator.wikimedia.org/T108455
*
* @param {StringStream} stream
* @private
*/
prepareItalicForCorrection( stream ) {
// See Parser::doQuotes() in MediaWiki Core, it works similarly.
@ -1058,6 +1075,7 @@ class CodeMirrorModeMediaWiki {
/**
* @see https://codemirror.net/docs/ref/#language.StreamParser
* @return {StreamParser}
* @private
*/
get mediawiki() {
return {
@ -1067,6 +1085,7 @@ class CodeMirrorModeMediaWiki {
* Initial State for the parser.
*
* @return {Object}
* @private
*/
startState: () => {
return {
@ -1087,6 +1106,7 @@ class CodeMirrorModeMediaWiki {
*
* @param {Object} state
* @return {Object}
* @private
*/
copyState: ( state ) => {
return {
@ -1109,6 +1129,7 @@ class CodeMirrorModeMediaWiki {
* @param {StringStream} stream
* @param {Object} state
* @return {string|null}
* @private
*/
token: ( stream, state ) => {
let style, p, t, f,
@ -1195,6 +1216,10 @@ class CodeMirrorModeMediaWiki {
return t.style;
},
/**
* @param {Object} state
* @private
*/
blankLine: ( state ) => {
if ( state.extMode && state.extMode.blankLine ) {
state.extMode.blankLine( state.extState );
@ -1206,30 +1231,24 @@ class CodeMirrorModeMediaWiki {
*
* @see CodeMirrorModeMediaWikiConfig.defaultTokenTable
* @return {Object<Tag>}
* @private
*/
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.
*
* @example
* import CodeMirror from './codemirror';
* import { mediaWikiLang } from './codemirror.mode.mediawiki';
* const cm = new CodeMirror( textarea );
* cm.initialize( [ ...cm.defaultExtensions, mediaWikiLang() ] );
*
* @param {mediaWikiLangConfig} [config] Configuration options for the MediaWiki mode.
* @member CodeMirrorModeMediaWiki
* @method
* @param {Object} [config] Configuration options for the MediaWiki mode.
* @param {boolean} [config.bidiIsolation=false] Enable bidi isolation around HTML tags.
* This should generally always be enabled on RTL pages, but it comes with a performance cost.
* @param {Object|null} [mwConfig] Ignore; used only by unit tests.
* @return {LanguageSupport}
* @stable to call
*/
export default ( config = { bidiIsolation: false }, mwConfig = null ) => {
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 `}}`)
* @param {SyntaxNode} node The SyntaxNode to check
* @return {boolean}
* @private
*/
const isBracket = ( node ) => node.name.split( '_' ).includes( modeConfig.tags.templateBracket ),
/**
* Check if a SyntaxNode is a template delimiter (`|`)
* @param {SyntaxNode} node The SyntaxNode to check
* @return {boolean}
* @private
*/
isDelimiter = ( node ) => node.name.split( '_' ).includes( modeConfig.tags.templateDelimiter ),
/**
* Check if a SyntaxNode is part of a template, except for the brackets
* @param {SyntaxNode} node The SyntaxNode to check
* @return {boolean}
* @private
*/
isTemplate = ( node ) => /-template[a-z\d-]+ground/u.test( node.name ) && !isBracket( node ),
/**
* Update the stack of opening (+) or closing (-) brackets
* @param {EditorState} state EditorState instance
* @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;
@ -36,6 +40,7 @@ const isBracket = ( node ) => node.name.split( '_' ).includes( modeConfig.tags.t
* @param {number|SyntaxNode} posOrNode Position or node
* @param {Tree|null} [tree] Syntax tree
* @return {{from: number, to: number}|null}
* @private
*/
const foldable = ( state, posOrNode, tree ) => {
if ( typeof posOrNode === 'number' ) {
@ -109,6 +114,7 @@ const foldable = ( state, posOrNode, tree ) => {
* Create a tooltip for folding a template
* @param {EditorState} state EditorState instance
* @return {Tooltip|null}
* @private
*/
const create = ( state ) => {
const { selection: { main: { head } } } = state,
@ -146,7 +152,10 @@ const create = ( state ) => {
return null;
};
/** @type {KeyBinding[]} */
/**
* @type {KeyBinding[]}
* @private
*/
const foldKeymap = [
{
// Fold the template at the selection/cursor
@ -221,7 +230,14 @@ const foldKeymap = [
{ 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 [
codeFolding( {
placeholderDOM( view ) {

View file

@ -2,20 +2,26 @@ import { EditorView } from '@codemirror/view';
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
* @class CodemirrorTextSelection
* @property {EditorView} view
* @property {jQuery} $cmDom
*/
export default class CodemirrorTextSelection {
class CodeMirrorTextSelection {
/**
* @constructor
* @param {EditorView} view
*/
constructor( view ) {
/**
* The CodeMirror view.
* @type {EditorView}
*/
this.view = view;
/**
* The CodeMirror DOM.
* @type {jQuery}
*/
this.$cmDom = $( view.dom );
}
@ -23,6 +29,7 @@ export default class CodemirrorTextSelection {
* Get the contents of the editor.
*
* @return {string}
* @stable to call
*/
getContents() {
return this.view.state.doc.toString();
@ -33,6 +40,7 @@ export default class CodemirrorTextSelection {
*
* @param {string} content
* @return {jQuery}
* @stable to call
*/
setContents( content ) {
this.view.dispatch( {
@ -48,8 +56,11 @@ export default class CodemirrorTextSelection {
/**
* 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}
* @stable to call
*/
getCaretPosition( options ) {
if ( !options.startAndEnd ) {
@ -65,6 +76,7 @@ export default class CodemirrorTextSelection {
* Scroll the editor to the current caret position.
*
* @return {jQuery}
* @stable to call
*/
scrollToCaretPosition() {
const scrollEffect = EditorView.scrollIntoView( this.view.state.selection.main.head );
@ -79,6 +91,7 @@ export default class CodemirrorTextSelection {
* Get the selected text.
*
* @return {string}
* @stable to call
*/
getSelection() {
return this.view.state.sliceDoc(
@ -91,7 +104,10 @@ export default class CodemirrorTextSelection {
* Set the selected text.
*
* @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}
* @stable to call
*/
setSelection( options ) {
this.view.dispatch( {
@ -106,6 +122,7 @@ export default class CodemirrorTextSelection {
*
* @param {string} value
* @return {jQuery}
* @stable to call
*/
replaceSelection( value ) {
this.view.dispatch(
@ -118,13 +135,23 @@ export default class CodemirrorTextSelection {
* Encapsulate the selected text with the given values.
*
* 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', 'selectPeri' and 'splitlines' options.
* @todo Add support for 'ownline' and 'splitlines' 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}
* @stable to call
*/
encapsulateSelection( options ) {
let selectedText,
@ -200,3 +227,5 @@ export default class CodemirrorTextSelection {
return this.$cmDom;
}
}
export default CodeMirrorTextSelection;

View file

@ -4,19 +4,32 @@ import { EditorView } from '@codemirror/view';
import { LanguageSupport } from '@codemirror/language';
/**
* @class CodeMirrorWikiEditor
* @property {LanguageSupport|Extension} langExtension
* @property {boolean} useCodeMirror
* CodeMirror integration with
* [WikiEditor](https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:WikiEditor).
*
* 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
* @param {jQuery} $textarea
* @param {LanguageSupport|Extension} langExtension
* @param {jQuery} $textarea The textarea to replace with CodeMirror.
* @param {LanguageSupport|Extension} langExtension Language support and its extension(s).
* @stable to call and override
*/
constructor( $textarea, langExtension ) {
super( $textarea );
/**
* Language support and its extension(s).
* @type {LanguageSupport|Extension}
*/
this.langExtension = langExtension;
/**
* Whether CodeMirror is currently enabled.
* @type {boolean}
*/
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() {
// If CodeMirror is already loaded, abort.
@ -48,7 +64,7 @@ export default class CodeMirrorWikiEditor extends CodeMirror {
* @see https://codemirror.net/docs/ref/#state.Extension
*/
const extensions = [
...this.defaultExtensions,
this.defaultExtensions,
this.langExtension,
EditorView.domEventHandlers( {
blur: () => this.$textarea.triggerHandler( 'blur' ),
@ -76,11 +92,22 @@ export default class CodeMirrorWikiEditor extends CodeMirror {
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 ) );
}
/**
* Adds the CodeMirror button to WikiEditor
* Adds the CodeMirror button to WikiEditor.
*
* @stable to call
*/
addCodeMirrorToWikiEditor() {
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() {
// 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() {
if ( this.view ) {
@ -186,3 +218,5 @@ export default class CodeMirrorWikiEditor extends CodeMirror {
} );
}
}
export default CodeMirrorWikiEditor;