Toggle component properties based on loading, load/error, image size


#1

Really struggling with this one. I have a component responsible for rendering an image. I’m trying to make the component do this:

-show ‘loading’ div until image is loaded

-detect image load errors, replace src with “placeholder” image

-set property based on whether an image meets a certain size criteria (to determine whether a’larger image’ modal should function)

I’m running into all sorts of issues because when one of these state-related properties is set, the component refreshes, starting the whole process over.

Right now, I can’t even figure out how to get the “loading” state off. it seems like this could should work:

export default Component.extend({

	imageSelector: '.main-image',
	modalActive: false,
	missingImage: 'Image-Coming-Soon.jpg',
	isLoading: true,

	didRender() {
		this._super(...arguments);

		let selector = get(this, 'imageSelector');

		this.$(selector).on('error', () => {
			let missing = get(this, 'missingImage')
			set(this, 'productImage', missing);
			this.$(selector).off('error');
		});

		this.$(selector).on('load', () => {

			set(this, 'isLoading', false); // this doesn't turn off loading state
			let width = this.$(selector).get(0).naturalWidth;
			let height = this.$(selector).get(0).naturalHeight;
			if (width >= 600 || height >= 600) {
				set(this, 'modalActive', true);
			}

			this.$(selector).off('load');
		});
	},

	willUpdate() {
		// if, say, a different image on the page is clicked, reset loading state
		this._super(...arguments);
		set(this, 'isLoading', true);
	}

});

with this component:

{{#if isLoading}}
	<div class="product-image-loader">loading...</div>
{{else}}
<div class="image-container">
	<a class="modal-link" href="#" {{action (action toggleImageModal) largeImage modalActive}}>
		<img src="{{productImage}}" class="img-responsive main-image" />
	</a>
</div>
{{/if}}

If I just set all the isLoading’s to false, it comes within the realm of working, but if I put console logs in the hooks and error/load conditions I see them multiple times in the same view. Like it’s recursing because of the state-change refreshes.

I could do all this with just jquery DOM manipulation, but I guess I’m not supposed to do that anymore :wink:

I’m either approaching this all wrong, or bumping against some unknown (to me) ember limitation. Any help appreciated!


#2

If you have code that interacts with the DOM manually, you’ll want to look at the didInsertElement hook. It gets called when the component has been put in the DOM, so is only called once


#3

Yeah, I was messing with that too but it doesn’t seem to help. But now I see the reason…

isLoading: true, // by default
...
this.$(selector).on('load', () => {
   set(this, 'isLoading', true);  // NEVER GETS CALLED BECAUSE HTML ISN'T IN DOM
   ...

template:

{{#if isLoading}}
	<div>loading...</div>
{{else}}
	<img src="{{productImage}}" />
{{/if}}

You can’t test for whether an image is loaded or not without showing the HTML for it :slight_smile: The same catch-22 is in effect for the image size.

So unless ember has a lower-level way for me to check for the existence of an image, and its dimensions, I think all the logic that responds to loading, load/error, and image size will need to happen in the DOM w/o ember’s help.

Or, I’m still looking at it wrong. If there is still an “ember” way to do it, somebody let me know!


#4

There are two ways you could go with this: create an image element outside of the DOM, or make an image component. The later being more idiomatic, although they are functionally similar

In memory image

Instead of binding to the DOM, you create an image element and listen for the same events on that. Once it’s loaded the image will be in the browser cache, so setting another image element (i.e. the one in the template) with the same source will result in an instant load

let img = new Image();
let $img = this.$(img); // wrap plain element with jQuery*
$img.on('load', () => { ... });

img.src = this.get('productImage');

* You can use the plain element if you also use the standard listener method addEventListener


Image as Ember Component

Instead of having a plain image element in your template, you can have a component with an img tag. This means that it can listen to events on itself, because it is an image element. Once you get an event you can send this information up to the parent component using an action

components/async-image.js (Doesn’t have a template)

export default Component.extend({
  tagName: 'img',
  didInsertElement() {
    this.$().on('load', () => {
      let onLoad = this.get('on-load');
      let src = this.get('src');
      let width = this.$().naturalWidth();
      let height = this.$().naturalHeight();
      onLoad(src, width, height);
    });

    this.$().on('error', () => {
      let onError = this.get('on-error');
     onError();
    });
  },
});

Remember to remove the listeners on destroy

components/modal-link.js (Or whatever the parent component is called)

export default Component.extend({
  productImageSrc: Ember.computed.reads('productImage'), // default image src to productImage

  onProductImageLoad(src, width, height) {
    this.set('isLoading', false);
    if (width >= 600 || height >= 600) {
      set(this, 'modalActive', true);
    }
  },

  onProductImageError() {
    this.set('productImageSrc', this.get('missingImage')); // set image src to fallback
  },
});

templates/components/modal-link.hbs

<a class="modal-link" href="#" {{action (action toggleImageModal) largeImage modalActive}}>
    {{async-image
        src=productImageSrc
        class="img-responsive main-image"
        on-load=(action onProductImageLoad)
        on-error=(action onProductImageError)
    }}
</a>

Note that these are just examples from memory so might not work as is. Hopefully this helps point you in the right direction


#5

Very cool! Yeah I was just starting down the road of new Image() but I like the image-as-component idea better. Thanks so much for your help!