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