How to Convert any HTML Element to Image using VueJs 3
Tags: vue, htmltoimage, domtoimage, converthtmlelementtoimage
Published: 2021-01-26 12:55:10

In my quest to upgrade my portfolio for tech recruiters I decided to work on some inspirational and unconventional projects using a myriad of tools. I started that adventure with just a single page application in VueJs 3.

I was faced with quite some challenges in making the project a reality. Firstly, I went on research for some API, Vue Libraries, and the best approach to fulfill the project. I was initially satisfied with the information I had amassed for the project.

But right in the middle of the project, I observed that VueJs 3 had some changes with the way third-party libraries are being used, this was a shocker to me and it took me a while to get around with it.

The following codes will show you what I did to achieve my goal.

For images, I used the image API of [Pixabay](https://pixabay.com). I used the [dom-to-image-more](https://www.npmjs.com/package/dom-to-image-more) npm package for converting Dom elements to images. **My goal was to build an app that converts a written quote into an image.**

I designed the app with the following components and folder structure.

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/wb0iguhzxgdti25bh43a.png)

My app contained the app component along with five children components for the hero, result, lab, imager, and footer with each having its own functionality.

The hero component has the following codes.

```javascript
<template>
  <div class="hero__container">
    <form
      @submit.prevent="onSubmit"
      class="hero__form"
      :style="{
        backgroundImage: `url(${require('@/assets/images/search.png')})`,
      }"
    >
      <div class="inner-form">
        <div class="input-field first-wrap">
          <div class="svg-wrapper">
            <svg
              xmlns="http://www.w3.org/2000/svg"
              width="24"
              height="24"
              viewBox="0 0 24 24"
            >
              <path
                d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
              ></path>
            </svg>
          </div>
          <input
            id="search"
            type="text"
            placeholder="What are you looking for?"
            v-model="keyword"
          />
        </div>
        <div class="input-field second-wrap">
          <button class="btn-search" type="submit">
            {{ searching ? "SEARCHING..." : "SEARCH" }}
          </button>
        </div>
      </div>
      <span class="info">ex. Love, Road, Flower, Garden, House</span>
    </form>
  </div>
</template>

<script>
import { mapActions, mapMutations } from "vuex";

export default {
  data() {
    return {
      keyword: "",
      searching: false,
    };
  },
  methods: {
    ...mapActions(["searchImages"]),
    ...mapMutations(["setKeyword"]),

    onSubmit() {
      this.searching = true;
      const keyword = this.keyword.split(" ").join("+");
      this.searchImages({ keyword })
        .then(() => this.setKeyword(keyword))
        .catch((error) => console.log(error))
        .finally(() => (this.searching = false))
    },
  },
};
</script>

<style scoped>
.hero__container {
  min-height: 50vh;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-pack: center;
  justify-content: center;
  font-family: poppins, sans-serif;
  background-position: bottom right;
  background-repeat: no-repeat;
  background-size: 100%;
  padding: 15px;
}

.hero__form {
  width: 100%;
  max-width: 790px;
  padding-top: 24vh;
}

.hero__form .inner-form {
  display: -ms-flexbox;
  display: flex;
  width: 100%;
  -ms-flex-pack: justify;
  justify-content: space-between;
  -ms-flex-align: center;
  align-items: center;
  box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
  border-radius: 34px;
  overflow: hidden;
  margin-bottom: 30px;
}

.hero__form .inner-form .input-field.first-wrap {
  -ms-flex-positive: 1;
  flex-grow: 1;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-align: center;
  align-items: center;
  background: #d9f1e3;
}

.hero__form .inner-form .input-field.first-wrap .svg-wrapper {
  min-width: 80px;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-pack: center;
  justify-content: center;
  -ms-flex-align: center;
  align-items: center;
}

.hero__form .inner-form .input-field input {
  height: 100%;
  background: 0 0;
  border: 0;
  display: block;
  width: 100%;
  padding: 10px 0;
  font-size: 16px;
  color: #000;
}

.hero__form .inner-form .input-field.second-wrap {
  min-width: 216px;
}

.hero__form .info {
  font-size: 15px;
  color: #ccc;
  padding-left: 26px;
}

.hero__form .inner-form .input-field.second-wrap .btn-search {
  height: 100%;
  width: 100%;
  white-space: nowrap;
  font-size: 16px;
  color: #fff;
  border: 0;
  cursor: pointer;
  position: relative;
  z-index: 0;
  background: #00ad5f;
  transition: all 0.2s ease-out, color 0.2s ease-out;
  font-weight: 300;
}

.hero__form .inner-form .input-field.second-wrap {
  min-width: 100px;
}

.hero__form .inner-form .input-field {
  height: 68px;
}

@media screen and (max-width: 992px) {
  .hero__form .inner-form .input-field {
    height: 50px;
  }
}
</style>

```
With this output:

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/cg5liewawededwdkn25h.png)

The hero component searches for images from [Pixabay](https://pixabay.com) API and sends them to the state management system Vuex.

```javascript
import axios from 'axios'
import { createStore } from "vuex"

const store = createStore({
  state: {
    images: [],
    image: null,
    selected: null,
    keyword: ''
  },
  mutations: {
    images: (state, payload) => (state.images.push(...payload)),
    search: (state, payload) => (state.images = payload),
    setKeyword: (state, payload) => (state.keyword = payload),
    selected: (state, payload) => (state.selected = payload),
    setImage: (state, payload) => (state.image = payload),
  },
  actions: {
    setSelected: (state, payload) => state.commit('selected', payload),
    loadImages(state, payload = {}) {
      const uri = `https://pixabay.com/api/?key=APIKEY&q=${payload?.keyword || '%27%27=photo'}&image_type=photo&pretty=true&safesearch=true&page=${payload?.page || 1}&per_page=6`
        
        return new Promise((respond, reject) => {
            axios.get(uri)
            .then((response) => {
                state.commit("images", response.data.hits)
                respond()
            })
            .catch(() => reject());
        })
    },
    searchImages(state, payload = {}) {
      const uri = `https://pixabay.com/api/?key=APIKEY&q=${payload?.keyword || '%27%27=photo'}&image_type=photo&pretty=true&safesearch=true&page=${payload?.page || 1}&per_page=6`
      
      return new Promise((respond, reject) => {
          axios.get(uri)
          .then((response) => {
              state.commit("search", response.data.hits)
              respond()
          })
          .catch(() => reject());
      })
  },
  },
  getters: {
    images: state => state.images,
    image: state => state.image,
    selected: state => state.selected,
    keyword: state => state.keyword,
  }
})

export default store
```

The result component retrieves the images from the Vuex store and renders them to the view.

```javascript
<template>
  <div class="result__container">
    <div
      class="result__card"
      v-for="image in images"
      :key="image.id"
    >
      <img
        class="result__image"
        :src="image.largeImageURL"
        :alt="image.tags"
        :class="{ result_selected: image == selected }"
        @click="onSelected(image)"
      />
    </div>
    <div class="break"></div>
    <button class="result__btn" @click="loadMore" :disabled="loading">{{ loading ? 'Loading...' : 'Load More' }}</button>
  </div>
</template>

<script>
import { mapGetters, mapActions} from 'vuex'

export default {
  name: "app-result",
  data() {
    return {
      page: 1,
      loading: false,
    };
  },
  created() {
    this.onLoadImages()
  },
  methods: {
    ...mapActions([
      'setSelected',
      'loadImages'
    ]),

    onSelected(image) {
      this.setSelected(image);
    },

    onLoadImages() {
      this.loading = true
      this.loadImages()
      .catch(error => console.log(error))
      .finally(() => this.loading = false)
    },

    loadMore() {
      this.loading = true
      this.loadImages({page: ++this.page, keyword: this.keyword})
      .catch(error => console.log(error))
      .finally(() => this.loading = false)
    }
  },
  computed: {
    ...mapGetters([
      'images',
      'selected',
      'keyword',
    ])
  }
};
</script>

<style scoped>
.result__container {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-around;
  max-width: 80%;
  margin: 50px auto;
}

.result__card {
  width: 30%;
  margin-top: 20px;
}

.result__image {
  flex-grow: 1;
  object-fit: cover;
  width: 100%;
  max-height: 250px;
  min-height: 250px;
  transition: all 0.2s ease-in-out;
  box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
  cursor: pointer;
  overflow: hidden;
}

.result__image:hover {
  transform: scale(1.11);
  box-shadow: 0 8px 20px 0 rgba(0, 173, 95, 0.3);
  border-radius: 9px;
}

.result_selected {
  transform: scale(1.11);
  box-shadow: 0 8px 20px 0 #ffc107;
  border-radius: 9px;
}

.result_selected:hover {
  box-shadow: 0 8px 20px 0 #ffc107;
}

.result__btn {
  border: transparent;
  cursor: pointer;
  padding: 10px;
  background: #00ad5f;
  color: white;
  border-radius: 9px;
  box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
  transition: all 0.2s ease-in-out;
  margin-top: 40px;
}

.result__btn:hover {
  background: transparent;
  color: #00ad5f;
}

.break {
  flex-basis: 100%;
  height: 0;
}

@media (max-width: 992px) {
  .result__card {
    width: 40%;
  }
}

@media (max-width: 768px) {
  .result__card {
    width: 90%;
  }
}
</style>
```

And this is the output:

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/sjz0sv7e1lmbokw794v8.png)

The Lab component utilizes a single image from the image array once a user selects an image and joins it with a supplied quote and also converts it to a base64 image string and saves it in the VueJs Store.

```javascript
<template>
  <div class="lab__container">
    <div
      ref="capture"
      :style="{
        background: `linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)),
            url(${image}) no-repeat center center / cover`,
      }"
      class="lab__card"
    >
      <p>{{ quote }}</p>
      <small>{{ name }}</small>
    </div>
    <div class="lab__tools">
      <h4>Eith Quote</h4>
      <textarea
        class="lab__form"
        placeholder="Type your quote here, be nice!"
        cols="30"
        rows="10"
        v-model="quote"
        maxlength="150"
      ></textarea>
      <input
        class="lab__form"
        type="text"
        placeholder="Your name"
        v-model="name"
      />
      <button class="lab__btn" @click="onCapture" :disabled="capturing">
        {{ capturing ? "Capturing..." : "Capture" }}
      </button>
    </div>
  </div>
</template>

<script>
import { mapGetters, mapMutations } from "vuex";
import domtoimage from "dom-to-image-more";

export default {
  name: "app-lab",
  data() {
    return {
      name: "",
      quote: "",
      image: "",
      capturing: false,
    };
  },
  methods: {
    ...mapMutations(["setImage"]),
    onCapture() {
      this.capturing = true;
      const capture = this.$refs.capture;

      domtoimage
        .toPng(capture)
        .then((dataUrl) => {
          this.setImage(dataUrl);
          this.capturing = false;
        })
        .catch((error) => {
          this.capturing = false;
          console.error("oops, something went wrong!", error);
        });
    },
  },
  computed: {
    ...mapGetters(["selected"]),
  },
  watch: {
    selected() {
      this.image = this.selected.largeImageURL;
    },
  },
};
</script>

<style scoped>
.lab__container {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-around;
  max-width: 100%;
  min-height: 448px;
  padding: 40px 50px;
  background-color: #d9f1e3;
}

.lab__card {
  width: 30%;
  width: 300px;
  height: 400px;
  border-radius: 9px;
  padding: 20px;
  object-fit: contain;
  transition: transform 0.45s;
  color: white;
  box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
}

.lab__card > p {
  font-size: 30px;
  font-style: italic;
  margin-bottom: 10px;
}

.lab__card > small {
  font-style: bold;
  float: right;
}

.lab__tools {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-around;
  width: 50%;
  background-color: #c0decd;
  border-radius: 9px;
  padding: 20px 50px;
}

.lab__tools > * {
  margin: 10px 0;
  width: 100%;
}

.lab__tools > textarea {
  width: 100%;
}

.lab__form {
  border: none;
  border-radius: 9px;
  padding: 10px;
  box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
}

.lab__btn {
  border: none;
  cursor: pointer;
  padding: 10px;
  background: #ffc107;
  color: white;
  border-radius: 9px;
  box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
  transition: all 0.2s ease-in-out;
}

.lab__btn:hover {
  background: transparent;
}

@media screen and (max-width: 992px) {
  .lab__tools,
  .lab__card {
    width: 100%;
  }

  .lab__tools {
    margin: 40px 0;
  }

  .lab__card > p {
    padding: 0.5rem 0;
  }
}

@media screen and (max-width: 410px) {
  .lab__card > p {
    font-size: 25px;
  }
}
</style>
```

This is the output:

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/80xsxk59gc0h1fvtkq56.png)

The imager component takes the saved image from the VueJs store and renders it to the view once the Lab component has done its job.

```javascript
<template>
  <div class="imager__container">
    <img :src="image" alt="imager" class="imager__image" />
    <div class="imager__btns">
      <a class="imager__btn__download" :href="image" download="quote_result.png"
        >Download</a
      >
      <button class="imager__btn__delete" @click="setImage('')">Delete</button>
    </div>
  </div>
</template>

<script>
import { mapGetters, mapMutations } from "vuex";
export default {
  name: "imager",
  methods: {
    ...mapMutations(["setImage"]),
  },
  computed: {
    ...mapGetters(["image"]),
  },
};
</script>

<style scoped>
.imager__container {
  display: grid;
  justify-content: space-around;
  max-width: 100%;
  min-height: 448px;
  padding: 40px 50px;
  background-color: white;
}

.imager__image {
  box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
}

.imager__btns {
  text-align: center;
}

.imager__btn__download,
.imager__btn__delete {
  border: transparent;
  cursor: pointer;
  padding: 10px;
  border-radius: 9px;
  box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
  transition: all 0.2s ease-in-out;
  margin: 40px 5px;
  text-decoration: none;
}

.imager__btn__download {
  background: #00ad5f;
  color: white;
}

.imager__btn__delete {
  background: white;
  color: #00ad5f;
}

.imager__btn__download:hover {
  background: white;
  color: #00ad5f;
}

.imager__btn__delete:hover {
  background: #00ad5f;
  color: white;
}
</style>
```

This is the output of the imager component:

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/cfudpna97fh74cty9aqf.png)

Lastly, we complemented the app with a footer which you can see below.

```javascript
<template>
  <div class="footer__container">
      <div class="footer__copywrite">
        <p>&copy; 2021 All right reserved</p>
        <small>Built by <a href="#">Gospel Darlington</a></small>
      </div>
  </div>
</template>

<script>
export default {

}
</script>

<style scoped>
    .footer__container {
        display: inline-block;
        text-align: center;
        width: 100%;
        background: #4d5458;
        padding: 10px;
    }

    .footer__copywrite  {
        color: white;
        padding: 10px 0;
    }

    .footer__copywrite > small > a  {
        color: orange;
        text-decoration: none;
        transition: all .2s ease-in-out;
    }

    .footer__copywrite > small > a:hover  {
        color: green;
    }
</style>
```

All these components were then brought together by the main component which is the App.vue file.

```javascript
<template>
  <div>
    <Hero/>
    <Result/>
    <Lab/>
    <Imager v-if="captured"/>
    <Footer/>
  </div>
</template>

<script>
import Hero from './components/Hero.vue'
import Result from './components/Result.vue'
import Lab from './components/Lab.vue'
import Imager from './components/Imager.vue'
import Footer from './components/Footer.vue'
import { mapGetters } from 'vuex'

export default {
  name: 'App',
  data() {
    return {
      captured: false
    }
  },
  components: {
    Hero,
    Result,
    Lab,
    Imager,
    Footer,
  },
  computed: {
    ...mapGetters([
      'image'
    ])
  },
  watch: {
    image() {
      this.captured = !!this.image 
    }
  }
}
</script>
<style>
  * {
    margin: 0;
    box-sizing: border-box;
  }
</style>
```

You can see the [demo](https://quotes-lab.web.app/) and the git repo [here](https://github.com/Daltonic/quoteLab)!