codeblockediting.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. /**
  2. * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * @module code-block/codeblockediting
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
  10. import CodeBlockCommand from './codeblockcommand';
  11. import { getLocalizedLanguageDefinitions } from './utils';
  12. const DEFAULT_ELEMENT = 'paragraph';
  13. /**
  14. * The editing part of the code block feature.
  15. *
  16. * Introduces the `'codeBlock'` command and the `'codeBlock'` model element.
  17. *
  18. * @extends module:core/plugin~Plugin
  19. */
  20. export default class CodeBlockEditing extends Plugin {
  21. /**
  22. * @inheritDoc
  23. */
  24. static get pluginName() {
  25. return 'CodeBlockEditing';
  26. }
  27. /**
  28. * @inheritDoc
  29. */
  30. static get requires() {
  31. return [ ShiftEnter ];
  32. }
  33. /**
  34. * @inheritDoc
  35. */
  36. constructor( editor ) {
  37. super( editor );
  38. editor.config.define( 'codeBlock', {
  39. languages: [
  40. { class: 'plaintext', label: 'Plain text' },
  41. { class: 'c', label: 'C' },
  42. { class: 'cs', label: 'C#' },
  43. { class: 'cpp', label: 'C++' },
  44. { class: 'css', label: 'CSS' },
  45. { class: 'diff', label: 'Diff' },
  46. { class: 'xml', label: 'HTML/XML' },
  47. { class: 'java', label: 'Java' },
  48. { class: 'javascript', label: 'JavaScript' },
  49. { class: 'php', label: 'PHP' },
  50. { class: 'python', label: 'Python' },
  51. { class: 'ruby', label: 'Ruby' },
  52. { class: 'typescript', label: 'TypeScript' },
  53. ]
  54. } );
  55. }
  56. /**
  57. * @inheritDoc
  58. */
  59. init() {
  60. const editor = this.editor;
  61. const schema = editor.model.schema;
  62. const model = editor.model;
  63. const localizedLanguageDefinitions = getLocalizedLanguageDefinitions( editor );
  64. const languageClasses = localizedLanguageDefinitions.map( def => def.class );
  65. const languageLabels = Object.assign( {}, ...localizedLanguageDefinitions.map( def => ( { [ def.class ]: def.label } ) ) );
  66. // Command.
  67. editor.commands.add( 'codeBlock', new CodeBlockCommand( editor ) );
  68. // Schema.
  69. schema.register( 'codeBlock', {
  70. inheritAllFrom: '$block',
  71. allowAttributes: [ 'language' ]
  72. } );
  73. // Disallow codeBlock in codeBlock.
  74. schema.addChildCheck( ( context, childDef ) => {
  75. if ( context.endsWith( 'codeBlock' ) && childDef.name === 'codeBlock' ) {
  76. return false;
  77. }
  78. } );
  79. // Disallow all attributes in `codeBlock`.
  80. schema.addAttributeCheck( ( context, attributeName ) => {
  81. if ( context.endsWith( 'codeBlock' ) || context.endsWith( 'codeBlock $text' ) ) {
  82. return attributeName === 'language';
  83. }
  84. } );
  85. // Conversion.
  86. editor.editing.downcastDispatcher.on( 'insert:codeBlock', modelToViewCodeBlockInsertion( model, languageLabels ) );
  87. editor.data.downcastDispatcher.on( 'insert:codeBlock', modelToViewCodeBlockInsertion( model ) );
  88. editor.data.downcastDispatcher.on( 'insert:softBreak', modelToViewSoftBreakInsertion( model ), { priority: 'high' } );
  89. editor.data.upcastDispatcher.on( 'element:pre', dataViewToModelCodeBlockInsertion( editor.data, languageClasses ) );
  90. // Intercept the clipboard input when the selection is anchored in the code block and force the clipboard
  91. // data to be pasted as a single plain text. Otherwise, the code lines will split the code block and
  92. // "spill out" as separate paragraphs.
  93. this.listenTo( editor.editing.view.document, 'clipboardInput', ( evt, data ) => {
  94. const modelSelection = model.document.selection;
  95. if ( !modelSelection.anchor.parent.is( 'codeBlock' ) ) {
  96. return;
  97. }
  98. const text = data.dataTransfer.getData( 'text/plain' );
  99. model.change( writer => {
  100. model.insertContent( rawSnippetTextToModelDocumentFragment( writer, text ), modelSelection );
  101. evt.stop();
  102. } );
  103. } );
  104. // Make sure multi–line selection is always wrapped in a code block. Otherwise, only the raw text
  105. // will be copied to the clipboard by the user and, upon the next paste, this bare text will not be
  106. // inserted as a code block, which is not the best UX.
  107. // Similarly, when the selection in a single line, the selected content should be an inline
  108. // code so it can be pasted later on and retain it's preformatted nature.
  109. this.listenTo( model, 'getSelectedContent', ( evt, [ selection ] ) => {
  110. const anchor = selection.anchor;
  111. if ( !anchor.parent.is( 'codeBlock' ) || !anchor.hasSameParentAs( selection.focus ) ) {
  112. return;
  113. }
  114. model.change( writer => {
  115. const docFragment = evt.return;
  116. // From:
  117. //
  118. // fo[o
  119. // <softBreak></softBreak>
  120. // b]ar
  121. //
  122. // into:
  123. //
  124. // <codeBlock language="...">
  125. // [o
  126. // <softBreak></softBreak>
  127. // b]
  128. // <codeBlock>
  129. //
  130. if ( docFragment.childCount > 1 || selection.containsEntireContent( anchor.parent ) ) {
  131. const codeBlock = writer.createElement( 'codeBlock', anchor.parent.getAttributes() );
  132. writer.append( docFragment, codeBlock );
  133. const newDocumentFragment = writer.createDocumentFragment();
  134. writer.append( codeBlock, newDocumentFragment );
  135. evt.return = newDocumentFragment;
  136. }
  137. // From:
  138. //
  139. // f[oo]
  140. //
  141. // into:
  142. //
  143. // <$text code="true">oo</text>
  144. //
  145. else {
  146. writer.setAttribute( 'code', true, docFragment.getChild( 0 ) );
  147. }
  148. } );
  149. } );
  150. }
  151. /**
  152. * @inheritDoc
  153. */
  154. afterInit() {
  155. const editor = this.editor;
  156. const view = editor.editing.view;
  157. const model = editor.model;
  158. const modelDoc = model.document;
  159. this.listenTo( view.document, 'enter', ( evt, data ) => {
  160. const positionParent = modelDoc.selection.getLastPosition().parent;
  161. if ( !positionParent.is( 'codeBlock' ) ) {
  162. return;
  163. }
  164. // Upon enter key press we can either leave the block if it's "two enters" in a row
  165. // or create a new code block line, preserving previous line's indentation.
  166. leaveBlock( data.isSoft ) || breakLine();
  167. data.preventDefault();
  168. evt.stop();
  169. } );
  170. // Normally, when the enter (or shift+enter) key is pressed, a soft line break is to be added to the
  171. // code block. Let's try to follow the indentation of the previous line when possible, for instance:
  172. //
  173. // // Before pressing enter (or shift enter)
  174. // <codeBlock>
  175. // " foo()"[] // Indent of 4 spaces.
  176. // </codeBlock>
  177. //
  178. // // After pressing:
  179. // <codeBlock>
  180. // " foo()" // Indent of 4 spaces.
  181. // <softBreak></softBreak> // A new soft break created by pressing enter.
  182. // " "[] // Retain the indent of 4 spaces.
  183. // </codeBlock>
  184. function breakLine() {
  185. const lastSelectionPosition = modelDoc.selection.getLastPosition();
  186. const node = lastSelectionPosition.nodeBefore || lastSelectionPosition.textNode;
  187. let leadingWhiteSpaces;
  188. // Figure out the indentation (white space chars) at the beginning of the line.
  189. if ( node && node.is( 'text' ) ) {
  190. leadingWhiteSpaces = node.data.match( /^(\s*)/ )[ 0 ];
  191. }
  192. // Keeping everything in a change block for a single undo step.
  193. editor.model.change( writer => {
  194. editor.execute( 'shiftEnter' );
  195. // If the line before being broken in two had some indentation, let's retain it
  196. // in the new line.
  197. if ( leadingWhiteSpaces ) {
  198. writer.insertText( leadingWhiteSpaces, modelDoc.selection.anchor );
  199. }
  200. } );
  201. }
  202. // Leave the code block when enter (but NOT shift+enter) has been pressed twice at the end
  203. // of the code block:
  204. //
  205. // // Before:
  206. // <codeBlock>foo[]</codeBlock>
  207. //
  208. // // After first press
  209. // <codeBlock>foo<softBreak></softBreak>[]</codeBlock>
  210. //
  211. // // After second press
  212. // <codeBlock>foo</codeBlock><paragraph>[]</paragraph>
  213. //
  214. function leaveBlock( isSoftEnter ) {
  215. const lastSelectionPosition = modelDoc.selection.getLastPosition();
  216. const nodeBefore = lastSelectionPosition.nodeBefore;
  217. let emptyLineRangeToRemoveOnDoubleEnter;
  218. if ( isSoftEnter || !modelDoc.selection.isCollapsed || !lastSelectionPosition.isAtEnd || !nodeBefore ) {
  219. return false;
  220. }
  221. // When the position is directly preceded by a soft break
  222. //
  223. // <codeBlock>foo<softBreak></softBreak>[]</codeBlock>
  224. //
  225. // it creates the following range that will be cleaned up before leaving:
  226. //
  227. // <codeBlock>foo[<softBreak></softBreak>]</codeBlock>
  228. //
  229. if ( nodeBefore.is( 'softBreak' ) ) {
  230. emptyLineRangeToRemoveOnDoubleEnter = model.createRangeOn( nodeBefore );
  231. }
  232. // When there's some text before the position made purely of white–space characters
  233. //
  234. // <codeBlock>foo<softBreak></softBreak> []</codeBlock>
  235. //
  236. // but NOT when it's the first one of the kind
  237. //
  238. // <codeBlock> []</codeBlock>
  239. //
  240. // it creates the following range to clean up before leaving:
  241. //
  242. // <codeBlock>foo[<softBreak></softBreak> ]</codeBlock>
  243. //
  244. else if (
  245. nodeBefore.is( 'text' ) &&
  246. !nodeBefore.data.match( /\S/ ) &&
  247. nodeBefore.previousSibling &&
  248. nodeBefore.previousSibling.is( 'softBreak' )
  249. ) {
  250. emptyLineRangeToRemoveOnDoubleEnter = model.createRange(
  251. model.createPositionBefore( nodeBefore.previousSibling ), model.createPositionAfter( nodeBefore )
  252. );
  253. }
  254. // Not leaving the block in the following cases:
  255. //
  256. // <codeBlock> []</codeBlock>
  257. // <codeBlock> a []</codeBlock>
  258. // <codeBlock>foo<softBreak></softBreak>bar[]</codeBlock>
  259. // <codeBlock>foo<softBreak></softBreak> a []</codeBlock>
  260. //
  261. else {
  262. return false;
  263. }
  264. // We're doing everything in a single change block to have a single undo step.
  265. editor.model.change( writer => {
  266. // Remove the last <softBreak> and all white space characters that followed it.
  267. writer.remove( emptyLineRangeToRemoveOnDoubleEnter );
  268. // "Clone" the <codeBlock> in the standard way.
  269. editor.execute( 'enter' );
  270. const newBlock = modelDoc.selection.anchor.parent;
  271. // Make the cloned <codeBlock> a regular <paragraph> (with clean attributes, so no language).
  272. writer.rename( newBlock, DEFAULT_ELEMENT );
  273. editor.model.schema.removeDisallowedAttributes( [ newBlock ], writer );
  274. // Eye candy.
  275. view.scrollToTheSelection();
  276. } );
  277. return true;
  278. }
  279. }
  280. }
  281. // A model-to-view converter for the codeBlock element.
  282. //
  283. // @param {module:engine/model/model~Model} model
  284. // @param {Object.<String,String>} [languageLabels] An object associating a programming language
  285. // classes with human–readable labels (as in the editor config).
  286. // @returns {Function} Returns a conversion callback.
  287. function modelToViewCodeBlockInsertion( model, languageLabels = {} ) {
  288. return ( evt, data, conversionApi ) => {
  289. const { writer, mapper, consumable } = conversionApi;
  290. if ( !consumable.consume( data.item, 'insert' ) ) {
  291. return;
  292. }
  293. const codeBlockLanguage = data.item.getAttribute( 'language' );
  294. const targetViewPosition = mapper.toViewPosition( model.createPositionBefore( data.item ) );
  295. const pre = writer.createContainerElement( 'pre', {
  296. // This attribute is only in the editing view.
  297. 'data-language': languageLabels[ codeBlockLanguage ] || null
  298. } );
  299. const code = writer.createContainerElement( 'code', {
  300. class: codeBlockLanguage
  301. } );
  302. writer.insert( writer.createPositionAt( pre, 0 ), code );
  303. writer.insert( targetViewPosition, pre );
  304. mapper.bindElements( data.item, code );
  305. };
  306. }
  307. // A model-to-view converter for the new line separator.
  308. //
  309. // @param {module:engine/model/model~Model} model
  310. // @returns {Function} Returns a conversion callback.
  311. function modelToViewSoftBreakInsertion( model ) {
  312. return ( evt, data, conversionApi ) => {
  313. if ( data.item.parent.name !== 'codeBlock' ) {
  314. return;
  315. }
  316. const { writer, mapper, consumable } = conversionApi;
  317. if ( !consumable.consume( data.item, 'insert' ) ) {
  318. return;
  319. }
  320. const position = mapper.toViewPosition( model.createPositionBefore( data.item ) );
  321. writer.insert( position, writer.createText( '\n' ) );
  322. };
  323. }
  324. // A view-to-model converter for pre > code html.
  325. //
  326. // @param {module:engine/controller/datacontroller~DataController} dataController
  327. // @param {Array.<String>} languageClasses An array of valid (as in the editor config) CSS classes
  328. // associated with programming languages.
  329. // @returns {Function} Returns a conversion callback.
  330. function dataViewToModelCodeBlockInsertion( dataController, languageClasses ) {
  331. return ( evt, data, conversionApi ) => {
  332. const viewItem = data.viewItem;
  333. const viewChild = viewItem.getChild( 0 );
  334. if ( !viewChild || !viewChild.is( 'code' ) ) {
  335. return;
  336. }
  337. const { consumable, writer } = conversionApi;
  338. if ( !consumable.test( viewItem, { name: true } ) || !consumable.test( viewChild, { name: true } ) ) {
  339. return;
  340. }
  341. const codeBlock = writer.createElement( 'codeBlock' );
  342. // Figure out if any of the <code> element's class names is a valid programming
  343. // language class. If so, use it on the model element (becomes the language of the entire block).
  344. for ( const className of viewChild.getClassNames() ) {
  345. if ( languageClasses.includes( className ) ) {
  346. writer.setAttribute( 'language', className, codeBlock );
  347. break;
  348. }
  349. }
  350. // If no language value was set, use the default language from the config.
  351. if ( !codeBlock.hasAttribute( 'language' ) ) {
  352. writer.setAttribute( 'language', languageClasses[ 0 ], codeBlock );
  353. }
  354. const stringifiedElement = dataController.processor.toData( viewChild );
  355. const textData = extractDataFromCodeElement( stringifiedElement );
  356. const fragment = rawSnippetTextToModelDocumentFragment( writer, textData );
  357. writer.append( fragment, codeBlock );
  358. // Let's see if the codeBlock can be inserted the current modelCursor.
  359. const splitResult = conversionApi.splitToAllowedParent( codeBlock, data.modelCursor );
  360. // When there is no split result it means that we can't insert element to model tree,
  361. // so let's skip it.
  362. if ( !splitResult ) {
  363. return;
  364. }
  365. // Insert element on allowed position.
  366. writer.insert( codeBlock, splitResult.position );
  367. consumable.consume( viewItem, { name: true } );
  368. consumable.consume( viewChild, { name: true } );
  369. const parts = conversionApi.getSplitParts( codeBlock );
  370. // Set conversion result range.
  371. data.modelRange = writer.createRange(
  372. conversionApi.writer.createPositionBefore( codeBlock ),
  373. conversionApi.writer.createPositionAfter( parts[ parts.length - 1 ] )
  374. );
  375. // If we had to split parent to insert our element then we want to continue conversion inside
  376. // the split parent.
  377. //
  378. // before split:
  379. //
  380. // <allowed><notAllowed>[]</notAllowed></allowed>
  381. //
  382. // after split:
  383. //
  384. // <allowed>
  385. // <notAllowed></notAllowed>
  386. // <converted></converted>
  387. // <notAllowed>[]</notAllowed>
  388. // </allowed>
  389. if ( splitResult.cursorParent ) {
  390. data.modelCursor = writer.createPositionAt( splitResult.cursorParent, 0 );
  391. } else {
  392. // Otherwise just continue after the inserted element.
  393. data.modelCursor = data.modelRange.end;
  394. }
  395. };
  396. }
  397. // Returns content of `<pre></pre>` with unescaped html inside.
  398. //
  399. // @param {String} stringifiedElement
  400. function extractDataFromCodeElement( stringifiedElement ) {
  401. const data = new RegExp( /^<code[^>]*>(.*)<\/code>$/, 's' ).exec( stringifiedElement )[ 1 ];
  402. return data
  403. .replace( /&lt;/g, '<' )
  404. .replace( /&gt;/g, '>' );
  405. }
  406. // For a plain text containing the code (snippet), it returns a document fragment containing
  407. // model text nodes separated by soft breaks (in place of new line characters "\n"), for instance:
  408. //
  409. // Input:
  410. //
  411. // "foo()
  412. // bar()"
  413. //
  414. // Output:
  415. //
  416. // <DocumentFragment>
  417. // "foo()"
  418. // <softBreak></softBreak>
  419. // "bar()"
  420. // </DocumentFragment>
  421. //
  422. // @param {module:engine/model/writer~Writer} writer
  423. // @param {String} text A raw code text to be converted.
  424. function rawSnippetTextToModelDocumentFragment( writer, text ) {
  425. const fragment = writer.createDocumentFragment();
  426. const textLines = text.split( '\n' ).map( data => writer.createText( data ) );
  427. const lastLine = textLines[ textLines.length - 1 ];
  428. for ( const node of textLines ) {
  429. writer.append( node, fragment );
  430. if ( node !== lastLine ) {
  431. writer.appendElement( 'softBreak', fragment );
  432. }
  433. }
  434. return fragment;
  435. }