8
0
Просмотр исходного кода

Default feature config and the "language" attribute in the model followed by the "class" attribute in the view.

Aleksander Nowodzinski 6 лет назад
Родитель
Сommit
da62d7e3ab

+ 106 - 26
packages/ckeditor5-code-block/src/codeblockediting.js

@@ -10,6 +10,7 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
 import CodeBlockCommand from './codeblockcommand';
+import { getLocalizedLanguageDefinitions } from './utils';
 
 /**
  * The editing part of the code block feature.
@@ -33,6 +34,31 @@ export default class CodeBlockEditing extends Plugin {
 		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
 	 */
@@ -41,11 +67,18 @@ export default class CodeBlockEditing extends Plugin {
 		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' } );
+		schema.register( 'codeBlock', {
+			inheritAllFrom: '$block',
+			allowAttributes: [ 'language' ]
+		} );
 
 		// Disallow codeBlock in codeBlock.
 		schema.addChildCheck( ( context, childDef ) => {
@@ -55,19 +88,17 @@ export default class CodeBlockEditing extends Plugin {
 		} );
 
 		// Disallow all attributes in `codeBlock`.
-		schema.addAttributeCheck( context => {
+		schema.addAttributeCheck( ( context, attributeName ) => {
 			if ( context.endsWith( 'codeBlock' ) || context.endsWith( 'codeBlock $text' ) ) {
-				return false;
+				return attributeName === 'language';
 			}
 		} );
 
 		// Conversion.
-		editor.editing.downcastDispatcher.on( 'insert:codeBlock', modelViewCodeBlockInsertion( model ) );
-
-		editor.data.downcastDispatcher.on( 'insert:codeBlock', modelViewCodeBlockInsertion( model ) );
-		editor.data.downcastDispatcher.on( 'insert:softBreak', modelViewSoftBreakInsertion( model ), { priority: 'high' } );
-
-		editor.data.upcastDispatcher.on( 'element:pre', dataViewModelCodeBlockInsertion( editor.data ) );
+		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
@@ -82,7 +113,7 @@ export default class CodeBlockEditing extends Plugin {
 			const text = data.dataTransfer.getData( 'text/plain' );
 
 			model.change( writer => {
-				model.insertContent( writer.createText( text ), modelSelection );
+				model.insertContent( rawSnippetTextToModelDocumentFragment( writer, text ), modelSelection );
 				evt.stop();
 			} );
 		} );
@@ -111,8 +142,10 @@ export default class CodeBlockEditing extends Plugin {
 // 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 modelViewCodeBlockInsertion( model ) {
+function modelToViewCodeBlockInsertion( model, languageLabels = {} ) {
 	return ( evt, data, conversionApi ) => {
 		const { writer, mapper, consumable } = conversionApi;
 
@@ -120,9 +153,15 @@ function modelViewCodeBlockInsertion( model ) {
 			return;
 		}
 
+		const codeBlockLanguage = data.item.getAttribute( 'language' );
 		const targetViewPosition = mapper.toViewPosition( model.createPositionBefore( data.item ) );
-		const pre = writer.createContainerElement( 'pre' );
-		const code = writer.createContainerElement( 'code' );
+		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 );
@@ -134,7 +173,7 @@ function modelViewCodeBlockInsertion( model ) {
 //
 // @param {module:engine/model/model~Model} model
 // @returns {Function} Returns a conversion callback.
-function modelViewSoftBreakInsertion( model ) {
+function modelToViewSoftBreakInsertion( model ) {
 	return ( evt, data, conversionApi ) => {
 		if ( data.item.parent.name !== 'codeBlock' ) {
 			return;
@@ -155,8 +194,10 @@ function modelViewSoftBreakInsertion( model ) {
 // A view-to-model converter for pre > code html.
 //
 // @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 dataViewModelCodeBlockInsertion( dataController ) {
+function dataViewToModelCodeBlockInsertion( dataController, languageClasses ) {
 	return ( evt, data, conversionApi ) => {
 		const viewItem = data.viewItem;
 		const viewChild = viewItem.getChild( 0 );
@@ -176,19 +217,24 @@ function dataViewModelCodeBlockInsertion( dataController ) {
 
 		const modelItem = writer.createElement( 'codeBlock' );
 
-		const stringifiedElement = dataController.processor.toData( viewChild );
-		const textData = extractDataFromCodeElement( stringifiedElement );
-		const textLines = textData.split( '\n' ).map( data => writer.createText( data ) );
-		const lastLine = textLines[ textLines.length - 1 ];
-
-		for ( const node of textLines ) {
-			writer.append( node, modelItem );
-
-			if ( node !== lastLine ) {
-				writer.appendElement( 'softBreak', modelItem );
+		// 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, modelItem );
+				break;
 			}
 		}
 
+		if ( !modelItem.hasAttribute( 'language' ) ) {
+			writer.setAttribute( 'language', languageClasses[ 0 ], modelItem );
+		}
+
+		const stringifiedElement = dataController.processor.toData( viewChild );
+		const textData = extractDataFromCodeElement( stringifiedElement );
+		const fragment = rawSnippetTextToModelDocumentFragment( writer, textData );
+
+		writer.append( fragment, modelItem );
 		writer.insert( modelItem, data.modelCursor );
 
 		data.modelCursor = writer.createPositionAfter( modelItem );
@@ -200,9 +246,43 @@ function dataViewModelCodeBlockInsertion( dataController ) {
 //
 // @param {String} stringifiedElement
 function extractDataFromCodeElement( stringifiedElement ) {
-	const data = new RegExp( /^<code>(.*)<\/code>$/, 's' ).exec( stringifiedElement )[ 1 ];
+	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/>
+//			"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 );
+		}
+	}
+
+	return fragment;
+}

+ 136 - 30
packages/ckeditor5-code-block/tests/codeblockediting.js

@@ -48,6 +48,95 @@ describe( 'CodeBlockEditing', () => {
 		expect( CodeBlockEditing.requires ).to.have.members( [ ShiftEnter ] );
 	} );
 
+	describe( 'config', () => {
+		describe( 'languages', () => {
+			describe( 'default value', () => {
+				it( 'should be set', () => {
+					expect( editor.config.get( 'codeBlock.languages' ) ).to.deep.equal( [
+						{ 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' }
+					] );
+				} );
+			} );
+
+			it( 'should be recognized when loading data', () => {
+				return ClassicTestEditor.create(
+					'<pre><code class="foo">bar</code></pre>' +
+					'<pre><code class="bar">baz</code></pre>',
+					{
+						plugins: [ CodeBlockEditing ],
+						codeBlock: {
+							languages: [
+								{ class: 'foo', label: 'Foo' },
+								{ class: 'bar', label: 'Bar' },
+							]
+						}
+					} )
+					.then( editor => {
+						model = editor.model;
+
+						expect( getModelData( model ) ).to.equal(
+							'<codeBlock language="foo">[]bar</codeBlock>' +
+							'<codeBlock language="bar">baz</codeBlock>'
+						);
+
+						return editor.destroy();
+					} );
+			} );
+
+			it( 'should use the first if the code in data has no language', () => {
+				return ClassicTestEditor
+					.create( '<pre><code>bar</code></pre>', {
+						plugins: [ CodeBlockEditing ],
+						codeBlock: {
+							languages: [
+								{ class: 'foo', label: 'Foo' },
+								{ class: 'bar', label: 'Bar' }
+							]
+						}
+					} )
+					.then( editor => {
+						model = editor.model;
+
+						expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">[]bar</codeBlock>' );
+
+						return editor.destroy();
+					} );
+			} );
+
+			it( 'should use the first if the code in data has an invalid language', () => {
+				return ClassicTestEditor
+					.create( '<pre><code class="baz">bar</code></pre>', {
+						plugins: [ CodeBlockEditing ],
+						codeBlock: {
+							languages: [
+								{ class: 'foo', label: 'Foo' },
+								{ class: 'bar', label: 'Bar' }
+							]
+						}
+					} )
+					.then( editor => {
+						model = editor.model;
+
+						expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">[]bar</codeBlock>' );
+
+						return editor.destroy();
+					} );
+			} );
+		} );
+	} );
+
 	it( 'adds a codeBlock command', () => {
 		expect( editor.commands.get( 'codeBlock' ) ).to.be.instanceOf( CodeBlockCommand );
 	} );
@@ -62,13 +151,13 @@ describe( 'CodeBlockEditing', () => {
 		expect( model.schema.checkChild( [ '$root', 'codeBlock' ], 'codeBlock' ) ).to.equal( false );
 	} );
 
-	it( 'disallows all attributes for codeBlock', () => {
-		setModelData( model, '<codeBlock>f[o]o</codeBlock>' );
+	it( 'disallows all attributes (except "language") for codeBlock', () => {
+		setModelData( model, '<codeBlock language="css">f[o]o</codeBlock>' );
 
 		editor.execute( 'alignment', { value: 'right' } );
 		editor.execute( 'bold' );
 
-		expect( getModelData( model ) ).to.equal( '<codeBlock>f[o]o</codeBlock>' );
+		expect( getModelData( model ) ).to.equal( '<codeBlock language="css">f[o]o</codeBlock>' );
 	} );
 
 	it( 'should force shiftEnter command when pressing enter inside a codeBlock', () => {
@@ -109,32 +198,35 @@ describe( 'CodeBlockEditing', () => {
 
 	describe( 'editing pipeline m -> v', () => {
 		it( 'should convert empty codeBlock to empty pre tag', () => {
-			setModelData( model, '<codeBlock></codeBlock>' );
+			setModelData( model, '<codeBlock language="plaintext"></codeBlock>' );
 
-			expect( getViewData( view ) ).to.equal( '<pre><code>[]</code></pre>' );
+			expect( getViewData( view ) ).to.equal( '<pre data-language="Plain text"><code class="plaintext">[]</code></pre>' );
 		} );
 
 		it( 'should convert non-empty codeBlock to pre tag', () => {
-			setModelData( model, '<codeBlock>Foo</codeBlock>' );
+			setModelData( model, '<codeBlock language="plaintext">Foo</codeBlock>' );
 
-			expect( getViewData( view ) ).to.equal( '<pre><code>{}Foo</code></pre>' );
+			expect( getViewData( view ) ).to.equal( '<pre data-language="Plain text"><code class="plaintext">{}Foo</code></pre>' );
 		} );
 
 		it( 'should convert codeBlock with softBreaks to pre tag #1', () => {
 			setModelData( model,
-				'<codeBlock>' +
+				'<codeBlock language="plaintext">' +
 					'Foo<softBreak></softBreak>' +
 					'Bar<softBreak></softBreak>' +
 					'Biz' +
 				'</codeBlock>'
 			);
 
-			expect( getViewData( view ) ).to.equal( '<pre><code>{}Foo<br></br>Bar<br></br>Biz</code></pre>' );
+			expect( getViewData( view ) ).to.equal(
+				'<pre data-language="Plain text">' +
+					'<code class="plaintext">{}Foo<br></br>Bar<br></br>Biz</code>' +
+				'</pre>' );
 		} );
 
 		it( 'should convert codeBlock with softBreaks to pre tag #2', () => {
 			setModelData( model,
-				'<codeBlock>' +
+				'<codeBlock language="plaintext">' +
 					'<softBreak></softBreak>' +
 					'<softBreak></softBreak>' +
 					'Foo' +
@@ -143,26 +235,29 @@ describe( 'CodeBlockEditing', () => {
 				'</codeBlock>'
 			);
 
-			expect( getViewData( view ) ).to.equal( '<pre><code>[]<br></br><br></br>Foo<br></br><br></br></code></pre>' );
+			expect( getViewData( view ) ).to.equal(
+				'<pre data-language="Plain text">' +
+					'<code class="plaintext">[]<br></br><br></br>Foo<br></br><br></br></code>' +
+				'</pre>' );
 		} );
 	} );
 
 	describe( 'data pipeline m -> v conversion ', () => {
 		it( 'should convert empty codeBlock to empty pre tag', () => {
-			setModelData( model, '<codeBlock></codeBlock>' );
+			setModelData( model, '<codeBlock language="plaintext"></codeBlock>' );
 
-			expect( editor.getData( { trim: 'none' } ) ).to.equal( '<pre><code>&nbsp;</code></pre>' );
+			expect( editor.getData( { trim: 'none' } ) ).to.equal( '<pre><code class="plaintext">&nbsp;</code></pre>' );
 		} );
 
 		it( 'should convert non-empty codeBlock to pre tag', () => {
-			setModelData( model, '<codeBlock>Foo</codeBlock>' );
+			setModelData( model, '<codeBlock language="plaintext">Foo</codeBlock>' );
 
-			expect( editor.getData() ).to.equal( '<pre><code>Foo</code></pre>' );
+			expect( editor.getData() ).to.equal( '<pre><code class="plaintext">Foo</code></pre>' );
 		} );
 
 		it( 'should convert codeBlock with softBreaks to pre tag #1', () => {
 			setModelData( model,
-				'<codeBlock>' +
+				'<codeBlock language="plaintext">' +
 					'Foo<softBreak></softBreak>' +
 					'Bar<softBreak></softBreak>' +
 					'Biz' +
@@ -170,12 +265,12 @@ describe( 'CodeBlockEditing', () => {
 				'<paragraph>A<softBreak></softBreak>B</paragraph>'
 			);
 
-			expect( editor.getData() ).to.equal( '<pre><code>Foo\nBar\nBiz</code></pre><p>A<br>B</p>' );
+			expect( editor.getData() ).to.equal( '<pre><code class="plaintext">Foo\nBar\nBiz</code></pre><p>A<br>B</p>' );
 		} );
 
 		it( 'should convert codeBlock with softBreaks to pre tag #2', () => {
 			setModelData( model,
-				'<codeBlock>' +
+				'<codeBlock language="plaintext">' +
 					'<softBreak></softBreak>' +
 					'<softBreak></softBreak>' +
 					'Foo' +
@@ -184,15 +279,18 @@ describe( 'CodeBlockEditing', () => {
 				'</codeBlock>'
 			);
 
-			expect( editor.getData() ).to.equal( '<pre><code>\n\nFoo\n\n</code></pre>' );
+			expect( editor.getData() ).to.equal( '<pre><code class="plaintext">\n\nFoo\n\n</code></pre>' );
 		} );
 
 		it( 'should convert codeBlock with html content', () => {
-			setModelData( model, '<codeBlock>[]</codeBlock>' );
+			setModelData( model, '<codeBlock language="plaintext">[]</codeBlock>' );
 
 			model.change( writer => writer.insertText( '<div><p>Foo</p></div>', model.document.selection.getFirstPosition() ) );
 
-			expect( editor.getData() ).to.equal( '<pre><code>&lt;div&gt;&lt;p&gt;Foo&lt;/p&gt;&lt;/div&gt;</code></pre>' );
+			expect( editor.getData() ).to.equal(
+				'<pre>' +
+					'<code class="plaintext">&lt;div&gt;&lt;p&gt;Foo&lt;/p&gt;&lt;/div&gt;</code>' +
+				'</pre>' );
 		} );
 
 		it( 'should be overridable', () => {
@@ -212,7 +310,7 @@ describe( 'CodeBlockEditing', () => {
 				api.writer.insert( position, api.writer.createText( '\n' ) );
 			}, { priority: 'highest' } );
 
-			setModelData( model, '<codeBlock>Foo<softBreak></softBreak>Bar</codeBlock>' );
+			setModelData( model, '<codeBlock language="plaintext">Foo<softBreak></softBreak>Bar</codeBlock>' );
 
 			expect( editor.getData() ).to.equal( '<code>Foo\nBar</code>' );
 		} );
@@ -234,14 +332,14 @@ describe( 'CodeBlockEditing', () => {
 		it( 'should convert pre > code to code block', () => {
 			editor.setData( '<pre><code></code></pre>' );
 
-			expect( getModelData( model ) ).to.equal( '<codeBlock>[]</codeBlock>' );
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="plaintext">[]</codeBlock>' );
 		} );
 
 		it( 'should convert pre > code with multi-line text to code block #1', () => {
 			editor.setData( '<pre><code>foo\nbar</code></pre>' );
 
 			expect( getModelData( model ) ).to.equal(
-				'<codeBlock>[]' +
+				'<codeBlock language="plaintext">[]' +
 					'foo' +
 					'<softBreak></softBreak>' +
 					'bar' +
@@ -253,7 +351,7 @@ describe( 'CodeBlockEditing', () => {
 			editor.setData( '<pre><code>\n\nfoo\n\n</code></pre>' );
 
 			expect( getModelData( model ) ).to.equal(
-				'<codeBlock>[]' +
+				'<codeBlock language="plaintext">[]' +
 					'<softBreak></softBreak>' +
 					'<softBreak></softBreak>' +
 					'foo' +
@@ -267,7 +365,7 @@ describe( 'CodeBlockEditing', () => {
 			editor.setData( '<pre><code><p>Foo</p>\n<p>Bar</p></code></pre>' );
 
 			expect( getModelData( model ) ).to.equal(
-				'<codeBlock>[]' +
+				'<codeBlock language="plaintext">[]' +
 					'<p>Foo</p>' +
 					'<softBreak></softBreak>' +
 					'<p>Bar</p>' +
@@ -278,13 +376,13 @@ describe( 'CodeBlockEditing', () => {
 		it( 'should convert pre > code tag with HTML and nested pre > code tag', () => {
 			editor.setData( '<pre><code><p>Foo</p><pre>Bar</pre><p>Biz</p></code></pre>' );
 
-			expect( getModelData( model ) ).to.equal( '<codeBlock>[]<p>Foo</p><pre>Bar</pre><p>Biz</p></codeBlock>' );
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="plaintext">[]<p>Foo</p><pre>Bar</pre><p>Biz</p></codeBlock>' );
 		} );
 
 		it( 'should convert pre > code tag with escaped html content', () => {
 			editor.setData( '<pre><code>&lt;div&gt;&lt;p&gt;Foo&lt;/p&gt;&lt;/div&gt;</code></pre>' );
 
-			expect( getModelData( model ) ).to.equal( '<codeBlock>[]<div><p>Foo</p></div></codeBlock>' );
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="plaintext">[]<div><p>Foo</p></div></codeBlock>' );
 		} );
 
 		it( 'should be overridable', () => {
@@ -323,7 +421,7 @@ describe( 'CodeBlockEditing', () => {
 		} );
 
 		it( 'should intercept input when selection anchored in the code block', () => {
-			setModelData( model, '<codeBlock>f[o]o</codeBlock>' );
+			setModelData( model, '<codeBlock language="css">f[o]o</codeBlock>' );
 
 			const dataTransferMock = {
 				getData: sinon.stub().withArgs( 'text/plain' ).returns( 'bar\nbaz\n' )
@@ -334,7 +432,15 @@ describe( 'CodeBlockEditing', () => {
 				stop: sinon.spy()
 			} );
 
-			expect( getModelData( model ) ).to.equal( '<codeBlock>fbar\nbaz\n[]o</codeBlock>' );
+			expect( getModelData( model ) ).to.equal(
+				'<codeBlock language="css">' +
+					'fbar' +
+					'<softBreak></softBreak>' +
+					'baz' +
+					'<softBreak></softBreak>' +
+					'[]o' +
+				'</codeBlock>' );
+
 			sinon.assert.calledOnce( dataTransferMock.getData );
 		} );
 	} );