mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/CodeMirror
synced 2024-11-23 13:56:44 +00:00
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:
parent
7d3482f89e
commit
925775778a
|
@ -1,4 +1,3 @@
|
||||||
/resources/lib/
|
/resources/lib/
|
||||||
/resources/dist/
|
|
||||||
/vendor
|
/vendor
|
||||||
/docs
|
/docs
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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 ),
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,8 @@
|
||||||
"commonjs": true
|
"commonjs": true
|
||||||
},
|
},
|
||||||
"globals": {
|
"globals": {
|
||||||
"Tree": "readonly"
|
"Tree": "readonly",
|
||||||
|
"ve": "readonly"
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"max-len": "off",
|
"max-len": "off",
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
7
resources/ve-cm/ve.ui.CodeMirror.init.js
Normal file
7
resources/ve-cm/ve.ui.CodeMirror.init.js
Normal 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' );
|
||||||
|
}
|
131
resources/ve-cm/ve.ui.CodeMirror.v6.less
Normal file
131
resources/ve-cm/ve.ui.CodeMirror.v6.less
Normal 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;
|
||||||
|
}
|
253
resources/ve-cm/ve.ui.CodeMirrorAction.v6.js
Normal file
253
resources/ve-cm/ve.ui.CodeMirrorAction.v6.js
Normal 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 );
|
||||||
|
}
|
93
resources/ve-cm/ve.ui.CodeMirrorTool.v6.js
Normal file
93
resources/ve-cm/ve.ui.CodeMirrorTool.v6.js
Normal 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'
|
||||||
|
)
|
||||||
|
);
|
|
@ -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 );
|
||||||
} );
|
} );
|
||||||
|
|
|
@ -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 );
|
||||||
|
|
Loading…
Reference in a new issue