/*! * VisualEditor UserInterface CodeMirrorAction class. * * @copyright 2011-2017 VisualEditor Team and others; see http://ve.mit-license.org */ /** * 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 */ /** * @return {boolean} */ ve.ui.CodeMirrorAction.static.isLineNumbering = function () { // T285660: Backspace related bug on Android browsers as of 2021 if ( /Android\b/.test( navigator.userAgent ) ) { return false; } var namespaces = mw.config.get( 'wgCodeMirrorLineNumberingNamespaces' ); // Set to [] to disable everywhere, or null to enable everywhere return !namespaces || namespaces.indexOf( mw.config.get( 'wgNamespaceNumber' ) ) !== -1; }; /** * @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 ) { var 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.data', 'ext.CodeMirror.lib', 'ext.CodeMirror.mode.mediawiki', 'jquery.client' ] ).then( function () { var config = mw.config.get( 'extCodeMirrorConfig' ); if ( !surface.mirror ) { // Action was toggled to false since promise started return; } mw.loader.using( config.pluginModules, function () { if ( !surface.mirror ) { // Action was toggled to false since promise started return; } var tabSizeValue = surfaceView.documentView.documentNode.$element.css( 'tab-size' ); var cmOptions = { value: surface.getDom(), mwConfig: config, readOnly: 'nocursor', lineWrapping: true, scrollbarStyle: 'null', specialChars: /^$/, viewportMargin: 5, tabSize: tabSizeValue ? +tabSizeValue : 8, // select mediawiki as text input mode mode: 'text/mediawiki', extraKeys: { Tab: false, 'Shift-Tab': false } }; cmOptions.matchBrackets = { highlightNonMatching: false, maxHighlightLineLength: 10000 }; if ( ve.ui.CodeMirrorAction.static.isLineNumbering() ) { $.extend( cmOptions, { // Set up a special "padding" gutter to create space between the line numbers // and page content. The first column name is a magic constant which causes // the built-in line number gutter to appear in the desired, leftmost position. gutters: [ 'CodeMirror-linenumbers', 'CodeMirror-linenumber-padding' ], lineNumbers: true } ); } surface.mirror = CodeMirror( surfaceView.$element[ 0 ], cmOptions ); // 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' ); } var profile = $.client.profile(); var 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' ); if ( cmOptions.lineNumbers ) { // Transfer gutter width to VE overlay. var updateGutter = function ( cmDisplay ) { surfaceView.$documentNode.css( 'margin-left', cmDisplay.gutters.offsetWidth ); }; CodeMirror.on( surface.mirror.display, 'updateGutter', updateGutter ); updateGutter( surface.mirror.display ); } /* 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.veLangChangeListener = action.onLangChange.bind( action ); surface.mirror.veSelectListener = action.onSelect.bind( action ); doc.on( 'precommit', surface.mirror.veTransactionListener ); surfaceView.getDocument().on( 'langChange', surface.mirror.veLangChangeListener ); surface.getModel().on( 'select', surface.mirror.veSelectListener ); action.onLangChange(); ve.init.target.once( 'surfaceReady', function () { if ( surface.mirror ) { surface.mirror.refresh(); } } ); } ); } ); } else if ( surface.mirror && enable !== true ) { if ( surface.mirror !== true ) { doc.off( 'precommit', surface.mirror.veTransactionListener ); surfaceView.getDocument().off( 'langChange', surface.mirror.veLangChangeListener ); 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', '' ); var mirrorElement = surface.mirror.getWrapperElement(); mirrorElement.parentNode.removeChild( mirrorElement ); } surface.mirror = null; } return true; }; /** * Handle select events from the surface model * * @param {ve.dm.Selection} selection */ ve.ui.CodeMirrorAction.prototype.onSelect = function ( selection ) { var range = selection.getCoveringRange(); // Do not re-trigger bracket matching as long as something is selected if ( !range || !range.isCollapsed() ) { return; } this.surface.mirror.setSelection( this.getPosFromOffset( range.from ) ); }; /** * Handle langChange events from the document view */ ve.ui.CodeMirrorAction.prototype.onLangChange = function () { var surface = this.surface, dir = surface.getView().getDocument().getDir(); surface.mirror.setOption( 'direction', dir ); }; /** * 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 ) { var offset = 0, replacements = [], action = this, store = this.surface.getModel().getDocument().getStore(), mirror = this.surface.mirror; tx.operations.forEach( function ( op ) { if ( op.type === 'retain' ) { offset += op.length; } else if ( op.type === 'replace' ) { replacements.push( { start: action.getPosFromOffset( offset ), // Don't bother recalculating end offset if not a removal, replaceRange works with just one arg end: op.remove.length ? action.getPosFromOffset( offset + op.remove.length ) : undefined, data: new ve.dm.ElementLinearData( store, op.insert ).getSourceText() } ); offset += op.remove.length; } } ); // Apply replacements in reverse to avoid having to shift offsets for ( var i = replacements.length - 1; i >= 0; i-- ) { mirror.replaceRange( replacements[ i ].data, replacements[ i ].start, replacements[ i ].end ); } // HACK: The absolutely positioned CodeMirror doesn't calculate the viewport // correctly when expanding from less than the viewport height. (T185184) if ( mirror.display.sizer.style.minHeight !== this.lastHeight ) { mirror.refresh(); this.lastHeight = mirror.display.sizer.style.minHeight; } }; /** * Convert a VE offset to a 2D CodeMirror position * * @param {number} veOffset VE linear model offset * @return {Object} Code mirror position, containing 'line' and 'ch' numbers */ ve.ui.CodeMirrorAction.prototype.getPosFromOffset = function ( veOffset ) { return this.surface.mirror.posFromIndex( this.surface.getModel().getSourceOffsetFromOffset( veOffset ) ); }; /* Registration */ ve.ui.actionFactory.register( ve.ui.CodeMirrorAction );