DEV Community

Cover image for How to Convert any HTML Element to Image using VueJs 3
Gospel Darlington
Gospel Darlington

Posted on

How to Convert any HTML Element to Image using VueJs 3

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. I used the 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

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.

<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>

Enter fullscreen mode Exit fullscreen mode

With this output:

Alt Text

The hero component searches for images from Pixabay API and sends them to the state management system Vuex.

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
Enter fullscreen mode Exit fullscreen mode

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

<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>
Enter fullscreen mode Exit fullscreen mode

And this is the output:

Alt Text

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.

<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>
Enter fullscreen mode Exit fullscreen mode

This is the output:

Alt Text

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.

<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>
Enter fullscreen mode Exit fullscreen mode

This is the output of the imager component:

Alt Text

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

<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>
Enter fullscreen mode Exit fullscreen mode

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

<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>
Enter fullscreen mode Exit fullscreen mode

You can see the demo and the git repo here!

Discussion (3)

Collapse
stritti profile image
Stephan Strittmatter

Great demo. I already wanted to do something like this to share an image of current Covid incidences within my widget: github.com/stritti/covid-ampel-widget
I will try to follow your article in the next days.

Collapse
daltonic profile image
Gospel Darlington Author

Thanks man, highly appreciated!

Collapse
daltonic profile image
Gospel Darlington Author

Thanks man, highly appreciated!