8
0

title.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  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 heading/title
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
  10. import ViewWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter';
  11. import {
  12. needsPlaceholder,
  13. showPlaceholder,
  14. hidePlaceholder,
  15. enablePlaceholder
  16. } from '@ckeditor/ckeditor5-engine/src/view/placeholder';
  17. /**
  18. * The Title plugin.
  19. *
  20. * It splits the document into `Title` and `Body` sections.
  21. *
  22. * @extends module:core/plugin~Plugin
  23. */
  24. export default class Title extends Plugin {
  25. /**
  26. * @inheritDoc
  27. */
  28. static get pluginName() {
  29. return 'Title';
  30. }
  31. /**
  32. * @inheritDoc
  33. */
  34. static get requires() {
  35. return [ Paragraph ];
  36. }
  37. /**
  38. * @inheritDoc
  39. */
  40. init() {
  41. const editor = this.editor;
  42. const model = editor.model;
  43. /**
  44. * Reference to the empty paragraph in the body
  45. * created when there's no element in the body for the placeholder purpose.
  46. *
  47. * @private
  48. * @type {null|module:engine/model/element~Element}
  49. */
  50. this._bodyPlaceholder = null;
  51. // To use Schema for disabling some features when selection is inside the title element
  52. // it's needed to create the following structure:
  53. //
  54. // <title>
  55. // <title-content>The title text</title-content>
  56. // </title>
  57. //
  58. // See: https://github.com/ckeditor/ckeditor5/issues/2005.
  59. model.schema.register( 'title', { isBlock: true, allowIn: '$root' } );
  60. model.schema.register( 'title-content', { isBlock: true, allowIn: 'title', allowAttributes: [ 'alignment' ] } );
  61. model.schema.extend( '$text', { allowIn: 'title-content' } );
  62. // Disallow all attributes in `title-content`.
  63. model.schema.addAttributeCheck( context => {
  64. if ( context.endsWith( 'title-content $text' ) ) {
  65. return false;
  66. }
  67. } );
  68. // Because of `title` is represented by two elements in the model
  69. // but only one in the view it's needed to adjust Mapper.
  70. editor.editing.mapper.on( 'modelToViewPosition', mapModelPositionToView( editor.editing.view ) );
  71. editor.data.mapper.on( 'modelToViewPosition', mapModelPositionToView( editor.editing.view ) );
  72. // `title-content` <-> `h1` conversion.
  73. editor.conversion.elementToElement( { model: 'title-content', view: 'h1' } );
  74. // Take care about proper `title` element structure.
  75. model.document.registerPostFixer( writer => this._fixTitleContent( writer ) );
  76. // Create and take care about proper position of a `title` element.
  77. model.document.registerPostFixer( writer => this._fixTitleElement( writer ) );
  78. // Prevent from adding extra paragraph after paste or enter.
  79. model.document.registerPostFixer( writer => this._preventExtraParagraphing( writer ) );
  80. // Attach `Title` and `Body` placeholders to the empty title and/or content.
  81. this._attachPlaceholders();
  82. // Attach Tab handling.
  83. this._attachTabPressHandling();
  84. }
  85. /**
  86. * Sets the title of the document. This methods does not change any content outside the title element.
  87. *
  88. * @param {String} data Data to be set as a document title.
  89. */
  90. setTitle( data ) {
  91. const editor = this.editor;
  92. const titleElement = this._getTitleElement();
  93. const titleContentElement = titleElement.getChild( 0 );
  94. editor.model.insertContent( editor.data.parse( data, 'title-content' ), titleContentElement, 'in' );
  95. }
  96. /**
  97. * Returns the title of the document. Note, that because this plugin does not allow any formatting inside
  98. * the title element, the output of this method will be a plain text, with no HTML tags. However, it
  99. * may contain some markers, like comments or suggestions. In such case, a special tag for the
  100. * marker will be included in the title text.
  101. *
  102. * @returns {String} Title of the document.
  103. */
  104. getTitle() {
  105. const titleElement = this._getTitleElement();
  106. const titleContentElement = titleElement.getChild( 0 );
  107. return this.editor.data.stringify( titleContentElement );
  108. }
  109. /**
  110. * Sets the body of the document.
  111. *
  112. * @returns {String} data Data to be set as a body of the document.
  113. */
  114. setBody( data ) {
  115. const editor = this.editor;
  116. const root = editor.model.document.getRoot();
  117. const range = editor.model.createRange(
  118. editor.model.createPositionAt( root.getChild( 0 ), 'after' ),
  119. editor.model.createPositionAt( root, 'end' )
  120. );
  121. editor.model.insertContent( editor.data.parse( data ), range );
  122. }
  123. /**
  124. * Returns the body of the document.
  125. *
  126. * @returns {String} Body of the document.
  127. */
  128. getBody() {
  129. const root = this.editor.model.document.getRoot();
  130. const viewWriter = new ViewWriter();
  131. // model -> view
  132. const viewDocumentFragment = this.editor.data.toView( root );
  133. // Remove title.
  134. viewWriter.remove( viewWriter.createRangeOn( viewDocumentFragment.getChild( 0 ) ) );
  135. // view -> data
  136. return this.editor.data.processor.toData( viewDocumentFragment );
  137. }
  138. /**
  139. * Returns `title` element when is in the document. Returns `undefined` otherwise.
  140. *
  141. * @private
  142. * @returns {module:engine/model/element~Element|undefined}
  143. */
  144. _getTitleElement() {
  145. const root = this.editor.model.document.getRoot();
  146. for ( const child of root.getChildren() ) {
  147. if ( child.is( 'title' ) ) {
  148. return child;
  149. }
  150. }
  151. }
  152. /**
  153. * Model post-fixer callback that ensures `title` has only one `title-content` child.
  154. * All additional children should be moved after `title` element and renamed to a paragraph.
  155. *
  156. * @private
  157. * @param {module:engine/model/writer~Writer} writer
  158. * @returns {Boolean}
  159. */
  160. _fixTitleContent( writer ) {
  161. const title = this._getTitleElement();
  162. if ( !title ) {
  163. return false;
  164. }
  165. const titleChildren = Array.from( title.getChildren() );
  166. let hasChanged = false;
  167. // Skip first child because it is an allowed element.
  168. titleChildren.shift();
  169. for ( const titleChild of titleChildren ) {
  170. writer.move( writer.createRangeOn( titleChild ), title, 'after' );
  171. writer.rename( titleChild, 'paragraph' );
  172. hasChanged = true;
  173. }
  174. return hasChanged;
  175. }
  176. /**
  177. * Model post-fixer callback that takes care about creating and keeping `title` element as a first child in a root.
  178. *
  179. * @private
  180. * @param {module:engine/model/writer~Writer} writer
  181. * @returns {Boolean}
  182. */
  183. _fixTitleElement( writer ) {
  184. const model = this.editor.model;
  185. const modelRoot = model.document.getRoot();
  186. let hasChanged = false;
  187. let index = 0;
  188. // Loop through root children and take care about a proper position of a title element.
  189. // Title always has to be the first element in the root.
  190. for ( const rootChild of modelRoot.getChildren() ) {
  191. // If the first element is not a title we need to fix it.
  192. if ( index === 0 && !rootChild.is( 'title' ) ) {
  193. const title = this._getTitleElement();
  194. // Change first element to the title if it can be a title.
  195. if ( Title.titleLikeElements.has( rootChild.name ) ) {
  196. const position = model.createPositionBefore( rootChild );
  197. const title = writer.createElement( 'title' );
  198. writer.insert( title, position );
  199. writer.append( rootChild, title );
  200. writer.rename( rootChild, 'title-content' );
  201. // After changing element to the title it has to be filtered out from disallowed attributes.
  202. model.schema.removeDisallowedAttributes( Array.from( rootChild.getChildren() ), writer );
  203. // If the first element cannot be a title but title is already in the root
  204. // than move the first element after a title.
  205. // It may happen e.g. when an image has been dropped before the title element.
  206. } else if ( title ) {
  207. writer.move( writer.createRangeOn( rootChild ), title, 'after' );
  208. // If there is no title or any element that could be a title then create an empty title.
  209. } else {
  210. writer.insertElement( 'title', modelRoot );
  211. writer.insertElement( 'title-content', modelRoot.getChild( 0 ) );
  212. }
  213. hasChanged = true;
  214. // If there is a title somewhere in the content.
  215. } else if ( index > 0 && rootChild.is( 'title' ) ) {
  216. // Rename it to a paragraph if it has a content.
  217. if ( model.hasContent( rootChild ) ) {
  218. writer.rename( rootChild, 'paragraph' );
  219. writer.unwrap( rootChild.getChild( 0 ) );
  220. // Or remove if it's empty (it's created as a result of splitting parents by insertContent method).
  221. } else {
  222. writer.remove( rootChild );
  223. }
  224. hasChanged = true;
  225. }
  226. index++;
  227. }
  228. // Attach `Body` placeholder when there is no element after a title.
  229. if ( modelRoot.childCount < 2 ) {
  230. this._bodyPlaceholder = writer.createElement( 'paragraph' );
  231. writer.insert( this._bodyPlaceholder, modelRoot, 1 );
  232. hasChanged = true;
  233. }
  234. return hasChanged;
  235. }
  236. /**
  237. * Prevents editor from adding extra paragraphs after pasting to the title element
  238. * or pressing an enter in the title element.
  239. *
  240. * @private
  241. * @param {module:engine/model/writer~Writer} writer
  242. * @returns {Boolean}
  243. */
  244. _preventExtraParagraphing( writer ) {
  245. const root = this.editor.model.document.getRoot();
  246. const placeholder = this._bodyPlaceholder;
  247. const shouldDeleteLastParagraph = (
  248. placeholder && placeholder.is( 'paragraph' ) && placeholder.childCount === 0 &&
  249. root.childCount > 2 && root.getChild( root.childCount - 1 ) === placeholder
  250. );
  251. if ( shouldDeleteLastParagraph ) {
  252. this._bodyPlaceholder = null;
  253. writer.remove( placeholder );
  254. return true;
  255. }
  256. return false;
  257. }
  258. /**
  259. * Attaches `Title` and `Body` placeholders to the title and/or content.
  260. *
  261. * @private
  262. */
  263. _attachPlaceholders() {
  264. const editor = this.editor;
  265. const t = editor.t;
  266. const view = editor.editing.view;
  267. const viewRoot = view.document.getRoot();
  268. const bodyPlaceholder = editor.config.get( 'placeholder' ) || t( 'Body' );
  269. const titlePlaceholder = t( 'Title' );
  270. // Attach placeholder to the view title element.
  271. editor.editing.downcastDispatcher.on( 'insert:title-content', ( evt, data, conversionApi ) => {
  272. enablePlaceholder( {
  273. view,
  274. element: conversionApi.mapper.toViewElement( data.item ),
  275. text: titlePlaceholder
  276. } );
  277. } );
  278. // Attach placeholder to first element after a title element and remove it if it's not needed anymore.
  279. // First element after title can change so we need to observe all changes keep placeholder in sync.
  280. let oldBody;
  281. // This post-fixer runs after the model post-fixer so we can assume that
  282. // the second child in view root will always exist.
  283. view.document.registerPostFixer( writer => {
  284. const body = viewRoot.getChild( 1 );
  285. let hasChanged = false;
  286. // If body element has changed we need to disable placeholder on the previous element
  287. // and enable on the new one.
  288. if ( body !== oldBody ) {
  289. if ( oldBody ) {
  290. hidePlaceholder( writer, oldBody );
  291. writer.removeAttribute( 'data-placeholder', oldBody );
  292. }
  293. writer.setAttribute( 'data-placeholder', bodyPlaceholder, body );
  294. oldBody = body;
  295. hasChanged = true;
  296. }
  297. // Then we need to display placeholder if it is needed.
  298. if ( needsPlaceholder( body ) && viewRoot.childCount === 2 && body.name === 'p' ) {
  299. hasChanged = showPlaceholder( writer, body ) ? true : hasChanged;
  300. // Or hide if it is not needed.
  301. } else {
  302. hasChanged = hidePlaceholder( writer, body ) ? true : hasChanged;
  303. }
  304. return hasChanged;
  305. } );
  306. }
  307. /**
  308. * Creates navigation between Title and Body sections using `Tab` and `Shift+Tab` keys.
  309. *
  310. * @private
  311. */
  312. _attachTabPressHandling() {
  313. const editor = this.editor;
  314. const model = editor.model;
  315. // Pressing `Tab` inside the title should move the caret to the body.
  316. editor.keystrokes.set( 'TAB', ( data, cancel ) => {
  317. model.change( writer => {
  318. const selection = model.document.selection;
  319. const selectedElements = Array.from( selection.getSelectedBlocks() );
  320. if ( selectedElements.length === 1 && selectedElements[ 0 ].name === 'title-content' ) {
  321. const firstBodyElement = model.document.getRoot().getChild( 1 );
  322. writer.setSelection( firstBodyElement, 0 );
  323. cancel();
  324. }
  325. } );
  326. } );
  327. // Pressing `Shift+Tab` at the beginning of the body should move the caret to the title.
  328. editor.keystrokes.set( 'SHIFT + TAB', ( data, cancel ) => {
  329. model.change( writer => {
  330. const selection = model.document.selection;
  331. if ( !selection.isCollapsed ) {
  332. return;
  333. }
  334. const root = editor.model.document.getRoot();
  335. const selectedElement = Array.from( selection.getSelectedBlocks() )[ 0 ];
  336. const selectionPosition = selection.getFirstPosition();
  337. const title = root.getChild( 0 );
  338. const body = root.getChild( 1 );
  339. if ( selectedElement === body && selectionPosition.isAtStart ) {
  340. writer.setSelection( title.getChild( 0 ), 0 );
  341. cancel();
  342. }
  343. } );
  344. } );
  345. }
  346. }
  347. /**
  348. * A list of element names which should be treated by the Title plugin as
  349. * title-like. This means that element on the list will be changed to the title
  350. * element when will be the first element in the root.
  351. *
  352. * @member {Set.<String>} module:heading/title~Title.titleLikeElements
  353. */
  354. Title.titleLikeElements = new Set( [ 'paragraph', 'heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6' ] );
  355. function mapModelPositionToView( editingView ) {
  356. return ( evt, data ) => {
  357. if ( !data.modelPosition.parent.is( 'title' ) ) {
  358. return;
  359. }
  360. const modelParent = data.modelPosition.parent.parent;
  361. const viewParent = data.mapper.toViewElement( modelParent );
  362. data.viewPosition = editingView.createPositionAt( viewParent, 0 );
  363. evt.stop();
  364. };
  365. }