codeblockediting.js 13 KB

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