Browse Source

Docs and code refactoring.

Aleksander Nowodzinski 6 years ago
parent
commit
c126c4f6a6

+ 15 - 0
packages/ckeditor5-code-block/src/codeblock.js

@@ -123,3 +123,18 @@ export default class CodeBlock extends Plugin {
  *
  * @member {Array.<module:code-block/codeblock~CodeBlockLanguageDefinition>} module:code-block/codeblock~CodeBlockConfig#languages
  */
+
+/**
+ * A sequence of characters inserted or removed from the code block lines when its indentation
+ * is changed by the user, for instance, using <kbd>Tab</kbd> and <kbd>Shift</kbd>+<kbd>Tab</kbd> keys.
+ *
+ * The default value is a single tab character ("	", `\u0009` in Unicode).
+ *
+ * This configuration is used by `indentCodeBlock` and `outdentCodeBlock` commands (instances of
+ * {@link module:code-block/indentcodeblockcommand~IndentCodeBlockCommand}).
+ *
+ * **Note**: Setting this configuration to `false` will disable the code block indentation commands
+ * and associated keystrokes.
+ *
+ * @member {String} module:code-block/codeblock~CodeBlockConfig#indentSequence
+ */

+ 152 - 342
packages/ckeditor5-code-block/src/codeblockediting.js

@@ -11,7 +11,16 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
 import CodeBlockCommand from './codeblockcommand';
 import IndentCodeBlockCommand from './indentcodeblockcommand';
-import { getLocalizedLanguageDefinitions, getLeadingWhiteSpaces } from './utils';
+import {
+	getLocalizedLanguageDefinitions,
+	getLeadingWhiteSpaces,
+	rawSnippetTextToModelDocumentFragment
+} from './utils';
+import {
+	modelToViewCodeBlockInsertion,
+	modelToDataViewSoftBreakInsertion,
+	dataViewToModelCodeBlockInsertion
+} from './converters';
 
 const DEFAULT_ELEMENT = 'paragraph';
 
@@ -121,10 +130,10 @@ export default class CodeBlockEditing extends Plugin {
 		// 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.downcastDispatcher.on( 'insert:softBreak', modelToDataViewSoftBreakInsertion( 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
+		// Intercept the clipboard input (paste) 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 ) => {
@@ -142,11 +151,11 @@ export default class CodeBlockEditing extends Plugin {
 			} );
 		} );
 
-		// 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.
+		// Make sure multi–line selection is always wrapped in a code block when `getSelectedContent()`
+		// is used (e.g. clipboard copy). Otherwise, only the raw text will be copied to the clipboard and,
+		// upon 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;
 
@@ -157,20 +166,7 @@ export default class CodeBlockEditing extends Plugin {
 			model.change( writer => {
 				const docFragment = evt.return;
 
-				// From:
-				//
-				//		fo[o
-				//		<softBreak></softBreak>
-				//		b]ar
-				//
-				// into:
-				//
-				//		<codeBlock language="...">
-				//			[o
-				//			<softBreak></softBreak>
-				//			b]
-				//		<codeBlock>
-				//
+				// fo[o<softBreak></softBreak>b]ar  ->   <codeBlock language="...">[o<softBreak></softBreak>b]<codeBlock>
 				if ( docFragment.childCount > 1 || selection.containsEntireContent( anchor.parent ) ) {
 					const codeBlock = writer.createElement( 'codeBlock', anchor.parent.getAttributes() );
 					writer.append( docFragment, codeBlock );
@@ -181,14 +177,7 @@ export default class CodeBlockEditing extends Plugin {
 					evt.return = newDocumentFragment;
 				}
 
-				// From:
-				//
-				//		f[oo]
-				//
-				// into:
-				//
-				//		<$text code="true">oo</text>
-				//
+				// "f[oo]"                          ->   <$text code="true">oo</text>
 				else {
 					writer.setAttribute( 'code', true, docFragment.getChild( 0 ) );
 				}
@@ -201,152 +190,7 @@ export default class CodeBlockEditing extends Plugin {
 	 */
 	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)
-		//		<codeBlock>
-		//		"    foo()"[]                   // Indent of 4 spaces.
-		//		</codeBlock>
-		//
-		//		// After pressing:
-		//		<codeBlock>
-		//			"    foo()"                 // Indent of 4 spaces.
-		//			<softBreak></softBreak>     // A new soft break created by pressing enter.
-		//			"    "[]                    // Retain the indent of 4 spaces.
-		//		</codeBlock>
-		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 = getLeadingWhiteSpaces( node );
-			}
-
-			// 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:
-		//		<codeBlock>foo[]</codeBlock>
-		//
-		//		// After first press
-		//		<codeBlock>foo<softBreak></softBreak>[]</codeBlock>
-		//
-		//		// After second press
-		//		<codeBlock>foo</codeBlock><paragraph>[]</paragraph>
-		//
-		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
-			//
-			//		<codeBlock>foo<softBreak></softBreak>[]</codeBlock>
-			//
-			// it creates the following range that will be cleaned up before leaving:
-			//
-			//		<codeBlock>foo[<softBreak></softBreak>]</codeBlock>
-			//
-			if ( nodeBefore.is( 'softBreak' ) ) {
-				emptyLineRangeToRemoveOnDoubleEnter = model.createRangeOn( nodeBefore );
-			}
-
-			// When there's some text before the position made purely of white–space characters
-			//
-			//		<codeBlock>foo<softBreak></softBreak>    []</codeBlock>
-			//
-			// but NOT when it's the first one of the kind
-			//
-			//		<codeBlock>    []</codeBlock>
-			//
-			// it creates the following range to clean up before leaving:
-			//
-			//		<codeBlock>foo[<softBreak></softBreak>    ]</codeBlock>
-			//
-			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:
-			//
-			//		<codeBlock>    []</codeBlock>
-			//		<codeBlock>  a []</codeBlock>
-			//		<codeBlock>foo<softBreak></softBreak>bar[]</codeBlock>
-			//		<codeBlock>foo<softBreak></softBreak> a []</codeBlock>
-			//
-			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 <softBreak> and all white space characters that followed it.
-				writer.remove( emptyLineRangeToRemoveOnDoubleEnter );
-
-				// "Clone" the <codeBlock> in the standard way.
-				editor.execute( 'enter' );
-
-				const newBlock = modelDoc.selection.anchor.parent;
-
-				// Make the cloned <codeBlock> a regular <paragraph> (with clean attributes, so no language).
-				writer.rename( newBlock, DEFAULT_ELEMENT );
-				editor.model.schema.removeDisallowedAttributes( [ newBlock ], writer );
-
-				// Eye candy.
-				view.scrollToTheSelection();
-			} );
-
-			return true;
-		}
-
-		const commands = this.editor.commands;
-
+		const commands = editor.commands;
 		const indent = commands.get( 'indent' );
 		const outdent = commands.get( 'outdent' );
 
@@ -357,191 +201,157 @@ export default class CodeBlockEditing extends Plugin {
 		if ( outdent ) {
 			outdent.registerChildCommand( commands.get( 'outdentCodeBlock' ) );
 		}
-	}
-}
 
-// A model-to-view converter for the codeBlock element.
-//
-// @param {module:engine/model/model~Model} model
-// @param {Object.<String,String>} [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;
-		}
+		// Customize the response to the <kbd>Enter</kbd> and <kbd>Shift</kbd>+<kbd>Enter</kbd>
+		// key press when the selection is in the code block. 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.
+		this.listenTo( editor.editing.view.document, 'enter', ( evt, data ) => {
+			const positionParent = editor.model.document.selection.getLastPosition().parent;
 
-		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
-		} );
+			if ( !positionParent.is( 'codeBlock' ) ) {
+				return;
+			}
 
-		writer.insert( writer.createPositionAt( pre, 0 ), code );
-		writer.insert( targetViewPosition, pre );
-		mapper.bindElements( data.item, code );
-	};
+			leaveBlockOnEnter( editor, data.isSoft ) || breakLineOnEnter( editor );
+
+			data.preventDefault();
+			evt.stop();
+		} );
+	}
 }
 
-// A model-to-view converter for the new line separator.
+// 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:
 //
-// @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;
-		}
+//		// Before pressing enter (or shift enter)
+//		<codeBlock>
+//		"    foo()"[]                   // Indent of 4 spaces.
+//		</codeBlock>
+//
+//		// After pressing:
+//		<codeBlock>
+//		"    foo()"                 // Indent of 4 spaces.
+//		<softBreak></softBreak>     // A new soft break created by pressing enter.
+//		"    "[]                    // Retain the indent of 4 spaces.
+//		</codeBlock>
+//
+// @param {module:core/editor/editor~Editor} editor
+function breakLineOnEnter( editor ) {
+	const model = editor.model;
+	const modelDoc = model.document;
+	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 = getLeadingWhiteSpaces( node );
+	}
 
-		const { writer, mapper, consumable } = conversionApi;
+	// Keeping everything in a change block for a single undo step.
+	editor.model.change( writer => {
+		editor.execute( 'shiftEnter' );
 
-		if ( !consumable.consume( data.item, 'insert' ) ) {
-			return;
+		// 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 );
 		}
-
-		const position = mapper.toViewPosition( model.createPositionBefore( data.item ) );
-
-		writer.insert( position, writer.createText( '\n' ) );
-	};
+	} );
 }
 
-// A view-to-model converter for pre > code html.
+// Leave the code block when Enter (but NOT Shift+Enter) has been pressed twice at the end
+// of the code block:
 //
-// @param {module:engine/controller/datacontroller~DataController} dataController
-// @param {Array.<String>} 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 <code> 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 );
+//		// Before:
+//		<codeBlock>foo[]</codeBlock>
+//
+//		// After first press:
+//		<codeBlock>foo<softBreak></softBreak>[]</codeBlock>
+//
+//		// After second press:
+//		<codeBlock>foo</codeBlock><paragraph>[]</paragraph>
+//
+// @param {module:core/editor/editor~Editor} editor
+// @param {Boolean} isSoftEnter When `true`, enter was pressed along with <kbd>Shift</kbd>.
+// @returns {Boolean} `true` when selection left the block. `false` if stayed.
+function leaveBlockOnEnter( editor, isSoftEnter ) {
+	const model = editor.model;
+	const modelDoc = model.document;
+	const view = editor.editing.view;
+	const lastSelectionPosition = modelDoc.selection.getLastPosition();
+	const nodeBefore = lastSelectionPosition.nodeBefore;
+
+	let emptyLineRangeToRemoveOnDoubleEnter;
+
+	if ( isSoftEnter || !modelDoc.selection.isCollapsed || !lastSelectionPosition.isAtEnd || !nodeBefore ) {
+		return false;
+	}
 
-		// Let's see if the codeBlock can be inserted the current modelCursor.
-		const splitResult = conversionApi.splitToAllowedParent( codeBlock, data.modelCursor );
+	// When the position is directly preceded by a soft break
+	//
+	//		<codeBlock>foo<softBreak></softBreak>[]</codeBlock>
+	//
+	// it creates the following range that will be cleaned up before leaving:
+	//
+	//		<codeBlock>foo[<softBreak></softBreak>]</codeBlock>
+	//
+	if ( nodeBefore.is( 'softBreak' ) ) {
+		emptyLineRangeToRemoveOnDoubleEnter = model.createRangeOn( nodeBefore );
+	}
 
-		// 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;
-		}
+	// When there's some text before the position made purely of white–space characters
+	//
+	//		<codeBlock>foo<softBreak></softBreak>    []</codeBlock>
+	//
+	// but NOT when it's the first one of the kind
+	//
+	//		<codeBlock>    []</codeBlock>
+	//
+	// it creates the following range to clean up before leaving:
+	//
+	//		<codeBlock>foo[<softBreak></softBreak>    ]</codeBlock>
+	//
+	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 )
+		);
+	}
 
-		// Insert element on allowed position.
-		writer.insert( codeBlock, splitResult.position );
+	// Not leaving the block in the following cases:
+	//
+	//		<codeBlock>    []</codeBlock>
+	//		<codeBlock>  a []</codeBlock>
+	//		<codeBlock>foo<softBreak></softBreak>bar[]</codeBlock>
+	//		<codeBlock>foo<softBreak></softBreak> a []</codeBlock>
+	//
+	else {
+		return false;
+	}
 
-		consumable.consume( viewItem, { name: true } );
-		consumable.consume( viewChild, { name: true } );
+	// We're doing everything in a single change block to have a single undo step.
+	editor.model.change( writer => {
+		// Remove the last <softBreak> and all white space characters that followed it.
+		writer.remove( emptyLineRangeToRemoveOnDoubleEnter );
 
-		const parts = conversionApi.getSplitParts( codeBlock );
+		// "Clone" the <codeBlock> in the standard way.
+		editor.execute( 'enter' );
 
-		// Set conversion result range.
-		data.modelRange = writer.createRange(
-			conversionApi.writer.createPositionBefore( codeBlock ),
-			conversionApi.writer.createPositionAfter( parts[ parts.length - 1 ] )
-		);
+		const newBlock = modelDoc.selection.anchor.parent;
 
-		// If we had to split parent to insert our element then we want to continue conversion inside
-		// the split parent.
-		//
-		// before split:
-		//
-		//		<allowed><notAllowed>[]</notAllowed></allowed>
-		//
-		// after split:
-		//
-		//		<allowed>
-		//			<notAllowed></notAllowed>
-		//			<converted></converted>
-		//			<notAllowed>[]</notAllowed>
-		//		</allowed>
-		if ( splitResult.cursorParent ) {
-			data.modelCursor = writer.createPositionAt( splitResult.cursorParent, 0 );
-		} else {
-			// Otherwise just continue after the inserted element.
-			data.modelCursor = data.modelRange.end;
-		}
-	};
-}
+		// Make the cloned <codeBlock> a regular <paragraph> (with clean attributes, so no language).
+		writer.rename( newBlock, DEFAULT_ELEMENT );
+		editor.model.schema.removeDisallowedAttributes( [ newBlock ], writer );
 
-// Returns content of `<pre></pre>` with unescaped html inside.
-//
-// @param {String} stringifiedElement
-function extractDataFromCodeElement( stringifiedElement ) {
-	const data = new RegExp( /^<code[^>]*>(.*)<\/code>$/, 's' ).exec( stringifiedElement )[ 1 ];
-
-	return data
-		.replace( /&lt;/g, '<' )
-		.replace( /&gt;/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:
-//
-//		<DocumentFragment>
-//			"foo()"
-//			<softBreak></softBreak>
-//			"bar()"
-//		</DocumentFragment>
-//
-// @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 );
-		}
-	}
+		// Eye candy.
+		view.scrollToTheSelection();
+	} );
 
-	return fragment;
+	return true;
 }

+ 197 - 0
packages/ckeditor5-code-block/src/converters.js

@@ -0,0 +1,197 @@
+/**
+ * @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/converters
+ */
+
+import { rawSnippetTextToModelDocumentFragment } from './utils';
+
+/**
+ * A model → view (both editing and data) converter for the `codeBlock` element.
+ *
+ * Sample input:
+ *
+ *		<codeBlock language="javascript">foo();<softBreak></softBreak>bar();</codeBlock>
+ *
+ * Sample output (editing):
+ *
+ *		<pre data-language="JavaScript"><code class="javascript">foo();<br />bar();</code></pre>
+ *
+ * Sample output (data, see {@link module:code-block/converters~modelToDataViewSoftBreakInsertion}):
+ *
+ *		<pre><code class="javascript">foo();\nbar();</code></pre>
+ *
+ * @param {module:engine/model/model~Model} model
+ * @param {Object.<String,String>} [languageLabels={}] An object associating a programming language
+ * classes with human–readable labels (as in the editor config). Labels are used in the editing
+ * view only.
+ * @returns {Function} Returns a conversion callback.
+ */
+export 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 → data-view converter for the new line (`softBreak`) separator.
+ *
+ * Sample input:
+ *
+ *		<codeBlock ...>foo();<softBreak></softBreak>bar();</codeBlock>
+ *
+ * Sample output:
+ *
+ *		<pre><code ...>foo();\nbar();</code></pre>
+ *
+ * @param {module:engine/model/model~Model} model
+ * @returns {Function} Returns a conversion callback.
+ */
+export function modelToDataViewSoftBreakInsertion( 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 → model converter for `<pre>` with `<code>` html.
+ *
+ * Sample input:
+ *
+ *		<pre><code class="javascript">foo();\nbar();</code></pre>
+ *
+ * Sample output:
+ *
+ *		<codeBlock language="javascript">foo();<softBreak></softBreak>bar();</codeBlock>
+ *
+ * @param {module:engine/controller/datacontroller~DataController} dataController
+ * @param {Array.<String>} languageClasses An array of valid (as in the editor config) CSS classes
+ * associated with code languages.
+ * @returns {Function} Returns a conversion callback.
+ */
+export 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 <code> 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:
+		//
+		//		<allowed><notAllowed>[]</notAllowed></allowed>
+		//
+		// after split:
+		//
+		//		<allowed>
+		//			<notAllowed></notAllowed>
+		//			<converted></converted>
+		//			<notAllowed>[]</notAllowed>
+		//		</allowed>
+		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 `<pre></pre>` with unescaped html inside.
+//
+// @param {String} stringifiedElement
+function extractDataFromCodeElement( stringifiedElement ) {
+	const data = new RegExp( /^<code[^>]*>(.*)<\/code>$/, 's' ).exec( stringifiedElement )[ 1 ];
+
+	return data
+		.replace( /&lt;/g, '<' )
+		.replace( /&gt;/g, '>' );
+}

+ 38 - 3
packages/ckeditor5-code-block/src/utils.js

@@ -33,9 +33,8 @@ export function getLocalizedLanguageDefinitions( editor ) {
 }
 
 /**
- * For a given model text node, it returns white spaces that precede
- * other characters in that node. This corresponds to the indentation
- * of the code block line.
+ * For a given model text node, it returns white spaces that precede other characters in that node.
+ * This corresponds to the indentation part of the code block line.
  *
  * @param {module:engine/model/text~Text} codeLineNodes
  * @returns {String}
@@ -43,3 +42,39 @@ export function getLocalizedLanguageDefinitions( editor ) {
 export function getLeadingWhiteSpaces( textNode ) {
 	return textNode.data.match( /^(\s*)/ )[ 0 ];
 }
+
+/**
+ * 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:
+ *
+ *		<DocumentFragment>
+ *			"foo()"
+ *			<softBreak></softBreak>
+ *			"bar()"
+ *		</DocumentFragment>
+ *
+ * @param {module:engine/model/writer~Writer} writer
+ * @param {String} text A raw code text to be converted.
+ */
+export 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;
+}