CodeMirror 6 for VE 2017 wikitext editor

Add new temporary ext.CodeMirror.visualEditor.init RL module which
selects the temporary ext.CodeMirror.visualEditor.v6 or non-v6 based on
$wgCodeMirrorV6. This will allow us to deploy CM6 further.

As a result of this work, the core CodeMirror class now has knowledge
of ve.ui.Surface.

Other changes:
* Add Compartment for specialCharsExtension so it can be disabled in VE.
* Add option to mediaWikiLang() to disable template folding.
* Add support for RTL wikis where $wgCodeMirrorRTL is enabled.
* Make CodeMirror.logUsage() and setCodeMirrorPreference() static.
* Fix unit and linting tests.

Some code courtesy of Fandom, GPLv2-or-later; see:
https://github.com/Wikia/mediawiki-extensions-CodeMirror/commit/ef297c48c

Bug: T357482
Change-Id: I15453b33e77e1c1b4d5e5183e41e53d56ff14c3e
This commit is contained in:
bhsd 2024-06-08 11:10:11 +08:00 committed by MusikAnimal
parent 7d3482f89e
commit 925775778a
14 changed files with 599 additions and 45 deletions

View file

@ -1,4 +1,3 @@
/resources/lib/ /resources/lib/
/resources/dist/
/vendor /vendor
/docs /docs

View file

@ -278,6 +278,34 @@
"packageFiles": [ "packageFiles": [
"codemirror.wikieditor.mediawiki.init.js" "codemirror.wikieditor.mediawiki.init.js"
] ]
},
"ext.CodeMirror.v6.visualEditor": {
"dependencies": [
"ext.visualEditor.mwcore",
"ext.visualEditor.mwmeta",
"mediawiki.api",
"user.options",
"ext.CodeMirror.v6"
],
"scripts": [
"ve-cm/ve.ui.CodeMirrorAction.v6.js",
"ve-cm/ve.ui.CodeMirrorTool.v6.js"
],
"styles": [
"ve-cm/ve.ui.CodeMirror.v6.less"
],
"messages": [
"codemirror-toggle-label"
]
},
"ext.CodeMirror.visualEditor.init": {
"packageFiles": [
"ve-cm/ve.ui.CodeMirror.init.js",
{
"name": "ext.CodeMirror.data.js",
"callback": "MediaWiki\\Extension\\CodeMirror\\DataScript::makeScript"
}
]
} }
}, },
"ResourceFileModulePaths": { "ResourceFileModulePaths": {
@ -333,7 +361,7 @@
}, },
"VisualEditor": { "VisualEditor": {
"PluginModules": [ "PluginModules": [
"ext.CodeMirror.visualEditor" "ext.CodeMirror.visualEditor.init"
] ]
}, },
"EventLogging": { "EventLogging": {

View file

@ -58,8 +58,10 @@ class DataScript {
// initialize configuration // initialize configuration
$config = [ $config = [
'useV6' => $mwConfig->get( 'CodeMirrorV6' ),
'lineNumberingNamespaces' => $mwConfig->get( 'CodeMirrorLineNumberingNamespaces' ), 'lineNumberingNamespaces' => $mwConfig->get( 'CodeMirrorLineNumberingNamespaces' ),
'templateFoldingNamespaces' => $mwConfig->get( 'CodeMirrorTemplateFoldingNamespaces' ), 'templateFoldingNamespaces' => $mwConfig->get( 'CodeMirrorTemplateFoldingNamespaces' ),
'isSupportedRtlWiki' => $mwConfig->get( 'CodeMirrorRTL' ),
'pluginModules' => $registry->getAttribute( 'CodeMirrorPluginModules' ), 'pluginModules' => $registry->getAttribute( 'CodeMirrorPluginModules' ),
'tagModes' => $tagModes, 'tagModes' => $tagModes,
'tags' => array_fill_keys( $tagNames, true ), 'tags' => array_fill_keys( $tagNames, true ),

View file

@ -44,7 +44,8 @@
"TagStyle": "https://codemirror.net/docs/ref/#language.TagStyle", "TagStyle": "https://codemirror.net/docs/ref/#language.TagStyle",
"Tooltip": "https://codemirror.net/docs/ref/#view.Tooltip", "Tooltip": "https://codemirror.net/docs/ref/#view.Tooltip",
"Tree": "https://lezer.codemirror.net/docs/ref/#common.Tree", "Tree": "https://lezer.codemirror.net/docs/ref/#common.Tree",
"ViewUpdate": "https://codemirror.net/docs/ref/#view.ViewUpdate" "ViewUpdate": "https://codemirror.net/docs/ref/#view.ViewUpdate",
"ve.ui.Surface": "https://doc.wikimedia.org/visualeditor-standalone/master/ve.ui.Surface.html"
} }
} }
} }

View file

@ -13,7 +13,8 @@
"commonjs": true "commonjs": true
}, },
"globals": { "globals": {
"Tree": "readonly" "Tree": "readonly",
"ve": "readonly"
}, },
"rules": { "rules": {
"max-len": "off", "max-len": "off",

View file

@ -37,10 +37,23 @@ class CodeMirror {
/** /**
* Instantiate a new CodeMirror instance. * Instantiate a new CodeMirror instance.
* *
* @param {HTMLTextAreaElement|jQuery|string} textarea Textarea to add syntax highlighting to. * @param {HTMLTextAreaElement|jQuery|string|ve.ui.Surface} textarea Textarea to
* add syntax highlighting to.
* @constructor * @constructor
*/ */
constructor( textarea ) { constructor( textarea ) {
if ( textarea.constructor.name === 'VeUiMWWikitextSurface' ) {
/**
* The VisualEditor surface CodeMirror is bound to.
*
* @type {ve.ui.Surface}
*/
this.surface = textarea;
// Let the content editable mimic the textarea.
// eslint-disable-next-line no-jquery/variable-pattern
textarea = this.surface.getView().$attachedRootNode;
}
/** /**
* The textarea that CodeMirror is bound to. * The textarea that CodeMirror is bound to.
* *
@ -64,7 +77,9 @@ class CodeMirror {
* *
* @type {boolean} * @type {boolean}
*/ */
this.readOnly = this.$textarea.prop( 'readonly' ); this.readOnly = this.surface ?
this.surface.getModel().isReadOnly() :
this.$textarea.prop( 'readonly' );
/** /**
* The [edit recovery]{@link https://www.mediawiki.org/wiki/Manual:Edit_Recovery} handler. * The [edit recovery]{@link https://www.mediawiki.org/wiki/Manual:Edit_Recovery} handler.
* *
@ -78,11 +93,17 @@ class CodeMirror {
*/ */
this.textSelection = null; this.textSelection = null;
/** /**
* Language direction extension. * Compartment for the language direction Extension.
* *
* @type {Compartment} * @type {Compartment}
*/ */
this.dirCompartment = new Compartment(); this.dirCompartment = new Compartment();
/**
* Compartment for the special characters Extension.
*
* @type {Compartment}
*/
this.specialCharsCompartment = new Compartment();
} }
/** /**
@ -97,7 +118,7 @@ class CodeMirror {
const extensions = [ const extensions = [
this.contentAttributesExtension, this.contentAttributesExtension,
this.phrasesExtension, this.phrasesExtension,
this.specialCharsExtension, this.specialCharsCompartment.of( this.specialCharsExtension ),
this.heightExtension, this.heightExtension,
this.updateExtension, this.updateExtension,
this.bracketMatchingExtension, this.bracketMatchingExtension,
@ -187,7 +208,7 @@ class CodeMirror {
get heightExtension() { get heightExtension() {
return EditorView.theme( { return EditorView.theme( {
'&': { '&': {
height: `${ this.$textarea.outerHeight() }px` height: this.surface ? '100%' : `${ this.$textarea.outerHeight() }px`
}, },
'.cm-scroller': { '.cm-scroller': {
overflow: 'auto' overflow: 'auto'
@ -205,19 +226,22 @@ class CodeMirror {
*/ */
get contentAttributesExtension() { get contentAttributesExtension() {
const classList = []; const classList = [];
// T245568: Sync text editor font preferences with CodeMirror // T245568: Sync text editor font preferences with CodeMirror,
const fontClass = Array.from( this.$textarea[ 0 ].classList ) // but don't do this for the 2017 wikitext editor.
.find( ( style ) => style.startsWith( 'mw-editfont-' ) ); if ( !this.surface ) {
if ( fontClass ) { const fontClass = Array.from( this.$textarea[ 0 ].classList )
classList.push( fontClass ); .find( ( style ) => style.startsWith( 'mw-editfont-' ) );
} if ( fontClass ) {
// Add colorblind mode if preference is set. classList.push( fontClass );
// This currently is only to be used for the MediaWiki markup language. }
if ( // Add colorblind mode if preference is set.
mw.user.options.get( 'usecodemirror-colorblind' ) && // This currently is only to be used for the MediaWiki markup language.
mw.config.get( 'wgPageContentModel' ) === 'wikitext' if (
) { mw.user.options.get( 'usecodemirror-colorblind' ) &&
classList.push( 'cm-mw-colorblind-colors' ); mw.config.get( 'wgPageContentModel' ) === 'wikitext'
) {
classList.push( 'cm-mw-colorblind-colors' );
}
} }
return [ return [
@ -379,7 +403,9 @@ class CodeMirror {
// Set up the initial EditorState of CodeMirror with contents of the native textarea. // Set up the initial EditorState of CodeMirror with contents of the native textarea.
this.state = EditorState.create( { this.state = EditorState.create( {
doc: this.$textarea.textSelection( 'getContents' ), doc: this.surface ?
this.surface.getDom() :
this.$textarea.textSelection( 'getContents' ),
extensions extensions
} ); } );
@ -387,15 +413,17 @@ class CodeMirror {
this.addCodeMirrorToDom(); this.addCodeMirrorToDom();
// Hide native textarea and sync CodeMirror contents upon submission. // Hide native textarea and sync CodeMirror contents upon submission.
this.$textarea.hide(); if ( !this.surface ) {
if ( this.$textarea[ 0 ].form ) { this.$textarea.hide();
this.$textarea[ 0 ].form.addEventListener( 'submit', () => { if ( this.$textarea[ 0 ].form ) {
this.$textarea.val( this.view.state.doc.toString() ); this.$textarea[ 0 ].form.addEventListener( 'submit', () => {
const scrollTop = document.getElementById( 'wpScrolltop' ); this.$textarea.val( this.view.state.doc.toString() );
if ( scrollTop ) { const scrollTop = document.getElementById( 'wpScrolltop' );
scrollTop.value = this.view.scrollDOM.scrollTop; if ( scrollTop ) {
} scrollTop.value = this.view.scrollDOM.scrollTop;
} ); }
} );
}
} }
// Register $.textSelection() on the .cm-editor element. // Register $.textSelection() on the .cm-editor element.
@ -426,7 +454,9 @@ class CodeMirror {
this.view = new EditorView( { this.view = new EditorView( {
state: this.state, state: this.state,
parent: this.$textarea.parent()[ 0 ] parent: this.surface ?
this.surface.getView().$element[ 0 ] :
this.$textarea.parent()[ 0 ]
} ); } );
} }
@ -443,7 +473,9 @@ class CodeMirror {
$( this.view.dom ).textSelection( 'unregister' ); $( this.view.dom ).textSelection( 'unregister' );
this.$textarea.textSelection( 'unregister' ); this.$textarea.textSelection( 'unregister' );
this.$textarea.unwrap( '.ext-codemirror-wrapper' ); this.$textarea.unwrap( '.ext-codemirror-wrapper' );
this.$textarea.val( this.view.state.doc.toString() ); if ( !this.surface ) {
this.$textarea.val( this.view.state.doc.toString() );
}
this.view.destroy(); this.view.destroy();
this.view = null; this.view = null;
this.$textarea.show(); this.$textarea.show();
@ -470,8 +502,10 @@ class CodeMirror {
* *
* @param {Object} data * @param {Object} data
* @stable to call * @stable to call
* @internal
* @ignore
*/ */
logUsage( data ) { static logUsage( data ) {
/* eslint-disable camelcase */ /* eslint-disable camelcase */
const event = Object.assign( { const event = Object.assign( {
session_token: mw.user.sessionId(), session_token: mw.user.sessionId(),
@ -491,7 +525,7 @@ class CodeMirror {
* @param {boolean} prefValue True, if CodeMirror should be enabled by default, otherwise false. * @param {boolean} prefValue True, if CodeMirror should be enabled by default, otherwise false.
* @stable to call and override * @stable to call and override
*/ */
setCodeMirrorPreference( prefValue ) { static setCodeMirrorPreference( prefValue ) {
// Skip for unnamed users // Skip for unnamed users
if ( !mw.user.isNamed() ) { if ( !mw.user.isNamed() ) {
return; return;

View file

@ -1262,6 +1262,7 @@ class CodeMirrorModeMediaWiki {
* @param {Object} [config] Configuration options for the MediaWiki mode. * @param {Object} [config] Configuration options for the MediaWiki mode.
* @param {boolean} [config.bidiIsolation=false] Enable bidi isolation around HTML tags. * @param {boolean} [config.bidiIsolation=false] Enable bidi isolation around HTML tags.
* This should generally always be enabled on RTL pages, but it comes with a performance cost. * This should generally always be enabled on RTL pages, but it comes with a performance cost.
* @param {boolean} [config.templateFolding=true] Enable template folding.
* @param {Object|null} [mwConfig] Ignore; used only by unit tests. * @param {Object|null} [mwConfig] Ignore; used only by unit tests.
* @return {LanguageSupport} * @return {LanguageSupport}
* @stable to call * @stable to call
@ -1277,10 +1278,11 @@ const mediaWikiLang = ( config = { bidiIsolation: false }, mwConfig = null ) =>
) )
) ]; ) ];
// Add template folding if in supported namespace.
const templateFoldingNs = mwConfig.templateFoldingNamespaces;
// Set to [] to disable everywhere, or null to enable everywhere. // Set to [] to disable everywhere, or null to enable everywhere.
if ( !templateFoldingNs || templateFoldingNs.includes( mw.config.get( 'wgNamespaceNumber' ) ) ) { 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 ); langExtension.push( templateFoldingExtension );
} }

View file

@ -63,7 +63,7 @@ class CodeMirrorWikiEditor extends CodeMirror {
setCodeMirrorPreference( prefValue ) { setCodeMirrorPreference( prefValue ) {
// Save state for function updateToolbarButton() // Save state for function updateToolbarButton()
this.useCodeMirror = prefValue; this.useCodeMirror = prefValue;
super.setCodeMirrorPreference( prefValue ); CodeMirror.setCodeMirrorPreference( prefValue );
} }
/** /**
@ -193,7 +193,7 @@ class CodeMirrorWikiEditor extends CodeMirror {
} }
this.updateToolbarButton(); this.updateToolbarButton();
this.logUsage( { CodeMirror.logUsage( {
editor: 'wikitext', editor: 'wikitext',
enabled: this.useCodeMirror, enabled: this.useCodeMirror,
toggled: false, toggled: false,
@ -235,7 +235,7 @@ class CodeMirrorWikiEditor extends CodeMirror {
} }
this.updateToolbarButton(); this.updateToolbarButton();
this.logUsage( { CodeMirror.logUsage( {
editor: 'wikitext', editor: 'wikitext',
enabled: this.useCodeMirror, enabled: this.useCodeMirror,
toggled: true, toggled: true,

View file

@ -0,0 +1,7 @@
require( '../../ext.CodeMirror.data.js' );
if ( mw.config.get( 'extCodeMirrorConfig' ).useV6 ) {
mw.loader.load( 'ext.CodeMirror.v6.visualEditor' );
} else {
mw.loader.load( 'ext.CodeMirror.visualEditor' );
}

View file

@ -0,0 +1,131 @@
.ve-init-mw-desktopArticleTarget {
.cm-editor {
background: transparent;
border: 0;
box-sizing: border-box;
height: auto;
left: 0;
pointer-events: none;
// stylelint-disable-next-line declaration-no-important
position: absolute !important;
top: 0;
width: 100%;
// Skin specific paddings
.skin-vector & {
padding: 0 1rem;
@media screen and ( min-width: 982px ) {
.skin-vector-legacy& {
padding: 0 1.5rem;
}
}
}
.skin-minerva &,
.skin-monobook & {
padding: 0;
}
}
.cm-gutters {
background-color: transparent;
padding-right: 8px;
/* @noflip */
border-right: 0;
}
/* RTL rules need to apply to the content, not the interface language */
.cm-editor[ dir='rtl' ] .cm-gutters {
/* @noflip */
padding-left: 8px;
/* @noflip */
padding-right: initial;
}
.cm-focused {
outline: 0;
}
.cm-line {
padding: 0;
}
.cm-content {
padding: 0;
}
.cm-gutterElement {
// stylelint-disable-next-line declaration-no-important
padding: 0 3px 0 4px !important;
}
.CodeMirror-selected {
display: none;
}
// Ensure surfaces are using identical font rules
.cm-scroller,
.cm-scroller *,
.ve-ui-mwWikitextSurface .ve-ce-paragraphNode {
// The following are already set by mw-editfont-monospace on the parent: font-size, font-family
word-wrap: break-word;
// Support: Chrome<76, Firefox<69
// Fallback for browsers which don't support break-spaces
white-space: pre-wrap;
word-break: normal;
-webkit-hyphens: manual;
-moz-hyphens: manual;
-ms-hyphens: manual;
hyphens: manual;
-webkit-font-variant-ligatures: contextual;
font-variant-ligatures: contextual;
// Monospace fonts can change width when bold
// stylelint-disable-next-line declaration-no-important
font-weight: normal !important;
// T252965
line-break: initial;
}
.cm-mw-section-1,
.cm-mw-section-1 ~ *,
.cm-mw-section-2,
.cm-mw-section-2 ~ *,
/* TODO: remove overqualified `span` after CM6 upgrade */
span.cm-mw-section-3 ~ *,
span.cm-mw-section-4 ~ *,
span.cm-mw-section-5 ~ *,
span.cm-mw-section-6 ~ * {
font-size: inherit;
line-height: inherit;
font-weight: inherit;
}
.mw-content-ltr .ve-ce-paragraphNode {
margin-left: 6px !important; /* stylelint-disable-line declaration-no-important */
}
.mw-content-rtl .ve-ce-paragraphNode {
/* @noflip */
margin-right: 6px !important; /* stylelint-disable-line declaration-no-important */
}
.cm-activeLineGutter {
background-color: transparent;
border-right: 0;
}
}
.ve-ce-documentNode-codeEditor-hide {
opacity: 0.4;
&::selection,
& *::selection {
background: #6da9f7 !important; /* stylelint-disable-line declaration-no-important */
}
}
.ve-ce-documentNode-codeEditor-webkit-hide {
-webkit-text-fill-color: transparent;
}

View file

@ -0,0 +1,253 @@
/*!
* VisualEditor UserInterface CodeMirrorAction class.
*/
/**
* CodeMirror action
*
* @class
* @extends ve.ui.Action
* @constructor
* @param {ve.ui.Surface} surface Surface to act on
*/
ve.ui.CodeMirrorAction = function VeUiCodeMirrorAction() {
// Parent constructor
ve.ui.CodeMirrorAction.super.apply( this, arguments );
};
/* Inheritance */
OO.inheritClass( ve.ui.CodeMirrorAction, ve.ui.Action );
/* Static Properties */
ve.ui.CodeMirrorAction.static.name = 'codeMirror';
/**
* @inheritdoc
*/
ve.ui.CodeMirrorAction.static.methods = [ 'toggle' ];
/* Methods */
/**
* @method
* @param {boolean} [enable] State to force toggle to, inverts current state if undefined
* @return {boolean} Action was executed
*/
ve.ui.CodeMirrorAction.prototype.toggle = function ( enable ) {
const action = this,
surface = this.surface,
surfaceView = surface.getView(),
doc = surface.getModel().getDocument();
if ( !surface.mirror && enable !== false ) {
surface.mirror = true;
mw.loader.using( [
'ext.CodeMirror.v6',
'ext.CodeMirror.v6.lib',
'ext.CodeMirror.v6.mode.mediawiki',
'jquery.client'
] ).then( ( require ) => {
const CodeMirror = require( 'ext.CodeMirror.v6' );
const codeMirrorLib = require( 'ext.CodeMirror.v6.lib' );
const mediawikiLang = require( 'ext.CodeMirror.v6.mode.mediawiki' );
if ( !surface.mirror ) {
// Action was toggled to false since promise started
return;
}
// The VE/CM overlay technique only works with monospace fonts
// (as we use width-changing bold as a highlight) so revert any editfont user preference
surfaceView.$element.removeClass( 'mw-editfont-sans-serif mw-editfont-serif' )
.addClass( 'mw-editfont-monospace' );
if ( mw.user.options.get( 'usecodemirror-colorblind' ) ) {
surfaceView.$element.addClass( 'cm-mw-colorblind-colors' );
}
surface.mirror = new CodeMirror( surface );
const lineHeightExtension = codeMirrorLib.EditorView.theme( {
'.cm-content': {
lineHeight: 1.5
}
} );
const profile = $.client.profile();
const supportsTransparentText = 'WebkitTextFillColor' in document.body.style &&
// Disable on Firefox+OSX (T175223)
!( profile.layout === 'gecko' && profile.platform === 'mac' );
surfaceView.$documentNode.addClass(
supportsTransparentText ?
've-ce-documentNode-codeEditor-webkit-hide' :
've-ce-documentNode-codeEditor-hide'
);
// TODO: pass bidiIsolation option to mediawikiLang() when it's more stable.
surface.mirror.initialize( surface.mirror.defaultExtensions.concat( mediawikiLang( {
templateFolding: false
} ), lineHeightExtension ) );
// 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() );
// Set focus on the surface view.
surfaceView.focus();
/* Events */
// As the action is regenerated each time, we need to store bound listeners
// in the mirror for later disconnection.
surface.mirror.veTransactionListener = action.onDocumentPrecommit.bind( action );
surface.mirror.veSelectListener = action.onSelect.bind( action );
surface.mirror.vePositionListener = action.onPosition.bind( action );
doc.on( 'precommit', surface.mirror.veTransactionListener );
surface.getModel().on( 'select', surface.mirror.veSelectListener );
surfaceView.on( 'position', surface.mirror.vePositionListener );
} );
} else if ( surface.mirror && enable !== true ) {
if ( surface.mirror !== true ) {
surfaceView.off( 'position', surface.mirror.vePositionListener );
doc.off( 'precommit', surface.mirror.veTransactionListener );
surface.getModel().off( 'select', surface.mirror.veSelectListener );
// Restore edit-font
// eslint-disable-next-line mediawiki/class-doc
surfaceView.$element.removeClass( 'mw-editfont-monospace' )
.addClass( 'mw-editfont-' + mw.user.options.get( 'editfont' ) );
surfaceView.$documentNode.removeClass(
've-ce-documentNode-codeEditor-webkit-hide',
've-ce-documentNode-codeEditor-hide'
);
// Reset gutter.
surfaceView.$documentNode.css( {
'margin-left': '',
'margin-right': ''
} );
// Set focus on the surface view.
surface.getView().focus();
surface.mirror.destroy();
surface.mirror.view = null;
}
surface.mirror = null;
}
return true;
};
/**
* Update margins to account for the CodeMirror gutter.
*
* @param {string} dir Document direction
*/
ve.ui.CodeMirrorAction.prototype.updateGutterWidth = function ( dir ) {
const guttersWidth = this.surface.mirror.view.dom.querySelector( '.cm-gutters' ).offsetWidth;
this.surface.getView().$documentNode.css( {
'margin-left': dir === 'rtl' ? 0 : guttersWidth - 6,
'margin-right': dir === 'rtl' ? guttersWidth - 6 : 0
} );
// Also update width of .cm-content due to apparent Chromium bug.
this.surface.mirror.view.contentDOM.style.width = 'calc(100% - ' + ( guttersWidth + 1 ) + 'px)';
};
/**
* Mirror document directionality changes to CodeMirror.
*/
ve.ui.CodeMirrorAction.prototype.onPosition = function () {
const codeMirrorLib = require( 'ext.CodeMirror.v6.lib' );
const veDir = this.surface.getView().getDocument().getDir();
const cmView = this.surface.mirror.view;
const cmDir = cmView.textDirection === codeMirrorLib.Direction.LTR ? 'ltr' : 'rtl';
if ( veDir !== cmDir ) {
cmView.dispatch( {
effects: this.surface.mirror.dirCompartment.reconfigure(
codeMirrorLib.EditorView.editorAttributes.of( { dir: veDir } )
)
} );
this.updateGutterWidth( veDir );
}
};
/**
* Handle select events from the surface model
*
* @param {ve.dm.Selection} selection
*/
ve.ui.CodeMirrorAction.prototype.onSelect = function ( selection ) {
const range = selection.getCoveringRange();
// Do not re-trigger bracket matching as long as something is selected
if ( !range || !range.isCollapsed() ) {
return;
}
const offset = this.surface.getModel().getSourceOffsetFromOffset( range.from );
this.surface.mirror.view.dispatch( {
selection: {
anchor: offset,
head: offset
}
} );
};
/**
* Handle precommit events from the document.
*
* The document is still in it's 'old' state before the transaction
* has been applied at this point.
*
* @param {ve.dm.Transaction} tx
*/
ve.ui.CodeMirrorAction.prototype.onDocumentPrecommit = function ( tx ) {
const replacements = [],
action = this,
store = this.surface.getModel().getDocument().getStore();
let offset = 0;
const documentNode = document.querySelector( '.ve-ce-documentNode' );
const guttersWidth = parseInt( document.querySelector( '.cm-gutters' ).offsetWidth );
const marginProperty = this.surface.getModel().getDocument().getDir() === 'rtl' ? 'margin-right' : 'margin-left';
// XXX: Why 6px?
documentNode.style[ marginProperty ] = ( guttersWidth - 6 ) + 'px';
tx.operations.forEach( ( op ) => {
if ( op.type === 'retain' ) {
offset += op.length;
} else if ( op.type === 'replace' ) {
replacements.push( {
from: action.surface.getModel().getSourceOffsetFromOffset( offset ),
to: action.surface.getModel().getSourceOffsetFromOffset( offset + op.remove.length ),
insert: new ve.dm.ElementLinearData( store, op.insert ).getSourceText()
} );
offset += op.remove.length;
}
} );
// Apply replacements in reverse to avoid having to shift offsets
for ( let i = replacements.length - 1; i >= 0; i-- ) {
this.surface.mirror.view.dispatch( { changes: replacements[ i ] } );
}
};
/* Registration */
// eslint-disable-next-line no-jquery/no-global-selector
const isRTL = $( '.mw-body-content .mw-parser-output' ).attr( 'dir' ) === 'rtl';
if ( !isRTL || ( isRTL && mw.config.get( 'extCodeMirrorConfig' ).isSupportedRtlWiki ) ) {
ve.ui.actionFactory.register( ve.ui.CodeMirrorAction );
}

View file

@ -0,0 +1,93 @@
/**
* VisualEditor UserInterface CodeMirror tool.
*
* @class
* @abstract
* @extends ve.ui.Tool
* @constructor
* @param {OO.ui.ToolGroup} toolGroup
* @param {Object} [config] Configuration options
*/
ve.ui.CodeMirrorTool = function VeUiCodeMirrorTool() {
// Parent constructor
ve.ui.CodeMirrorTool.super.apply( this, arguments );
this.extCodeMirror = require( 'ext.CodeMirror.v6' );
// Events
this.toolbar.connect( this, { surfaceChange: 'onSurfaceChange' } );
};
/* Inheritance */
OO.inheritClass( ve.ui.CodeMirrorTool, ve.ui.Tool );
/* Static properties */
ve.ui.CodeMirrorTool.static.name = 'codeMirror';
ve.ui.CodeMirrorTool.static.autoAddToCatchall = false;
ve.ui.CodeMirrorTool.static.title = OO.ui.deferMsg( 'codemirror-toggle-label' );
ve.ui.CodeMirrorTool.static.icon = 'highlight';
ve.ui.CodeMirrorTool.static.group = 'utility';
ve.ui.CodeMirrorTool.static.commandName = 'codeMirror';
ve.ui.CodeMirrorTool.static.deactivateOnSelect = false;
/**
* @inheritdoc
*/
ve.ui.CodeMirrorTool.prototype.onSelect = function () {
// Parent method
ve.ui.CodeMirrorTool.super.prototype.onSelect.apply( this, arguments );
const useCodeMirror = !!this.toolbar.surface.mirror;
this.setActive( useCodeMirror );
this.extCodeMirror.setCodeMirrorPreference( useCodeMirror );
this.extCodeMirror.logUsage( {
editor: 'wikitext-2017',
enabled: useCodeMirror,
toggled: true,
// eslint-disable-next-line camelcase
edit_start_ts_ms: ( this.toolbar.target.startTimeStamp * 1000 ) || 0
} );
};
/**
* @inheritdoc
*/
ve.ui.CodeMirrorTool.prototype.onSurfaceChange = function ( oldSurface, newSurface ) {
const isDisabled = newSurface.getMode() !== 'source';
this.setDisabled( isDisabled );
if ( !isDisabled ) {
const command = this.getCommand();
const surface = this.toolbar.getSurface();
const useCodeMirror = mw.user.options.get( 'usecodemirror' ) > 0;
command.execute( surface, [ useCodeMirror ] );
this.setActive( useCodeMirror );
if ( this.toolbar.target.startTimeStamp ) {
this.extCodeMirror.logUsage( {
editor: 'wikitext-2017',
enabled: useCodeMirror,
toggled: false,
// eslint-disable-next-line camelcase
edit_start_ts_ms: ( this.toolbar.target.startTimeStamp * 1000 ) || 0
} );
}
}
};
ve.ui.CodeMirrorTool.prototype.onUpdateState = function () {};
/* Registration */
ve.ui.toolFactory.register( ve.ui.CodeMirrorTool );
/* Command */
ve.ui.commandRegistry.register(
new ve.ui.Command(
'codeMirror', 'codeMirror', 'toggle'
)
);

View file

@ -60,7 +60,7 @@ describe( 'initialize', () => {
describe( 'logUsage', () => { describe( 'logUsage', () => {
it( 'should track usage of CodeMirror with the correct data', () => { it( 'should track usage of CodeMirror with the correct data', () => {
cm.logUsage( { CodeMirror.logUsage( {
editor: 'wikitext', editor: 'wikitext',
enabled: true, enabled: true,
toggled: false toggled: false
@ -85,14 +85,14 @@ describe( 'setCodeMirrorPreference', () => {
} ); } );
it( 'should save using the API with the correct value', () => { it( 'should save using the API with the correct value', () => {
cm.setCodeMirrorPreference( true ); CodeMirror.setCodeMirrorPreference( true );
expect( mw.Api.prototype.saveOption ).toHaveBeenCalledWith( 'usecodemirror', 1 ); expect( mw.Api.prototype.saveOption ).toHaveBeenCalledWith( 'usecodemirror', 1 );
expect( mw.user.options.set ).toHaveBeenCalledWith( 'usecodemirror', 1 ); expect( mw.user.options.set ).toHaveBeenCalledWith( 'usecodemirror', 1 );
} ); } );
it( 'should not save preferences if the user is not named', () => { it( 'should not save preferences if the user is not named', () => {
mw.user.isNamed = jest.fn().mockReturnValue( false ); mw.user.isNamed = jest.fn().mockReturnValue( false );
cm.setCodeMirrorPreference( true ); CodeMirror.setCodeMirrorPreference( true );
expect( mw.Api.prototype.saveOption ).toHaveBeenCalledTimes( 0 ); expect( mw.Api.prototype.saveOption ).toHaveBeenCalledTimes( 0 );
expect( mw.user.options.set ).toHaveBeenCalledTimes( 0 ); expect( mw.user.options.set ).toHaveBeenCalledTimes( 0 );
} ); } );

View file

@ -15,6 +15,9 @@ class DataScriptTest extends \MediaWikiIntegrationTestCase {
$script = DataScript::makeScript( $context ); $script = DataScript::makeScript( $context );
$this->assertStringContainsString( '"extCodeMirrorConfig":', $script ); $this->assertStringContainsString( '"extCodeMirrorConfig":', $script );
$this->assertStringContainsString( '"lineNumberingNamespaces":', $script );
$this->assertStringContainsString( '"templateFoldingNamespaces":', $script );
$this->assertStringContainsString( '"isSupportedRtlWiki":', $script );
$this->assertStringContainsString( '"pluginModules":', $script ); $this->assertStringContainsString( '"pluginModules":', $script );
$this->assertStringContainsString( '"tagModes":', $script ); $this->assertStringContainsString( '"tagModes":', $script );
$this->assertStringContainsString( '"tags":', $script ); $this->assertStringContainsString( '"tags":', $script );