converters.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. /**
  2. * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module image/image/converters
  7. */
  8. import ModelDocumentFragment from '@ckeditor/ckeditor5-engine/src/model/documentfragment';
  9. /**
  10. * Returns a function that converts the image view representation:
  11. *
  12. * <figure class="image"><img src="..." alt="..."></img></figure>
  13. *
  14. * to the model representation:
  15. *
  16. * <image src="..." alt="..."></image>
  17. *
  18. * The entire content of the `<figure>` element except the first `<img>` is being converted as children
  19. * of the `<image>` model element.
  20. *
  21. * @returns {Function}
  22. */
  23. export function viewFigureToModel() {
  24. return ( evt, data, consumable, conversionApi ) => {
  25. // Do not convert if this is not an "image figure".
  26. if ( !consumable.test( data.input, { name: true, class: 'image' } ) ) {
  27. return;
  28. }
  29. // Do not convert if image cannot be placed in model at this context.
  30. if ( !conversionApi.schema.check( { name: 'image', inside: data.context, attributes: 'src' } ) ) {
  31. return;
  32. }
  33. // Find an image element inside the figure element.
  34. const viewImage = Array.from( data.input.getChildren() ).find( viewChild => viewChild.is( 'img' ) );
  35. // Do not convert if image element is absent, is missing src attribute or was already converted.
  36. if ( !viewImage || !viewImage.hasAttribute( 'src' ) || !consumable.test( viewImage, { name: true } ) ) {
  37. return;
  38. }
  39. // Convert view image to model image.
  40. const modelImage = conversionApi.convertItem( viewImage, consumable, data );
  41. // Convert rest of figure element's children, but in the context of model image, because those converted
  42. // children will be added as model image children.
  43. data.context.push( modelImage );
  44. const modelChildren = conversionApi.convertChildren( data.input, consumable, data );
  45. data.context.pop();
  46. // Add converted children to model image.
  47. data.batch.insert( modelChildren, modelImage );
  48. // Set model image as conversion result.
  49. data.output = modelImage;
  50. };
  51. }
  52. /**
  53. * Creates the image attribute converter for provided model conversion dispatchers.
  54. *
  55. * @param {Array.<module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher>} dispatchers
  56. * @param {String} attributeName
  57. * @param {Function} [converter] Custom converter for the attribute - default one converts attribute from model `image` element
  58. * to the same attribute in `img` in the view.
  59. */
  60. export function createImageAttributeConverter( dispatchers, attributeName, converter = modelToViewAttributeConverter ) {
  61. for ( const dispatcher of dispatchers ) {
  62. dispatcher.on( `addAttribute:${ attributeName }:image`, converter() );
  63. dispatcher.on( `changeAttribute:${ attributeName }:image`, converter() );
  64. dispatcher.on( `removeAttribute:${ attributeName }:image`, converter() );
  65. }
  66. }
  67. /**
  68. * Converter used to convert `srcset` model image's attribute to `srcset`, `sizes` and `width` attributes in the view.
  69. *
  70. * @return {Function}
  71. */
  72. export function srcsetAttributeConverter() {
  73. return ( evt, data, consumable, conversionApi ) => {
  74. const parts = evt.name.split( ':' );
  75. const consumableType = parts[ 0 ] + ':' + parts[ 1 ];
  76. const modelImage = data.item;
  77. if ( !consumable.consume( modelImage, consumableType ) ) {
  78. return;
  79. }
  80. const figure = conversionApi.mapper.toViewElement( modelImage );
  81. const img = figure.getChild( 0 );
  82. const type = parts[ 0 ];
  83. if ( type == 'removeAttribute' ) {
  84. const srcset = data.attributeOldValue;
  85. if ( srcset.data ) {
  86. img.removeAttribute( 'srcset' );
  87. img.removeAttribute( 'sizes' );
  88. if ( srcset.width ) {
  89. img.removeAttribute( 'width' );
  90. }
  91. }
  92. } else {
  93. const srcset = data.attributeNewValue;
  94. if ( srcset.data ) {
  95. img.setAttribute( 'srcset', srcset.data );
  96. // Always outputting `100vw`. See https://github.com/ckeditor/ckeditor5-image/issues/2.
  97. img.setAttribute( 'sizes', '100vw' );
  98. if ( srcset.width ) {
  99. img.setAttribute( 'width', srcset.width );
  100. }
  101. }
  102. }
  103. };
  104. }
  105. // Returns model to view image converter converting given attribute, and adding it to `img` element nested inside `figure` element.
  106. //
  107. // @private
  108. function modelToViewAttributeConverter() {
  109. return ( evt, data, consumable, conversionApi ) => {
  110. const parts = evt.name.split( ':' );
  111. const consumableType = parts[ 0 ] + ':' + parts[ 1 ];
  112. const modelImage = data.item;
  113. if ( !consumable.consume( modelImage, consumableType ) ) {
  114. return;
  115. }
  116. const figure = conversionApi.mapper.toViewElement( modelImage );
  117. const img = figure.getChild( 0 );
  118. const type = parts[ 0 ];
  119. if ( type == 'removeAttribute' ) {
  120. img.removeAttribute( data.attributeKey );
  121. } else {
  122. img.setAttribute( data.attributeKey, data.attributeNewValue );
  123. }
  124. };
  125. }
  126. // Holds all images that were converted for autohoisting.
  127. const autohoistedImages = new WeakSet();
  128. /**
  129. * A converter which converts `<img>` {@link module:engine/view/element~Element view elements} that can be hoisted.
  130. *
  131. * If an `<img>` view element has not been converted, this converter checks if that element could be converted in any
  132. * context "above". If it could, the converter converts the `<img>` element even though it is not allowed in the current
  133. * context and marks it to be autohoisted. Then {@link module:image/image/converters~hoistImageThroughElement another converter}
  134. * moves the converted element to the correct location.
  135. */
  136. export function convertHoistableImage( evt, data, consumable, conversionApi ) {
  137. const img = data.input;
  138. // If the image has not been consumed (converted)...
  139. if ( !consumable.test( img, { name: true, attribute: [ 'src' ] } ) ) {
  140. return;
  141. }
  142. // At this point the image has not been converted because it was not allowed by schema. It might be in wrong
  143. // context or missing an attribute, but above we already checked whether the image has mandatory src attribute.
  144. // If the image would be allowed if it was in one of its ancestors...
  145. const allowedContext = _findAllowedContext( { name: 'image', attributes: [ 'src' ] }, data.context, conversionApi.schema );
  146. if ( !allowedContext ) {
  147. return;
  148. }
  149. // Convert it in that context...
  150. const newData = Object.assign( {}, data );
  151. newData.context = allowedContext;
  152. data.output = conversionApi.convertItem( img, consumable, newData );
  153. // And mark that image to be hoisted.
  154. autohoistedImages.add( data.output );
  155. }
  156. // Basing on passed `context`, searches for "closest" context in which model element represented by `modelData`
  157. // would be allowed by `schema`.
  158. //
  159. // @private
  160. // @param {Object} modelData Object describing model element to check. Has two properties: `name` with model element name
  161. // and `attributes` with keys of attributes of that model element.
  162. // @param {Array} context Context in which original conversion was supposed to take place.
  163. // @param {module:engine/model/schema~Schema} schema Schema to check with.
  164. // @returns {Array|null} Context in which described model element would be allowed by `schema` or `null` if such context
  165. // could not been found.
  166. function _findAllowedContext( modelData, context, schema ) {
  167. // Copy context array so we won't modify original array.
  168. context = context.slice();
  169. // Prepare schema query to check with schema.
  170. // Since `inside` property is passed as reference to `context` variable, we don't need to modify `schemaQuery`.
  171. const schemaQuery = {
  172. name: modelData.name,
  173. attributes: modelData.attributes,
  174. inside: context
  175. };
  176. // Try out all possible contexts.
  177. while ( context.length && !schema.check( schemaQuery ) ) {
  178. const parent = context.pop();
  179. const parentName = typeof parent === 'string' ? parent : parent.name;
  180. // Do not try to autohoist "above" limiting element.
  181. if ( schema.limits.has( parentName ) ) {
  182. return null;
  183. }
  184. }
  185. // If `context` has any items it means that image is allowed in that context. Return that context.
  186. // If `context` has no items it means that image was not allowed in any of possible contexts. Return `null`.
  187. return context.length ? context : null;
  188. }
  189. /**
  190. * A converter which hoists `<image>` {@link module:engine/model/element~Element model elements} to allowed context.
  191. *
  192. * It looks through all children of the converted {@link module:engine/view/element~Element view element} if it
  193. * was converted to a model element. It breaks the model element if an `<image>` to-be-hoisted is found.
  194. *
  195. * <div><paragraph>x<image src="foo.jpg"></image>x</paragraph></div> ->
  196. * <div><paragraph>x</paragraph></div><image src="foo.jpg"></image><div><paragraph>x</paragraph></div>
  197. *
  198. * This works deeply, as shown in the example. This converter added for the `<paragraph>` element will break the `<paragraph>`
  199. * element and pass the {@link module:engine/model/documentfragment~DocumentFragment document fragment} in `data.output`.
  200. * Then, the `<div>` will be handled by this converter and will be once again broken to hoist the `<image>` up to the root.
  201. *
  202. * **Note:** This converter should be executed only after the view element has already been converted, which means that
  203. * `data.output` for that view element should be already generated when this converter is fired.
  204. */
  205. export function hoistImageThroughElement( evt, data ) {
  206. // If this element has been properly converted...
  207. if ( !data.output ) {
  208. return;
  209. }
  210. // And it is an element...
  211. // (If it is document fragment autohoisting does not have to break anything anyway.)
  212. // (And if it is text there are no children here.)
  213. if ( !data.output.is( 'element' ) ) {
  214. return;
  215. }
  216. // This will hold newly generated output. At the beginning it is only the original element.
  217. const newOutput = [];
  218. // Check if any of its children is to be hoisted...
  219. // Start from the last child - it is easier to break that way.
  220. for ( let i = data.output.childCount - 1; i >= 0; i-- ) {
  221. const child = data.output.getChild( i );
  222. if ( autohoistedImages.has( child ) ) {
  223. // Break autohoisted element's parent:
  224. // <parent>{ left-children... }<authoistedElement />{ right-children... }</parent> --->
  225. // <parent>{ left-children... }</parent><autohoistedElement /><parent>{ right-children... }</parent>
  226. //
  227. // or
  228. //
  229. // <parent>{ left-children... }<autohoistedElement /></parent> --->
  230. // <parent>{ left-children... }</parent><autohoistedElement />
  231. //
  232. // or
  233. //
  234. // <parent><autohoistedElement />{ right-children... }</parent> --->
  235. // <autohoistedElement /><parent>{ right-children... }</parent>
  236. //
  237. // or
  238. //
  239. // <parent><autohoistedElement /></parent> ---> <autohoistedElement />
  240. // Check how many right-children there are.
  241. const rightChildrenCount = data.output.childCount - i - 1;
  242. let rightParent = null;
  243. // If there are any right-children, clone the prent element and insert those children there.
  244. if ( rightChildrenCount > 0 ) {
  245. rightParent = data.output.clone( false );
  246. rightParent.appendChildren( data.output.removeChildren( i + 1, rightChildrenCount ) );
  247. }
  248. // Remove the autohoisted element from its parent.
  249. child.remove();
  250. // Break "leading" `data.output` in `newOutput` into one or more pieces:
  251. // Remove "leading" `data.output` (note that `data.output` is always first item in `newOutput`).
  252. newOutput.shift();
  253. // Add the newly created parent of the right-children at the beginning.
  254. if ( rightParent ) {
  255. newOutput.unshift( rightParent );
  256. }
  257. // Add autohoisted element at the beginning.
  258. newOutput.unshift( child );
  259. // Add `data.output` at the beginning, if there is anything left in it.
  260. if ( data.output.childCount > 0 ) {
  261. newOutput.unshift( data.output );
  262. }
  263. }
  264. }
  265. // If the output has changed pass it further.
  266. if ( newOutput.length ) {
  267. data.output = new ModelDocumentFragment( newOutput );
  268. }
  269. }