/**
* @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module code-block/codeblockediting
*/
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
import CodeBlockCommand from './codeblockcommand';
import { getLocalizedLanguageDefinitions } from './utils';
const DEFAULT_ELEMENT = 'paragraph';
/**
* The editing part of the code block feature.
*
* Introduces the `'codeBlock'` command and the `'codeBlock'` model element.
*
* @extends module:core/plugin~Plugin
*/
export default class CodeBlockEditing extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'CodeBlockEditing';
}
/**
* @inheritDoc
*/
static get requires() {
return [ ShiftEnter ];
}
/**
* @inheritDoc
*/
constructor( editor ) {
super( editor );
editor.config.define( 'codeBlock', {
languages: [
{ class: 'plaintext', label: 'Plain text' },
{ class: 'c', label: 'C' },
{ class: 'cs', label: 'C#' },
{ class: 'cpp', label: 'C++' },
{ class: 'css', label: 'CSS' },
{ class: 'diff', label: 'Diff' },
{ class: 'xml', label: 'HTML/XML' },
{ class: 'java', label: 'Java' },
{ class: 'javascript', label: 'JavaScript' },
{ class: 'php', label: 'PHP' },
{ class: 'python', label: 'Python' },
{ class: 'ruby', label: 'Ruby' },
{ class: 'typescript', label: 'TypeScript' },
]
} );
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const schema = editor.model.schema;
const model = editor.model;
const localizedLanguageDefinitions = getLocalizedLanguageDefinitions( editor );
const languageClasses = localizedLanguageDefinitions.map( def => def.class );
const languageLabels = Object.assign( {}, ...localizedLanguageDefinitions.map( def => ( { [ def.class ]: def.label } ) ) );
// Command.
editor.commands.add( 'codeBlock', new CodeBlockCommand( editor ) );
// Schema.
schema.register( 'codeBlock', {
inheritAllFrom: '$block',
allowAttributes: [ 'language' ]
} );
// Disallow codeBlock in codeBlock.
schema.addChildCheck( ( context, childDef ) => {
if ( context.endsWith( 'codeBlock' ) && childDef.name === 'codeBlock' ) {
return false;
}
} );
// Disallow all attributes in `codeBlock`.
schema.addAttributeCheck( ( context, attributeName ) => {
if ( context.endsWith( 'codeBlock' ) || context.endsWith( 'codeBlock $text' ) ) {
return attributeName === 'language';
}
} );
// Conversion.
editor.editing.downcastDispatcher.on( 'insert:codeBlock', modelToViewCodeBlockInsertion( model, languageLabels ) );
editor.data.downcastDispatcher.on( 'insert:codeBlock', modelToViewCodeBlockInsertion( model ) );
editor.data.downcastDispatcher.on( 'insert:softBreak', modelToViewSoftBreakInsertion( model ), { priority: 'high' } );
editor.data.upcastDispatcher.on( 'element:pre', dataViewToModelCodeBlockInsertion( editor.data, languageClasses ) );
// Intercept the clipboard input when the selection is anchored in the code block and force the clipboard
// data to be pasted as a single plain text. Otherwise, the code lines will split the code block and
// "spill out" as separate paragraphs.
this.listenTo( editor.editing.view.document, 'clipboardInput', ( evt, data ) => {
const modelSelection = model.document.selection;
if ( !modelSelection.anchor.parent.is( 'codeBlock' ) ) {
return;
}
const text = data.dataTransfer.getData( 'text/plain' );
model.change( writer => {
model.insertContent( rawSnippetTextToModelDocumentFragment( writer, text ), modelSelection );
evt.stop();
} );
} );
// Make sure multi–line selection is always wrapped in a code block. Otherwise, only the raw text
// will be copied to the clipboard by the user and, upon the next paste, this bare text will not be
// inserted as a code block, which is not the best UX.
// Similarly, when the selection in a single line, the selected content should be an inline
// code so it can be pasted later on and retain it's preformatted nature.
this.listenTo( model, 'getSelectedContent', ( evt, [ selection ] ) => {
const anchor = selection.anchor;
if ( !anchor.parent.is( 'codeBlock' ) || !anchor.hasSameParentAs( selection.focus ) ) {
return;
}
model.change( writer => {
const docFragment = evt.return;
// From:
//
// fo[o
//
// b]ar
//
// into:
//
//
// [o
//
// b]
//
//
if ( docFragment.childCount > 1 || selection.containsEntireContent( anchor.parent ) ) {
const codeBlock = writer.createElement( 'codeBlock', anchor.parent.getAttributes() );
writer.append( docFragment, codeBlock );
const newDocumentFragment = writer.createDocumentFragment();
writer.append( codeBlock, newDocumentFragment );
evt.return = newDocumentFragment;
}
// From:
//
// f[oo]
//
// into:
//
// <$text code="true">oo
//
else {
writer.setAttribute( 'code', true, docFragment.getChild( 0 ) );
}
} );
} );
}
/**
* @inheritDoc
*/
afterInit() {
const editor = this.editor;
const view = editor.editing.view;
const model = editor.model;
const modelDoc = model.document;
this.listenTo( view.document, 'enter', ( evt, data ) => {
const positionParent = modelDoc.selection.getLastPosition().parent;
if ( !positionParent.is( 'codeBlock' ) ) {
return;
}
// Upon enter key press we can either leave the block if it's "two enters" in a row
// or create a new code block line, preserving previous line's indentation.
leaveBlock( data.isSoft ) || breakLine();
data.preventDefault();
evt.stop();
} );
// Normally, when the enter (or shift+enter) key is pressed, a soft line break is to be added to the
// code block. Let's try to follow the indentation of the previous line when possible, for instance:
//
// // Before pressing enter (or shift enter)
//
// " foo()"[] // Indent of 4 spaces.
//
//
// // After pressing:
//
// " foo()" // Indent of 4 spaces.
// // A new soft break created by pressing enter.
// " "[] // Retain the indent of 4 spaces.
//
function breakLine() {
const lastSelectionPosition = modelDoc.selection.getLastPosition();
const node = lastSelectionPosition.nodeBefore || lastSelectionPosition.textNode;
let leadingWhiteSpaces;
// Figure out the indentation (white space chars) at the beginning of the line.
if ( node && node.is( 'text' ) ) {
leadingWhiteSpaces = node.data.match( /^(\s*)/ )[ 0 ];
}
// Keeping everything in a change block for a single undo step.
editor.model.change( writer => {
editor.execute( 'shiftEnter' );
// If the line before being broken in two had some indentation, let's retain it
// in the new line.
if ( leadingWhiteSpaces ) {
writer.insertText( leadingWhiteSpaces, modelDoc.selection.anchor );
}
} );
}
// Leave the code block when enter (but NOT shift+enter) has been pressed twice at the end
// of the code block:
//
// // Before:
// foo[]
//
// // After first press
// foo[]
//
// // After second press
// foo[]
//
function leaveBlock( isSoftEnter ) {
const lastSelectionPosition = modelDoc.selection.getLastPosition();
const nodeBefore = lastSelectionPosition.nodeBefore;
let emptyLineRangeToRemoveOnDoubleEnter;
if ( isSoftEnter || !modelDoc.selection.isCollapsed || !lastSelectionPosition.isAtEnd || !nodeBefore ) {
return false;
}
// When the position is directly preceded by a soft break
//
// foo[]
//
// it creates the following range that will be cleaned up before leaving:
//
// foo[]
//
if ( nodeBefore.is( 'softBreak' ) ) {
emptyLineRangeToRemoveOnDoubleEnter = model.createRangeOn( nodeBefore );
}
// When there's some text before the position made purely of white–space characters
//
// foo []
//
// but NOT when it's the first one of the kind
//
// []
//
// it creates the following range to clean up before leaving:
//
// foo[ ]
//
else if (
nodeBefore.is( 'text' ) &&
!nodeBefore.data.match( /\S/ ) &&
nodeBefore.previousSibling &&
nodeBefore.previousSibling.is( 'softBreak' )
) {
emptyLineRangeToRemoveOnDoubleEnter = model.createRange(
model.createPositionBefore( nodeBefore.previousSibling ), model.createPositionAfter( nodeBefore )
);
}
// Not leaving the block in the following cases:
//
// []
// a []
// foobar[]
// foo a []
//
else {
return false;
}
// We're doing everything in a single change block to have a single undo step.
editor.model.change( writer => {
// Remove the last and all white space characters that followed it.
writer.remove( emptyLineRangeToRemoveOnDoubleEnter );
// "Clone" the in the standard way.
editor.execute( 'enter' );
const newBlock = modelDoc.selection.anchor.parent;
// Make the cloned a regular (with clean attributes, so no language).
writer.rename( newBlock, DEFAULT_ELEMENT );
editor.model.schema.removeDisallowedAttributes( [ newBlock ], writer );
// Eye candy.
view.scrollToTheSelection();
} );
return true;
}
}
}
// A model-to-view converter for the codeBlock element.
//
// @param {module:engine/model/model~Model} model
// @param {Object.} [languageLabels] An object associating a programming language
// classes with human–readable labels (as in the editor config).
// @returns {Function} Returns a conversion callback.
function modelToViewCodeBlockInsertion( model, languageLabels = {} ) {
return ( evt, data, conversionApi ) => {
const { writer, mapper, consumable } = conversionApi;
if ( !consumable.consume( data.item, 'insert' ) ) {
return;
}
const codeBlockLanguage = data.item.getAttribute( 'language' );
const targetViewPosition = mapper.toViewPosition( model.createPositionBefore( data.item ) );
const pre = writer.createContainerElement( 'pre', {
// This attribute is only in the editing view.
'data-language': languageLabels[ codeBlockLanguage ] || null
} );
const code = writer.createContainerElement( 'code', {
class: codeBlockLanguage
} );
writer.insert( writer.createPositionAt( pre, 0 ), code );
writer.insert( targetViewPosition, pre );
mapper.bindElements( data.item, code );
};
}
// A model-to-view converter for the new line separator.
//
// @param {module:engine/model/model~Model} model
// @returns {Function} Returns a conversion callback.
function modelToViewSoftBreakInsertion( model ) {
return ( evt, data, conversionApi ) => {
if ( data.item.parent.name !== 'codeBlock' ) {
return;
}
const { writer, mapper, consumable } = conversionApi;
if ( !consumable.consume( data.item, 'insert' ) ) {
return;
}
const position = mapper.toViewPosition( model.createPositionBefore( data.item ) );
writer.insert( position, writer.createText( '\n' ) );
};
}
// A view-to-model converter for pre > code html.
//
// @param {module:engine/controller/datacontroller~DataController} dataController
// @param {Array.} languageClasses An array of valid (as in the editor config) CSS classes
// associated with programming languages.
// @returns {Function} Returns a conversion callback.
function dataViewToModelCodeBlockInsertion( dataController, languageClasses ) {
return ( evt, data, conversionApi ) => {
const viewItem = data.viewItem;
const viewChild = viewItem.getChild( 0 );
if ( !viewChild || !viewChild.is( 'code' ) ) {
return;
}
const { consumable, writer } = conversionApi;
if ( !consumable.test( viewItem, { name: true } ) || !consumable.test( viewChild, { name: true } ) ) {
return;
}
const codeBlock = writer.createElement( 'codeBlock' );
// Figure out if any of the element's class names is a valid programming
// language class. If so, use it on the model element (becomes the language of the entire block).
for ( const className of viewChild.getClassNames() ) {
if ( languageClasses.includes( className ) ) {
writer.setAttribute( 'language', className, codeBlock );
break;
}
}
// If no language value was set, use the default language from the config.
if ( !codeBlock.hasAttribute( 'language' ) ) {
writer.setAttribute( 'language', languageClasses[ 0 ], codeBlock );
}
const stringifiedElement = dataController.processor.toData( viewChild );
const textData = extractDataFromCodeElement( stringifiedElement );
const fragment = rawSnippetTextToModelDocumentFragment( writer, textData );
writer.append( fragment, codeBlock );
// Let's see if the codeBlock can be inserted the current modelCursor.
const splitResult = conversionApi.splitToAllowedParent( codeBlock, data.modelCursor );
// When there is no split result it means that we can't insert element to model tree,
// so let's skip it.
if ( !splitResult ) {
return;
}
// Insert element on allowed position.
writer.insert( codeBlock, splitResult.position );
consumable.consume( viewItem, { name: true } );
consumable.consume( viewChild, { name: true } );
const parts = conversionApi.getSplitParts( codeBlock );
// Set conversion result range.
data.modelRange = writer.createRange(
conversionApi.writer.createPositionBefore( codeBlock ),
conversionApi.writer.createPositionAfter( parts[ parts.length - 1 ] )
);
// If we had to split parent to insert our element then we want to continue conversion inside
// the split parent.
//
// before split:
//
// []
//
// after split:
//
//
//
//
// []
//
if ( splitResult.cursorParent ) {
data.modelCursor = writer.createPositionAt( splitResult.cursorParent, 0 );
} else {
// Otherwise just continue after the inserted element.
data.modelCursor = data.modelRange.end;
}
};
}
// Returns content of `` with unescaped html inside.
//
// @param {String} stringifiedElement
function extractDataFromCodeElement( stringifiedElement ) {
const data = new RegExp( /^]*>(.*)<\/code>$/, 's' ).exec( stringifiedElement )[ 1 ];
return data
.replace( /</g, '<' )
.replace( />/g, '>' );
}
// For a plain text containing the code (snippet), it returns a document fragment containing
// model text nodes separated by soft breaks (in place of new line characters "\n"), for instance:
//
// Input:
//
// "foo()
// bar()"
//
// Output:
//
//
// "foo()"
//
// "bar()"
//
//
// @param {module:engine/model/writer~Writer} writer
// @param {String} text A raw code text to be converted.
function rawSnippetTextToModelDocumentFragment( writer, text ) {
const fragment = writer.createDocumentFragment();
const textLines = text.split( '\n' ).map( data => writer.createText( data ) );
const lastLine = textLines[ textLines.length - 1 ];
for ( const node of textLines ) {
writer.append( node, fragment );
if ( node !== lastLine ) {
writer.appendElement( 'softBreak', fragment );
}
}
return fragment;
}