const {
Direction,
EditorState,
EditorView,
Extension,
LanguageSupport
} = require( 'ext.CodeMirror.v6.lib' );
const CodeMirror = require( 'ext.CodeMirror.v6' );
class CodeMirrorVisualEditor extends CodeMirror {
/**
* @param {ve.ui.Surface} surface
* @param {LanguageSupport|Extension} langExtension
*/
constructor( surface, langExtension = [] ) {
// Let the content editable mimic the textarea.
super( surface.getView().$attachedRootNode[ 0 ], langExtension );
/**
* The VisualEditor surface CodeMirror is bound to.
*
* @type {ve.ui.Surface}
*/
this.surface = surface;
/**
* The ContentEditable surface.
*
* @type {ve.ce.Surface}
*/
this.surfaceView = surface.getView();
/**
* @inheritDoc
* @override
*/
this.readOnly = this.surface.getModel().isReadOnly();
}
/**
* @inheritDoc
*/
get defaultExtensions() {
return [
this.contentAttributesExtension,
this.editorAttributesExtension,
this.heightExtension,
this.updateExtension,
this.dirExtension,
this.preferences.extension,
EditorState.readOnly.of( this.readOnly ),
this.langExtension,
EditorView.theme( {
'.cm-content': {
lineHeight: 1.5
}
} )
];
}
/**
* @inheritDoc
*/
get heightExtension() {
return EditorView.theme( {
'&': {
height: '100%'
}
} );
}
/**
* @inheritDoc
*/
get contentAttributesExtension() {
// Add colorblind mode if preference is set.
// This currently is only to be used for the MediaWiki markup language.
const useColorBlind = mw.user.options.get( 'usecodemirror-colorblind' ) &&
mw.config.get( 'wgPageContentModel' ) === 'wikitext';
return EditorView.contentAttributes.of( {
class: useColorBlind ? 'cm-mw-colorblind-colors' : '',
spellcheck: 'true'
} );
}
/**
* @inheritDoc
*/
addEditRecoveredHandler() {}
/**
* @inheritDoc
*/
addTextAreaJQueryHook() {}
/**
* @inheritDoc
*/
addFormSubmitHandler() {}
/**
* @inheritDoc
*/
activate() {
super.activate();
CodeMirror.setCodeMirrorPreference( true );
// Force infinite viewport in CodeMirror to prevent misalignment of
// the VE surface and the CodeMirror view. See T357482#10076432.
this.view.viewState.printing = true;
const profile = $.client.profile();
const supportsTransparentText = 'WebkitTextFillColor' in document.body.style &&
// Disable on Firefox+OSX (T175223)
!( profile.layout === 'gecko' && profile.platform === 'mac' );
this.surfaceView.$documentNode.addClass(
supportsTransparentText ?
've-ce-documentNode-codeEditor-webkit-hide' :
've-ce-documentNode-codeEditor-hide'
);
// 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
this.surfaceView.$element.removeClass( 'mw-editfont-sans-serif mw-editfont-serif' )
.addClass( 'mw-editfont-monospace' );
// Account for the gutter width in the margin.
this.updateGutterWidth( this.surfaceView.getDocument().getDir() );
// Set focus on the surface view.
this.surfaceView.focus();
// As the action is regenerated each time,
// we need to track the listeners for later disconnection.
/**
* @type {Function}
* @private
*/
this.transactionListener = this.onDocumentPrecommit.bind( this );
this.surface.getModel().getDocument().on( 'precommit', this.transactionListener );
/**
* @type {Function}
* @private
*/
this.selectListener = this.onSelect.bind( this );
this.surface.getModel().on( 'select', this.selectListener );
/**
* @type {Function}
* @private
*/
this.positionListener = this.onPosition.bind( this );
this.surfaceView.on( 'position', this.positionListener );
// Sync document directionality changes to CodeMirror.
this.onPosition();
}
/**
* @inheritDoc
*/
deactivate() {
super.deactivate();
CodeMirror.setCodeMirrorPreference( false );
this.surfaceView.$documentNode.removeClass(
've-ce-documentNode-codeEditor-webkit-hide ve-ce-documentNode-codeEditor-hide'
);
// Restore edit-font
// eslint-disable-next-line mediawiki/class-doc
this.surfaceView.$element.removeClass( 'mw-editfont-monospace' )
.addClass( 'mw-editfont-' + mw.user.options.get( 'editfont' ) );
// Reset gutter.
this.surfaceView.$documentNode.css( {
'margin-left': '',
'margin-right': ''
} );
this.surface.getModel().getDocument().off( 'precommit', this.transactionListener );
this.surface.getModel().off( 'select', this.selectListener );
this.surfaceView.off( 'position', this.positionListener );
// Set focus on the surface view.
this.surfaceView.focus();
}
/**
* Log usage of CodeMirror to the VisualEditorFeatureUse schema.
*
* @see https://phabricator.wikimedia.org/T373710
* @see https://meta.wikimedia.org/wiki/Schema:VisualEditorFeatureUse
* @see https://www.mediawiki.org/wiki/VisualEditor/FeatureUse_data_dictionary
* @inheritDoc
*/
logEditFeature( action ) {
mw.track( 'visualEditorFeatureUse', { feature: 'codemirror', action } );
}
/**
* @inheritDoc
*/
setupFeatureLogging() {}
/**
* Update margins to account for the CodeMirror gutter.
*
* @param {string} dir Document direction
* @private
*/
updateGutterWidth( dir ) {
const gutter = this.view.dom.querySelector( '.cm-gutters' );
if ( !gutter ) {
// Line numbering is disabled.
return;
}
const guttersWidth = gutter.getBoundingClientRect().width;
this.surfaceView.$documentNode.css( {
'margin-left': dir === 'rtl' ? 0 : guttersWidth,
'margin-right': dir === 'rtl' ? guttersWidth : 0
} );
// Also update width of .cm-content due to apparent Chromium bug.
this.view.contentDOM.style.width = 'calc(100% - ' + guttersWidth + 'px)';
}
/**
* Sync document directionality changes to CodeMirror.
*
* @private
*/
onPosition() {
const veDir = this.surfaceView.getDocument().getDir();
const cmDir = this.view.textDirection === Direction.LTR ? 'ltr' : 'rtl';
if ( veDir !== cmDir ) {
this.view.dispatch( {
effects: this.dirCompartment.reconfigure(
EditorView.editorAttributes.of( { dir: veDir } )
)
} );
this.updateGutterWidth( veDir );
}
}
/**
* Handle select events from the surface model.
*
* @param {ve.dm.Selection} selection
* @private
*/
onSelect( selection ) {
const range = selection.getCoveringRange();
// Do not re-trigger bracket matching as long as something is selected
if ( !range || !range.isCollapsed() ) {
return;
}
// T382769: the selection range from `textSelection( 'setContents' )`
// exceeds the document length.
const offset = Math.min(
this.surface.getModel().getSourceOffsetFromOffset( range.from ),
this.view.state.doc.length
);
this.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
* @private
*/
onDocumentPrecommit( tx ) {
const replacements = [],
model = this.surface.getModel(),
store = model.getDocument().getStore();
let offset = 0;
tx.operations.forEach( ( op ) => {
if ( op.type === 'retain' ) {
offset += op.length;
} else if ( op.type === 'replace' ) {
replacements.push( {
from: model.getSourceOffsetFromOffset( offset ),
to: model.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-- ) {
// T382769: the replacement range from `textSelection( 'setContents' )`
// exceeds the document length by one character and inserts an extra newline
const { from, to, insert } = replacements[ i ],
isSetContents = to === this.view.state.doc.length + 1 &&
insert.endsWith( '\n' );
this.view.dispatch( {
changes: {
from,
to: isSetContents ? to - 1 : to,
insert: isSetContents ? insert.slice( 0, -1 ) : insert
}
} );
}
this.updateGutterWidth( this.surfaceView.getDocument().getDir() );
}
}
module.exports = CodeMirrorVisualEditor;