slotconversion.js 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  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. /* globals console, window, document */
  6. import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
  7. import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
  8. import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
  9. const byClassName = className => element => element.hasClass( className );
  10. const getRandom = () => parseInt( Math.random() * 1000 );
  11. function mapMeta( editor ) {
  12. return metaElement => {
  13. if ( metaElement.hasClass( 'box-meta-header' ) ) {
  14. const title = getChildren( editor, metaElement )
  15. .filter( byClassName( 'box-meta-header-title' ) )
  16. .pop().getChild( 0 ).getChild( 0 ).data;
  17. return {
  18. header: {
  19. title
  20. }
  21. };
  22. }
  23. if ( metaElement.hasClass( 'box-meta-author' ) ) {
  24. const link = metaElement.getChild( 0 );
  25. return {
  26. author: {
  27. name: link.getChild( 0 ).data,
  28. website: link.getAttribute( 'href' )
  29. }
  30. };
  31. }
  32. };
  33. }
  34. function getChildren( editor, viewElement ) {
  35. return [ ...( editor.editing.view.createRangeIn( viewElement ) ) ]
  36. .filter( ( { type } ) => type === 'elementStart' )
  37. .map( ( { item } ) => item );
  38. }
  39. function getBoxUpcastConverter( editor ) {
  40. return dispatcher => dispatcher.on( 'element:div', ( event, data, conversionApi ) => {
  41. const viewElement = data.viewItem;
  42. const writer = conversionApi.writer;
  43. if ( !viewElement.hasClass( 'box' ) ) {
  44. return;
  45. }
  46. const box = writer.createElement( 'box' );
  47. if ( !conversionApi.safeInsert( box, data.modelCursor ) ) {
  48. return;
  49. }
  50. const elements = getChildren( editor, viewElement );
  51. const fields = elements.filter( byClassName( 'box-content-field' ) );
  52. const metaElements = elements.filter( byClassName( 'box-meta' ) );
  53. const meta = metaElements.map( mapMeta( editor ) ).reduce( ( prev, current ) => Object.assign( prev, current ), {} );
  54. writer.setAttribute( 'meta', meta, box );
  55. for ( const field of fields ) {
  56. const boxField = writer.createElement( 'boxField' );
  57. conversionApi.safeInsert( boxField, writer.createPositionAt( box, field.index ) );
  58. conversionApi.convertChildren( field, boxField );
  59. }
  60. conversionApi.consumable.consume( viewElement, { name: true } );
  61. elements.map( element => {
  62. conversionApi.consumable.consume( element, { name: true } );
  63. } );
  64. conversionApi.updateConversionResult( box, data );
  65. } );
  66. }
  67. function downcastBox( modelElement, conversionApi ) {
  68. const { writer } = conversionApi;
  69. const viewBox = writer.createContainerElement( 'div', { class: 'box' } );
  70. conversionApi.mapper.bindElements( modelElement, viewBox );
  71. const contentWrap = writer.createContainerElement( 'div', { class: 'box-content' } );
  72. writer.insert( writer.createPositionAt( viewBox, 0 ), contentWrap );
  73. for ( const [ meta, metaValue ] of Object.entries( modelElement.getAttribute( 'meta' ) ) ) {
  74. if ( meta === 'header' ) {
  75. const header = writer.createRawElement( 'div', {
  76. class: 'box-meta box-meta-header'
  77. }, domElement => {
  78. domElement.innerHTML = `<div class="box-meta-header-title"><h2>${ metaValue.title }</h2></div>`;
  79. } );
  80. writer.insert( writer.createPositionBefore( contentWrap ), header );
  81. }
  82. if ( meta === 'author' ) {
  83. const author = writer.createRawElement( 'div', {
  84. class: 'box-meta box-meta-author'
  85. }, domElement => {
  86. domElement.innerHTML = `<a href="${ metaValue.website }">${ metaValue.name }</a>`;
  87. } );
  88. writer.insert( writer.createPositionAfter( contentWrap ), author );
  89. }
  90. }
  91. for ( const field of modelElement.getChildren() ) {
  92. const viewField = writer.createContainerElement( 'div', { class: 'box-content-field' } );
  93. writer.insert( writer.createPositionAt( contentWrap, field.index ), viewField );
  94. conversionApi.mapper.bindElements( field, viewField );
  95. conversionApi.consumable.consume( field, 'insert' );
  96. // Might be simplified to:
  97. //
  98. // writer.defineSlot( field, viewField, field.index );
  99. //
  100. // but would require a converter:
  101. //
  102. // editor.conversion.for( 'downcast' ).elementToElement( { // .slotToElement()?
  103. // model: 'viewField',
  104. // view: { name: 'div', class: 'box-content-field' }
  105. // } );
  106. }
  107. // At this point we're inserting whole "component". Equivalent to (JSX-like notation):
  108. //
  109. // "rendered" view Mapping/source
  110. //
  111. // <div:container class="box"> <-- top-level box
  112. // <div:raw class="box-meta box-meta-header">...</div:raw> box[meta.header]
  113. // <div:container class="box-content">
  114. // <div:container class="box-content-field">...</div:container> <-- this is "slot" boxField
  115. // ... many
  116. // <div:container class="box-content-field">...</div:container> <-- this is "slot" boxField
  117. // </div:container>
  118. // <div:raw class="box-meta box-meta-author">...</div:raw> box[meta.author]
  119. // </div:container>
  120. return viewBox;
  121. }
  122. function addButton( editor, uiName, label, callback ) {
  123. editor.ui.componentFactory.add( uiName, locale => {
  124. const view = new ButtonView( locale );
  125. view.set( { label, withText: true } );
  126. view.listenTo( view, 'execute', () => {
  127. const parent = editor.model.document.selection.getFirstPosition().parent;
  128. const boxField = parent.findAncestor( 'boxField' );
  129. if ( !boxField ) {
  130. return;
  131. }
  132. editor.model.change( writer => callback( writer, boxField.findAncestor( 'box' ), boxField ) );
  133. } );
  134. return view;
  135. } );
  136. }
  137. function addBoxMetaButton( editor, uiName, label, updateWith ) {
  138. addButton( editor, uiName, label, ( writer, box ) => {
  139. writer.setAttribute( 'meta', {
  140. ...box.getAttribute( 'meta' ),
  141. ...updateWith()
  142. }, box );
  143. } );
  144. }
  145. function Box( editor ) {
  146. editor.model.schema.register( 'box', {
  147. allowIn: '$root',
  148. isObject: true,
  149. isSelectable: true,
  150. allowAttributes: [ 'infoBoxMeta' ]
  151. } );
  152. editor.model.schema.register( 'boxField', {
  153. allowContentOf: '$root',
  154. allowIn: 'box',
  155. isLimit: true
  156. } );
  157. editor.conversion.for( 'upcast' ).add( getBoxUpcastConverter( editor ) );
  158. editor.conversion.for( 'downcast' ).elementToElement( {
  159. model: 'box',
  160. view: downcastBox,
  161. triggerBy: {
  162. attributes: [ 'meta' ],
  163. children: [ 'boxField' ]
  164. }
  165. } );
  166. addBoxMetaButton( editor, 'boxTitle', 'Box title', () => ( {
  167. header: { title: `Random title no. ${ getRandom() }.` }
  168. } ) );
  169. addBoxMetaButton( editor, 'boxAuthor', 'Box author', () => ( {
  170. author: {
  171. website: `www.example.com/${ getRandom() }`,
  172. name: `Random author no. ${ getRandom() }`
  173. }
  174. } ) );
  175. addButton( editor, 'addBoxField', '+', ( writer, box, boxField ) => {
  176. const newBoxField = writer.createElement( 'boxField' );
  177. writer.insert( newBoxField, box, boxField.index );
  178. writer.insert( writer.createElement( 'paragraph' ), newBoxField, 0 );
  179. } );
  180. addButton( editor, 'removeBoxField', '-', ( writer, box, boxField ) => {
  181. writer.remove( boxField );
  182. } );
  183. }
  184. function AddRenderCount( editor ) {
  185. let insertCount = 0;
  186. const nextInsert = () => insertCount++;
  187. editor.conversion.for( 'downcast' ).add( dispatcher => dispatcher.on( 'insert', ( event, data, conversionApi ) => {
  188. const view = conversionApi.mapper.toViewElement( data.item );
  189. if ( view ) {
  190. const insertCount = nextInsert();
  191. conversionApi.writer.setAttribute( 'data-insert-count', `${ insertCount }`, view );
  192. conversionApi.writer.setAttribute( 'title', `Insertion counter: ${ insertCount }`, view );
  193. }
  194. }, { priority: 'lowest' } ) );
  195. }
  196. ClassicEditor
  197. .create( document.querySelector( '#editor' ), {
  198. plugins: [ ArticlePluginSet, Box, AddRenderCount ],
  199. toolbar: [
  200. 'heading',
  201. '|',
  202. 'boxTitle',
  203. 'boxAuthor',
  204. 'addBoxField',
  205. 'removeBoxField',
  206. '|',
  207. 'bold',
  208. 'italic',
  209. 'link',
  210. 'bulletedList',
  211. 'numberedList',
  212. '|',
  213. 'outdent',
  214. 'indent',
  215. '|',
  216. 'blockQuote',
  217. 'insertTable',
  218. 'mediaEmbed',
  219. 'undo',
  220. 'redo'
  221. ],
  222. image: {
  223. toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
  224. },
  225. table: {
  226. contentToolbar: [
  227. 'tableColumn',
  228. 'tableRow',
  229. 'mergeTableCells'
  230. ]
  231. }
  232. } )
  233. .then( editor => {
  234. window.editor = editor;
  235. } )
  236. .catch( err => {
  237. console.error( err.stack );
  238. } );