From 13c9eae26ee50f3ceb94fdf978ea16151657c3da Mon Sep 17 00:00:00 2001 From: MusikAnimal Date: Thu, 15 Aug 2024 21:52:13 -0400 Subject: [PATCH] CodeMirrorPreferences: add panel to tweak prefs with the editor open This is toggled by pressing Mod-Shift-, (or Command-Shift-, on MacOS), which then puts focus on the preferences panel. It can be closed with the Escape key, just like other CM panels. The CodeMirror class comes with these extension which can be toggled in preferences: * Bracket matching * Line numbering * Line wrapping * Highlight the active line * Show special characters Only bracket matching, line numbering, and line wrapping are available in the 2017 editor. The bidi isolation and template folding extensions are registered in CodeMirrorModeMediaWiki as they are MW-specific. CodeMirrorPreferences' new registerExtension() method allows any consumer of CodeMirror to add any arbitrary extensions to the preferences panel. This is expected to be called *after* CodeMirror has finished initializing. The 'ext.CodeMirror.ready' hook now passes the CodeMirror instance to accommodate this. The preferences are stored as a single user option in the database, called 'codemirror-preferences'. The defaults can be configured with the $wgCodeMirrorDefaultPreferences configuration setting. The sysadmin-facing values are the familiar boolean, but since CodeMirror is widely used, we make extra efforts to reduce the storage footprint (see T54777). This includes only storing preferences that differ from the defaults, and using binary representation instead of boolean values, since the user option is stored as a string. For now, all preferences are ignored in the 2017 editor. In a future patch, we may add some as toggleable Tools in the VE toolbar. Other changes: * Refactor CSS to use a .darkmode() mixin * Add a method to create a CSS-only fieldset in CodeMirrorPanel * Fix Jest tests now that there are more calls to mw.user.options.get() * Adjust Selenium tests to always use CM6 * Adjust Selenium tests to delete test pages (useful for local dev) * Remove unused code Bug: T359498 Change-Id: I70dcf2f49418cea632c452c1266440effad634f3 --- extension.json | 35 +- i18n/en.json | 9 + i18n/qqq.json | 9 + includes/DataScript.php | 1 + includes/Hooks.php | 4 + jsdoc.json | 9 +- resources/codemirror.js | 68 +++- resources/codemirror.less | 52 +-- resources/codemirror.mediawiki.js | 23 +- resources/codemirror.mediawiki.less | 15 +- resources/codemirror.mixins.less | 13 + resources/codemirror.panel.js | 25 +- resources/codemirror.preferences.js | 329 ++++++++++++++++++ .../codemirror.wikieditor.mediawiki.init.js | 10 +- resources/ve-cm/ve.ui.CodeMirrorAction.v6.js | 18 +- tests/jest/codemirror.bidiIsolation.test.js | 3 + tests/jest/codemirror.preferences.test.js | 77 ++++ tests/jest/setup.js | 9 +- tests/selenium/pageobjects/edit.page.js | 10 +- .../specs/highlighting-wikitext2010.js | 6 + .../specs/templateFolding-wikitext2010.js | 2 +- .../specs/textSelection-wikitext2010.js | 2 +- tests/selenium/userpreferences.js | 10 +- 23 files changed, 628 insertions(+), 111 deletions(-) create mode 100644 resources/codemirror.mixins.less create mode 100644 resources/codemirror.preferences.js create mode 100644 tests/jest/codemirror.preferences.test.js 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();