NewsletterCreator / src / pages / NewsletterEditor.vue
NewsletterEditor.vue
Raw
<template>
  <q-page class="row window-height">
    <!-- left side -->
    <div class="column col-7 q-pb-md window-height scroll hide-scrollbar" @click="this.edited = true">
      <div class="col-1">
        <div class="q-pa-md row">
          <div class="backButton">
            <q-btn round color="accent" icon="arrow_back" @click="navigateBack"/>
          </div>
          <q-input :label="$t('title')" v-model="store.newsletter.title" id="title" style="max-width: 20rem;" />
        </div>
      </div>
      <div class="col-11" style="max-width: 100%">
        <div class="q-pa-md">
          <q-list>
            <q-expansion-item v-for="(section, index) in store.newsletter.sections" class="shadow-1 overflow-hidden q-my-md accordeon" group="categories" :label="$t('section.' + section.name)"
              :aria-label="section.name" header-class="bg-primary text-white" expand-icon-class="text-white" :key="section.name" :default-opened="index === 0" ref="categories" >
              <q-card>
                <q-card-section>
                    <draggable
                      tag="ul"
                      v-model="section.elements"
                      v-bind="{ animation: 200, group: section.name }"
                      class="sectionList"
                      handle=".handle"
                      item-key="id"
                    >
                      <template #item="{ element }">
                        <li class="q-my-md" :key="element.id" :id="element.id">
                          <component :is="getElement(element.type)" :data="element.data" :id="element.id" ref="element" ></component>
                        </li>
                      </template>
                    </draggable>
                  <!-- Add new Element area -->
                  <div class="full-width flex flex-center" style="height: 3rem;">
                    <div class="full-width bg-accent rounded-borders flex flex-center" style="height: 0.5rem;">
                      <q-btn-dropdown round color="accent" dropdown-icon="add" class="position-relative addElementButton">
                        <q-list>
                          <q-item v-for="element in store.elements" :key="element.name" clickable v-close-popup @click="addElement(element.name, index)">
                            <q-item-section>
                              <q-item-label>{{ $t('element.' + element.name) }}</q-item-label>
                            </q-item-section>
                          </q-item>
                        </q-list>
                      </q-btn-dropdown>
                    </div>
                  </div>
                </q-card-section>
              </q-card>
            </q-expansion-item>
          </q-list>
        </div>
      </div>
    </div>

      <!-- right side -->
    <div class="window-height col-5">
      <div class="q-py-s row full-height full-width">
        <div class="full-width q-pr-md" style="height: calc(100% - 4rem);">
          <iframe ref="previewIframe" :srcdoc="newsletterHtml" frameborder="0" class="full-width full-height overflow-hidden shadow-2" @load="onIframeLoad"></iframe>
        </div>
        <q-page-sticky position="top-right" :offset="[30, 30]">
          <q-btn fab icon="fullscreen" color="accent" @click="fullscreen = true" class="fullscreenButton" />
        </q-page-sticky>
        <div class="full-width row">
          <q-btn color="primary" :label="$t('save')" class="saveBtn q-mr-md" @click="saveNewsletter('save')" />
          <q-btn  color="accent" :label="$t('export')" class="saveBtn" @click="saveNewsletter('export')" />
        </div>
      </div>
    </div>

    <!-- fullscreen preview -->
    <q-dialog
      v-model="fullscreen"
      persistent
      :maximized="true"
      transition-show="slide-left"
      transition-hide="slide-right"
    >
      <div class="q-pa-md">
        <q-page-sticky position="top-right" :offset="[50, 20]">
          <q-btn fab icon="close" color="accent" @click="fullscreen = false" class="fullscreenButton" />
        </q-page-sticky>
        <iframe ref="previewIframe" :srcdoc="newsletterHtml" frameborder="0" class="full-width full-height overflow-hidden shadow-2" @load="addIframeScript"></iframe>
      </div>
    </q-dialog>

    <!-- error message -->
    <q-dialog v-model="showErrorMessage" seamless position="right">
      <q-card style="width: 350px">
        <q-linear-progress :value="1" color="negative" />
        <q-card-section class="row items-center no-wrap">
          <div>
              <div class="text-weight-bold">{{ $t('errorMessage') }}</div>
            <a v-for="error in errors" class="cursor-pointer" :key="error.element" @click="scrollToElement(error.element)">
              <q-separator/>
              <div v-for="info in error.info" class="text-gray" :key="info.id" >{{ info.message }}</div>
            </a>
          </div>
          <q-space />
          <q-btn flat round icon="close" @click="showErrorMessage = false" />
        </q-card-section>
      </q-card>
    </q-dialog>

    <!-- unsaved changes dialog -->
    <q-dialog v-model="showUnsavedChanges" persistent>
      <q-card>
        <q-card-section class="row items-center">
          <q-avatar icon="save" color="primary" text-color="white" />
          <span class="q-ml-sm">{{ $t('unsavedChangesMessage') }}</span>
        </q-card-section>

        <q-card-actions align="right">
          <q-btn flat :label="$t('unsavedChangesConfirm')" color="primary" v-close-popup @click="navigateBack(e, true)"/>
          <q-btn flat :label="$t('save')" color="primary" v-close-popup @click="saveNewsletter('save')" />
        </q-card-actions>
      </q-card>
    </q-dialog>
  </q-page>
</template>

<script>
import { useNewsletterStore } from 'src/stores/newsletter.js'
import draggable from 'vuedraggable'
import bus from 'src/utils/eventBus'
import { scroll } from 'quasar'
const { getScrollTarget, setVerticalScrollPosition } = scroll

export default {
  setup () {
    const store = useNewsletterStore()
    return {
      store
    }
  },
  data () {
    return {
      showErrorMessage: false,
      fullscreen: false,
      edited: true, // tracking changes if user exits without saving
      showUnsavedChanges: false,
      newsletterHtml: '',
      // newsletter preview scroll savepoints
      scrollTop: 0,
      scrollLeft: 0
    }
  },
  components: {
    draggable
  },
  computed: {
    // get all errors from elements in an array
    errors () {
      const errors = []
      for (const section of this.store.newsletter.sections) {
        for (const element of section.elements) {
          if (element.data.errors.length > 0) {
            errors.push({ element: element.id, info: element.data.errors })
          }
        }
      }
      return errors.flat()
    },
    errorCount () {
      return this.errors.length
    }
  },
  watch: {
    // if new errors show up show error popup if no errors: hide it
    errorCount (newValue, oldValue) {
      if (newValue > oldValue) {
        this.showErrorMessage = true
      } else if (newValue === 0) {
        this.showErrorMessage = false
      }
    },
    // save the newsletter preview scroll to restore the newsletter view when refreshing content
    newsletterHtml: {
      handler (newVal, oldVal) {
        this.saveScroll()
        this.$nextTick(() => {
          this.restoreScroll()
        })
      },
      immediate: true
    },
    // watch nesletter render in store and update this. newsletterHtml if not changed for 5 seconds
    'store.renderNewsletter': {
      handler (newVal, oldVal) {
        setTimeout(() => {
          if (this.store.renderNewsletter === newVal) {
            this.newsletterHtml = newVal
          }
        }, 1000)
      },
      immediate: true
    }
  },
  methods: {
    navigateBack (evt, forced = false) {
      // show modal if potentially unsaved changes else navigate back
      if (!this.edited || forced) {
        this.$router.push('/')
      }
      this.showUnsavedChanges = true
    },
    // dynamically load element components from store by their type
    getElement (type) {
      const element = this.store.elements[type]
      if (!element) {
        console.error(`Element type "${type}" not found.`)
        return
      }
      return this.store.elements[type].component
    },
    addElement (name, section) {
      const { addElement } = useNewsletterStore()
      addElement(name, section)
    },
    // focus a newsletterelement by scrolling to it and setting of an animation
    scrollToElement (id) {
      const section = this.store.newsletter.sections.find(section => section.elements.some(element => element.id === id))
      const index = this.store.newsletter.sections.indexOf(section)
      this.$refs.categories[index].show()
      const element = document.getElementById(id)

      setTimeout(() => {
        const target = getScrollTarget(element)
        const offset = element.offsetTop
        const duration = 1000
        setVerticalScrollPosition(target, offset, duration)
        element.classList.add('effect')
      }, 1000)

      setTimeout(() => {
        element.classList.remove('effect')
      }, 5000)
    },
    // utility functions for save, restore scroll and scaling the content to fit into the preview width and reloading iframe with blur
    saveScroll () {
      const iframe = this.$refs.previewIframe
      if (iframe && iframe.contentWindow) {
        iframe.style.filter = 'blur(5px)'
        this.scrollTop = iframe.contentWindow.scrollY
        this.scrollLeft = iframe.contentWindow.scrollX
      }
    },
    restoreScroll () {
      const iframe = this.$refs.previewIframe
      if (iframe && iframe.contentWindow) {
        iframe.contentWindow.scrollTo(this.scrollLeft, this.scrollTop)
      }
    },
    onIframeLoad () {
      this.restoreScroll()
      this.scaleIframeContent()
      this.addIframeScript()
    },
    scaleIframeContent () {
      const iframe = this.$refs.previewIframe
      if (iframe && iframe.contentDocument) {
        const iframeDoc = iframe.contentDocument
        // calculate scale factor based on width of iframe
        const iframeWidth = iframeDoc.body.clientWidth
        const newsletterWith = 900
        const scaleFactor = iframeWidth / newsletterWith
        iframeDoc.body.style.transform = `scale(${scaleFactor})`
        iframeDoc.body.style.transformOrigin = '0 0'
        iframeDoc.body.style.width = `${100 / scaleFactor}%`
        iframeDoc.body.style.height = `${100 / scaleFactor}%`
      }
      iframe.style.filter = 'none'
    },
    addIframeScript () {
      const iframe = this.$refs.previewIframe
      if (iframe && iframe.contentDocument) {
        const iframeDoc = iframe.contentDocument
        // add custom script to document
        // prevent default action on all links to not disturb vue router
        iframeDoc.querySelectorAll('a').forEach(function (a) {
          if (a.hasAttribute('href') && a.getAttribute('href').startsWith('#')) {
            a.addEventListener('click', function (e) {
              e.preventDefault()
              // recreate anchor link by scrolling element into view
              iframeDoc.querySelector(`[name="${a.getAttribute('href').substring(1)}"]`).scrollIntoView({
                behavior: 'smooth'
              })
            })
          } else {
            a.setAttribute('target', '_blank')
          }
        })

        // addd glow to section on hover
        iframeDoc.querySelectorAll('tr[section]').forEach((section) => {
          section.addEventListener('mouseover', () => {
            section.style.boxShadow = '0 0 10px #9B0A7D'
          })
          section.addEventListener('mouseout', () => {
            section.style.boxShadow = 'none'
          })
          section.addEventListener('click', e => {
            // if not clicked on a tag
            if (e.target.tagName !== 'A') {
              this.scrollToElement(section.getAttribute('section'))
            }
          })
        })
      }
    },
    // exporting or saving as json data file mode being save or export
    saveNewsletter (mode) {
      const title = this.store.newsletter.title ? this.store.newsletter.title : 'newsletter'
      switch (mode) {
        // TODO: name the files with the newsletter title
        case 'save':
          this.downloadFile(JSON.stringify(this.store.newsletter), title + '.newsletter', 'application/json')
          this.edited = false
          break
        case 'export':
          bus.emit('validate')
          // wait for validation to finish 5 sec for savety reasons
          setTimeout(() => {
            if (this.errorCount === 0) {
              this.downloadFile(this.store.renderNewsletter, title + '.html', 'text/html')
            }
          }, 1000)
          break
        default:
          console.error('Mode not supported')
      }
    },
    // save a file
    downloadFile (data, filename, type) {
      const file = new Blob([data], { type })
      if (window.navigator.msSaveOrOpenBlob) { // IE
        window.navigator.msSaveOrOpenBlob(file, filename)
      } else { // Others
        const a = document.createElement('a'),
          url = URL.createObjectURL(file)
        a.href = url
        a.download = filename
        document.body.appendChild(a)
        a.click()
        setTimeout(function () {
          document.body.removeChild(a)
          window.URL.revokeObjectURL(url)
        }, 0)
      }
    },
    handleBeforeUnload (e) {
      if (this.edited) {
        if (this.$q.platform.is.electron) {
          this.showUnsavedChanges = true
        }
        e.preventDefault()
        e.returnValue = this.$t('unsavedChangesMessage')
      }
    }
    // TODO: export and save as methods width global data checks
  },
  mounted () {
    const iframe = this.$refs.previewIframe
    iframe.style.filter = 'none'
    window.addEventListener('beforeunload', this.handleBeforeUnload)
  },
  beforeUnmount () {
    window.removeEventListener('beforeunload', this.handleBeforeUnload)
  }
}
</script>

<style scoped>
.backButton {
  display: flex;
  align-items: center;
  margin-right: 1rem;
}

.accordeon {
  border-radius: 24px;
}

.sectionList {
  list-style-type: none;
  padding: 0;
}

.addElementButton{
  transform: translate(0, -50%);
}
.saveBtn {
  height: 2rem;
}

.fullscreenButton {
  opacity: 0.5;
}

.fullscreenButton:hover {
  opacity: 1;
}

/* mark effect */
.effect {
  position: relative;
  overflow:hidden;
}

.effect:after {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  animation: width 2s ease-in-out infinite;
  mix-blend-mode: color;
  background-color: rgb(204, 0, 255);
}

@keyframes width {
  from {
    transform:translateX(-100%);
  }
  to {
    transform:translateX(100%);
  }
}
</style>