<template> <ThemeProvider class="page-preview__wrapper" :class="`page-preview__wrapper--${deviceType.type}`" @click.self="actionSelectElement({ element: null })" > <PreviewNavigationBar :page="page" :style="{ maxWidth }" /> <div ref="preview" class="page-preview" :style="{ 'max-width': maxWidth }"> <div ref="previewScaled" class="page-preview__scaled" tabindex="0" @keydown="handleKeyDown" > <CallToAction v-if="!elements.length" class="page-preview__empty" icon="baserow-icon-plus" icon-color="neutral" icon-size="large" icon-rounded @click="$refs.addElementModal.show()" > {{ $t('pagePreview.emptyMessage') }} </CallToAction> <AddElementModal ref="addElementModal" :page="page" /> <ElementPreview v-for="(element, index) in elements" :key="element.id" is-root-element :element="element" :is-first-element="index === 0" :is-last-element="index === elements.length - 1" :is-copying="copyingElementIndex === index" @move="moveElement(element, $event)" /> </div> </div> </ThemeProvider> </template> <script> import { mapActions, mapGetters } from 'vuex' import ElementPreview from '@baserow/modules/builder/components/elements/ElementPreview' import { notifyIf } from '@baserow/modules/core/utils/error' import PreviewNavigationBar from '@baserow/modules/builder/components/page/PreviewNavigationBar' import { PLACEMENTS } from '@baserow/modules/builder/enums' import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal.vue' import ThemeProvider from '@baserow/modules/builder/components/theme/ThemeProvider.vue' export default { name: 'PagePreview', components: { ThemeProvider, AddElementModal, ElementPreview, PreviewNavigationBar, }, inject: ['page', 'workspace'], data() { return { // The element that is currently being copied copyingElementIndex: null, // The resize observer to resize the preview when the wrapper size change resizeObserver: null, } }, computed: { PLACEMENTS: () => PLACEMENTS, ...mapGetters({ deviceTypeSelected: 'page/getDeviceTypeSelected', elementSelected: 'element/getSelected', getChildren: 'element/getChildren', getClosestSiblingElement: 'element/getClosestSiblingElement', }), elements() { return this.$store.getters['element/getRootElements'](this.page) }, elementSelectedId() { return this.elementSelected?.id }, deviceType() { return this.deviceTypeSelected ? this.$registry.get('device', this.deviceTypeSelected) : null }, maxWidth() { return this.deviceType?.maxWidth ? `${this.deviceType.maxWidth}px` : 'unset' }, parentOfElementSelected() { if (!this.elementSelected?.parent_element_id) { return null } return this.$store.getters['element/getElementById']( this.page, this.elementSelected.parent_element_id ) }, canCreateElement() { return this.$hasPermission( 'builder.page.create_element', this.page, this.workspace.id ) }, canUpdateSelectedElement() { return this.$hasPermission( 'builder.page.element.update', this.elementSelected, this.workspace.id ) }, canDeleteSelectedElement() { return this.$hasPermission( 'builder.page.element.delete', this.elementSelected, this.workspace.id ) }, }, watch: { deviceType(value) { this.$nextTick(() => { this.updatePreviewScale(value) }) }, elementSelectedId(newValue) { if (newValue) { this.$refs.previewScaled.focus() } }, }, mounted() { this.resizeObserver = new ResizeObserver(() => { this.onWindowResized() }) this.resizeObserver.observe(this.$el) this.onWindowResized() document.addEventListener('keydown', this.preventScrollIfFocused) }, destroyed() { this.resizeObserver.unobserve(this.$el) document.removeEventListener('keydown', this.preventScrollIfFocused) }, methods: { ...mapActions({ actionDuplicateElement: 'element/duplicate', actionDeleteElement: 'element/delete', actionMoveElement: 'element/moveElement', actionSelectElement: 'element/select', actionSelectNextElement: 'element/selectNextElement', }), preventScrollIfFocused(e) { if (this.$refs.previewScaled === document.activeElement) { switch (e.key) { case 'ArrowLeft': case 'ArrowRight': case 'ArrowUp': case 'ArrowDown': e.preventDefault() break } } }, onWindowResized() { this.$nextTick(() => { this.updatePreviewScale(this.deviceType) }) }, updatePreviewScale(deviceType) { // The widths are the minimum width the preview must have. If the preview dom // element becomes smaller than the target, it will be scaled down so that the // actual width remains the same, and it will preview the correct device. const { clientWidth: currentWidth, clientHeight: currentHeight } = this.$refs.preview const targetWidth = deviceType?.minWidth let scale = 1 if (currentWidth < targetWidth) { // Round scale at 2 decimals scale = Math.round((currentWidth / targetWidth) * 100) / 100 } const previewScaled = this.$refs.previewScaled previewScaled.style.transform = `scale(${scale})` previewScaled.style.transformOrigin = `0 0` previewScaled.style.width = `${currentWidth / scale}px` previewScaled.style.height = `${currentHeight / scale}px` }, async moveElement(placement) { if (!this.elementSelected?.id || !this.canUpdateSelectedElement) { return } const elementType = this.$registry.get( 'element', this.elementSelected.type ) const placementsDisabled = elementType.getPlacementsDisabled( this.page, this.elementSelected ) if (placementsDisabled.includes(placement)) { return } try { await this.actionMoveElement({ page: this.page, element: this.elementSelected, placement, }) await this.actionSelectElement({ element: this.elementSelected }) } catch (error) { notifyIf(error) } }, async selectNextElement(placement) { if (!this.elementSelected?.id) { return } const elementType = this.$registry.get( 'element', this.elementSelected.type ) const placementsDisabled = elementType.getPlacementsDisabled( this.page, this.elementSelected ) if (placementsDisabled.includes(placement)) { return } try { await this.actionSelectNextElement({ page: this.page, element: this.elementSelected, placement, }) } catch (error) { notifyIf(error) } }, async duplicateElement() { if (!this.elementSelected?.id || !this.canCreateElement) { return } this.isDuplicating = true try { await this.actionDuplicateElement({ page: this.page, elementId: this.elementSelected.id, }) } catch (error) { notifyIf(error) } this.isDuplicating = false }, async deleteElement() { if (!this.elementSelected?.id || !this.canDeleteSelectedElement) { return } try { const siblingElementToSelect = this.getClosestSiblingElement( this.page, this.elementSelected ) await this.actionDeleteElement({ page: this.page, elementId: this.elementSelected.id, }) if (siblingElementToSelect?.id) { await this.actionSelectElement({ element: siblingElementToSelect }) } } catch (error) { notifyIf(error) } }, selectParentElement() { if (this.parentOfElementSelected) { this.actionSelectElement({ element: this.parentOfElementSelected }) } }, selectChildElement() { const children = this.getChildren(this.page, this.elementSelected) if (children.length) { this.actionSelectElement({ element: children[0] }) } }, handleKeyDown(e) { let shouldPrevent = true const alternateAction = e.altKey || e.ctrlKey || e.metaKey switch (e.key) { case 'ArrowUp': if (alternateAction) { this.moveElement(PLACEMENTS.BEFORE) } else { this.selectNextElement(PLACEMENTS.BEFORE) } break case 'ArrowDown': if (alternateAction) { this.moveElement(PLACEMENTS.AFTER) } else { this.selectNextElement(PLACEMENTS.AFTER) } break case 'ArrowLeft': if (alternateAction) { this.moveElement(PLACEMENTS.LEFT) } else { this.selectNextElement(PLACEMENTS.LEFT) } break case 'ArrowRight': if (alternateAction) { this.moveElement(PLACEMENTS.RIGHT) } else { this.selectNextElement(PLACEMENTS.RIGHT) } break case 'Backspace': case 'Clear': case 'Delete': this.deleteElement() break case 'c': this.selectChildElement() break case 'd': this.duplicateElement() break case 'p': this.selectParentElement() break default: shouldPrevent = false } if (shouldPrevent) { e.stopPropagation() e.preventDefault() } }, }, } </script>