codeblockediting.js 13 KB

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