From 925775778a2fe20aa5bb8e4485aa15f51ae41426 Mon Sep 17 00:00:00 2001 From: bhsd <2545473905@qq.com> Date: Sat, 8 Jun 2024 11:10:11 +0800 Subject: [PATCH] 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 --- .eslintignore | 1 - extension.json | 30 ++- includes/DataScript.php | 2 + jsdoc.json | 3 +- resources/.eslintrc.json | 3 +- resources/codemirror.js | 98 ++++--- resources/codemirror.mediawiki.js | 8 +- resources/codemirror.wikieditor.js | 6 +- resources/ve-cm/ve.ui.CodeMirror.init.js | 7 + resources/ve-cm/ve.ui.CodeMirror.v6.less | 131 ++++++++++ resources/ve-cm/ve.ui.CodeMirrorAction.v6.js | 253 +++++++++++++++++++ resources/ve-cm/ve.ui.CodeMirrorTool.v6.js | 93 +++++++ tests/jest/codemirror.test.js | 6 +- tests/phpunit/DataScriptTest.php | 3 + 14 files changed, 599 insertions(+), 45 deletions(-) create mode 100644 resources/ve-cm/ve.ui.CodeMirror.init.js create mode 100644 resources/ve-cm/ve.ui.CodeMirror.v6.less create mode 100644 resources/ve-cm/ve.ui.CodeMirrorAction.v6.js create mode 100644 resources/ve-cm/ve.ui.CodeMirrorTool.v6.js diff --git a/.eslintignore b/.eslintignore index faa887d9..d06079a1 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,3 @@ /resources/lib/ -/resources/dist/ /vendor /docs diff --git a/extension.json b/extension.json index adb55a9d..47460389 100644 --- a/extension.json +++ b/extension.json @@ -278,6 +278,34 @@ "packageFiles": [ "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": { @@ -333,7 +361,7 @@ }, "VisualEditor": { "PluginModules": [ - "ext.CodeMirror.visualEditor" + "ext.CodeMirror.visualEditor.init" ] }, "EventLogging": { diff --git a/includes/DataScript.php b/includes/DataScript.php index 27f444c3..77d41e63 100644 --- a/includes/DataScript.php +++ b/includes/DataScript.php @@ -58,8 +58,10 @@ class DataScript { // initialize configuration $config = [ + 'useV6' => $mwConfig->get( 'CodeMirrorV6' ), 'lineNumberingNamespaces' => $mwConfig->get( 'CodeMirrorLineNumberingNamespaces' ), 'templateFoldingNamespaces' => $mwConfig->get( 'CodeMirrorTemplateFoldingNamespaces' ), + 'isSupportedRtlWiki' => $mwConfig->get( 'CodeMirrorRTL' ), 'pluginModules' => $registry->getAttribute( 'CodeMirrorPluginModules' ), 'tagModes' => $tagModes, 'tags' => array_fill_keys( $tagNames, true ), diff --git a/jsdoc.json b/jsdoc.json index b680b653..da4cb81d 100644 --- a/jsdoc.json +++ b/jsdoc.json @@ -44,7 +44,8 @@ "TagStyle": "https://codemirror.net/docs/ref/#language.TagStyle", "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" + "ViewUpdate": "https://codemirror.net/docs/ref/#view.ViewUpdate", + "ve.ui.Surface": "https://doc.wikimedia.org/visualeditor-standalone/master/ve.ui.Surface.html" } } } diff --git a/resources/.eslintrc.json b/resources/.eslintrc.json index 8e065e0b..281480d9 100644 --- a/resources/.eslintrc.json +++ b/resources/.eslintrc.json @@ -13,7 +13,8 @@ "commonjs": true }, "globals": { - "Tree": "readonly" + "Tree": "readonly", + "ve": "readonly" }, "rules": { "max-len": "off", diff --git a/resources/codemirror.js b/resources/codemirror.js index 04f6336a..66bfe77a 100644 --- a/resources/codemirror.js +++ b/resources/codemirror.js @@ -37,10 +37,23 @@ class CodeMirror { /** * 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( 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. * @@ -64,7 +77,9 @@ class CodeMirror { * * @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. * @@ -78,11 +93,17 @@ class CodeMirror { */ this.textSelection = null; /** - * Language direction extension. + * Compartment for the language direction Extension. * * @type {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 = [ this.contentAttributesExtension, this.phrasesExtension, - this.specialCharsExtension, + this.specialCharsCompartment.of( this.specialCharsExtension ), this.heightExtension, this.updateExtension, this.bracketMatchingExtension, @@ -187,7 +208,7 @@ class CodeMirror { get heightExtension() { return EditorView.theme( { '&': { - height: `${ this.$textarea.outerHeight() }px` + height: this.surface ? '100%' : `${ this.$textarea.outerHeight() }px` }, '.cm-scroller': { overflow: 'auto' @@ -205,19 +226,22 @@ class CodeMirror { */ get contentAttributesExtension() { const classList = []; - // T245568: Sync text editor font preferences with CodeMirror - const fontClass = Array.from( this.$textarea[ 0 ].classList ) - .find( ( style ) => style.startsWith( 'mw-editfont-' ) ); - if ( fontClass ) { - classList.push( fontClass ); - } - // Add colorblind mode if preference is set. - // This currently is only to be used for the MediaWiki markup language. - if ( - mw.user.options.get( 'usecodemirror-colorblind' ) && - mw.config.get( 'wgPageContentModel' ) === 'wikitext' - ) { - classList.push( 'cm-mw-colorblind-colors' ); + // T245568: Sync text editor font preferences with CodeMirror, + // but don't do this for the 2017 wikitext editor. + if ( !this.surface ) { + const fontClass = Array.from( this.$textarea[ 0 ].classList ) + .find( ( style ) => style.startsWith( 'mw-editfont-' ) ); + if ( fontClass ) { + classList.push( fontClass ); + } + // Add colorblind mode if preference is set. + // This currently is only to be used for the MediaWiki markup language. + if ( + mw.user.options.get( 'usecodemirror-colorblind' ) && + mw.config.get( 'wgPageContentModel' ) === 'wikitext' + ) { + classList.push( 'cm-mw-colorblind-colors' ); + } } return [ @@ -379,7 +403,9 @@ class CodeMirror { // Set up the initial EditorState of CodeMirror with contents of the native textarea. this.state = EditorState.create( { - doc: this.$textarea.textSelection( 'getContents' ), + doc: this.surface ? + this.surface.getDom() : + this.$textarea.textSelection( 'getContents' ), extensions } ); @@ -387,15 +413,17 @@ class CodeMirror { this.addCodeMirrorToDom(); // Hide native textarea and sync CodeMirror contents upon submission. - this.$textarea.hide(); - if ( this.$textarea[ 0 ].form ) { - this.$textarea[ 0 ].form.addEventListener( 'submit', () => { - this.$textarea.val( this.view.state.doc.toString() ); - const scrollTop = document.getElementById( 'wpScrolltop' ); - if ( scrollTop ) { - scrollTop.value = this.view.scrollDOM.scrollTop; - } - } ); + if ( !this.surface ) { + this.$textarea.hide(); + if ( this.$textarea[ 0 ].form ) { + this.$textarea[ 0 ].form.addEventListener( 'submit', () => { + this.$textarea.val( this.view.state.doc.toString() ); + const scrollTop = document.getElementById( 'wpScrolltop' ); + if ( scrollTop ) { + scrollTop.value = this.view.scrollDOM.scrollTop; + } + } ); + } } // Register $.textSelection() on the .cm-editor element. @@ -426,7 +454,9 @@ class CodeMirror { this.view = new EditorView( { 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.$textarea.textSelection( 'unregister' ); 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 = null; this.$textarea.show(); @@ -470,8 +502,10 @@ class CodeMirror { * * @param {Object} data * @stable to call + * @internal + * @ignore */ - logUsage( data ) { + static logUsage( data ) { /* eslint-disable camelcase */ const event = Object.assign( { session_token: mw.user.sessionId(), @@ -491,7 +525,7 @@ class CodeMirror { * @param {boolean} prefValue True, if CodeMirror should be enabled by default, otherwise false. * @stable to call and override */ - setCodeMirrorPreference( prefValue ) { + static setCodeMirrorPreference( prefValue ) { // Skip for unnamed users if ( !mw.user.isNamed() ) { return; diff --git a/resources/codemirror.mediawiki.js b/resources/codemirror.mediawiki.js index d757139b..77b22e98 100644 --- a/resources/codemirror.mediawiki.js +++ b/resources/codemirror.mediawiki.js @@ -1262,6 +1262,7 @@ class CodeMirrorModeMediaWiki { * @param {Object} [config] Configuration options for the MediaWiki mode. * @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. + * @param {boolean} [config.templateFolding=true] Enable template folding. * @param {Object|null} [mwConfig] Ignore; used only by unit tests. * @return {LanguageSupport} * @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. - 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 ); } diff --git a/resources/codemirror.wikieditor.js b/resources/codemirror.wikieditor.js index 8edb6af6..0c0bc19c 100644 --- a/resources/codemirror.wikieditor.js +++ b/resources/codemirror.wikieditor.js @@ -63,7 +63,7 @@ class CodeMirrorWikiEditor extends CodeMirror { setCodeMirrorPreference( prefValue ) { // Save state for function updateToolbarButton() this.useCodeMirror = prefValue; - super.setCodeMirrorPreference( prefValue ); + CodeMirror.setCodeMirrorPreference( prefValue ); } /** @@ -193,7 +193,7 @@ class CodeMirrorWikiEditor extends CodeMirror { } this.updateToolbarButton(); - this.logUsage( { + CodeMirror.logUsage( { editor: 'wikitext', enabled: this.useCodeMirror, toggled: false, @@ -235,7 +235,7 @@ class CodeMirrorWikiEditor extends CodeMirror { } this.updateToolbarButton(); - this.logUsage( { + CodeMirror.logUsage( { editor: 'wikitext', enabled: this.useCodeMirror, toggled: true, diff --git a/resources/ve-cm/ve.ui.CodeMirror.init.js b/resources/ve-cm/ve.ui.CodeMirror.init.js new file mode 100644 index 00000000..5c9b7e92 --- /dev/null +++ b/resources/ve-cm/ve.ui.CodeMirror.init.js @@ -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' ); +} diff --git a/resources/ve-cm/ve.ui.CodeMirror.v6.less b/resources/ve-cm/ve.ui.CodeMirror.v6.less new file mode 100644 index 00000000..4210a65d --- /dev/null +++ b/resources/ve-cm/ve.ui.CodeMirror.v6.less @@ -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; +} diff --git a/resources/ve-cm/ve.ui.CodeMirrorAction.v6.js b/resources/ve-cm/ve.ui.CodeMirrorAction.v6.js new file mode 100644 index 00000000..b47d149d --- /dev/null +++ b/resources/ve-cm/ve.ui.CodeMirrorAction.v6.js @@ -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 ); +} diff --git a/resources/ve-cm/ve.ui.CodeMirrorTool.v6.js b/resources/ve-cm/ve.ui.CodeMirrorTool.v6.js new file mode 100644 index 00000000..a25c3637 --- /dev/null +++ b/resources/ve-cm/ve.ui.CodeMirrorTool.v6.js @@ -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' + ) +); diff --git a/tests/jest/codemirror.test.js b/tests/jest/codemirror.test.js index a533f5b8..8bf38a50 100644 --- a/tests/jest/codemirror.test.js +++ b/tests/jest/codemirror.test.js @@ -60,7 +60,7 @@ describe( 'initialize', () => { describe( 'logUsage', () => { it( 'should track usage of CodeMirror with the correct data', () => { - cm.logUsage( { + CodeMirror.logUsage( { editor: 'wikitext', enabled: true, toggled: false @@ -85,14 +85,14 @@ describe( 'setCodeMirrorPreference', () => { } ); 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.user.options.set ).toHaveBeenCalledWith( 'usecodemirror', 1 ); } ); it( 'should not save preferences if the user is not named', () => { mw.user.isNamed = jest.fn().mockReturnValue( false ); - cm.setCodeMirrorPreference( true ); + CodeMirror.setCodeMirrorPreference( true ); expect( mw.Api.prototype.saveOption ).toHaveBeenCalledTimes( 0 ); expect( mw.user.options.set ).toHaveBeenCalledTimes( 0 ); } ); diff --git a/tests/phpunit/DataScriptTest.php b/tests/phpunit/DataScriptTest.php index 114f5483..e0d29ed8 100644 --- a/tests/phpunit/DataScriptTest.php +++ b/tests/phpunit/DataScriptTest.php @@ -15,6 +15,9 @@ class DataScriptTest extends \MediaWikiIntegrationTestCase { $script = DataScript::makeScript( $context ); $this->assertStringContainsString( '"extCodeMirrorConfig":', $script ); + $this->assertStringContainsString( '"lineNumberingNamespaces":', $script ); + $this->assertStringContainsString( '"templateFoldingNamespaces":', $script ); + $this->assertStringContainsString( '"isSupportedRtlWiki":', $script ); $this->assertStringContainsString( '"pluginModules":', $script ); $this->assertStringContainsString( '"tagModes":', $script ); $this->assertStringContainsString( '"tags":', $script );