Merge pull request #2576 from Budibase/responsive-preview

Responsive preview
This commit is contained in:
Andrew Kingston 2021-09-10 12:13:46 +01:00 committed by GitHub
commit 10182e19c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 272 additions and 136 deletions

View File

@ -43,6 +43,7 @@ const INITIAL_FRONTEND_STATE = {
deviceAwareness: false, deviceAwareness: false,
state: false, state: false,
customThemes: false, customThemes: false,
devicePreview: false,
}, },
currentFrontEndType: "none", currentFrontEndType: "none",
selectedScreenId: "", selectedScreenId: "",
@ -56,6 +57,7 @@ const INITIAL_FRONTEND_STATE = {
clientLibPath: "", clientLibPath: "",
theme: "", theme: "",
customTheme: {}, customTheme: {},
previewDevice: "desktop",
} }
export const getFrontendStore = () => { export const getFrontendStore = () => {
@ -230,6 +232,12 @@ export const getFrontendStore = () => {
await store.actions.layouts.save(selectedAsset) await store.actions.layouts.save(selectedAsset)
} }
}, },
setDevice: device => {
store.update(state => {
state.previewDevice = device
return state
})
},
}, },
layouts: { layouts: {
select: layoutId => { select: layoutId => {

View File

@ -83,10 +83,11 @@
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
margin-top: -10px;
} }
.components :global(> *) { .components :global(> *) {
margin-top: 10px; height: 32px;
display: grid;
place-items: center;
} }
.buttonContent { .buttonContent {

View File

@ -50,6 +50,7 @@
previewType: $store.currentFrontEndType, previewType: $store.currentFrontEndType,
theme: $store.theme, theme: $store.theme,
customTheme: $store.customTheme, customTheme: $store.customTheme,
previewDevice: $store.previewDevice,
} }
// Saving pages and screens to the DB causes them to have _revs. // Saving pages and screens to the DB causes them to have _revs.
@ -140,11 +141,12 @@
</div> </div>
{/if} {/if}
<iframe <iframe
style="height: 100%; width: 100%"
title="componentPreview" title="componentPreview"
bind:this={iframe} bind:this={iframe}
srcdoc={template} srcdoc={template}
class:hidden={loading || error} class:hidden={loading || error}
class:tablet={$store.previewDevice === "tablet"}
class:mobile={$store.previewDevice === "mobile"}
/> />
</div> </div>
<ConfirmDialog <ConfirmDialog
@ -160,6 +162,8 @@
.component-container { .component-container {
grid-row-start: middle; grid-row-start: middle;
grid-column-start: middle; grid-column-start: middle;
display: grid;
place-items: center;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
margin: auto; margin: auto;
@ -200,4 +204,9 @@
font-weight: 400; font-weight: 400;
margin: 0; margin: 0;
} }
iframe {
width: 100%;
height: 100%;
}
</style> </style>

View File

@ -0,0 +1,22 @@
<script>
import { ActionGroup, ActionButton } from "@budibase/bbui"
import { store } from "builderStore"
</script>
<ActionGroup compact>
<ActionButton
icon="DeviceDesktop"
selected={$store.previewDevice === "desktop"}
on:click={() => store.actions.preview.setDevice("desktop")}
/>
<ActionButton
icon="DeviceTablet"
selected={$store.previewDevice === "tablet"}
on:click={() => store.actions.preview.setDevice("tablet")}
/>
<ActionButton
icon="DevicePhone"
selected={$store.previewDevice === "mobile"}
on:click={() => store.actions.preview.setDevice("mobile")}
/>
</ActionGroup>

View File

@ -17,18 +17,14 @@ export default `
margin: 0; margin: 0;
} }
html { html {
height: calc(100% - 16px); height: 100%;
width: calc(100% - 16px); width: 100%;
overflow: hidden; overflow: hidden;
margin: 8px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
} }
html.loaded {
box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.1);
}
body { body {
flex: 1 1 auto; flex: 1 1 auto;
overflow: hidden; overflow: hidden;
@ -67,7 +63,8 @@ export default `
previewType, previewType,
appId, appId,
theme, theme,
customTheme customTheme,
previewDevice
} = parsed } = parsed
// Set some flags so the app knows we're in the builder // Set some flags so the app knows we're in the builder
@ -80,6 +77,7 @@ export default `
window["##BUDIBASE_PREVIEW_TYPE##"] = previewType window["##BUDIBASE_PREVIEW_TYPE##"] = previewType
window["##BUDIBASE_PREVIEW_THEME##"] = theme window["##BUDIBASE_PREVIEW_THEME##"] = theme
window["##BUDIBASE_PREVIEW_CUSTOM_THEME##"] = customTheme window["##BUDIBASE_PREVIEW_CUSTOM_THEME##"] = customTheme
window["##BUDIBASE_PREVIEW_DEVICE##"] = previewDevice
// Initialise app // Initialise app
try { try {

View File

@ -15,6 +15,7 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import AppThemeSelect from "components/design/AppPreview/AppThemeSelect.svelte" import AppThemeSelect from "components/design/AppPreview/AppThemeSelect.svelte"
import ThemeEditor from "components/design/AppPreview/ThemeEditor.svelte" import ThemeEditor from "components/design/AppPreview/ThemeEditor.svelte"
import DevicePreviewSelect from "components/design/AppPreview/DevicePreviewSelect.svelte"
// Cache previous values so we don't update the URL more than necessary // Cache previous values so we don't update the URL more than necessary
let previousType let previousType
@ -151,6 +152,9 @@
{#if $currentAsset} {#if $currentAsset}
<div class="preview-header"> <div class="preview-header">
<ComponentSelectionList /> <ComponentSelectionList />
{#if $store.clientFeatures.devicePreview}
<DevicePreviewSelect />
{/if}
{#if $store.clientFeatures.customThemes} {#if $store.clientFeatures.customThemes}
<ThemeEditor /> <ThemeEditor />
{:else if $store.clientFeatures.spectrumThemes} {:else if $store.clientFeatures.spectrumThemes}
@ -208,7 +212,8 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: flex-start;
gap: 1rem;
} }
.preview-header > :global(*) { .preview-header > :global(*) {
flex: 0 0 auto; flex: 0 0 auto;

View File

@ -4,7 +4,8 @@
"intelligentLoading": true, "intelligentLoading": true,
"deviceAwareness": true, "deviceAwareness": true,
"state": true, "state": true,
"customThemes": true "customThemes": true,
"devicePreview": true
}, },
"layout": { "layout": {
"name": "Layout", "name": "Layout",

View File

@ -85,30 +85,45 @@
<UserBindingsProvider> <UserBindingsProvider>
<DeviceBindingsProvider> <DeviceBindingsProvider>
<StateBindingsProvider> <StateBindingsProvider>
<CustomThemeWrapper> <!-- Settings bar can be rendered outside of device preview -->
<div id="app-root" class:preview={$builderStore.inBuilder}>
{#key $screenStore.activeLayout._id}
<Component instance={$screenStore.activeLayout.props} />
{/key}
</div>
</CustomThemeWrapper>
<NotificationDisplay />
<ConfirmationDisplay />
<PeekScreenDisplay />
<!-- Key block needs to be outside the if statement or it breaks --> <!-- Key block needs to be outside the if statement or it breaks -->
{#key $builderStore.selectedComponentId} {#key $builderStore.selectedComponentId}
{#if $builderStore.inBuilder} {#if $builderStore.inBuilder}
<SettingsBar /> <SettingsBar />
{/if} {/if}
{/key} {/key}
<!--
<!-- Clip boundary for selection indicators -->
<div
id="clip-root"
class:preview={$builderStore.inBuilder}
class:tablet-preview={$builderStore.previewDevice === "tablet"}
class:mobile-preview={$builderStore.previewDevice === "mobile"}
>
<!-- Actual app -->
<div id="app-root">
<CustomThemeWrapper>
{#key $screenStore.activeLayout._id}
<Component instance={$screenStore.activeLayout.props} />
{/key}
<!-- Layers on top of app -->
<NotificationDisplay />
<ConfirmationDisplay />
<PeekScreenDisplay />
</CustomThemeWrapper>
</div>
<!-- Selection indicators should be bounded by device -->
<!--
We don't want to key these by componentID as they control their own We don't want to key these by componentID as they control their own
re-mounting to avoid flashes. re-mounting to avoid flashes.
--> -->
{#if $builderStore.inBuilder} {#if $builderStore.inBuilder}
<SelectionIndicator /> <SelectionIndicator />
<HoverIndicator /> <HoverIndicator />
{/if} {/if}
</div>
</StateBindingsProvider> </StateBindingsProvider>
</DeviceBindingsProvider> </DeviceBindingsProvider>
</UserBindingsProvider> </UserBindingsProvider>
@ -117,20 +132,33 @@
{/if} {/if}
<style> <style>
#spectrum-root, #spectrum-root {
#app-root {
height: 100%;
width: 100%;
padding: 0; padding: 0;
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
height: 100%;
width: 100%;
background: transparent;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
#clip-root {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background-color: transparent;
} }
#app-root { #app-root {
position: relative; overflow: hidden;
} height: 100%;
#app-root.preview { width: 100%;
border: 1px solid var(--spectrum-global-color-gray-300);
} }
.error { .error {
position: absolute; position: absolute;
width: 100%; width: 100%;
@ -157,4 +185,22 @@
.error :global(h1) { .error :global(h1) {
font-weight: 400; font-weight: 400;
} }
/* Preview styles */
/* The additional 6px of size is to account for 4px padding and 2px border */
#clip-root.preview {
padding: 2px;
}
#clip-root.tablet-preview {
width: calc(1024px + 6px);
height: calc(768px + 6px);
}
#clip-root.mobile-preview {
width: calc(390px + 6px);
height: calc(844px + 6px);
}
.preview #app-root {
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
}
</style> </style>

View File

@ -5,6 +5,7 @@
const { routeStore, styleable, linkable, builderStore } = getContext("sdk") const { routeStore, styleable, linkable, builderStore } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
const context = getContext("context")
export let title export let title
export let hideTitle = false export let hideTitle = false
@ -58,9 +59,19 @@
} }
</script> </script>
<div class="layout layout--{typeClass}" use:styleable={$component.styles}> <div
class="layout layout--{typeClass}"
use:styleable={$component.styles}
class:desktop={!$context.device.mobile && !$context.device.tablet}
class:mobile={!!$context.device.mobile}
>
{#if typeClass !== "none"} {#if typeClass !== "none"}
<div class="nav-wrapper" class:sticky class:hidden={isPeeking}> <div
class="nav-wrapper"
class:sticky
class:hidden={isPeeking}
style={`--height:${$context.device.height}px; --width:${$context.device.width}px;`}
>
<div class="nav nav--{typeClass} size--{widthClass}"> <div class="nav nav--{typeClass} size--{widthClass}">
<div class="nav-header"> <div class="nav-header">
{#if validLinks?.length} {#if validLinks?.length}
@ -286,101 +297,97 @@
} }
/* Desktop nav overrides */ /* Desktop nav overrides */
@media (min-width: 600px) { .desktop.layout--left {
.layout--left { flex-direction: row;
flex-direction: row; overflow: hidden;
overflow: hidden; }
} .desktop.layout--left .main-wrapper {
.layout--left .main-wrapper { height: 100%;
height: 100%; overflow: auto;
overflow: auto; }
}
.nav--left { .desktop .nav--left {
width: 250px; width: 250px;
padding: var(--spacing-xl); padding: var(--spacing-xl);
} }
.nav--left .links { .desktop .nav--left .links {
margin-top: var(--spacing-m); margin-top: var(--spacing-m);
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
} }
.nav--left .link { .desktop .nav--left .link {
font-size: var(--spectrum-global-dimension-font-size-150); font-size: var(--spectrum-global-dimension-font-size-150);
}
} }
/* Mobile nav overrides */ /* Mobile nav overrides */
@media (max-width: 600px) { .mobile .nav-wrapper {
.nav-wrapper { position: sticky;
position: sticky; top: 0;
top: 0; left: 0;
left: 0; box-shadow: 0 0 8px -1px rgba(0, 0, 0, 0.075);
box-shadow: 0 0 8px -1px rgba(0, 0, 0, 0.075); }
}
/* Show close button in drawer */ /* Show close button in drawer */
.close { .mobile .close {
display: block; display: block;
} }
/* Force standard top bar */ /* Force standard top bar */
.nav { .mobile .nav {
padding: var(--spacing-m) 16px; padding: var(--spacing-m) 16px;
} }
.burger { .mobile .burger {
display: grid; display: grid;
place-items: center; place-items: center;
} }
.logo { .mobile .logo {
flex: 0 0 auto; flex: 0 0 auto;
} }
.logo :global(h1) { .mobile .logo :global(h1) {
display: none; display: none;
} }
/* Reduce padding */ /* Reduce padding */
.main { .mobile .main {
padding: 16px; padding: 16px;
} }
/* Transform links into drawer */ /* Transform links into drawer */
.links { .mobile .links {
margin-top: 0; margin-top: 0;
position: fixed; position: absolute;
top: 0; top: 0;
left: -250px; left: -250px;
transform: translateX(0); transform: translateX(0);
width: 250px; width: 250px;
transition: transform 0.26s ease-in-out, opacity 0.26s ease-in-out; transition: transform 0.26s ease-in-out, opacity 0.26s ease-in-out;
height: 100vh; height: var(--height);
opacity: 0; opacity: 0;
background: var(--navBackground); background: var(--navBackground);
z-index: 999; z-index: 999;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
padding: var(--spacing-xl); padding: var(--spacing-xl);
} }
.link { .mobile .link {
width: calc(100% - 30px); width: calc(100% - 30px);
font-size: 120%; font-size: 120%;
} }
.links.visible { .mobile .links.visible {
opacity: 1; opacity: 1;
transform: translateX(250px); transform: translateX(250px);
box-shadow: 0 0 80px 20px rgba(0, 0, 0, 0.3); box-shadow: 0 0 80px 20px rgba(0, 0, 0, 0.3);
} }
.mobile-click-handler.visible { .mobile .mobile-click-handler.visible {
position: fixed; position: absolute;
display: block; display: block;
top: 0; top: 0;
left: 0; left: 0;
width: 100vw; width: var(--width);
height: 100vh; height: var(--height);
z-index: 998; z-index: 998;
}
} }
</style> </style>

View File

@ -3,21 +3,25 @@
import { onMount } from "svelte" import { onMount } from "svelte"
let width = window.innerWidth let width = window.innerWidth
let height = window.innerHeight
const tabletBreakpoint = 768 const tabletBreakpoint = 768
const desktopBreakpoint = 1280 const desktopBreakpoint = 1280
const resizeObserver = new ResizeObserver(entries => { const resizeObserver = new ResizeObserver(entries => {
if (entries?.[0]) { if (entries?.[0]) {
width = entries[0].contentRect?.width width = entries[0].contentRect?.width
height = entries[0].contentRect?.height
} }
}) })
$: data = { $: data = {
mobile: width && width < tabletBreakpoint, mobile: width && width < tabletBreakpoint,
tablet: width && width >= tabletBreakpoint && width < desktopBreakpoint, tablet: width && width >= tabletBreakpoint && width < desktopBreakpoint,
width,
height,
} }
onMount(() => { onMount(() => {
const doc = document.documentElement const doc = document.getElementById("app-root")
resizeObserver.observe(doc) resizeObserver.observe(doc)
return () => { return () => {

View File

@ -18,10 +18,11 @@
}} }}
out:fade={{ duration: transition ? 130 : 0 }} out:fade={{ duration: transition ? 130 : 0 }}
class="indicator" class="indicator"
class:flipped={top < 20}
style="top: {top}px; left: {left}px; width: {width}px; height: {height}px; --color: {color}; --zIndex: {zIndex};" style="top: {top}px; left: {left}px; width: {width}px; height: {height}px; --color: {color}; --zIndex: {zIndex};"
> >
{#if text} {#if text}
<div class="text" class:flipped={top < 22}> <div class="text" class:flipped={top < 20}>
{text} {text}
</div> </div>
{/if} {/if}
@ -29,11 +30,17 @@
<style> <style>
.indicator { .indicator {
position: fixed; position: absolute;
z-index: var(--zIndex); z-index: var(--zIndex);
border: 2px solid var(--color); border: 2px solid var(--color);
pointer-events: none; pointer-events: none;
border-radius: 4px; border-top-right-radius: 4px;
border-top-left-radius: 0;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.indicator.flipped {
border-top-left-radius: 4px;
} }
.text { .text {
background-color: var(--color); background-color: var(--color);
@ -45,16 +52,17 @@
padding: 0 8px 2px 8px; padding: 0 8px 2px 8px;
transform: translateY(-100%); transform: translateY(-100%);
font-size: 11px; font-size: 11px;
border-top-left-radius: 2px; border-top-left-radius: 4px;
border-top-right-radius: 2px; border-top-right-radius: 4px;
border-bottom-right-radius: 2px; border-bottom-right-radius: 4px;
white-space: nowrap; white-space: nowrap;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
} }
.flipped { .text.flipped {
border-top-left-radius: 4px;
transform: translateY(0%); transform: translateY(0%);
top: -2px; top: -2px;
} }

View File

@ -70,17 +70,22 @@
updating = false updating = false
} }
const device = document.getElementById("app-root")
const deviceBounds = device.getBoundingClientRect()
children.forEach((child, idx) => { children.forEach((child, idx) => {
const callback = createIntersectionCallback(idx) const callback = createIntersectionCallback(idx)
const threshold = children.length > 1 ? 1 : 0 const threshold = children.length > 1 ? 1 : 0
const observer = new IntersectionObserver(callback, { threshold }) const observer = new IntersectionObserver(callback, {
threshold,
root: device,
})
observer.observe(child) observer.observe(child)
observers.push(observer) observers.push(observer)
const elBounds = child.getBoundingClientRect() const elBounds = child.getBoundingClientRect()
nextIndicators.push({ nextIndicators.push({
top: elBounds.top + scrollY - 2, top: elBounds.top + scrollY - deviceBounds.top,
left: elBounds.left + scrollX - 2, left: elBounds.left + scrollX - deviceBounds.left,
width: elBounds.width + 4, width: elBounds.width + 4,
height: elBounds.height + 4, height: elBounds.height + 4,
visible: false, visible: false,

View File

@ -26,8 +26,16 @@
const id = $builderStore.selectedComponentId const id = $builderStore.selectedComponentId
const parent = document.getElementsByClassName(id)?.[0] const parent = document.getElementsByClassName(id)?.[0]
const element = parent?.childNodes?.[0] const element = parent?.childNodes?.[0]
// The settings bar is higher in the dom tree than the selection indicators
// as we want to be able to render the settings bar wider than the screen,
// or outside the screen.
// Therefore we use the clip root rather than the app root to determine
// its position.
const device = document.getElementById("clip-root")
if (element && self) { if (element && self) {
// Batch reads to minimize reflow // Batch reads to minimize reflow
const deviceBounds = device.getBoundingClientRect()
const elBounds = element.getBoundingClientRect() const elBounds = element.getBoundingClientRect()
const width = self.offsetWidth const width = self.offsetWidth
const height = self.offsetHeight const height = self.offsetHeight
@ -35,9 +43,16 @@
// Vertically, always render above unless no room, then render inside // Vertically, always render above unless no room, then render inside
let newTop = elBounds.top + scrollY - verticalOffset - height let newTop = elBounds.top + scrollY - verticalOffset - height
if (newTop < deviceBounds.top - 50) {
newTop = deviceBounds.top - 50
}
if (newTop < 0) { if (newTop < 0) {
newTop = 0 newTop = 0
} }
const deviceBottom = deviceBounds.top + deviceBounds.height
if (newTop > deviceBottom - 44) {
newTop = deviceBottom - 44
}
// Horizontally, try to center first. // Horizontally, try to center first.
// Failing that, render to left edge of component. // Failing that, render to left edge of component.

View File

@ -18,6 +18,7 @@ const loadBudibase = () => {
previewType: window["##BUDIBASE_PREVIEW_TYPE##"], previewType: window["##BUDIBASE_PREVIEW_TYPE##"],
theme: window["##BUDIBASE_PREVIEW_THEME##"], theme: window["##BUDIBASE_PREVIEW_THEME##"],
customTheme: window["##BUDIBASE_PREVIEW_CUSTOM_THEME##"], customTheme: window["##BUDIBASE_PREVIEW_CUSTOM_THEME##"],
previewDevice: window["##BUDIBASE_PREVIEW_DEVICE##"],
}) })
// Set app ID - this window flag is set by both the preview and the real // Set app ID - this window flag is set by both the preview and the real

View File

@ -20,6 +20,9 @@ const createBuilderStore = () => {
previewId: null, previewId: null,
previewType: null, previewType: null,
selectedPath: [], selectedPath: [],
theme: null,
customTheme: null,
previewDevice: "desktop",
} }
const writableStore = writable(initialState) const writableStore = writable(initialState)
const derivedStore = derived(writableStore, $state => { const derivedStore = derived(writableStore, $state => {

View File

@ -7,7 +7,10 @@
<svelte:head> <svelte:head>
<meta charset="utf8" /> <meta charset="utf8" />
<meta name="viewport" content="width=device-width" /> <meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>{title}</title> <title>{title}</title>
<link rel="icon" type="image/png" href={favicon} /> <link rel="icon" type="image/png" href={favicon} />