codeblockediting.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  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 IndentCodeBlockCommand from './indentcodeblockcommand';
  12. import {
  13. getLocalizedLanguageDefinitions,
  14. getLeadingWhiteSpaces,
  15. rawSnippetTextToModelDocumentFragment
  16. } from './utils';
  17. import {
  18. modelToViewCodeBlockInsertion,
  19. modelToDataViewSoftBreakInsertion,
  20. dataViewToModelCodeBlockInsertion
  21. } from './converters';
  22. const DEFAULT_ELEMENT = 'paragraph';
  23. /**
  24. * The editing part of the code block feature.
  25. *
  26. * Introduces the `'codeBlock'` command and the `'codeBlock'` model element.
  27. *
  28. * @extends module:core/plugin~Plugin
  29. */
  30. export default class CodeBlockEditing extends Plugin {
  31. /**
  32. * @inheritDoc
  33. */
  34. static get pluginName() {
  35. return 'CodeBlockEditing';
  36. }
  37. /**
  38. * @inheritDoc
  39. */
  40. static get requires() {
  41. return [ ShiftEnter ];
  42. }
  43. /**
  44. * @inheritDoc
  45. */
  46. constructor( editor ) {
  47. super( editor );
  48. editor.config.define( 'codeBlock', {
  49. languages: [
  50. { class: 'plaintext', label: 'Plain text' },
  51. { class: 'c', label: 'C' },
  52. { class: 'cs', label: 'C#' },
  53. { class: 'cpp', label: 'C++' },
  54. { class: 'css', label: 'CSS' },
  55. { class: 'diff', label: 'Diff' },
  56. { class: 'xml', label: 'HTML/XML' },
  57. { class: 'java', label: 'Java' },
  58. { class: 'javascript', label: 'JavaScript' },
  59. { class: 'php', label: 'PHP' },
  60. { class: 'python', label: 'Python' },
  61. { class: 'ruby', label: 'Ruby' },
  62. { class: 'typescript', label: 'TypeScript' },
  63. ],
  64. // A single tab.
  65. indentSequence: ' '
  66. } );
  67. }
  68. /**
  69. * @inheritDoc
  70. */
  71. init() {
  72. const editor = this.editor;
  73. const schema = editor.model.schema;
  74. const model = editor.model;
  75. const localizedLanguageDefinitions = getLocalizedLanguageDefinitions( editor );
  76. const languageClasses = localizedLanguageDefinitions.map( def => def.class );
  77. const languageLabels = Object.assign( {}, ...localizedLanguageDefinitions.map( def => ( { [ def.class ]: def.label } ) ) );
  78. // The main command.
  79. editor.commands.add( 'codeBlock', new CodeBlockCommand( editor ) );
  80. // Commands that change the indentation.
  81. editor.commands.add( 'indentCodeBlock', new IndentCodeBlockCommand( editor, 'forward' ) );
  82. editor.commands.add( 'outdentCodeBlock', new IndentCodeBlockCommand( editor, 'backward' ) );
  83. const getCommandExecuter = commandName => {
  84. return ( data, cancel ) => {
  85. const command = this.editor.commands.get( commandName );
  86. if ( command.isEnabled ) {
  87. this.editor.execute( commandName );
  88. cancel();
  89. }
  90. };
  91. };
  92. editor.keystrokes.set( 'Tab', getCommandExecuter( 'indentCodeBlock' ) );
  93. editor.keystrokes.set( 'Shift+Tab', getCommandExecuter( 'outdentCodeBlock' ) );
  94. // Schema.
  95. schema.register( 'codeBlock', {
  96. inheritAllFrom: '$block',
  97. allowAttributes: [ 'language' ]
  98. } );
  99. // Disallow all attributes in `codeBlock`.
  100. schema.addAttributeCheck( ( context, attributeName ) => {
  101. if ( context.endsWith( 'codeBlock' ) || context.endsWith( 'codeBlock $text' ) ) {
  102. return attributeName === 'language';
  103. }
  104. } );
  105. // Conversion.
  106. editor.editing.downcastDispatcher.on( 'insert:codeBlock', modelToViewCodeBlockInsertion( model, languageLabels ) );
  107. editor.data.downcastDispatcher.on( 'insert:codeBlock', modelToViewCodeBlockInsertion( model ) );
  108. editor.data.downcastDispatcher.on( 'insert:softBreak', modelToDataViewSoftBreakInsertion( model ), { priority: 'high' } );
  109. editor.data.upcastDispatcher.on( 'element:pre', dataViewToModelCodeBlockInsertion( editor.data, languageClasses ) );
  110. // Intercept the clipboard input (paste) when the selection is anchored in the code block and force the clipboard
  111. // data to be pasted as a single plain text. Otherwise, the code lines will split the code block and
  112. // "spill out" as separate paragraphs.
  113. this.listenTo( editor.editing.view.document, 'clipboardInput', ( evt, data ) => {
  114. const modelSelection = model.document.selection;
  115. if ( !modelSelection.anchor.parent.is( 'codeBlock' ) ) {
  116. return;
  117. }
  118. const text = data.dataTransfer.getData( 'text/plain' );
  119. model.change( writer => {
  120. model.insertContent( rawSnippetTextToModelDocumentFragment( writer, text ), modelSelection );
  121. evt.stop();
  122. } );
  123. } );
  124. // Make sure multi–line selection is always wrapped in a code block when `getSelectedContent()`
  125. // is used (e.g. clipboard copy). Otherwise, only the raw text will be copied to the clipboard and,
  126. // upon next paste, this bare text will not be inserted as a code block, which is not the best UX.
  127. // Similarly, when the selection in a single line, the selected content should be an inline code
  128. // so it can be pasted later on and retain it's preformatted nature.
  129. this.listenTo( model, 'getSelectedContent', ( evt, [ selection ] ) => {
  130. const anchor = selection.anchor;
  131. if ( selection.isCollapsed || !anchor.parent.is( 'codeBlock' ) || !anchor.hasSameParentAs( selection.focus ) ) {
  132. return;
  133. }
  134. model.change( writer => {
  135. const docFragment = evt.return;
  136. // fo[o<softBreak></softBreak>b]ar -> <codeBlock language="...">[o<softBreak></softBreak>b]<codeBlock>
  137. if ( docFragment.childCount > 1 || selection.containsEntireContent( anchor.parent ) ) {
  138. const codeBlock = writer.createElement( 'codeBlock', anchor.parent.getAttributes() );
  139. writer.append( docFragment, codeBlock );
  140. const newDocumentFragment = writer.createDocumentFragment();
  141. writer.append( codeBlock, newDocumentFragment );
  142. evt.return = newDocumentFragment;
  143. }
  144. // "f[oo]" -> <$text code="true">oo</text>
  145. else {
  146. const textNode = docFragment.getChild( 0 );
  147. if ( schema.checkAttribute( textNode, 'code' ) ) {
  148. writer.setAttribute( 'code', true, textNode );
  149. }
  150. }
  151. } );
  152. } );
  153. }
  154. /**
  155. * @inheritDoc
  156. */
  157. afterInit() {
  158. const editor = this.editor;
  159. const commands = editor.commands;
  160. const indent = commands.get( 'indent' );
  161. const outdent = commands.get( 'outdent' );
  162. if ( indent ) {
  163. indent.registerChildCommand( commands.get( 'indentCodeBlock' ) );
  164. }
  165. if ( outdent ) {
  166. outdent.registerChildCommand( commands.get( 'outdentCodeBlock' ) );
  167. }
  168. // Customize the response to the <kbd>Enter</kbd> and <kbd>Shift</kbd>+<kbd>Enter</kbd>
  169. // key press when the selection is in the code block. Upon enter key press we can either
  170. // leave the block if it's "two enters" in a row or create a new code block line, preserving
  171. // previous line's indentation.
  172. this.listenTo( editor.editing.view.document, 'enter', ( evt, data ) => {
  173. const positionParent = editor.model.document.selection.getLastPosition().parent;
  174. if ( !positionParent.is( 'codeBlock' ) ) {
  175. return;
  176. }
  177. leaveBlockStartOnEnter( editor, data.isSoft ) ||
  178. leaveBlockEndOnEnter( editor, data.isSoft ) ||
  179. breakLineOnEnter( editor );
  180. data.preventDefault();
  181. evt.stop();
  182. } );
  183. }
  184. }
  185. // Normally, when the Enter (or Shift+Enter) key is pressed, a soft line break is to be added to the
  186. // code block. Let's try to follow the indentation of the previous line when possible, for instance:
  187. //
  188. // // Before pressing enter (or shift enter)
  189. // <codeBlock>
  190. // " foo()"[] // Indent of 4 spaces.
  191. // </codeBlock>
  192. //
  193. // // After pressing:
  194. // <codeBlock>
  195. // " foo()" // Indent of 4 spaces.
  196. // <softBreak></softBreak> // A new soft break created by pressing enter.
  197. // " "[] // Retain the indent of 4 spaces.
  198. // </codeBlock>
  199. //
  200. // @param {module:core/editor/editor~Editor} editor
  201. function breakLineOnEnter( editor ) {
  202. const model = editor.model;
  203. const modelDoc = model.document;
  204. const lastSelectionPosition = modelDoc.selection.getLastPosition();
  205. const node = lastSelectionPosition.nodeBefore || lastSelectionPosition.textNode;
  206. let leadingWhiteSpaces;
  207. // Figure out the indentation (white space chars) at the beginning of the line.
  208. if ( node && node.is( 'text' ) ) {
  209. leadingWhiteSpaces = getLeadingWhiteSpaces( node );
  210. }
  211. // Keeping everything in a change block for a single undo step.
  212. editor.model.change( writer => {
  213. editor.execute( 'shiftEnter' );
  214. // If the line before being broken in two had some indentation, let's retain it
  215. // in the new line.
  216. if ( leadingWhiteSpaces ) {
  217. writer.insertText( leadingWhiteSpaces, modelDoc.selection.anchor );
  218. }
  219. } );
  220. }
  221. // Leave the code block when Enter (but NOT Shift+Enter) has been pressed twice at the beginning
  222. // of the code block:
  223. //
  224. // // Before:
  225. // <codeBlock>[]<softBreak></softBreak>foo</codeBlock>
  226. //
  227. // // After pressing:
  228. // <paragraph>[]</paragraph><codeBlock>foo</codeBlock>
  229. //
  230. // @param {module:core/editor/editor~Editor} editor
  231. // @param {Boolean} isSoftEnter When `true`, enter was pressed along with <kbd>Shift</kbd>.
  232. // @returns {Boolean} `true` when selection left the block. `false` if stayed.
  233. function leaveBlockStartOnEnter( editor, isSoftEnter ) {
  234. const model = editor.model;
  235. const modelDoc = model.document;
  236. const view = editor.editing.view;
  237. const lastSelectionPosition = modelDoc.selection.getLastPosition();
  238. const nodeAfter = lastSelectionPosition.nodeAfter;
  239. if ( isSoftEnter || !modelDoc.selection.isCollapsed || !lastSelectionPosition.isAtStart ) {
  240. return false;
  241. }
  242. if ( !nodeAfter || !nodeAfter.is( 'softBreak' ) ) {
  243. return false;
  244. }
  245. // We're doing everything in a single change block to have a single undo step.
  246. editor.model.change( writer => {
  247. // "Clone" the <codeBlock> in the standard way.
  248. editor.execute( 'enter' );
  249. // The cloned block exists now before the original code block.
  250. const newBlock = modelDoc.selection.anchor.parent.previousSibling;
  251. // Make the cloned <codeBlock> a regular <paragraph> (with clean attributes, so no language).
  252. writer.rename( newBlock, DEFAULT_ELEMENT );
  253. writer.setSelection( newBlock, 'in' );
  254. editor.model.schema.removeDisallowedAttributes( [ newBlock ], writer );
  255. // Remove the <softBreak> that originally followed the selection position.
  256. writer.remove( nodeAfter );
  257. } );
  258. // Eye candy.
  259. view.scrollToTheSelection();
  260. return true;
  261. }
  262. // Leave the code block when Enter (but NOT Shift+Enter) has been pressed twice at the end
  263. // of the code block:
  264. //
  265. // // Before:
  266. // <codeBlock>foo[]</codeBlock>
  267. //
  268. // // After first press:
  269. // <codeBlock>foo<softBreak></softBreak>[]</codeBlock>
  270. //
  271. // // After second press:
  272. // <codeBlock>foo</codeBlock><paragraph>[]</paragraph>
  273. //
  274. // @param {module:core/editor/editor~Editor} editor
  275. // @param {Boolean} isSoftEnter When `true`, enter was pressed along with <kbd>Shift</kbd>.
  276. // @returns {Boolean} `true` when selection left the block. `false` if stayed.
  277. function leaveBlockEndOnEnter( editor, isSoftEnter ) {
  278. const model = editor.model;
  279. const modelDoc = model.document;
  280. const view = editor.editing.view;
  281. const lastSelectionPosition = modelDoc.selection.getLastPosition();
  282. const nodeBefore = lastSelectionPosition.nodeBefore;
  283. let emptyLineRangeToRemoveOnEnter;
  284. if ( isSoftEnter || !modelDoc.selection.isCollapsed || !lastSelectionPosition.isAtEnd || !nodeBefore ) {
  285. return false;
  286. }
  287. // When the position is directly preceded by a soft break
  288. //
  289. // <codeBlock>foo<softBreak></softBreak>[]</codeBlock>
  290. //
  291. // it creates the following range that will be cleaned up before leaving:
  292. //
  293. // <codeBlock>foo[<softBreak></softBreak>]</codeBlock>
  294. //
  295. if ( nodeBefore.is( 'softBreak' ) ) {
  296. emptyLineRangeToRemoveOnEnter = model.createRangeOn( nodeBefore );
  297. }
  298. // When there's some text before the position made purely of white–space characters
  299. //
  300. // <codeBlock>foo<softBreak></softBreak> []</codeBlock>
  301. //
  302. // but NOT when it's the first one of the kind
  303. //
  304. // <codeBlock> []</codeBlock>
  305. //
  306. // it creates the following range to clean up before leaving:
  307. //
  308. // <codeBlock>foo[<softBreak></softBreak> ]</codeBlock>
  309. //
  310. else if (
  311. nodeBefore.is( 'text' ) &&
  312. !nodeBefore.data.match( /\S/ ) &&
  313. nodeBefore.previousSibling &&
  314. nodeBefore.previousSibling.is( 'softBreak' )
  315. ) {
  316. emptyLineRangeToRemoveOnEnter = model.createRange(
  317. model.createPositionBefore( nodeBefore.previousSibling ), model.createPositionAfter( nodeBefore )
  318. );
  319. }
  320. // Not leaving the block in the following cases:
  321. //
  322. // <codeBlock> []</codeBlock>
  323. // <codeBlock> a []</codeBlock>
  324. // <codeBlock>foo<softBreak></softBreak>bar[]</codeBlock>
  325. // <codeBlock>foo<softBreak></softBreak> a []</codeBlock>
  326. //
  327. else {
  328. return false;
  329. }
  330. // We're doing everything in a single change block to have a single undo step.
  331. editor.model.change( writer => {
  332. // Remove the last <softBreak> and all white space characters that followed it.
  333. writer.remove( emptyLineRangeToRemoveOnEnter );
  334. // "Clone" the <codeBlock> in the standard way.
  335. editor.execute( 'enter' );
  336. const newBlock = modelDoc.selection.anchor.parent;
  337. // Make the cloned <codeBlock> a regular <paragraph> (with clean attributes, so no language).
  338. writer.rename( newBlock, DEFAULT_ELEMENT );
  339. editor.model.schema.removeDisallowedAttributes( [ newBlock ], writer );
  340. } );
  341. // Eye candy.
  342. view.scrollToTheSelection();
  343. return true;
  344. }