todolistconverters.js 12 KB

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