model-selection-to-view-converters.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. /**
  2. * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. import ViewElement from '../view/element';
  6. import ViewRange from '../view/range';
  7. import viewWriter from '../view/writer';
  8. import { createViewElementFromHighlightDescriptor } from './model-to-view-converters';
  9. /**
  10. * Contains {@link module:engine/model/selection~Selection model selection} to
  11. * {@link module:engine/view/selection~Selection view selection} converters for
  12. * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}.
  13. *
  14. * @module engine/conversion/model-selection-to-view-converters
  15. */
  16. /**
  17. * Function factory, creates a converter that converts non-collapsed {@link module:engine/model/selection~Selection model selection} to
  18. * {@link module:engine/view/selection~Selection view selection}. The converter consumes appropriate value from `consumable` object
  19. * and maps model positions from selection to view positions.
  20. *
  21. * modelDispatcher.on( 'selection', convertRangeSelection() );
  22. *
  23. * @returns {Function} Selection converter.
  24. */
  25. export function convertRangeSelection() {
  26. return ( evt, data, consumable, conversionApi ) => {
  27. const selection = data.selection;
  28. if ( selection.isCollapsed ) {
  29. return;
  30. }
  31. if ( !consumable.consume( selection, 'selection' ) ) {
  32. return;
  33. }
  34. conversionApi.viewSelection.removeAllRanges();
  35. for ( const range of selection.getRanges() ) {
  36. const viewRange = conversionApi.mapper.toViewRange( range );
  37. conversionApi.viewSelection.addRange( viewRange, selection.isBackward );
  38. }
  39. };
  40. }
  41. /**
  42. * Function factory, creates a converter that converts collapsed {@link module:engine/model/selection~Selection model selection} to
  43. * {@link module:engine/view/selection~Selection view selection}. The converter consumes appropriate value from `consumable` object,
  44. * maps model selection position to view position and breaks {@link module:engine/view/attributeelement~AttributeElement attribute elements}
  45. * at the selection position.
  46. *
  47. * modelDispatcher.on( 'selection', convertCollapsedSelection() );
  48. *
  49. * Example of view state before and after converting collapsed selection:
  50. *
  51. * <p><strong>f^oo<strong>bar</p>
  52. * -> <p><strong>f</strong>^<strong>oo</strong>bar</p>
  53. *
  54. * By breaking attribute elements like `<strong>`, selection is in correct element. See also complementary
  55. * {@link module:engine/conversion/model-selection-to-view-converters~convertSelectionAttribute attribute converter}
  56. * for selection attributes,
  57. * which wraps collapsed selection into view elements. Those converters together ensure, that selection ends up in
  58. * appropriate attribute elements.
  59. *
  60. * See also {@link module:engine/conversion/model-selection-to-view-converters~clearAttributes} which does a clean-up
  61. * by merging attributes.
  62. *
  63. * @returns {Function} Selection converter.
  64. */
  65. export function convertCollapsedSelection() {
  66. return ( evt, data, consumable, conversionApi ) => {
  67. const selection = data.selection;
  68. if ( !selection.isCollapsed ) {
  69. return;
  70. }
  71. if ( !consumable.consume( selection, 'selection' ) ) {
  72. return;
  73. }
  74. const modelPosition = selection.getFirstPosition();
  75. const viewPosition = conversionApi.mapper.toViewPosition( modelPosition );
  76. const brokenPosition = viewWriter.breakAttributes( viewPosition );
  77. conversionApi.viewSelection.removeAllRanges();
  78. conversionApi.viewSelection.addRange( new ViewRange( brokenPosition, brokenPosition ) );
  79. };
  80. }
  81. /**
  82. * Function factory, creates a converter that converts {@link module:engine/model/selection~Selection model selection} attributes to
  83. * {@link module:engine/view/attributeelement~AttributeElement view attribute elements}. The converter works only for collapsed selection.
  84. * The converter consumes appropriate value from `consumable` object, maps model selection position to view position and
  85. * wraps that position into a view attribute element.
  86. *
  87. * The wrapping node depends on passed parameter. If {@link module:engine/view/element~Element} was passed, it will be cloned and
  88. * the copy will become the wrapping element. If `Function` is provided, it is passed all the parameters of the
  89. * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:selectionAttribute selectionAttribute event}.
  90. * It's expected that the function returns a {@link module:engine/view/attributeelement~AttributeElement}.
  91. * The result of the function will be the wrapping element.
  92. *
  93. * modelDispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
  94. *
  95. * function styleElementCreator( styleValue ) {
  96. * if ( styleValue == 'important' ) {
  97. * return new ViewAttributeElement( 'strong', { style: 'text-transform:uppercase;' } );
  98. * } else if ( styleValue == 'gold' ) {
  99. * return new ViewAttributeElement( 'span', { style: 'color:yellow;' } );
  100. * }
  101. * }
  102. * modelDispatcher.on( 'selectionAttribute:style', convertSelectionAttribute( styleCreator ) );
  103. * modelDispatcher.on( 'selection', convertCollapsedSelection() );
  104. * modelDispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
  105. * modelDispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'strong' ) ) );
  106. *
  107. * Example of view states before and after converting collapsed selection:
  108. *
  109. * <p><em>f^oo</em>bar</p>
  110. * -> <p><em>f</em>^<em>oo</em>bar</p>
  111. * -> <p><em>f^oo</em>bar</p>
  112. *
  113. * Example of view state after converting collapsed selection. The scenario is: selection is inside bold text (`<strong>` element)
  114. * but it does not have bold attribute itself and has italic attribute instead (let's assume that user turned off bold and turned
  115. * on italic with selection collapsed):
  116. *
  117. * <p><strong>f^oo<strong>bar</p>
  118. * -> <p><strong>f</strong>^<strong>oo<strong>bar</p>
  119. * -> <p><strong>f</strong><em>^</em><strong>oo</strong>bar</p>
  120. *
  121. * In first example, nothing has changed, because first `<em>` element got broken by `convertCollapsedSelection()` converter,
  122. * but then it got wrapped-back by `convertSelectionAttribute()` converter. In second example, notice how `<strong>` element
  123. * is broken to prevent putting selection in it, since selection has no `bold` attribute.
  124. *
  125. * @param {module:engine/view/attributeelement~AttributeElement|Function} elementCreator View element,
  126. * or function returning a view element, which will be used for wrapping.
  127. * @returns {Function} Selection converter.
  128. */
  129. export function convertSelectionAttribute( elementCreator ) {
  130. return ( evt, data, consumable, conversionApi ) => {
  131. const viewElement = elementCreator instanceof ViewElement ?
  132. elementCreator.clone( true ) :
  133. elementCreator( data.value, data, data.selection, consumable, conversionApi );
  134. if ( !viewElement ) {
  135. return;
  136. }
  137. const consumableName = 'selectionAttribute:' + data.key;
  138. wrapCollapsedSelectionPosition( data.selection, conversionApi.viewSelection, viewElement, consumable, consumableName );
  139. };
  140. }
  141. /**
  142. * Performs similar conversion as {@link ~convertSelectionAttribute}, but depends on a marker name of a marker in which
  143. * collapsed selection is placed.
  144. *
  145. * modelDispatcher.on( 'selectionMarker:searchResult', convertSelectionMarker( { class: 'search' } ) );
  146. *
  147. * @see module:engine/conversion/model-selection-to-view-converters~convertSelectionAttribute
  148. * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} highlightDescriptor Highlight
  149. * descriptor object or function returning a descriptor object.
  150. * @returns {Function} Selection converter.
  151. */
  152. export function convertSelectionMarker( highlightDescriptor ) {
  153. return ( evt, data, consumable, conversionApi ) => {
  154. const descriptor = typeof highlightDescriptor == 'function' ?
  155. highlightDescriptor( data, consumable, conversionApi ) :
  156. highlightDescriptor;
  157. if ( !descriptor ) {
  158. return;
  159. }
  160. if ( !descriptor.id ) {
  161. descriptor.id = data.markerName;
  162. }
  163. const viewElement = createViewElementFromHighlightDescriptor( descriptor );
  164. const consumableName = 'selectionMarker:' + data.markerName;
  165. wrapCollapsedSelectionPosition( data.selection, conversionApi.viewSelection, viewElement, consumable, consumableName );
  166. };
  167. }
  168. // Helper function for `convertSelectionAttribute` and `convertSelectionMarker`, which perform similar task.
  169. function wrapCollapsedSelectionPosition( modelSelection, viewSelection, viewElement, consumable, consumableName ) {
  170. if ( !modelSelection.isCollapsed ) {
  171. return;
  172. }
  173. if ( !consumable.consume( modelSelection, consumableName ) ) {
  174. return;
  175. }
  176. let viewPosition = viewSelection.getFirstPosition();
  177. // This hack is supposed to place attribute element *after* all ui elements if the attribute element would be
  178. // the only non-ui child and thus receive a block filler.
  179. // This is needed to properly render ui elements. Block filler is a <br /> element. If it is placed before
  180. // UI element, the ui element will most probably be incorrectly rendered (in next line). #1072.
  181. if ( shouldPushAttributeElement( viewPosition.parent ) ) {
  182. viewPosition = viewPosition.getLastMatchingPosition( value => value.item.is( 'uiElement' ) );
  183. }
  184. // End of hack.
  185. viewPosition = viewWriter.wrapPosition( viewPosition, viewElement );
  186. viewSelection.removeAllRanges();
  187. viewSelection.addRange( new ViewRange( viewPosition, viewPosition ) );
  188. }
  189. function shouldPushAttributeElement( parent ) {
  190. if ( !parent.is( 'element' ) ) {
  191. return false;
  192. }
  193. for ( const child of parent.getChildren() ) {
  194. if ( !child.is( 'uiElement' ) ) {
  195. return false;
  196. }
  197. }
  198. return true;
  199. }
  200. /**
  201. * Function factory, creates a converter that clears artifacts after the previous
  202. * {@link module:engine/model/selection~Selection model selection} conversion. It removes all empty
  203. * {@link module:engine/view/attributeelement~AttributeElement view attribute elements} and merge sibling attributes at all start and end
  204. * positions of all ranges.
  205. *
  206. * <p><strong>^</strong></p>
  207. * -> <p>^</p>
  208. *
  209. * <p><strong>foo</strong>^<strong>bar</strong>bar</p>
  210. * -> <p><strong>foo^bar<strong>bar</p>
  211. *
  212. * <p><strong>foo</strong><em>^</em><strong>bar</strong>bar</p>
  213. * -> <p><strong>foo^bar<strong>bar</p>
  214. *
  215. * This listener should be assigned before any converter for the new selection:
  216. *
  217. * modelDispatcher.on( 'selection', clearAttributes() );
  218. *
  219. * See {@link module:engine/conversion/model-selection-to-view-converters~convertCollapsedSelection}
  220. * which do the opposite by breaking attributes in the selection position.
  221. *
  222. * @returns {Function} Selection converter.
  223. */
  224. export function clearAttributes() {
  225. return ( evt, data, consumable, conversionApi ) => {
  226. for ( const range of conversionApi.viewSelection.getRanges() ) {
  227. // Not collapsed selection should not have artifacts.
  228. if ( range.isCollapsed ) {
  229. // Position might be in the node removed by the view writer.
  230. if ( range.end.parent.document ) {
  231. viewWriter.mergeAttributes( range.start );
  232. }
  233. }
  234. }
  235. conversionApi.viewSelection.removeAllRanges();
  236. };
  237. }
  238. /**
  239. * Function factory, creates a converter that clears fake selection marking after the previous
  240. * {@link module:engine/model/selection~Selection model selection} conversion.
  241. */
  242. export function clearFakeSelection() {
  243. return ( evt, data, consumable, conversionApi ) => conversionApi.viewSelection.setFake( false );
  244. }