diff --git a/extension.json b/extension.json index 3d2ba324..1ca99f0b 100644 --- a/extension.json +++ b/extension.json @@ -37,6 +37,19 @@ "value": null, "description": "List of namespace IDs where line numbering should be enabled, or `null` to enable for all namespaces. Set to [] to disable everywhere.", "public": true + }, + "CodeMirrorDefaultPreferences": { + "value": { + "activeLine": false, + "bidiIsolation": false, + "bracketMatching": true, + "lineNumbering": true, + "lineWrapping": true, + "specialChars": true, + "templateFolding": true + }, + "description": "Defaults for CodeMirror user preferences. See https://w.wiki/BwzZ for more information.", + "public": true } }, "MessagesDirs": { @@ -179,9 +192,10 @@ "packageFiles": [ "codemirror.js", "codemirror.textSelection.js", + "codemirror.panel.js", "codemirror.search.js", "codemirror.gotoLine.js", - "codemirror.panel.js", + "codemirror.preferences.js", { "name": "ext.CodeMirror.data.js", "callback": "MediaWiki\\Extension\\CodeMirror\\DataScript::makeScript" @@ -195,6 +209,7 @@ "CdxButton", "CdxCheckbox", "CdxLabel", + "CdxField", "CdxTextInput", "CdxToggleButton", "CdxToggleButtonGroup" @@ -203,21 +218,27 @@ "codemirror-all", "codemirror-all-tooltip", "codemirror-by-word", + "codemirror-close", "codemirror-control-character", "codemirror-done", "codemirror-find", - "codemirror-fold-template", + "codemirror-find-results", "codemirror-folded-code", "codemirror-goto-line", "codemirror-goto-line-go", "codemirror-match-case", "codemirror-next", + "codemirror-prefs-activeline", + "codemirror-prefs-bracketmatching", + "codemirror-prefs-linenumbering", + "codemirror-prefs-linewrapping", + "codemirror-prefs-specialchars", + "codemirror-prefs-title", "codemirror-previous", "codemirror-regexp", "codemirror-replace", "codemirror-replace-all", "codemirror-replace-placeholder", - "codemirror-find-results", "codemirror-special-char-backspace", "codemirror-special-char-bell", "codemirror-special-char-carriage-return", @@ -272,6 +293,11 @@ "dependencies": [ "ext.CodeMirror.v6", "ext.CodeMirror.v6.lib" + ], + "messages": [ + "codemirror-fold-template", + "codemirror-prefs-bidiisolation", + "codemirror-prefs-templatefolding" ] }, "ext.CodeMirror.v6.WikiEditor": { @@ -334,7 +360,8 @@ "ForeignResourcesDir": "resources/lib", "DefaultUserOptions": { "usecodemirror": 0, - "usecodemirror-colorblind": 0 + "usecodemirror-colorblind": 0, + "codemirror-preferences": "" }, "QUnitTestModule": { "localBasePath": "resources/legacy/mode/mediawiki/tests", diff --git a/i18n/en.json b/i18n/en.json index 636b9f15..aa34e7e6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -10,9 +10,18 @@ "codemirror-toggle-label-short": "Syntax", "codemirror-prefs-summary": "You can learn more about this feature by reading the [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Extension:CodeMirror help page].", "codemirror-prefs-enable": "Enable syntax highlighting for wikitext", + "codemirror-prefs-title": "Syntax highlighting preferences", + "codemirror-prefs-templatefolding": "Enable folding of template parameters", + "codemirror-prefs-bidiisolation": "Isolate bidirectional text", + "codemirror-prefs-bracketmatching": "Enable bracket matching", + "codemirror-prefs-linenumbering": "Show line numbers", + "codemirror-prefs-linewrapping": "Wrap lines", + "codemirror-prefs-activeline": "Highlight the active line", + "codemirror-prefs-specialchars": "Show special characters", "codemirror-v6-prefs-colorblind": "Use colorblind-friendly scheme", "codemirror-prefs-colorblind": "Enable colorblind-friendly scheme for syntax highlighting when editing wikitext", "codemirror-prefs-colorblind-help": "If you use a gadget for syntax highlighting, this preference will not work.", + "codemirror-close": "Close", "codemirror-find": "Find", "codemirror-next": "Find next", "codemirror-previous": "Find previous", diff --git a/i18n/qqq.json b/i18n/qqq.json index 14116b0f..78556725 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -15,9 +15,18 @@ "codemirror-toggle-label-short": "Label shown next to the CodeMirror icon in the editing toolbar. This message should be as brief as possible. {{msg-mw|codemirror-toggle-label}} is the full message, and is shown as the tooltip for the button.", "codemirror-prefs-summary": "Used in [[Special:Preferences]] in the section titled {{msg-mw|prefs-syntax-highlighting}}, at the top as a summary for the whole section.", "codemirror-prefs-enable": "Used in user preferences as label for enabling syntax highlighting.", + "codemirror-prefs-title": "Syntax highlighting preferences", + "codemirror-prefs-templatefolding": "Label for the option to enable folding of template parameters in the CodeMirror preferences panel.", + "codemirror-prefs-bidiisolation": "Label for the option to enable bidirectional text isolation in the CodeMirror preferences panel.", + "codemirror-prefs-bracketmatching": "Label for the option to enable bracket matching in the CodeMirror preferences panel.", + "codemirror-prefs-linenumbering": "Label for the option to show line numbers in the CodeMirror preferences panel.", + "codemirror-prefs-linewrapping": "Label for the option to wrap lines in the CodeMirror preferences panel.", + "codemirror-prefs-activeline": "Label for the option to highlight the active line in the CodeMirror preferences panel.", + "codemirror-prefs-specialchars": "Label for the option to show special characters in the CodeMirror preferences panel.", "codemirror-v6-prefs-colorblind": "Used in user preferences as label for enabling the colorblind-friendly option. This is a shorter version of {{msg-mw|codemirror-prefs-colorblind}} shown under section {{msg-mw|prefs-syntax-highlighting}} on wikis using CodeMirror 6.", "codemirror-prefs-colorblind": "Used in user preferences as label for enabling the colorblind-friendly option.", "codemirror-prefs-colorblind-help": "Used in user preferences as remark on the colorblind-friendly option.", + "codemirror-close": "Tooltip text for the 'Close' button in CodeMirror panels.", "codemirror-find": "Placeholder text for the input in the CodeMirror search panel.", "codemirror-next": "Tooltip text for the 'Find next' button in the CodeMirror search panel.", "codemirror-previous": "Tooltip text for the 'Find previous' button in the CodeMirror search panel.", diff --git a/includes/DataScript.php b/includes/DataScript.php index d4541658..35e43d10 100644 --- a/includes/DataScript.php +++ b/includes/DataScript.php @@ -59,6 +59,7 @@ class DataScript { // initialize configuration $config = [ 'useV6' => $mwConfig->get( 'CodeMirrorV6' ), + 'defaultPreferences' => $mwConfig->get( 'CodeMirrorDefaultPreferences' ), 'lineNumberingNamespaces' => $mwConfig->get( 'CodeMirrorLineNumberingNamespaces' ), 'templateFoldingNamespaces' => $mwConfig->get( 'CodeMirrorTemplateFoldingNamespaces' ), 'pluginModules' => $registry->getAttribute( 'CodeMirrorPluginModules' ), diff --git a/includes/Hooks.php b/includes/Hooks.php index 70f97034..a4f8af39 100644 --- a/includes/Hooks.php +++ b/includes/Hooks.php @@ -228,5 +228,9 @@ class Hooks implements 'section' => 'editing/syntax-highlighting', 'disable-if' => [ '!==', 'usecodemirror', '1' ] ]; + + $defaultPreferences['codemirror-preferences'] = [ + 'type' => 'api', + ]; } } diff --git a/jsdoc.json b/jsdoc.json index 10e5d843..736de4dc 100644 --- a/jsdoc.json +++ b/jsdoc.json @@ -25,7 +25,6 @@ "maintitle": "CodeMirror", "repository": "https://gerrit.wikimedia.org/g/mediawiki/extensions/CodeMirror", "linkMap": { - "jQuery.fn.textSelection": "https://doc.wikimedia.org/mediawiki-core/master/js/jQueryPlugins.html#.textSelection", "Compartment": "https://codemirror.net/docs/ref/#state.Compartment", "Decoration": "https://codemirror.net/docs/ref/#view.Decoration", "DecorationSet": "https://codemirror.net/docs/ref/#view.DecorationSet", @@ -35,6 +34,7 @@ "Extension": "https://codemirror.net/docs/ref/#state.Extension", "KeyBinding": "https://codemirror.net/docs/ref/#view.KeyBinding", "LanguageSupport": "https://codemirror.net/docs/ref/#language.LanguageSupport", + "mw.Api": "https://doc.wikimedia.org/mediawiki-core/master/js/mw.Api.html", "Panel": "https://codemirror.net/docs/ref/#view.Panel", "PluginSpec": "https://codemirror.net/docs/ref/#view.PluginSpec", "RangeSet": "https://codemirror.net/docs/ref/#state.RangeSet", @@ -49,7 +49,12 @@ "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", - "ve.ui.Surface": "https://doc.wikimedia.org/visualeditor-standalone/master/ve.ui.Surface.html" + "jQuery.fn.textSelection": "https://doc.wikimedia.org/mediawiki-core/master/js/jQueryPlugins.html#.textSelection", + "ve.dm.Selection": "https://doc.wikimedia.org/visualeditor-standalone/master/ve.dm.Selection.html", + "ve.dm.Transaction": "https://doc.wikimedia.org/visualeditor-standalone/master/ve.dm.Transaction.html", + "ve.ui.Action": "https://doc.wikimedia.org/visualeditor-standalone/master/ve.ui.Action.html", + "ve.ui.Surface": "https://doc.wikimedia.org/visualeditor-standalone/master/ve.ui.Surface.html", + "ve.ui.Tool": "https://doc.wikimedia.org/visualeditor-standalone/master/ve.ui.Tool.html" } } } diff --git a/resources/codemirror.js b/resources/codemirror.js index 56deb1e0..2e8e4c77 100644 --- a/resources/codemirror.js +++ b/resources/codemirror.js @@ -1,14 +1,15 @@ const { + Compartment, EditorState, EditorView, Extension, - Compartment, KeyBinding, ViewUpdate, bracketMatching, crosshairCursor, defaultKeymap, drawSelection, + highlightActiveLine, highlightSpecialChars, history, historyKeymap, @@ -20,6 +21,7 @@ const { const CodeMirrorTextSelection = require( './codemirror.textSelection.js' ); const CodeMirrorSearch = require( './codemirror.search.js' ); const CodeMirrorGotoLine = require( './codemirror.gotoLine.js' ); +const CodeMirrorPreferences = require( './codemirror.preferences.js' ); require( './ext.CodeMirror.data.js' ); /** @@ -96,17 +98,23 @@ class CodeMirror { */ this.textSelection = null; /** - * Compartment for the language direction Extension. + * Compartment to control the direction of the editor. * * @type {Compartment} */ this.dirCompartment = new Compartment(); /** - * Compartment for the special characters Extension. + * The CodeMirror preferences panel. * - * @type {Compartment} + * @type {CodeMirrorPreferences} */ - this.specialCharsCompartment = new Compartment(); + this.preferences = new CodeMirrorPreferences( { + bracketMatching: this.bracketMatchingExtension, + lineNumbering: this.lineNumberingExtension, + lineWrapping: this.lineWrappingExtension, + activeLine: this.activeLineExtension, + specialChars: this.specialCharsExtension + }, !!this.surface ); } /** @@ -121,12 +129,11 @@ class CodeMirror { const extensions = [ this.contentAttributesExtension, this.phrasesExtension, - this.specialCharsCompartment.of( this.specialCharsExtension ), this.heightExtension, this.updateExtension, - this.bracketMatchingExtension, this.dirExtension, this.searchExtension, + this.preferences.extension, EditorState.readOnly.of( this.readOnly ), EditorView.domEventHandlers( { blur: () => { @@ -136,7 +143,6 @@ class CodeMirror { this.$textarea[ 0 ].dispatchEvent( new Event( 'focus' ) ); } } ), - EditorView.lineWrapping, keymap.of( defaultKeymap ), EditorState.allowMultipleSelections.of( true ), drawSelection(), @@ -161,15 +167,36 @@ class CodeMirror { ) ); } - // Set to [] to disable everywhere, or null to enable everywhere - const namespaces = mw.config.get( 'extCodeMirrorConfig' ).lineNumberingNamespaces; - if ( !namespaces || namespaces.includes( mw.config.get( 'wgNamespaceNumber' ) ) ) { - extensions.push( lineNumbers() ); - } - return extensions; } + /** + * Extension for highlighting the active line. + * + * @return {Extension} + */ + get activeLineExtension() { + return highlightActiveLine(); + } + + /** + * Extension for line wrapping. + * + * @return {Extension} + */ + get lineWrappingExtension() { + return EditorView.lineWrapping; + } + + /** + * Extension for line numbering. + * + * @return {Extension|Extension[]} + */ + get lineNumberingExtension() { + return lineNumbers(); + } + /** * Extension for search and goto line functionality. * @@ -319,7 +346,7 @@ class CodeMirror { * @stable to call */ get specialCharsExtension() { - // Keys are the decimal unicode number, values are the messages. + // Keys are the decimal Unicode number, values are the messages. const messages = { 0: mw.msg( 'codemirror-special-char-null' ), 7: mw.msg( 'codemirror-special-char-bell' ), @@ -368,6 +395,12 @@ class CodeMirror { } ); } + /** + * This extension adds the ability to change the direction of the editor. + * + * @type {Extension} + * @stable to call + */ get dirExtension() { return [ this.dirCompartment.of( EditorView.editorAttributes.of( { @@ -449,10 +482,11 @@ class CodeMirror { * Called just after CodeMirror is initialized. * * @event CodeMirror~'ext.CodeMirror.ready' - * @param {jQuery} $view The CodeMirror view. + * @param {jQuery} $view The CodeMirror view element. + * @param {EditorState} state The CodeMirror instance. * @stable to use */ - mw.hook( 'ext.CodeMirror.ready' ).fire( $( this.view.dom ) ); + mw.hook( 'ext.CodeMirror.ready' ).fire( $( this.view.dom ), this ); } /** diff --git a/resources/codemirror.less b/resources/codemirror.less index c8ea3da5..a2112b54 100644 --- a/resources/codemirror.less +++ b/resources/codemirror.less @@ -1,38 +1,17 @@ @import 'mediawiki.skin.variables.less'; +@import './codemirror.mixins.less'; .cm-editor { border: @border-width-base @border-style-base @border-color-subtle; .cm-selectionBackground { background: #d9d9d9; - - @media screen { - html.skin-theme-clientpref-night & { - background: #222; - } - } - - @media screen and ( prefers-color-scheme: dark ) { - html.skin-theme-clientpref-os & { - background: #222; - } - } + .darkmode( background, #222 ); } &.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground { background: #d7d4f0; - - @media screen { - html.skin-theme-clientpref-night & { - background: #233; - } - } - - @media screen and ( prefers-color-scheme: dark ) { - html.skin-theme-clientpref-os & { - background: #233; - } - } + .darkmode( background, #233 ); } } @@ -85,6 +64,11 @@ border-left-color: @color-emphasized; } +.cm-editor .cm-activeLine { + background-color: rgba( 204, 238, 255, 0.27 ); + .darkmode( background-color, rgba( 71, 71, 124, 0.2 ) ); +} + .cm-editor .cm-tooltip { background-color: @background-color-neutral-subtle; border-color: @border-color-base; @@ -106,6 +90,11 @@ .cm-mw-panel { border-bottom: @border-style-base @border-width-base @border-color-subtle; padding: @spacing-50; + position: relative; + } + + .cm-mw-panel--fieldset legend { + margin-bottom: @spacing-50; } .cm-mw-panel--text-input { @@ -113,6 +102,10 @@ flex-grow: 1; } + .cm-mw-panel--checkbox { + margin-bottom: @spacing-25; + } + .cm-mw-panel--row { align-items: center; column-gap: @spacing-50; @@ -136,6 +129,12 @@ background-color: @color-inverted; } } + + .cm-mw-panel-close { + position: absolute; + right: @spacing-50; + top: @spacing-50; + } } .cm-mw-find-results { @@ -170,3 +169,8 @@ background-color: @color-base; .cdx-mixin-css-icon( @cdx-icon-next, @color-base, @size-icon-medium, true ); } + +.cm-mw-icon--close { + background-color: @color-base; + .cdx-mixin-css-icon( @cdx-icon-close, @color-base, @size-icon-medium, true ); +} diff --git a/resources/codemirror.mediawiki.js b/resources/codemirror.mediawiki.js index bebb783f..e98a1ab2 100644 --- a/resources/codemirror.mediawiki.js +++ b/resources/codemirror.mediawiki.js @@ -1286,19 +1286,16 @@ const mediaWikiLang = ( config = { bidiIsolation: false }, mwConfig = null ) => ) ) ]; - // Set to [] to disable everywhere, or null to enable everywhere. - const templateFoldingNs = mwConfig.templateFoldingNamespaces; - const shouldUseFolding = !templateFoldingNs || templateFoldingNs.includes( mw.config.get( 'wgNamespaceNumber' ) ); - // Add template folding if in supported namespace. - if ( shouldUseFolding && ( config.templateFolding || config.templateFolding === undefined ) ) { - langExtension.push( templateFoldingExtension ); - } - - // Bundle the bidi isolation extension, as it's coded specifically for MediaWiki. - // This is behind a config option for performance reasons (we only use it on RTL pages). - if ( config.bidiIsolation ) { - langExtension.push( bidiIsolationExtension ); - } + // Register MW-specific Extensions into CodeMirror preferences. Whether they are enabled + // or not is determined by the user's preferences and wiki configuration. + mw.hook( 'ext.CodeMirror.ready' ).add( ( $textarea, cm ) => { + if ( config.templateFolding !== false ) { + cm.preferences.registerExtension( 'templateFolding', templateFoldingExtension, cm.view ); + } + if ( config.bidiIsolation ) { + cm.preferences.registerExtension( 'bidiIsolation', bidiIsolationExtension, cm.view ); + } + } ); return new LanguageSupport( lang, langExtension ); }; diff --git a/resources/codemirror.mediawiki.less b/resources/codemirror.mediawiki.less index 78b6325c..e5fd85df 100644 --- a/resources/codemirror.mediawiki.less +++ b/resources/codemirror.mediawiki.less @@ -1,4 +1,5 @@ @import 'mediawiki.skin.variables.less'; +@import './codemirror.mixins.less'; @error-color: @color-destructive; @link-color: @color-progressive; @@ -23,20 +24,6 @@ background-color: average( average( @template-shade, @ext-shade ), @link-shade ); } -.darkmode( @prop, @value ) { - @media screen { - html.skin-theme-clientpref-night & { - @{prop}: @value; - } - } - - @media screen and ( prefers-color-scheme: dark ) { - html.skin-theme-clientpref-os & { - @{prop}: @value; - } - } -} - .wikitext-formatting-color { color: @wikitext-formatting-color; .darkmode( color, @wikitext-formatting-color-dark ); diff --git a/resources/codemirror.mixins.less b/resources/codemirror.mixins.less new file mode 100644 index 00000000..d79e2557 --- /dev/null +++ b/resources/codemirror.mixins.less @@ -0,0 +1,13 @@ +.darkmode( @prop, @value ) { + @media screen { + html.skin-theme-clientpref-night & { + @{prop}: @value; + } + } + + @media screen and ( prefers-color-scheme: dark ) { + html.skin-theme-clientpref-os & { + @{prop}: @value; + } + } +} diff --git a/resources/codemirror.panel.js b/resources/codemirror.panel.js index 939f60df..77978921 100644 --- a/resources/codemirror.panel.js +++ b/resources/codemirror.panel.js @@ -12,9 +12,7 @@ class CodeMirrorPanel { * @constructor */ constructor() { - /** - * @type {EditorView} - */ + /** @type {EditorView} */ this.view = undefined; } @@ -200,6 +198,27 @@ class CodeMirrorPanel { return btn; } + + /** + * Get a CSS-only Codex Fieldset. + * + * @param {string} legendText + * @param {...HTMLElement[]} fields + * @return {Element} + */ + getFieldset( legendText, ...fields ) { + const fieldset = document.createElement( 'fieldset' ); + fieldset.className = 'cm-mw-panel--fieldset cdx-field'; + const legend = document.createElement( 'legend' ); + legend.className = 'cdx-label'; + const innerSpan = document.createElement( 'span' ); + innerSpan.className = 'cdx-label__label__text'; + innerSpan.textContent = legendText; + legend.appendChild( innerSpan ); + fieldset.appendChild( legend ); + fieldset.append( ...fields ); + return fieldset; + } } module.exports = CodeMirrorPanel; diff --git a/resources/codemirror.preferences.js b/resources/codemirror.preferences.js new file mode 100644 index 00000000..a2c0e1f8 --- /dev/null +++ b/resources/codemirror.preferences.js @@ -0,0 +1,329 @@ +const { + Compartment, + EditorView, + Extension, + StateEffect, + StateEffectType, + StateField, + keymap, + showPanel +} = require( 'ext.CodeMirror.v6.lib' ); +const CodeMirrorPanel = require( './codemirror.panel.js' ); +require( './ext.CodeMirror.data.js' ); + +/** + * CodeMirrorPreferences is a panel that allows users to configure CodeMirror preferences. + * It is toggled by pressing `Mod`-`Shift`-`,` (or `Command`+`Shift`+`,` on macOS). + * + * Note that this code, like MediaWiki Core, refers to the user's preferences as "options". + * In this class, "preferences" refer to the user's preferences for CodeMirror, which + * are stored as a single user 'option' in the database. + */ +class CodeMirrorPreferences extends CodeMirrorPanel { + /** + * @param {Object} extensionRegistry Key-value pairs of CodeMirror Extensions. + * @param {boolean} [isVisualEditor=false] Whether the VE 2017 editor is being used. + */ + constructor( extensionRegistry, isVisualEditor = false ) { + super(); + + /** @type {string} */ + this.optionName = 'codemirror-preferences'; + + /** @type {boolean} */ + this.isVisualEditor = isVisualEditor; + + // VisualEditor only supports a subset of Extensions. + const veSupportedExtensions = [ + 'bracketMatching', + 'lineWrapping', + 'lineNumbering' + ]; + + /** + * Registry of CodeMirror Extensions that are made available to CodeMirrorPreferences. + * + * @type {Object} + */ + this.extensionRegistry = extensionRegistry; + + /** @type {mw.Api} */ + this.api = new mw.Api(); + + /** + * Registry of CodeMirror Compartments that are made available for + * reconfiguration in CodeMirrorPreferences. + * + * @type {Object} + */ + this.compartmentRegistry = {}; + for ( const extName of Object.keys( extensionRegistry ) ) { + if ( isVisualEditor && !veSupportedExtensions.includes( extName ) ) { + delete this.extensionRegistry[ extName ]; + continue; + } + this.compartmentRegistry[ extName ] = new Compartment(); + } + + /** @type {StateEffectType} */ + this.prefsToggleEffect = StateEffect.define(); + + /** @type {StateField} */ + this.panelStateField = StateField.define( { + create: () => true, + update: ( value, transaction ) => { + for ( const e of transaction.effects ) { + if ( e.is( this.prefsToggleEffect ) ) { + value = e.value; + } + } + return value; + }, + // eslint-disable-next-line arrow-body-style + provide: ( stateField ) => { + // eslint-disable-next-line arrow-body-style + return showPanel.from( stateField, ( on ) => { + return on ? () => this.panel : null; + } ); + } + } ); + + /** + * The user's CodeMirror preferences. + * + * @type {Object} + */ + this.preferences = this.fetchPreferences(); + } + + /** + * The default CodeMirror preferences, as defined by `$wgCodeMirrorPreferences`. + * + * @return {Object} + */ + get defaultPreferences() { + return mw.config.get( 'extCodeMirrorConfig' ).defaultPreferences; + } + + /** + * Fetch the user's CodeMirror preferences from the user options API, + * or clientside storage for unnamed users. + * + * @return {Object} + */ + fetchPreferences() { + let storageObj = this.defaultPreferences; + + if ( mw.user.isNamed() ) { + try { + storageObj = JSON.parse( mw.user.options.get( this.optionName ) ); + } catch ( e ) { + // Invalid JSON, or no preferences set. + } + } else { + storageObj = mw.storage.getObject( this.optionName ) || this.defaultPreferences; + } + + storageObj = Object.assign( {}, this.defaultPreferences, storageObj ); + + // Convert binary representation to boolean. + const preferences = {}; + for ( const prefName in storageObj ) { + preferences[ prefName ] = !!storageObj[ prefName ]; + } + return preferences; + } + + /** + * Set the given CodeMirror preference and update the user option in the database, + * or clientside storage for unnamed users. + * + * @param {string} key + * @param {Mixed} value + */ + setPreference( key, value ) { + this.preferences[ key ] = value; + + // Only save the preferences that differ from the defaults, + // and use a binary representation for storage. This is to prevent + // bloat of the user_properties table (T54777). + const storageObj = {}; + for ( const prefName in this.preferences ) { + if ( !!this.preferences[ prefName ] !== !!this.defaultPreferences[ prefName ] ) { + storageObj[ prefName ] = this.preferences[ prefName ] ? 1 : 0; + } + } + mw.user.options.set( this.optionName, JSON.stringify( storageObj ) ); + + // Save the preferences to the database or clientside storage. + if ( mw.user.isNamed() ) { + this.api.saveOption( this.optionName, JSON.stringify( storageObj ) ); + } else { + mw.storage.setObject( this.optionName, storageObj ); + } + } + + /** + * Get the value of the given CodeMirror preference. + * + * @param {string} prefName + * @return {boolean} + */ + getPreference( prefName ) { + // First check the preference explicitly set by the user. + // For now, we don't allow CodeMirror preferences to override + // config settings in the 2017 editor, since there's no UI to set them. + if ( !this.isVisualEditor && this.preferences[ prefName ] !== undefined ) { + return this.preferences[ prefName ]; + } + + // Otherwise, go by the defaults. + + // Some preferences can be set per-namespace through wiki configuration. + // Values are an array of namespace IDs, [] to disable everywhere, + // or null to enable everywhere. + const namespacePrefs = [ 'lineNumbering', 'templateFolding' ]; + if ( namespacePrefs.includes( prefName ) ) { + const namespaces = mw.config.get( 'extCodeMirrorConfig' )[ prefName + 'Namespaces' ]; + return !namespaces || namespaces.includes( mw.config.get( 'wgNamespaceNumber' ) ); + } + + // These preferences do not have configuration settings. + return this.defaultPreferences[ prefName ]; + } + + /** + * Register an {@link Extension} with CodeMirrorPreferences, along with a + * corresponding {@link Compartment} so that the Extension can be reconfigured. + * + * @param {string} name + * @param {Extension} extension + * @param {EditorView} view + * @internal + */ + registerExtension( name, extension, view ) { + this.extensionRegistry[ name ] = extension; + this.compartmentRegistry[ name ] = new Compartment(); + view.dispatch( { + effects: StateEffect.appendConfig.of( + this.compartmentRegistry[ name ].of( + this.getPreference( name ) ? this.extensionRegistry[ name ] : [] + ) + ) + } ); + } + + /** + * @inheritDoc + */ + get extension() { + return [ + keymap.of( { + key: 'Mod-Shift-,', + run: ( view ) => { + this.view = view; + const effects = [ this.prefsToggleEffect.of( true ) ]; + if ( !this.view.state.field( this.panelStateField, false ) ) { + effects.push( StateEffect.appendConfig.of( [ this.panelStateField ] ) ); + } + this.view.dispatch( { effects } ); + this.view.dom.querySelector( + '.cm-mw-preferences-panel input:first-child' + ).focus(); + return true; + } + } ), + // Compartmentalized extensions + Object.keys( this.extensionRegistry ).map( + ( name ) => this.compartmentRegistry[ name ].of( + // Only apply the extension if the preference (or default pref) is enabled. + this.getPreference( name ) ? this.extensionRegistry[ name ] : [] + ) + ) + ]; + } + + /** + * @inheritDoc + */ + get panel() { + const container = document.createElement( 'div' ); + container.className = 'cm-mw-preferences-panel cm-mw-panel'; + container.addEventListener( 'keydown', this.onKeydown.bind( this ) ); + + const wrappers = []; + for ( const prefName in this.extensionRegistry ) { + const [ wrapper ] = this.getCheckbox( + prefName, + `codemirror-prefs-${ prefName.toLowerCase() }`, + this.getPreference( prefName ) + ); + wrappers.push( wrapper ); + } + const fieldset = this.getFieldset( mw.msg( 'codemirror-prefs-title' ), ...wrappers ); + container.appendChild( fieldset ); + + const closeBtn = this.getButton( 'codemirror-close', 'close', true ); + closeBtn.classList.add( 'cdx-button--weight-quiet', 'cm-mw-panel-close' ); + container.appendChild( closeBtn ); + closeBtn.addEventListener( 'click', () => { + this.toggle( this.view, false ); + } ); + + return { + dom: container, + top: true + }; + } + + /** + * Toggle display of the preferences panel. + * + * @param {EditorView} view + * @param {boolean} [force] + * @return {boolean} + */ + toggle( view, force ) { + this.view = view; + const bool = typeof force === 'boolean' ? + force : + !this.view.state.field( this.panelStateField ); + this.view.dispatch( { + effects: this.prefsToggleEffect.of( bool ) + } ); + return true; + } + + /** + * Handle keydown events on the preferences panel. + * + * @param {KeyboardEvent} event + */ + onKeydown( event ) { + if ( event.key === 'Escape' ) { + event.preventDefault(); + this.toggle( this.view, false ); + this.view.focus(); + } else if ( event.key === 'Enter' ) { + event.preventDefault(); + } + } + + /** + * @inheritDoc + */ + getCheckbox( name, label, checked ) { + const compartment = this.compartmentRegistry[ name ]; + const extension = this.extensionRegistry[ name ]; + const [ wrapper, input ] = super.getCheckbox( name, label, checked ); + input.addEventListener( 'change', () => { + this.view.dispatch( { + effects: compartment.reconfigure( input.checked ? extension : [] ) + } ); + this.setPreference( name, input.checked ); + } ); + return [ wrapper, input ]; + } +} + +module.exports = CodeMirrorPreferences; diff --git a/resources/codemirror.wikieditor.mediawiki.init.js b/resources/codemirror.wikieditor.mediawiki.init.js index 1404bae4..1125014c 100644 --- a/resources/codemirror.wikieditor.mediawiki.init.js +++ b/resources/codemirror.wikieditor.mediawiki.init.js @@ -6,12 +6,10 @@ const urlParams = new URLSearchParams( window.location.search ); if ( mw.loader.getState( 'ext.wikiEditor' ) ) { mw.hook( 'wikiEditor.toolbarReady' ).add( ( $textarea ) => { - const cmWE = new CodeMirrorWikiEditor( - $textarea, - mediaWikiLang( { - bidiIsolation: $textarea.attr( 'dir' ) === 'rtl' && urlParams.get( 'cm6bidi' ) - } ) - ); + const mwLang = mediaWikiLang( { + bidiIsolation: urlParams.get( 'cm6bidi' ) + } ); + const cmWE = new CodeMirrorWikiEditor( $textarea, mwLang ); cmWE.addCodeMirrorToWikiEditor(); } ); } diff --git a/resources/ve-cm/ve.ui.CodeMirrorAction.v6.js b/resources/ve-cm/ve.ui.CodeMirrorAction.v6.js index d8d86a69..218a9226 100644 --- a/resources/ve-cm/ve.ui.CodeMirrorAction.v6.js +++ b/resources/ve-cm/ve.ui.CodeMirrorAction.v6.js @@ -85,21 +85,16 @@ ve.ui.CodeMirrorAction.prototype.toggle = function ( enable ) { 've-ce-documentNode-codeEditor-hide' ); - // TODO: pass bidiIsolation option to mediawikiLang() when it's more stable. surface.mirror.initialize( surface.mirror.defaultExtensions.concat( mediawikiLang( { + // These should never be enabled in VE + bidiIsolation: false, templateFolding: false } ), lineHeightExtension ) ); + // Force infinite viewport in CodeMirror to prevent misalignment of // the VE surface and the CodeMirror view. See T357482#10076432. surface.mirror.view.viewState.printing = true; - // Disable the Extension that highlights special characters. - surface.mirror.view.dispatch( { - effects: surface.mirror.specialCharsCompartment.reconfigure( - codeMirrorLib.EditorView.editorAttributes.of( [] ) - ) - } ); - // Account for the gutter width in the margin. action.updateGutterWidth( doc.getDir() ); @@ -158,7 +153,12 @@ ve.ui.CodeMirrorAction.prototype.toggle = function ( enable ) { * @param {string} dir Document direction */ ve.ui.CodeMirrorAction.prototype.updateGutterWidth = function ( dir ) { - const guttersWidth = this.surface.mirror.view.dom.querySelector( '.cm-gutters' ).getBoundingClientRect().width; + const gutter = this.surface.mirror.view.dom.querySelector( '.cm-gutters' ); + if ( !gutter ) { + // Line numbering is disabled. + return; + } + const guttersWidth = gutter.getBoundingClientRect().width; this.surface.getView().$documentNode.css( { 'margin-left': dir === 'rtl' ? 0 : guttersWidth, 'margin-right': dir === 'rtl' ? guttersWidth : 0 diff --git a/tests/jest/codemirror.bidiIsolation.test.js b/tests/jest/codemirror.bidiIsolation.test.js index 0ea8a08d..d1fda580 100644 --- a/tests/jest/codemirror.bidiIsolation.test.js +++ b/tests/jest/codemirror.bidiIsolation.test.js @@ -1,5 +1,6 @@ const CodeMirror = require( '../../resources/codemirror.js' ); const mediaWikiLang = require( '../../resources/codemirror.mediawiki.js' ); +const bidiIsolationExtension = require( '../../resources/codemirror.mediawiki.bidiIsolation.js' ); const testCases = [ { @@ -24,6 +25,8 @@ const mwLang = mediaWikiLang( { tags: { ref: true } } ); cm.initialize( [ ...cm.defaultExtensions, mwLang ] ); +// Normally ran by mw.hook, but we don't mock the hook system in the Jest tests. +cm.preferences.registerExtension( 'bidiIsolation', bidiIsolationExtension, cm.view ); describe( 'CodeMirrorBidiIsolation', () => { it.each( testCases )( diff --git a/tests/jest/codemirror.preferences.test.js b/tests/jest/codemirror.preferences.test.js new file mode 100644 index 00000000..61ffb9f7 --- /dev/null +++ b/tests/jest/codemirror.preferences.test.js @@ -0,0 +1,77 @@ +/* eslint-disable-next-line n/no-missing-require */ +const { Compartment, EditorView } = require( 'ext.CodeMirror.v6.lib' ); +const CodeMirrorPreferences = require( '../../resources/codemirror.preferences.js' ); + +describe( 'CodeMirrorPreferences', () => { + let mockDefaultPreferences, mockUserPreferences, getCodeMirrorPreferences; + + beforeEach( () => { + mockDefaultPreferences = ( config = { fooExtension: false, barExtension: true } ) => { + mw.config.get = jest.fn().mockReturnValue( { + defaultPreferences: config + } ); + }; + mockUserPreferences = ( preferences = {} ) => { + mw.user.options.get = jest.fn().mockReturnValue( preferences ); + }; + /* eslint-disable-next-line arrow-body-style */ + getCodeMirrorPreferences = () => { + return new CodeMirrorPreferences( { + fooExtension: EditorView.theme(), + barExtension: EditorView.theme() + } ); + }; + } ); + + it( 'defaultPreferences', () => { + mockDefaultPreferences(); + const preferences = getCodeMirrorPreferences(); + expect( preferences.defaultPreferences ).toStrictEqual( { + fooExtension: false, + barExtension: true + } ); + } ); + + it( 'fetchPreferences', () => { + mockDefaultPreferences(); + mockUserPreferences( '{"fooExtension":1}' ); + mw.user.isNamed = jest.fn().mockReturnValue( true ); + const preferences = getCodeMirrorPreferences(); + expect( preferences.fetchPreferences() ).toStrictEqual( { + fooExtension: true, + barExtension: true + } ); + } ); + + it( 'setPreference', () => { + mockDefaultPreferences(); + mw.user.isNamed = jest.fn().mockReturnValue( true ); + const preferences = getCodeMirrorPreferences(); + preferences.setPreference( 'fooExtension', true ); + expect( preferences.preferences.fooExtension ).toStrictEqual( true ); + expect( mw.user.options.set ).toHaveBeenCalledWith( 'codemirror-preferences', '{"fooExtension":1}' ); + expect( mw.Api.prototype.saveOption ).toHaveBeenCalledWith( 'codemirror-preferences', '{"fooExtension":1}' ); + } ); + + it( 'getPreference', () => { + mockDefaultPreferences(); + mockUserPreferences( '{"barExtension":0}' ); + const preferences = getCodeMirrorPreferences(); + expect( preferences.getPreference( 'fooExtension' ) ).toStrictEqual( false ); + expect( preferences.getPreference( 'barExtension' ) ).toStrictEqual( false ); + } ); + + it( 'registerExtension', () => { + mockDefaultPreferences( { fooExtension: false, barExtension: false } ); + mockUserPreferences( '{"fooExtension":0,"barExtension":1}' ); + const fooExtension = EditorView.theme(); + const barExtension = EditorView.theme(); + const preferences = getCodeMirrorPreferences( { fooExtension, barExtension } ); + const view = new EditorView(); + preferences.registerExtension( 'barExtension', barExtension, view ); + expect( preferences.extensionRegistry.barExtension ).toStrictEqual( barExtension ); + expect( preferences.compartmentRegistry.barExtension ).toBeInstanceOf( Compartment ); + expect( preferences.compartmentRegistry.barExtension.get( view.state ).length ) + .toStrictEqual( 2 ); + } ); +} ); diff --git a/tests/jest/setup.js b/tests/jest/setup.js index e7f1acb1..1d20bc00 100644 --- a/tests/jest/setup.js +++ b/tests/jest/setup.js @@ -6,8 +6,13 @@ jest.mock( '../../resources/ext.CodeMirror.data.js', () => jest.fn(), { virtual: global.mw = require( '@wikimedia/mw-node-qunit/src/mockMediaWiki.js' )(); mw.user = Object.assign( mw.user, { options: { - // Only called for 'usecodemirror' option. - get: jest.fn().mockReturnValue( 1 ), + get: jest.fn().mockImplementation( ( key ) => { + if ( key === 'codemirror-preferences' ) { + return '{"bracketMatching":1,"lineWrapping":1,"activeLine":0,"specialChars":1,"bidiIsolation":1}'; + } + // Only called for 'usecodemirror' option. + return '1'; + } ), set: jest.fn() }, sessionId: jest.fn().mockReturnValue( 'abc' ), diff --git a/tests/selenium/pageobjects/edit.page.js b/tests/selenium/pageobjects/edit.page.js index 796307f2..dd03d697 100644 --- a/tests/selenium/pageobjects/edit.page.js +++ b/tests/selenium/pageobjects/edit.page.js @@ -4,15 +4,13 @@ const Page = require( 'wdio-mediawiki/Page' ); // Copied from mediawiki-core edit.page.js class EditPage extends Page { - async openForEditing( title, cm6enable = false ) { + async openForEditing( title ) { const queryParams = { action: 'edit', vehidebetadialog: 1, - hidewelcomedialog: 1 + hidewelcomedialog: 1, + cm6enable: 1 }; - if ( cm6enable ) { - queryParams.cm6enable = '1'; - } await super.openTitle( title, queryParams ); } @@ -67,7 +65,7 @@ class EditPage extends Page { } get highlightedBrackets() { - return $$( '.CodeMirror-line .cm-mw-matchingbracket' ); + return $$( '.cm-line .cm-matchingBracket' ); } async getHighlightedMatchingBrackets() { diff --git a/tests/selenium/specs/highlighting-wikitext2010.js b/tests/selenium/specs/highlighting-wikitext2010.js index 14a065a9..a4ad9777 100644 --- a/tests/selenium/specs/highlighting-wikitext2010.js +++ b/tests/selenium/specs/highlighting-wikitext2010.js @@ -4,6 +4,7 @@ const assert = require( 'assert' ), EditPage = require( '../pageobjects/edit.page' ), FixtureContent = require( '../fixturecontent' ), UserPreferences = require( '../userpreferences' ), + Api = require( 'wdio-mediawiki/Api.js' ), Util = require( 'wdio-mediawiki/Util' ); describe( 'CodeMirror bracket match highlighting for the wikitext 2010 editor', () => { @@ -31,4 +32,9 @@ describe( 'CodeMirror bracket match highlighting for the wikitext 2010 editor', await EditPage.cursorToPosition( 3 ); assert.strictEqual( await EditPage.getHighlightedMatchingBrackets(), '{}' ); } ); + + after( async () => { + const bot = await Api.bot(); + bot.delete( title, 'Test cleanup' ).catch( ( e ) => console.error( e ) ); + } ); } ); diff --git a/tests/selenium/specs/templateFolding-wikitext2010.js b/tests/selenium/specs/templateFolding-wikitext2010.js index a4c27c32..0ad19a8a 100644 --- a/tests/selenium/specs/templateFolding-wikitext2010.js +++ b/tests/selenium/specs/templateFolding-wikitext2010.js @@ -15,7 +15,7 @@ describe( 'CodeMirror template folding for the wikitext 2010 editor', () => { await LoginPage.loginAdmin(); await FixtureContent.createFixturePage( title ); await UserPreferences.enableWikitext2010EditorWithCodeMirror(); - await EditPage.openForEditing( title, true ); + await EditPage.openForEditing( title ); await EditPage.wikiEditorToolbar.waitForDisplayed(); await browser.execute( () => { $( '.cm-editor' ).textSelection( 'setContents', '{{foo|1={{bar|{{baz|{{PAGENAME}}}}}}}}' ); diff --git a/tests/selenium/specs/textSelection-wikitext2010.js b/tests/selenium/specs/textSelection-wikitext2010.js index 65e2c950..06d45987 100644 --- a/tests/selenium/specs/textSelection-wikitext2010.js +++ b/tests/selenium/specs/textSelection-wikitext2010.js @@ -15,7 +15,7 @@ describe( 'CodeMirror textSelection for the wikitext 2010 editor', () => { await LoginPage.loginAdmin(); await FixtureContent.createFixturePage( title ); await UserPreferences.enableWikitext2010EditorWithCodeMirror(); - await EditPage.openForEditing( title, true ); + await EditPage.openForEditing( title ); await EditPage.wikiEditorToolbar.waitForDisplayed(); await EditPage.clickText(); } ); diff --git a/tests/selenium/userpreferences.js b/tests/selenium/userpreferences.js index cb2100c7..713b3e11 100644 --- a/tests/selenium/userpreferences.js +++ b/tests/selenium/userpreferences.js @@ -24,19 +24,11 @@ class UserPreferences { await this.setPreferences( { usebetatoolbar: '1', usecodemirror: '1', + 'codemirror-preferences': '{"bracketMatching":1,"lineWrapping":1,"activeLine":0,"specialChars":1,"bidiIsolation":0}', 'visualeditor-enable': '0', 'visualeditor-newwikitext': '0' } ); } - - async enableWikitext2017EditorWithCodeMirror() { - await this.setPreferences( { - usebetatoolbar: null, - usecodemirror: '1', - 'visualeditor-enable': '1', - 'visualeditor-newwikitext': '1' - } ); - } } module.exports = new UserPreferences();