8
0

codeblockediting.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  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 codeBlock in codeBlock.
  100. schema.addChildCheck( ( context, childDef ) => {
  101. if ( context.endsWith( 'codeBlock' ) && childDef.name === 'codeBlock' ) {
  102. return false;
  103. }
  104. } );
  105. // Disallow all attributes in `codeBlock`.
  106. schema.addAttributeCheck( ( context, attributeName ) => {
  107. if ( context.endsWith( 'codeBlock' ) || context.endsWith( 'codeBlock $text' ) ) {
  108. return attributeName === 'language';
  109. }
  110. } );
  111. // Conversion.
  112. editor.editing.downcastDispatcher.on( 'insert:codeBlock', modelToViewCodeBlockInsertion( model, languageLabels ) );
  113. editor.data.downcastDispatcher.on( 'insert:codeBlock', modelToViewCodeBlockInsertion( model ) );
  114. editor.data.downcastDispatcher.on( 'insert:softBreak', modelToDataViewSoftBreakInsertion( model ), { priority: 'high' } );
  115. editor.data.upcastDispatcher.on( 'element:pre', dataViewToModelCodeBlockInsertion( editor.data, languageClasses ) );
  116. // Intercept the clipboard input (paste) when the selection is anchored in the code block and force the clipboard
  117. // data to be pasted as a single plain text. Otherwise, the code lines will split the code block and
  118. // "spill out" as separate paragraphs.
  119. this.listenTo( editor.editing.view.document, 'clipboardInput', ( evt, data ) => {
  120. const modelSelection = model.document.selection;
  121. if ( !modelSelection.anchor.parent.is( 'codeBlock' ) ) {
  122. return;
  123. }
  124. const text = data.dataTransfer.getData( 'text/plain' );
  125. model.change( writer => {
  126. model.insertContent( rawSnippetTextToModelDocumentFragment( writer, text ), modelSelection );
  127. evt.stop();
  128. } );
  129. } );
  130. // Make sure multi–line selection is always wrapped in a code block when `getSelectedContent()`
  131. // is used (e.g. clipboard copy). Otherwise, only the raw text will be copied to the clipboard and,
  132. // upon next paste, this bare text will not be inserted as a code block, which is not the best UX.
  133. // Similarly, when the selection in a single line, the selected content should be an inline code
  134. // so it can be pasted later on and retain it's preformatted nature.
  135. this.listenTo( model, 'getSelectedContent', ( evt, [ selection ] ) => {
  136. const anchor = selection.anchor;
  137. if ( selection.isCollapsed || !anchor.parent.is( 'codeBlock' ) || !anchor.hasSameParentAs( selection.focus ) ) {
  138. return;
  139. }
  140. model.change( writer => {
  141. const docFragment = evt.return;
  142. // fo[o<softBreak></softBreak>b]ar -> <codeBlock language="...">[o<softBreak></softBreak>b]<codeBlock>
  143. if ( docFragment.childCount > 1 || selection.containsEntireContent( anchor.parent ) ) {
  144. const codeBlock = writer.createElement( 'codeBlock', anchor.parent.getAttributes() );
  145. writer.append( docFragment, codeBlock );
  146. const newDocumentFragment = writer.createDocumentFragment();
  147. writer.append( codeBlock, newDocumentFragment );
  148. evt.return = newDocumentFragment;
  149. }
  150. // "f[oo]" -> <$text code="true">oo</text>
  151. else {
  152. writer.setAttribute( 'code', true, docFragment.getChild( 0 ) );
  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( 'codeBlock' ) ) {
  178. return;
  179. }
  180. leaveBlockOnEnter( editor, data.isSoft ) || breakLineOnEnter( editor );
  181. data.preventDefault();
  182. evt.stop();
  183. } );
  184. }
  185. }
  186. // Normally, when the Enter (or Shift+Enter) key is pressed, a soft line break is to be added to the
  187. // code block. Let's try to follow the indentation of the previous line when possible, for instance:
  188. //
  189. // // Before pressing enter (or shift enter)
  190. // <codeBlock>
  191. // " foo()"[] // Indent of 4 spaces.
  192. // </codeBlock>
  193. //
  194. // // After pressing:
  195. // <codeBlock>
  196. // " foo()" // Indent of 4 spaces.
  197. // <softBreak></softBreak> // A new soft break created by pressing enter.
  198. // " "[] // Retain the indent of 4 spaces.
  199. // </codeBlock>
  200. //
  201. // @param {module:core/editor/editor~Editor} editor
  202. function breakLineOnEnter( editor ) {
  203. const model = editor.model;
  204. const modelDoc = model.document;
  205. const lastSelectionPosition = modelDoc.selection.getLastPosition();
  206. const node = lastSelectionPosition.nodeBefore || lastSelectionPosition.textNode;
  207. let leadingWhiteSpaces;
  208. // Figure out the indentation (white space chars) at the beginning of the line.
  209. if ( node && node.is( 'text' ) ) {
  210. leadingWhiteSpaces = getLeadingWhiteSpaces( node );
  211. }
  212. // Keeping everything in a change block for a single undo step.
  213. editor.model.change( writer => {
  214. editor.execute( 'shiftEnter' );
  215. // If the line before being broken in two had some indentation, let's retain it
  216. // in the new line.
  217. if ( leadingWhiteSpaces ) {
  218. writer.insertText( leadingWhiteSpaces, modelDoc.selection.anchor );
  219. }
  220. } );
  221. }
  222. // Leave the code block when Enter (but NOT Shift+Enter) has been pressed twice at the end
  223. // of the code block:
  224. //
  225. // // Before:
  226. // <codeBlock>foo[]</codeBlock>
  227. //
  228. // // After first press:
  229. // <codeBlock>foo<softBreak></softBreak>[]</codeBlock>
  230. //
  231. // // After second press:
  232. // <codeBlock>foo</codeBlock><paragraph>[]</paragraph>
  233. //
  234. // @param {module:core/editor/editor~Editor} editor
  235. // @param {Boolean} isSoftEnter When `true`, enter was pressed along with <kbd>Shift</kbd>.
  236. // @returns {Boolean} `true` when selection left the block. `false` if stayed.
  237. function leaveBlockOnEnter( editor, isSoftEnter ) {
  238. const model = editor.model;
  239. const modelDoc = model.document;
  240. const view = editor.editing.view;
  241. const lastSelectionPosition = modelDoc.selection.getLastPosition();
  242. const nodeBefore = lastSelectionPosition.nodeBefore;
  243. let emptyLineRangeToRemoveOnDoubleEnter;
  244. if ( isSoftEnter || !modelDoc.selection.isCollapsed || !lastSelectionPosition.isAtEnd || !nodeBefore ) {
  245. return false;
  246. }
  247. // When the position is directly preceded by a soft break
  248. //
  249. // <codeBlock>foo<softBreak></softBreak>[]</codeBlock>
  250. //
  251. // it creates the following range that will be cleaned up before leaving:
  252. //
  253. // <codeBlock>foo[<softBreak></softBreak>]</codeBlock>
  254. //
  255. if ( nodeBefore.is( 'softBreak' ) ) {
  256. emptyLineRangeToRemoveOnDoubleEnter = model.createRangeOn( nodeBefore );
  257. }
  258. // When there's some text before the position made purely of white–space characters
  259. //
  260. // <codeBlock>foo<softBreak></softBreak> []</codeBlock>
  261. //
  262. // but NOT when it's the first one of the kind
  263. //
  264. // <codeBlock> []</codeBlock>
  265. //
  266. // it creates the following range to clean up before leaving:
  267. //
  268. // <codeBlock>foo[<softBreak></softBreak> ]</codeBlock>
  269. //
  270. else if (
  271. nodeBefore.is( 'text' ) &&
  272. !nodeBefore.data.match( /\S/ ) &&
  273. nodeBefore.previousSibling &&
  274. nodeBefore.previousSibling.is( 'softBreak' )
  275. ) {
  276. emptyLineRangeToRemoveOnDoubleEnter = model.createRange(
  277. model.createPositionBefore( nodeBefore.previousSibling ), model.createPositionAfter( nodeBefore )
  278. );
  279. }
  280. // Not leaving the block in the following cases:
  281. //
  282. // <codeBlock> []</codeBlock>
  283. // <codeBlock> a []</codeBlock>
  284. // <codeBlock>foo<softBreak></softBreak>bar[]</codeBlock>
  285. // <codeBlock>foo<softBreak></softBreak> a []</codeBlock>
  286. //
  287. else {
  288. return false;
  289. }
  290. // We're doing everything in a single change block to have a single undo step.
  291. editor.model.change( writer => {
  292. // Remove the last <softBreak> and all white space characters that followed it.
  293. writer.remove( emptyLineRangeToRemoveOnDoubleEnter );
  294. // "Clone" the <codeBlock> in the standard way.
  295. editor.execute( 'enter' );
  296. const newBlock = modelDoc.selection.anchor.parent;
  297. // Make the cloned <codeBlock> a regular <paragraph> (with clean attributes, so no language).
  298. writer.rename( newBlock, DEFAULT_ELEMENT );
  299. editor.model.schema.removeDisallowedAttributes( [ newBlock ], writer );
  300. // Eye candy.
  301. view.scrollToTheSelection();
  302. } );
  303. return true;
  304. }