todolistconverters.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  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 list/todolistconverters
  7. */
  8. /* global document */
  9. import { generateLiInUl, injectViewList, positionAfterUiElements, findNestedList } from './utils';
  10. import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
  11. /**
  12. * A model-to-view converter for the `listItem` model element insertion.
  13. *
  14. * It converts the `listItem` model element to an unordered list with a {@link module:engine/view/uielement~UIElement checkbox element}
  15. * at the beginning of each list item. It also merges the list with surrounding lists (if available).
  16. *
  17. * It is used by {@link module:engine/controller/editingcontroller~EditingController}.
  18. *
  19. * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert
  20. * @param {module:engine/model/model~Model} model Model instance.
  21. * @param {Function} onCheckboxChecked Callback function.
  22. * @returns {Function} Returns a conversion callback.
  23. */
  24. export function modelViewInsertion( model, onCheckboxChecked ) {
  25. return ( evt, data, conversionApi ) => {
  26. const consumable = conversionApi.consumable;
  27. if ( !consumable.test( data.item, 'insert' ) ||
  28. !consumable.test( data.item, 'attribute:listType' ) ||
  29. !consumable.test( data.item, 'attribute:listIndent' )
  30. ) {
  31. return;
  32. }
  33. if ( data.item.getAttribute( 'listType' ) != 'todo' ) {
  34. return;
  35. }
  36. const modelItem = data.item;
  37. consumable.consume( modelItem, 'insert' );
  38. consumable.consume( modelItem, 'attribute:listType' );
  39. consumable.consume( modelItem, 'attribute:listIndent' );
  40. consumable.consume( modelItem, 'attribute:todoListChecked' );
  41. const viewWriter = conversionApi.writer;
  42. const viewItem = generateLiInUl( modelItem, conversionApi );
  43. const isChecked = !!modelItem.getAttribute( 'todoListChecked' );
  44. const checkmarkElement = createCheckmarkElement( modelItem, viewWriter, isChecked, onCheckboxChecked );
  45. const span = viewWriter.createContainerElement( 'span', {
  46. class: 'todo-list__label__description'
  47. } );
  48. viewWriter.addClass( 'todo-list', viewItem.parent );
  49. viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), checkmarkElement );
  50. viewWriter.insert( viewWriter.createPositionAfter( checkmarkElement ), span );
  51. injectViewList( modelItem, viewItem, conversionApi, model );
  52. };
  53. }
  54. /**
  55. * A model-to-view converter for the `listItem` model element insertion.
  56. *
  57. * It is used by {@link module:engine/controller/datacontroller~DataController}.
  58. *
  59. * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert
  60. * @param {module:engine/model/model~Model} model Model instance.
  61. * @returns {Function} Returns a conversion callback.
  62. */
  63. export function dataModelViewInsertion( model ) {
  64. return ( evt, data, conversionApi ) => {
  65. const consumable = conversionApi.consumable;
  66. if ( !consumable.test( data.item, 'insert' ) ||
  67. !consumable.test( data.item, 'attribute:listType' ) ||
  68. !consumable.test( data.item, 'attribute:listIndent' )
  69. ) {
  70. return;
  71. }
  72. if ( data.item.getAttribute( 'listType' ) != 'todo' ) {
  73. return;
  74. }
  75. const modelItem = data.item;
  76. consumable.consume( modelItem, 'insert' );
  77. consumable.consume( modelItem, 'attribute:listType' );
  78. consumable.consume( modelItem, 'attribute:listIndent' );
  79. consumable.consume( modelItem, 'attribute:todoListChecked' );
  80. const viewWriter = conversionApi.writer;
  81. const viewItem = generateLiInUl( modelItem, conversionApi );
  82. viewWriter.addClass( 'todo-list', viewItem.parent );
  83. const label = viewWriter.createContainerElement( 'label', {
  84. class: 'todo-list__label'
  85. } );
  86. const checkbox = viewWriter.createEmptyElement( 'input', {
  87. type: 'checkbox',
  88. disabled: 'disabled'
  89. } );
  90. const span = viewWriter.createContainerElement( 'span', {
  91. class: 'todo-list__label__description'
  92. } );
  93. if ( modelItem.getAttribute( 'todoListChecked' ) ) {
  94. viewWriter.setAttribute( 'checked', 'checked', checkbox );
  95. }
  96. viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), label );
  97. viewWriter.insert( viewWriter.createPositionAt( label, 0 ), checkbox );
  98. viewWriter.insert( viewWriter.createPositionAfter( checkbox ), span );
  99. injectViewList( modelItem, viewItem, conversionApi, model );
  100. };
  101. }
  102. /**
  103. * A view-to-model converter for the checkbox element inside a view list item.
  104. *
  105. * It changes the `listType` of the model `listItem` to a `todo` value.
  106. * When a view checkbox element is marked as checked, an additional `todoListChecked="true"` attribute is added to the model item.
  107. *
  108. * It is used by {@link module:engine/controller/datacontroller~DataController}.
  109. *
  110. * @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
  111. * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
  112. * @param {Object} data An object containing conversion input, a placeholder for conversion output and possibly other values.
  113. * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion interface to be used by the callback.
  114. */
  115. export function dataViewModelCheckmarkInsertion( evt, data, conversionApi ) {
  116. const modelCursor = data.modelCursor;
  117. const modelItem = modelCursor.parent;
  118. const viewItem = data.viewItem;
  119. if ( viewItem.getAttribute( 'type' ) != 'checkbox' || modelItem.name != 'listItem' || !modelCursor.isAtStart ) {
  120. return;
  121. }
  122. if ( !conversionApi.consumable.consume( viewItem, { name: true } ) ) {
  123. return;
  124. }
  125. const writer = conversionApi.writer;
  126. writer.setAttribute( 'listType', 'todo', modelItem );
  127. if ( data.viewItem.hasAttribute( 'checked' ) ) {
  128. writer.setAttribute( 'todoListChecked', true, modelItem );
  129. }
  130. data.modelRange = writer.createRange( modelCursor );
  131. }
  132. /**
  133. * A model-to-view converter for the `listType` attribute change on the `listItem` model element.
  134. *
  135. * This change means that the `<li>` element parent changes to `<ul class="todo-list">` and a
  136. * {@link module:engine/view/uielement~UIElement checkbox UI element} is added at the beginning
  137. * of the list item element (or vice versa).
  138. *
  139. * This converter is preceded by {@link module:list/converters~modelViewChangeType} and followed by
  140. * {@link module:list/converters~modelViewMergeAfterChangeType} to handle splitting and merging surrounding lists of the same type.
  141. *
  142. * It is used by {@link module:engine/controller/editingcontroller~EditingController}.
  143. *
  144. * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
  145. * @param {Function} onCheckedChange Callback fired after clicking the checkbox UI element.
  146. * @param {module:engine/view/view~View} view Editing view controller.
  147. * @returns {Function} Returns a conversion callback.
  148. */
  149. export function modelViewChangeType( onCheckedChange, view ) {
  150. return ( evt, data, conversionApi ) => {
  151. const viewItem = conversionApi.mapper.toViewElement( data.item );
  152. const viewWriter = conversionApi.writer;
  153. const labelElement = findLabel( viewItem, view );
  154. if ( data.attributeNewValue == 'todo' ) {
  155. const isChecked = !!data.item.getAttribute( 'todoListChecked' );
  156. const checkmarkElement = createCheckmarkElement( data.item, viewWriter, isChecked, onCheckedChange );
  157. const span = viewWriter.createContainerElement( 'span', {
  158. class: 'todo-list__label__description'
  159. } );
  160. const itemRange = viewWriter.createRangeIn( viewItem );
  161. const nestedList = findNestedList( viewItem );
  162. const descriptionStart = positionAfterUiElements( itemRange.start );
  163. const descriptionEnd = nestedList ? viewWriter.createPositionBefore( nestedList ) : itemRange.end;
  164. const descriptionRange = viewWriter.createRange( descriptionStart, descriptionEnd );
  165. viewWriter.addClass( 'todo-list', viewItem.parent );
  166. viewWriter.move( descriptionRange, viewWriter.createPositionAt( span, 0 ) );
  167. viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), checkmarkElement );
  168. viewWriter.insert( viewWriter.createPositionAfter( checkmarkElement ), span );
  169. } else if ( data.attributeOldValue == 'todo' ) {
  170. const descriptionSpan = findDescription( viewItem, view );
  171. viewWriter.removeClass( 'todo-list', viewItem.parent );
  172. viewWriter.remove( labelElement );
  173. viewWriter.move( viewWriter.createRangeIn( descriptionSpan ), viewWriter.createPositionBefore( descriptionSpan ) );
  174. viewWriter.remove( descriptionSpan );
  175. }
  176. };
  177. }
  178. /**
  179. * A model-to-view converter for the `todoListChecked` attribute change on the `listItem` model element.
  180. *
  181. * It marks the {@link module:engine/view/uielement~UIElement checkbox UI element} as checked.
  182. *
  183. * It is used by {@link module:engine/controller/editingcontroller~EditingController}.
  184. *
  185. * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
  186. * @param {Function} onCheckedChange Callback fired after clicking the checkbox UI element.
  187. * @returns {Function} Returns a conversion callback.
  188. */
  189. export function modelViewChangeChecked( onCheckedChange ) {
  190. return ( evt, data, conversionApi ) => {
  191. // Do not convert `todoListChecked` attribute when to-do list item has changed to other list item.
  192. // This attribute will be removed by the model post fixer.
  193. if ( data.item.getAttribute( 'listType' ) != 'todo' ) {
  194. return;
  195. }
  196. if ( !conversionApi.consumable.consume( data.item, 'attribute:todoListChecked' ) ) {
  197. return;
  198. }
  199. const { mapper, writer: viewWriter } = conversionApi;
  200. const isChecked = !!data.item.getAttribute( 'todoListChecked' );
  201. const viewItem = mapper.toViewElement( data.item );
  202. // Because of m -> v position mapper we can be sure checkbox is always at the beginning.
  203. const oldCheckmarkElement = viewItem.getChild( 0 );
  204. const newCheckmarkElement = createCheckmarkElement( data.item, viewWriter, isChecked, onCheckedChange );
  205. viewWriter.insert( viewWriter.createPositionAfter( oldCheckmarkElement ), newCheckmarkElement );
  206. viewWriter.remove( oldCheckmarkElement );
  207. };
  208. }
  209. /**
  210. * A model-to-view position at zero offset mapper.
  211. *
  212. * This helper ensures that position inside todo-list in the view is mapped after the checkbox.
  213. *
  214. * It only handles the position at the beginning of a list item as other positions are properly mapped be the default mapper.
  215. *
  216. * @param {module:engine/view/view~View} view
  217. * @return {Function}
  218. */
  219. export function mapModelToViewPosition( view ) {
  220. return ( evt, data ) => {
  221. const modelPosition = data.modelPosition;
  222. const parent = modelPosition.parent;
  223. if ( !parent.is( 'element', 'listItem' ) || parent.getAttribute( 'listType' ) != 'todo' ) {
  224. return;
  225. }
  226. const viewLi = data.mapper.toViewElement( parent );
  227. const descSpan = findDescription( viewLi, view );
  228. if ( descSpan ) {
  229. data.viewPosition = data.mapper.findPositionIn( descSpan, modelPosition.offset );
  230. }
  231. };
  232. }
  233. // Creates a checkbox UI element.
  234. //
  235. // @private
  236. // @param {module:engine/model/item~Item} modelItem
  237. // @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
  238. // @param {Boolean} isChecked
  239. // @param {Function} onChange
  240. // @returns {module:view/uielement~UIElement}
  241. function createCheckmarkElement( modelItem, viewWriter, isChecked, onChange ) {
  242. const uiElement = viewWriter.createUIElement(
  243. 'label',
  244. {
  245. class: 'todo-list__label',
  246. contenteditable: false
  247. },
  248. function( domDocument ) {
  249. const checkbox = createElement( document, 'input', { type: 'checkbox' } );
  250. if ( isChecked ) {
  251. checkbox.setAttribute( 'checked', 'checked' );
  252. }
  253. checkbox.addEventListener( 'change', () => onChange( modelItem ) );
  254. const domElement = this.toDomElement( domDocument );
  255. domElement.appendChild( checkbox );
  256. return domElement;
  257. }
  258. );
  259. return uiElement;
  260. }
  261. // Helper method to find label element inside li.
  262. function findLabel( viewItem, view ) {
  263. const range = view.createRangeIn( viewItem );
  264. for ( const value of range ) {
  265. if ( value.item.is( 'uiElement', 'label' ) ) {
  266. return value.item;
  267. }
  268. }
  269. }
  270. function findDescription( viewItem, view ) {
  271. const range = view.createRangeIn( viewItem );
  272. for ( const value of range ) {
  273. if ( value.item.is( 'containerElement', 'span' ) && value.item.hasClass( 'todo-list__label__description' ) ) {
  274. return value.item;
  275. }
  276. }
  277. }