element-reconversion-demo.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /* globals
  6. ClassicEditor, Plugin, ButtonView, Command, toWidget, toWidgetEditable, createElement, findOptimalInsertionPosition,
  7. console, window, prompt, document
  8. */
  9. import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
  10. /**
  11. * Helper for extracting the side card type from a view element based on its CSS class.
  12. */
  13. const getTypeFromViewElement = viewElement => {
  14. for ( const type of [ 'default', 'alternate' ] ) {
  15. if ( viewElement.hasClass( `side-card-${ type }` ) ) {
  16. return type;
  17. }
  18. }
  19. return 'default';
  20. };
  21. /**
  22. * Single upcast converter to the <sideCard/> element with all its attributes.
  23. */
  24. const upcastCard = ( viewElement, { writer } ) => {
  25. const sideCard = writer.createElement( 'sideCard' );
  26. const type = getTypeFromViewElement( viewElement );
  27. writer.setAttribute( 'cardType', type, sideCard );
  28. const urlWrapper = [ ...viewElement.getChildren() ].find( child => {
  29. return child.is( 'element', 'div' ) && child.hasClass( 'side-card-url' );
  30. } );
  31. if ( urlWrapper ) {
  32. writer.setAttribute( 'cardURL', urlWrapper.getChild( 0 ).data, sideCard );
  33. }
  34. return sideCard;
  35. };
  36. /**
  37. * Helper for creating a DOM button with an editor callback.
  38. */
  39. const addActionButton = ( text, callback, domElement, editor ) => {
  40. const domDocument = domElement.ownerDocument;
  41. const button = createElement( domDocument, 'button', {}, [ text ] );
  42. button.addEventListener( 'click', () => {
  43. editor.model.change( callback );
  44. } );
  45. domElement.appendChild( button );
  46. return button;
  47. };
  48. /**
  49. * Helper function that creates the card editing UI inside the card.
  50. */
  51. const createActionsView = ( editor, modelElement ) => function( domElement ) {
  52. //
  53. // Set the URL action button.
  54. //
  55. addActionButton( 'Set URL', writer => {
  56. // eslint-disable-next-line no-alert
  57. const newURL = prompt( 'Set URL', modelElement.getAttribute( 'cardURL' ) || '' );
  58. writer.setAttribute( 'cardURL', newURL, modelElement );
  59. }, domElement, editor );
  60. const currentType = modelElement.getAttribute( 'cardType' );
  61. const newType = currentType === 'default' ? 'alternate' : 'default';
  62. //
  63. // Change the card action button.
  64. //
  65. addActionButton( 'Change type', writer => {
  66. writer.setAttribute( 'cardType', newType, modelElement );
  67. }, domElement, editor );
  68. const childCount = modelElement.childCount;
  69. //
  70. // Add the content section to the card action button.
  71. //
  72. const addButton = addActionButton( 'Add section', writer => {
  73. writer.insertElement( 'sideCardSection', modelElement, 'end' );
  74. }, domElement, editor );
  75. // Disable the button so only 1-3 content boxes are in the card (there will always be a title).
  76. if ( childCount > 4 ) {
  77. addButton.setAttribute( 'disabled', 'disabled' );
  78. }
  79. //
  80. // Remove the content section from the card action button.
  81. //
  82. const removeButton = addActionButton( 'Remove section', writer => {
  83. writer.remove( modelElement.getChild( childCount - 1 ) );
  84. }, domElement, editor );
  85. // Disable the button so only 1-3 content boxes are in the card (there will always be a title).
  86. if ( childCount < 3 ) {
  87. removeButton.setAttribute( 'disabled', 'disabled' );
  88. }
  89. };
  90. /**
  91. * The downcast converter for the <sideCard/> element.
  92. *
  93. * It returns the full view structure based on the current state of the model element.
  94. */
  95. const downcastSideCard = ( editor, { asWidget } ) => {
  96. return ( modelElement, { writer, consumable, mapper } ) => {
  97. const type = modelElement.getAttribute( 'cardType' ) || 'default';
  98. // The main view element for the side card.
  99. const sideCardView = writer.createContainerElement( 'aside', {
  100. class: `side-card side-card-${ type }`
  101. } );
  102. // Create inner views from the side card children.
  103. for ( const child of modelElement.getChildren() ) {
  104. const childView = writer.createEditableElement( 'div' );
  105. // Child is either a "title" or "section".
  106. if ( child.is( 'element', 'sideCardTitle' ) ) {
  107. writer.addClass( 'side-card-title', childView );
  108. } else {
  109. writer.addClass( 'side-card-section', childView );
  110. }
  111. // It is important to consume and bind converted elements.
  112. consumable.consume( child, 'insert' );
  113. mapper.bindElements( child, childView );
  114. // Make it an editable part of the widget.
  115. if ( asWidget ) {
  116. toWidgetEditable( childView, writer );
  117. }
  118. writer.insert( writer.createPositionAt( sideCardView, 'end' ), childView );
  119. }
  120. const urlAttribute = modelElement.getAttribute( 'cardURL' );
  121. // Do not render an empty URL field.
  122. if ( urlAttribute ) {
  123. const urlBox = writer.createRawElement( 'div', {
  124. class: 'side-card-url'
  125. }, function( domElement ) {
  126. domElement.innerText = `URL: "${ urlAttribute }"`;
  127. } );
  128. writer.insert( writer.createPositionAt( sideCardView, 'end' ), urlBox );
  129. }
  130. // Inner element used to render a simple UI that allows to change the side card's attributes.
  131. // It will only be needed in the editing view inside the widgetized element.
  132. // The data output should not contain this section.
  133. if ( asWidget ) {
  134. const actionsView = writer.createRawElement( 'div', {
  135. class: 'side-card-actions',
  136. contenteditable: 'false', // Prevents editing of the element.
  137. 'data-cke-ignore-events': 'true' // Allows using custom UI elements inside the editing view.
  138. }, createActionsView( editor, modelElement ) ); // See the full code for details.
  139. writer.insert( writer.createPositionAt( sideCardView, 'end' ), actionsView );
  140. toWidget( sideCardView, writer, { widgetLabel: 'Side card', hasSelectionHandle: true } );
  141. }
  142. return sideCardView;
  143. };
  144. };
  145. class InsertCardCommand extends Command {
  146. /**
  147. * Refresh used schema definition to check if a side card can be inserted in the current selection.
  148. */
  149. refresh() {
  150. const model = this.editor.model;
  151. const validParent = findOptimalInsertionPosition( model.document.selection, model );
  152. this.isEnabled = model.schema.checkChild( validParent, 'sideCard' );
  153. }
  154. /**
  155. * Creates a full side card element with all required children and attributes.
  156. */
  157. execute() {
  158. const model = this.editor.model;
  159. const selection = model.document.selection;
  160. const insertPosition = findOptimalInsertionPosition( selection, model );
  161. model.change( writer => {
  162. const sideCard = writer.createElement( 'sideCard', { cardType: 'default' } );
  163. const title = writer.createElement( 'sideCardTitle' );
  164. const section = writer.createElement( 'sideCardSection' );
  165. const paragraph = writer.createElement( 'paragraph' );
  166. writer.insert( title, sideCard, 0 );
  167. writer.insert( section, sideCard, 1 );
  168. writer.insert( paragraph, section, 0 );
  169. model.insertContent( sideCard, insertPosition );
  170. writer.setSelection( writer.createPositionAt( title, 0 ) );
  171. } );
  172. }
  173. }
  174. class ComplexBox extends Plugin {
  175. constructor( editor ) {
  176. super( editor );
  177. this._defineSchema();
  178. this._defineConversion();
  179. editor.commands.add( 'insertCard', new InsertCardCommand( editor ) );
  180. this._defineUI();
  181. }
  182. _defineConversion() {
  183. const editor = this.editor;
  184. const conversion = editor.conversion;
  185. conversion.for( 'upcast' )
  186. .elementToElement( {
  187. view: { name: 'aside', classes: [ 'side-card' ] },
  188. model: upcastCard
  189. } )
  190. .elementToElement( {
  191. view: { name: 'div', classes: [ 'side-card-title' ] },
  192. model: 'sideCardTitle'
  193. } )
  194. .elementToElement( {
  195. view: { name: 'div', classes: [ 'side-card-section' ] },
  196. model: 'sideCardSection'
  197. } );
  198. // The downcast conversion must be split as you need a widget in the editing pipeline.
  199. conversion.for( 'editingDowncast' ).elementToElement( {
  200. model: 'sideCard',
  201. view: downcastSideCard( editor, { asWidget: true } ),
  202. triggerBy: {
  203. attributes: [ 'cardType', 'cardURL' ],
  204. children: [ 'sideCardSection' ]
  205. }
  206. } );
  207. // The data downcast is always executed from the current model stat, so `triggerBy` will take no effect.
  208. conversion.for( 'dataDowncast' ).elementToElement( {
  209. model: 'sideCard',
  210. view: downcastSideCard( editor, { asWidget: false } )
  211. } );
  212. }
  213. _defineSchema() {
  214. const schema = this.editor.model.schema;
  215. // The main element with attributes for type and URL:
  216. schema.register( 'sideCard', {
  217. allowWhere: '$block',
  218. isObject: true,
  219. allowAttributes: [ 'cardType', 'cardURL' ]
  220. } );
  221. // Disallow side card nesting.
  222. schema.addChildCheck( ( context, childDefinition ) => {
  223. if ( [ ...context.getNames() ].includes( 'sideCard' ) && childDefinition.name === 'sideCard' ) {
  224. return false;
  225. }
  226. } );
  227. // A text-only title.
  228. schema.register( 'sideCardTitle', {
  229. isLimit: true,
  230. allowIn: 'sideCard'
  231. } );
  232. // Allow text in title...
  233. schema.extend( '$text', { allowIn: 'sideCardTitle' } );
  234. // ...but disallow any text attribute inside.
  235. schema.addAttributeCheck( context => {
  236. if ( context.endsWith( 'sideCardTitle $text' ) ) {
  237. return false;
  238. }
  239. } );
  240. // A content block which can have any content allowed in $root.
  241. schema.register( 'sideCardSection', {
  242. isLimit: true,
  243. allowIn: 'sideCard',
  244. allowContentOf: '$root'
  245. } );
  246. }
  247. _defineUI() {
  248. const editor = this.editor;
  249. // Defines a simple text button.
  250. editor.ui.componentFactory.add( 'insertCard', locale => {
  251. const button = new ButtonView( locale );
  252. const command = editor.commands.get( 'insertCard' );
  253. button.set( {
  254. withText: true,
  255. icon: false,
  256. label: 'Insert card'
  257. } );
  258. button.bind( 'isEnabled' ).to( command );
  259. button.on( 'execute', () => {
  260. editor.execute( 'insertCard' );
  261. editor.editing.view.focus();
  262. } );
  263. return button;
  264. } );
  265. }
  266. }
  267. ClassicEditor
  268. .create( document.querySelector( '#editor-element-reconversion' ), {
  269. cloudServices: CS_CONFIG,
  270. extraPlugins: [ ComplexBox ],
  271. image: {
  272. toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
  273. },
  274. table: {
  275. contentToolbar: [
  276. 'tableColumn',
  277. 'tableRow',
  278. 'mergeTableCells'
  279. ]
  280. },
  281. toolbar: {
  282. items: [ 'heading', '|', 'bold', 'italic', '|', 'insertCard' ],
  283. viewportTopOffset: window.getViewportTopOffsetConfig()
  284. }
  285. } )
  286. .then( editor => {
  287. window.editor = editor;
  288. } )
  289. .catch( err => {
  290. console.error( err );
  291. } );