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
This commit is contained in:
MusikAnimal 2024-08-15 21:52:13 -04:00
parent 5d6d0ba56a
commit 13c9eae26e
23 changed files with 628 additions and 111 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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.",

View file

@ -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' ),

View file

@ -228,5 +228,9 @@ class Hooks implements
'section' => 'editing/syntax-highlighting',
'disable-if' => [ '!==', 'usecodemirror', '1' ]
];
$defaultPreferences['codemirror-preferences'] = [
'type' => 'api',
];
}
}

View file

@ -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"
}
}
}

View file

@ -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 );
}
/**

View file

@ -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 );
}

View file

@ -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 );
};

View file

@ -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 );

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -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<Extension>}
*/
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<Compartment>}
*/
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;

View file

@ -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();
} );
}

View file

@ -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

View file

@ -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 )(

View file

@ -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 );
} );
} );

View file

@ -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' ),

View file

@ -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() {

View file

@ -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 ) );
} );
} );

View file

@ -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}}}}}}}}' );

View file

@ -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();
} );

View file

@ -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();