8
0

title.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  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 heading/title
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
  10. import ViewDocumentFragment from '@ckeditor/ckeditor5-engine/src/view/documentfragment';
  11. import ViewDowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter';
  12. import first from '@ckeditor/ckeditor5-utils/src/first';
  13. import {
  14. needsPlaceholder,
  15. showPlaceholder,
  16. hidePlaceholder,
  17. enablePlaceholder
  18. } from '@ckeditor/ckeditor5-engine/src/view/placeholder';
  19. // A list of element names that should be treated by the Title plugin as title-like.
  20. // This means that an element of a type from this list will be changed to a title element
  21. // when it is the first element in the root.
  22. const titleLikeElements = new Set( [ 'paragraph', 'heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6' ] );
  23. /**
  24. * The Title plugin.
  25. *
  26. * It splits the document into `Title` and `Body` sections.
  27. *
  28. * @extends module:core/plugin~Plugin
  29. */
  30. export default class Title extends Plugin {
  31. /**
  32. * @inheritDoc
  33. */
  34. static get pluginName() {
  35. return 'Title';
  36. }
  37. /**
  38. * @inheritDoc
  39. */
  40. static get requires() {
  41. return [ Paragraph ];
  42. }
  43. /**
  44. * @inheritDoc
  45. */
  46. init() {
  47. const editor = this.editor;
  48. const model = editor.model;
  49. /**
  50. * A reference to an empty paragraph in the body
  51. * created when there is no element in the body for the placeholder purposes.
  52. *
  53. * @private
  54. * @type {null|module:engine/model/element~Element}
  55. */
  56. this._bodyPlaceholder = null;
  57. // To use the schema for disabling some features when the selection is inside the title element
  58. // it is needed to create the following structure:
  59. //
  60. // <title>
  61. // <title-content>The title text</title-content>
  62. // </title>
  63. //
  64. // See: https://github.com/ckeditor/ckeditor5/issues/2005.
  65. model.schema.register( 'title', { isBlock: true, allowIn: '$root' } );
  66. model.schema.register( 'title-content', { isBlock: true, allowIn: 'title', allowAttributes: [ 'alignment' ] } );
  67. model.schema.extend( '$text', { allowIn: 'title-content' } );
  68. // Disallow all attributes in `title-content`.
  69. model.schema.addAttributeCheck( context => {
  70. if ( context.endsWith( 'title-content $text' ) ) {
  71. return false;
  72. }
  73. } );
  74. // Because `title` is represented by two elements in the model
  75. // but only one in the view, it is needed to adjust Mapper.
  76. editor.editing.mapper.on( 'modelToViewPosition', mapModelPositionToView( editor.editing.view ) );
  77. editor.data.mapper.on( 'modelToViewPosition', mapModelPositionToView( editor.editing.view ) );
  78. // Conversion.
  79. editor.conversion.for( 'downcast' ).elementToElement( { model: 'title-content', view: 'h1' } );
  80. // Custom converter is used for data v -> m conversion to avoid calling post-fixer when setting data.
  81. // See https://github.com/ckeditor/ckeditor5/issues/2036.
  82. editor.data.upcastDispatcher.on( 'element:h1', dataViewModelH1Insertion, { priority: 'high' } );
  83. editor.data.upcastDispatcher.on( 'element:h2', dataViewModelH1Insertion, { priority: 'high' } );
  84. editor.data.upcastDispatcher.on( 'element:h3', dataViewModelH1Insertion, { priority: 'high' } );
  85. // Take care about correct `title` element structure.
  86. model.document.registerPostFixer( writer => this._fixTitleContent( writer ) );
  87. // Create and take care of correct position of a `title` element.
  88. model.document.registerPostFixer( writer => this._fixTitleElement( writer ) );
  89. // Create element for `Body` placeholder if it is missing.
  90. model.document.registerPostFixer( writer => this._fixBodyElement( writer ) );
  91. // Prevent from adding extra at the end of the document.
  92. model.document.registerPostFixer( writer => this._fixExtraParagraph( writer ) );
  93. // Attach `Title` and `Body` placeholders to the empty title and/or content.
  94. this._attachPlaceholders();
  95. // Attach Tab handling.
  96. this._attachTabPressHandling();
  97. }
  98. /**
  99. * Returns the title of the document. Note that because this plugin does not allow any formatting inside
  100. * the title element, the output of this method will be a plain text, with no HTML tags.
  101. *
  102. * It is not recommended to use this method together with features that insert markers to the
  103. * data output, like comments or track changes features. If such markers start in the title and end in the
  104. * body, the result of this method might be incorrect.
  105. *
  106. * @returns {String} The title of the document.
  107. */
  108. getTitle() {
  109. const titleElement = this._getTitleElement();
  110. const titleContentElement = titleElement.getChild( 0 );
  111. return this.editor.data.stringify( titleContentElement );
  112. }
  113. /**
  114. * Returns the body of the document.
  115. *
  116. * Note that it is not recommended to use this method together with features that insert markers to the
  117. * data output, like comments or track changes features. If such markers start in the title and end in the
  118. * body, the result of this method might be incorrect.
  119. *
  120. * @returns {String} The body of the document.
  121. */
  122. getBody() {
  123. const editor = this.editor;
  124. const data = editor.data;
  125. const model = editor.model;
  126. const root = editor.model.document.getRoot();
  127. const viewWriter = new ViewDowncastWriter( editor.editing.view.document );
  128. const rootRange = model.createRangeIn( root );
  129. const viewDocumentFragment = new ViewDocumentFragment( editor.editing.view.document );
  130. // Convert the entire root to view.
  131. data.mapper.clearBindings();
  132. data.mapper.bindElements( root, viewDocumentFragment );
  133. data.downcastDispatcher.convertInsert( rootRange, viewWriter );
  134. // Convert all markers that intersects with body.
  135. const bodyStartPosition = model.createPositionAfter( root.getChild( 0 ) );
  136. const bodyRange = model.createRange( bodyStartPosition, model.createPositionAt( root, 'end' ) );
  137. for ( const marker of model.markers ) {
  138. const intersection = bodyRange.getIntersection( marker.getRange() );
  139. if ( intersection ) {
  140. data.downcastDispatcher.convertMarkerAdd( marker.name, intersection, viewWriter );
  141. }
  142. }
  143. // Remove title element from view.
  144. viewWriter.remove( viewWriter.createRangeOn( viewDocumentFragment.getChild( 0 ) ) );
  145. // view -> data
  146. return editor.data.processor.toData( viewDocumentFragment );
  147. }
  148. /**
  149. * Returns the `title` element when it is in the document. Returns `undefined` otherwise.
  150. *
  151. * @private
  152. * @returns {module:engine/model/element~Element|undefined}
  153. */
  154. _getTitleElement() {
  155. const root = this.editor.model.document.getRoot();
  156. for ( const child of root.getChildren() ) {
  157. if ( isTitle( child ) ) {
  158. return child;
  159. }
  160. }
  161. }
  162. /**
  163. * Model post-fixer callback that ensures that `title` has only one `title-content` child.
  164. * All additional children should be moved after the `title` element and renamed to a paragraph.
  165. *
  166. * @private
  167. * @param {module:engine/model/writer~Writer} writer
  168. * @returns {Boolean}
  169. */
  170. _fixTitleContent( writer ) {
  171. const title = this._getTitleElement();
  172. // There's no title in the content - it will be created by _fixTitleElement post-fixer.
  173. if ( !title || title.maxOffset === 1 ) {
  174. return false;
  175. }
  176. const titleChildren = Array.from( title.getChildren() );
  177. // Skip first child because it is an allowed element.
  178. titleChildren.shift();
  179. for ( const titleChild of titleChildren ) {
  180. writer.move( writer.createRangeOn( titleChild ), title, 'after' );
  181. writer.rename( titleChild, 'paragraph' );
  182. }
  183. return true;
  184. }
  185. /**
  186. * Model post-fixer callback that creates a title element when it is missing,
  187. * takes care of the correct position of it and removes additional title elements.
  188. *
  189. * @private
  190. * @param {module:engine/model/writer~Writer} writer
  191. * @returns {Boolean}
  192. */
  193. _fixTitleElement( writer ) {
  194. const model = this.editor.model;
  195. const modelRoot = model.document.getRoot();
  196. const titleElements = Array.from( modelRoot.getChildren() ).filter( isTitle );
  197. const firstTitleElement = titleElements[ 0 ];
  198. const firstRootChild = modelRoot.getChild( 0 );
  199. // When title element is at the beginning of the document then try to fix additional
  200. // title elements (if there are any) and stop post-fixer as soon as possible.
  201. if ( firstRootChild.is( 'title' ) ) {
  202. return fixAdditionalTitleElements( titleElements, writer, model );
  203. }
  204. // When there is no title in the document and first element in the document cannot be changed
  205. // to the title then create an empty title element at the beginning of the document.
  206. if ( !firstTitleElement && !titleLikeElements.has( firstRootChild.name ) ) {
  207. const title = writer.createElement( 'title' );
  208. writer.insert( title, modelRoot );
  209. writer.insertElement( 'title-content', title );
  210. return true;
  211. }
  212. // At this stage, we are sure the title is somewhere in the content. It has to be fixed.
  213. // Change the first element in the document to the title if it can be changed (is title-like).
  214. if ( titleLikeElements.has( firstRootChild.name ) ) {
  215. changeElementToTitle( firstRootChild, writer, model );
  216. // Otherwise, move the first occurrence of the title element to the beginning of the document.
  217. } else {
  218. writer.move( writer.createRangeOn( firstTitleElement ), modelRoot, 0 );
  219. }
  220. fixAdditionalTitleElements( titleElements, writer, model );
  221. return true;
  222. }
  223. /**
  224. * Model post-fixer callback that adds an empty paragraph at the end of the document
  225. * when it is needed for the placeholder purposes.
  226. *
  227. * @private
  228. * @param {module:engine/model/writer~Writer} writer
  229. * @returns {Boolean}
  230. */
  231. _fixBodyElement( writer ) {
  232. const modelRoot = this.editor.model.document.getRoot();
  233. if ( modelRoot.childCount < 2 ) {
  234. this._bodyPlaceholder = writer.createElement( 'paragraph' );
  235. writer.insert( this._bodyPlaceholder, modelRoot, 1 );
  236. return true;
  237. }
  238. return false;
  239. }
  240. /**
  241. * Model post-fixer callback that removes a paragraph from the end of the document
  242. * if it was created for the placeholder purposes and is not needed anymore.
  243. *
  244. * @private
  245. * @param {module:engine/model/writer~Writer} writer
  246. * @returns {Boolean}
  247. */
  248. _fixExtraParagraph( writer ) {
  249. const root = this.editor.model.document.getRoot();
  250. const placeholder = this._bodyPlaceholder;
  251. if ( shouldRemoveLastParagraph( placeholder, root ) ) {
  252. this._bodyPlaceholder = null;
  253. writer.remove( placeholder );
  254. return true;
  255. }
  256. return false;
  257. }
  258. /**
  259. * Attaches the `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 sourceElement = editor.sourceElement;
  269. const titlePlaceholder = editor.config.get( 'title.placeholder' ) || t( 'Type your title' );
  270. const bodyPlaceholder = editor.config.get( 'placeholder' ) ||
  271. sourceElement && sourceElement.tagName.toLowerCase() === 'textarea' && sourceElement.getAttribute( 'placeholder' ) ||
  272. t( 'Type or paste your content here.' );
  273. // Attach placeholder to the view title element.
  274. editor.editing.downcastDispatcher.on( 'insert:title-content', ( evt, data, conversionApi ) => {
  275. enablePlaceholder( {
  276. view,
  277. element: conversionApi.mapper.toViewElement( data.item ),
  278. text: titlePlaceholder
  279. } );
  280. } );
  281. // Attach placeholder to first element after a title element and remove it if it's not needed anymore.
  282. // First element after title can change so we need to observe all changes keep placeholder in sync.
  283. let oldBody;
  284. // This post-fixer runs after the model post-fixer so we can assume that
  285. // the second child in view root will always exist.
  286. view.document.registerPostFixer( writer => {
  287. const body = viewRoot.getChild( 1 );
  288. let hasChanged = false;
  289. // If body element has changed we need to disable placeholder on the previous element
  290. // and enable on the new one.
  291. if ( body !== oldBody ) {
  292. if ( oldBody ) {
  293. hidePlaceholder( writer, oldBody );
  294. writer.removeAttribute( 'data-placeholder', oldBody );
  295. }
  296. writer.setAttribute( 'data-placeholder', bodyPlaceholder, body );
  297. oldBody = body;
  298. hasChanged = true;
  299. }
  300. // Then we need to display placeholder if it is needed.
  301. if ( needsPlaceholder( body ) && viewRoot.childCount === 2 && body.name === 'p' ) {
  302. hasChanged = showPlaceholder( writer, body ) ? true : hasChanged;
  303. // Or hide if it is not needed.
  304. } else {
  305. hasChanged = hidePlaceholder( writer, body ) ? true : hasChanged;
  306. }
  307. return hasChanged;
  308. } );
  309. }
  310. /**
  311. * Creates navigation between the title and body sections using <kbd>Tab</kbd> and <kbd>Shift</kbd>+<kbd>Tab</kbd> keys.
  312. *
  313. * @private
  314. */
  315. _attachTabPressHandling() {
  316. const editor = this.editor;
  317. const model = editor.model;
  318. // Pressing <kbd>Tab</kbd> inside the title should move the caret to the body.
  319. editor.keystrokes.set( 'TAB', ( data, cancel ) => {
  320. model.change( writer => {
  321. const selection = model.document.selection;
  322. const selectedElements = Array.from( selection.getSelectedBlocks() );
  323. if ( selectedElements.length === 1 && selectedElements[ 0 ].is( 'title-content' ) ) {
  324. const firstBodyElement = model.document.getRoot().getChild( 1 );
  325. writer.setSelection( firstBodyElement, 0 );
  326. cancel();
  327. }
  328. } );
  329. } );
  330. // Pressing <kbd>Shift</kbd>+<kbd>Tab</kbd> at the beginning of the body should move the caret to the title.
  331. editor.keystrokes.set( 'SHIFT + TAB', ( data, cancel ) => {
  332. model.change( writer => {
  333. const selection = model.document.selection;
  334. if ( !selection.isCollapsed ) {
  335. return;
  336. }
  337. const root = editor.model.document.getRoot();
  338. const selectedElement = first( selection.getSelectedBlocks() );
  339. const selectionPosition = selection.getFirstPosition();
  340. const title = root.getChild( 0 );
  341. const body = root.getChild( 1 );
  342. if ( selectedElement === body && selectionPosition.isAtStart ) {
  343. writer.setSelection( title.getChild( 0 ), 0 );
  344. cancel();
  345. }
  346. } );
  347. } );
  348. }
  349. }
  350. // A view-to-model converter for the h1 that appears at the beginning of the document (a title element).
  351. //
  352. // @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
  353. // @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
  354. // @param {Object} data An object containing conversion input, a placeholder for conversion output and possibly other values.
  355. // @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion interface to be used by the callback.
  356. function dataViewModelH1Insertion( evt, data, conversionApi ) {
  357. const modelCursor = data.modelCursor;
  358. const viewItem = data.viewItem;
  359. if ( !modelCursor.isAtStart || !modelCursor.parent.is( '$root' ) ) {
  360. return;
  361. }
  362. if ( !conversionApi.consumable.consume( viewItem, { name: true } ) ) {
  363. return;
  364. }
  365. const modelWriter = conversionApi.writer;
  366. const title = modelWriter.createElement( 'title' );
  367. const titleContent = modelWriter.createElement( 'title-content' );
  368. modelWriter.append( titleContent, title );
  369. modelWriter.insert( title, modelCursor );
  370. conversionApi.convertChildren( viewItem, modelWriter.createPositionAt( titleContent, 0 ) );
  371. data.modelRange = modelWriter.createRangeOn( title );
  372. data.modelCursor = modelWriter.createPositionAt( data.modelRange.end );
  373. }
  374. // Maps position from the beginning of the model `title` element to the beginning of the view `h1` element.
  375. //
  376. // <title>^<title-content>Foo</title-content></title> -> <h1>^Foo</h1>
  377. //
  378. // @param {module:editor/view/view~View} editingView
  379. function mapModelPositionToView( editingView ) {
  380. return ( evt, data ) => {
  381. const positionParent = data.modelPosition.parent;
  382. if ( !positionParent.is( 'title' ) ) {
  383. return;
  384. }
  385. const modelTitleElement = positionParent.parent;
  386. const viewElement = data.mapper.toViewElement( modelTitleElement );
  387. data.viewPosition = editingView.createPositionAt( viewElement, 0 );
  388. evt.stop();
  389. };
  390. }
  391. // Returns true when given element is a title. Returns false otherwise.
  392. //
  393. // @param {module:engine/model/element~Element} element
  394. // @returns {Boolean}
  395. function isTitle( element ) {
  396. return element.is( 'title' );
  397. }
  398. // Changes the given element to the title element.
  399. //
  400. // @param {module:engine/model/element~Element} element
  401. // @param {module:engine/model/writer~Writer} writer
  402. // @param {module:engine/model/model~Model} model
  403. function changeElementToTitle( element, writer, model ) {
  404. const title = writer.createElement( 'title' );
  405. writer.insert( title, element, 'before' );
  406. writer.insert( element, title, 0 );
  407. writer.rename( element, 'title-content' );
  408. model.schema.removeDisallowedAttributes( [ element ], writer );
  409. }
  410. // Loops over the list of title elements and fixes additional ones.
  411. //
  412. // @param {Array.<module:engine/model/element~Element>} titleElements
  413. // @param {module:engine/model/writer~Writer} writer
  414. // @param {module:engine/model/model~Model} model
  415. // @returns {Boolean} Returns true when there was any change. Returns false otherwise.
  416. function fixAdditionalTitleElements( titleElements, writer, model ) {
  417. let hasChanged = false;
  418. for ( const title of titleElements ) {
  419. if ( title.index !== 0 ) {
  420. fixTitleElement( title, writer, model );
  421. hasChanged = true;
  422. }
  423. }
  424. return hasChanged;
  425. }
  426. // Changes given title element to a paragraph or removes it when it is empty.
  427. //
  428. // @param {module:engine/model/element~Element} title
  429. // @param {module:engine/model/writer~Writer} writer
  430. // @param {module:engine/model/model~Model} model
  431. function fixTitleElement( title, writer, model ) {
  432. const child = title.getChild( 0 );
  433. // Empty title should be removed.
  434. // It is created as a result of pasting to the title element.
  435. if ( child.isEmpty ) {
  436. writer.remove( title );
  437. return;
  438. }
  439. writer.move( writer.createRangeOn( child ), title, 'before' );
  440. writer.rename( child, 'paragraph' );
  441. writer.remove( title );
  442. model.schema.removeDisallowedAttributes( [ child ], writer );
  443. }
  444. // Returns true when the last paragraph in the document was created only for the placeholder
  445. // purpose and it's not needed anymore. Returns false otherwise.
  446. //
  447. // @param {module:engine/model/rootelement~RootElement} root
  448. // @param {module:engine/model/element~Element} placeholder
  449. // @returns {Boolean}
  450. function shouldRemoveLastParagraph( placeholder, root ) {
  451. if ( !placeholder || !placeholder.is( 'paragraph' ) || placeholder.childCount ) {
  452. return false;
  453. }
  454. if ( root.childCount <= 2 || root.getChild( root.childCount - 1 ) !== placeholder ) {
  455. return false;
  456. }
  457. return true;
  458. }
  459. /**
  460. * The configuration of the {@link module:heading/title~Title title feature}.
  461. *
  462. * Read more in {@link module:heading/title~TitleConfig}.
  463. *
  464. * @member {module:heading/title~TitleConfig} module:core/editor/editorconfig~EditorConfig#title
  465. */
  466. /**
  467. * The configuration of the {@link module:heading/title~Title title feature}.
  468. *
  469. * ClassicEditor
  470. * .create( document.querySelector( '#editor' ), {
  471. * plugins: [ Title, ... ],
  472. * title: {
  473. * placeholder: 'My custom placeholder for the title'
  474. * },
  475. * placeholder: 'My custom placeholder for the body'
  476. * } )
  477. * .then( ... )
  478. * .catch( ... );
  479. *
  480. * See {@link module:core/editor/editorconfig~EditorConfig all editor configuration options}.
  481. *
  482. * @interface TitleConfig
  483. */
  484. /**
  485. * Defines a custom value of the placeholder for the title field.
  486. *
  487. * Read more in {@link module:heading/title~TitleConfig}.
  488. *
  489. * @member {String} module:heading/title~TitleConfig#placeholder
  490. */