Merge pull request #1778 from umbraco/feature/document-preview
Feature: Document Preview
This commit is contained in:
@@ -99,6 +99,11 @@ export class UmbAppElement extends UmbLitElement {
|
||||
component: () => import('../upgrader/upgrader.element.js'),
|
||||
guards: [this.#isAuthorizedGuard()],
|
||||
},
|
||||
{
|
||||
path: 'preview',
|
||||
component: () => import('../preview/preview.element.js'),
|
||||
guards: [this.#isAuthorizedGuard()],
|
||||
},
|
||||
{
|
||||
path: 'logout',
|
||||
resolve: () => {
|
||||
|
||||
32
src/Umbraco.Web.UI.Client/src/apps/preview/apps/manifests.ts
Normal file
32
src/Umbraco.Web.UI.Client/src/apps/preview/apps/manifests.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ManifestPreviewAppProvider } from '@umbraco-cms/backoffice/extension-registry';
|
||||
|
||||
export const manifests: Array<ManifestPreviewAppProvider> = [
|
||||
{
|
||||
type: 'previewApp',
|
||||
alias: 'Umb.PreviewApps.Device',
|
||||
name: 'Preview: Device Switcher',
|
||||
element: () => import('./preview-device.element.js'),
|
||||
weight: 400,
|
||||
},
|
||||
{
|
||||
type: 'previewApp',
|
||||
alias: 'Umb.PreviewApps.Culture',
|
||||
name: 'Preview: Culture Switcher',
|
||||
element: () => import('./preview-culture.element.js'),
|
||||
weight: 300,
|
||||
},
|
||||
{
|
||||
type: 'previewApp',
|
||||
alias: 'Umb.PreviewApps.OpenWebsite',
|
||||
name: 'Preview: Open Website Button',
|
||||
element: () => import('./preview-open-website.element.js'),
|
||||
weight: 200,
|
||||
},
|
||||
{
|
||||
type: 'previewApp',
|
||||
alias: 'Umb.PreviewApps.Exit',
|
||||
name: 'Preview: Exit Button',
|
||||
element: () => import('./preview-exit.element.js'),
|
||||
weight: 100,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,103 @@
|
||||
import { UMB_PREVIEW_CONTEXT } from '../preview.context.js';
|
||||
import { css, customElement, html, nothing, repeat, state } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
|
||||
import { UmbLanguageCollectionRepository } from '@umbraco-cms/backoffice/language';
|
||||
import type { UmbLanguageDetailModel } from '@umbraco-cms/backoffice/language';
|
||||
|
||||
const elementName = 'umb-preview-culture';
|
||||
|
||||
@customElement(elementName)
|
||||
export class UmbPreviewCultureElement extends UmbLitElement {
|
||||
#languageRepository = new UmbLanguageCollectionRepository(this);
|
||||
|
||||
@state()
|
||||
private _culture?: UmbLanguageDetailModel;
|
||||
|
||||
@state()
|
||||
private _cultures: Array<UmbLanguageDetailModel> = [];
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.#getCultures();
|
||||
}
|
||||
|
||||
async #getCultures() {
|
||||
const { data: langauges } = await this.#languageRepository.requestCollection({ skip: 0, take: 100 });
|
||||
this._cultures = langauges?.items ?? [];
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const culture = searchParams.get('culture');
|
||||
|
||||
if (culture && culture !== this._culture?.unique) {
|
||||
this._culture = this._cultures.find((c) => c.unique === culture);
|
||||
}
|
||||
}
|
||||
|
||||
async #onClick(culture: UmbLanguageDetailModel) {
|
||||
if (this._culture === culture) return;
|
||||
this._culture = culture;
|
||||
|
||||
const previewContext = await this.getContext(UMB_PREVIEW_CONTEXT);
|
||||
previewContext.updateIFrame({ culture: culture.unique });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._cultures.length <= 1) return nothing;
|
||||
return html`
|
||||
<uui-button look="primary" popovertarget="cultures-popover">
|
||||
<div>
|
||||
<uui-icon name="icon-globe"></uui-icon>
|
||||
<span>${this._culture?.name ?? this.localize.term('treeHeaders_languages')}</span>
|
||||
</div>
|
||||
</uui-button>
|
||||
<uui-popover-container id="cultures-popover" placement="top-end">
|
||||
<umb-popover-layout>
|
||||
${repeat(
|
||||
this._cultures,
|
||||
(item) => item.unique,
|
||||
(item) => html`
|
||||
<uui-menu-item
|
||||
label=${item.name}
|
||||
?active=${item.unique === this._culture?.unique}
|
||||
@click=${() => this.#onClick(item)}>
|
||||
<uui-icon slot="icon" name="icon-globe"></uui-icon>
|
||||
</uui-menu-item>
|
||||
`,
|
||||
)}
|
||||
</umb-popover-layout>
|
||||
</uui-popover-container>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
border-left: 1px solid var(--uui-color-header-contrast);
|
||||
--uui-button-font-weight: 400;
|
||||
--uui-button-padding-left-factor: 3;
|
||||
--uui-button-padding-right-factor: 3;
|
||||
}
|
||||
|
||||
uui-button > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
umb-popover-layout {
|
||||
--uui-color-surface: var(--uui-color-header-surface);
|
||||
--uui-color-border: var(--uui-color-header-surface);
|
||||
color: var(--uui-color-header-contrast);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
export { UmbPreviewCultureElement as element };
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[elementName]: UmbPreviewCultureElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { UMB_PREVIEW_CONTEXT } from '../preview.context.js';
|
||||
import { css, customElement, html, property, repeat } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
|
||||
|
||||
export interface UmbPreviewDevice {
|
||||
alias: string;
|
||||
label: string;
|
||||
css: string;
|
||||
icon: string;
|
||||
dimensions: { height: string; width: string };
|
||||
}
|
||||
|
||||
const elementName = 'umb-preview-device';
|
||||
|
||||
@customElement(elementName)
|
||||
export class UmbPreviewDeviceElement extends UmbLitElement {
|
||||
#devices: Array<UmbPreviewDevice> = [
|
||||
{
|
||||
alias: 'fullsize',
|
||||
label: 'Fit browser',
|
||||
css: 'fullsize',
|
||||
icon: 'icon-application-window-alt',
|
||||
dimensions: { height: '100%', width: '100%' },
|
||||
},
|
||||
{
|
||||
alias: 'desktop',
|
||||
label: 'Desktop',
|
||||
css: 'desktop shadow',
|
||||
icon: 'icon-display',
|
||||
dimensions: { height: '1080px', width: '1920px' },
|
||||
},
|
||||
{
|
||||
alias: 'laptop',
|
||||
label: 'Laptop',
|
||||
css: 'laptop shadow',
|
||||
icon: 'icon-laptop',
|
||||
dimensions: { height: '768px', width: '1366px' },
|
||||
},
|
||||
{
|
||||
alias: 'ipad-portrait',
|
||||
label: 'Tablet portrait',
|
||||
css: 'ipad-portrait shadow',
|
||||
icon: 'icon-ipad',
|
||||
dimensions: { height: '929px', width: '769px' },
|
||||
},
|
||||
{
|
||||
alias: 'ipad-landscape',
|
||||
label: 'Tablet landscape',
|
||||
css: 'ipad-landscape shadow flip',
|
||||
icon: 'icon-ipad',
|
||||
dimensions: { height: '675px', width: '1024px' },
|
||||
},
|
||||
{
|
||||
alias: 'smartphone-portrait',
|
||||
label: 'Smartphone portrait',
|
||||
css: 'smartphone-portrait shadow',
|
||||
icon: 'icon-iphone',
|
||||
dimensions: { height: '640px', width: '360px' },
|
||||
},
|
||||
{
|
||||
alias: 'smartphone-landscape',
|
||||
label: 'Smartphone landscape',
|
||||
css: 'smartphone-landscape shadow flip',
|
||||
icon: 'icon-iphone',
|
||||
dimensions: { height: '360px', width: '640px' },
|
||||
},
|
||||
];
|
||||
|
||||
@property({ attribute: false, type: Object })
|
||||
device = this.#devices[0];
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.#changeDevice(this.device);
|
||||
}
|
||||
|
||||
async #changeDevice(device: UmbPreviewDevice) {
|
||||
if (device === this.device) return;
|
||||
|
||||
this.device = device;
|
||||
|
||||
const previewContext = await this.getContext(UMB_PREVIEW_CONTEXT);
|
||||
|
||||
previewContext?.updateIFrame({
|
||||
className: device.css,
|
||||
height: device.dimensions.height,
|
||||
width: device.dimensions.width,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<uui-button look="primary" popovertarget="devices-popover">
|
||||
<div>
|
||||
<uui-icon name=${this.device.icon} class=${this.device.css.includes('flip') ? 'flip' : ''}></uui-icon>
|
||||
<span>${this.device.label}</span>
|
||||
</div>
|
||||
</uui-button>
|
||||
<uui-popover-container id="devices-popover" placement="top-end">
|
||||
<umb-popover-layout>
|
||||
${repeat(
|
||||
this.#devices,
|
||||
(item) => item.alias,
|
||||
(item) => html`
|
||||
<uui-menu-item
|
||||
label=${item.label}
|
||||
?active=${item === this.device}
|
||||
@click=${() => this.#changeDevice(item)}>
|
||||
<uui-icon slot="icon" name=${item.icon} class=${item.css.includes('flip') ? 'flip' : ''}></uui-icon>
|
||||
</uui-menu-item>
|
||||
`,
|
||||
)}
|
||||
</umb-popover-layout>
|
||||
</uui-popover-container>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
border-left: 1px solid var(--uui-color-header-contrast);
|
||||
--uui-button-font-weight: 400;
|
||||
--uui-button-padding-left-factor: 3;
|
||||
--uui-button-padding-right-factor: 3;
|
||||
}
|
||||
|
||||
uui-button > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
umb-popover-layout {
|
||||
--uui-color-surface: var(--uui-color-header-surface);
|
||||
--uui-color-border: var(--uui-color-header-surface);
|
||||
color: var(--uui-color-header-contrast);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
export { UmbPreviewDeviceElement as element };
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[elementName]: UmbPreviewDeviceElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { UMB_PREVIEW_CONTEXT } from '../preview.context.js';
|
||||
import { css, customElement, html } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
|
||||
|
||||
const elementName = 'umb-preview-exit';
|
||||
@customElement(elementName)
|
||||
export class UmbPreviewExitElement extends UmbLitElement {
|
||||
async #onClick() {
|
||||
const previewContext = await this.getContext(UMB_PREVIEW_CONTEXT);
|
||||
previewContext.exitPreview(0);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<uui-button look="primary" @click=${this.#onClick}>
|
||||
<div>
|
||||
<uui-icon name="icon-power"></uui-icon>
|
||||
<span>${this.localize.term('preview_endLabel')}</span>
|
||||
</div>
|
||||
</uui-button>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
border-left: 1px solid var(--uui-color-header-contrast);
|
||||
--uui-button-font-weight: 400;
|
||||
--uui-button-padding-left-factor: 3;
|
||||
--uui-button-padding-right-factor: 3;
|
||||
}
|
||||
|
||||
uui-button > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
export { UmbPreviewExitElement as element };
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[elementName]: UmbPreviewExitElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { UMB_PREVIEW_CONTEXT } from '../preview.context.js';
|
||||
import { css, customElement, html } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
|
||||
|
||||
const elementName = 'umb-preview-open-website';
|
||||
@customElement(elementName)
|
||||
export class UmbPreviewOpenWebsiteElement extends UmbLitElement {
|
||||
async #onClick() {
|
||||
const previewContext = await this.getContext(UMB_PREVIEW_CONTEXT);
|
||||
previewContext.openWebsite();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<uui-button look="primary" @click=${this.#onClick}>
|
||||
<div>
|
||||
<uui-icon name="icon-out"></uui-icon>
|
||||
<span>${this.localize.term('preview_openWebsiteLabel')}</span>
|
||||
</div>
|
||||
</uui-button>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
border-left: 1px solid var(--uui-color-header-contrast);
|
||||
--uui-button-font-weight: 400;
|
||||
--uui-button-padding-left-factor: 3;
|
||||
--uui-button-padding-right-factor: 3;
|
||||
}
|
||||
|
||||
uui-button > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
export { UmbPreviewOpenWebsiteElement as element };
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[elementName]: UmbPreviewOpenWebsiteElement;
|
||||
}
|
||||
}
|
||||
208
src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts
Normal file
208
src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { UMB_APP_CONTEXT } from '../app/app.context.js';
|
||||
import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
|
||||
import { umbConfirmModal } from '@umbraco-cms/backoffice/modal';
|
||||
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
|
||||
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
|
||||
import { UmbDocumentPreviewRepository } from '@umbraco-cms/backoffice/document';
|
||||
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
|
||||
const UMB_LOCALSTORAGE_SESSION_KEY = 'umb:previewSessions';
|
||||
|
||||
export class UmbPreviewContext extends UmbContextBase<UmbPreviewContext> {
|
||||
#culture?: string | null;
|
||||
#serverUrl: string = '';
|
||||
#webSocket?: WebSocket;
|
||||
#unique?: string | null;
|
||||
|
||||
#iframeReady = new UmbBooleanState(false);
|
||||
public readonly iframeReady = this.#iframeReady.asObservable();
|
||||
|
||||
#previewUrl = new UmbStringState(undefined);
|
||||
public readonly previewUrl = this.#previewUrl.asObservable();
|
||||
|
||||
#documentPreviewRepository = new UmbDocumentPreviewRepository(this);
|
||||
|
||||
constructor(host: UmbControllerHost) {
|
||||
super(host, UMB_PREVIEW_CONTEXT);
|
||||
this.#init();
|
||||
}
|
||||
|
||||
async #init() {
|
||||
const appContext = await this.getContext(UMB_APP_CONTEXT);
|
||||
this.#serverUrl = appContext.getServerUrl();
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
this.#culture = params.get('culture');
|
||||
this.#unique = params.get('id');
|
||||
|
||||
if (!this.#unique) {
|
||||
console.error('No unique ID found in query string.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.#setPreviewUrl();
|
||||
}
|
||||
|
||||
#configureWebSocket() {
|
||||
if (this.#webSocket && this.#webSocket.readyState < 2) return;
|
||||
|
||||
const url = `${this.#serverUrl.replace('https://', 'wss://')}/umbraco/PreviewHub`;
|
||||
|
||||
this.#webSocket = new WebSocket(url);
|
||||
|
||||
this.#webSocket.addEventListener('open', () => {
|
||||
// NOTE: SignalR protocol handshake; it requires a terminating control character.
|
||||
const endChar = String.fromCharCode(30);
|
||||
this.#webSocket?.send(`{"protocol":"json","version":1}${endChar}`);
|
||||
});
|
||||
|
||||
this.#webSocket.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
if (!event?.data) return;
|
||||
|
||||
// NOTE: Strip the terminating control character, (from SignalR).
|
||||
const data = event.data.substring(0, event.data.length - 1);
|
||||
const json = JSON.parse(data) as { type: number; target: string; arguments: Array<string> };
|
||||
|
||||
if (json.type === 1 && json.target === 'refreshed') {
|
||||
const pageId = json.arguments?.[0];
|
||||
if (pageId === this.#unique) {
|
||||
this.#setPreviewUrl({ rnd: Math.random() });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#getSessionCount(): number {
|
||||
return Math.max(Number(localStorage.getItem(UMB_LOCALSTORAGE_SESSION_KEY)), 0) || 0;
|
||||
}
|
||||
|
||||
#setPreviewUrl(args?: { serverUrl?: string; unique?: string | null; culture?: string | null; rnd?: number }) {
|
||||
const host = args?.serverUrl || this.#serverUrl;
|
||||
const path = args?.unique || this.#unique;
|
||||
const params = new URLSearchParams();
|
||||
const culture = args?.culture || this.#culture;
|
||||
|
||||
if (culture) params.set('culture', culture);
|
||||
if (args?.rnd) params.set('rnd', args.rnd.toString());
|
||||
|
||||
this.#previewUrl.setValue(`${host}/${path}?${params}`);
|
||||
}
|
||||
|
||||
#setSessionCount(sessions: number) {
|
||||
localStorage.setItem(UMB_LOCALSTORAGE_SESSION_KEY, sessions.toString());
|
||||
}
|
||||
|
||||
checkSession() {
|
||||
const sessions = this.#getSessionCount();
|
||||
if (sessions > 0) return;
|
||||
|
||||
umbConfirmModal(this._host, {
|
||||
headline: `Preview website?`,
|
||||
content: `You have ended preview mode, do you want to enable it again to view the latest saved version of your website?`,
|
||||
cancelLabel: 'View published version',
|
||||
confirmLabel: 'Preview latest version',
|
||||
})
|
||||
.then(() => {
|
||||
this.restartSession();
|
||||
})
|
||||
.catch(() => {
|
||||
this.exitSession();
|
||||
});
|
||||
}
|
||||
|
||||
async exitPreview(sessions: number = 0) {
|
||||
this.#setSessionCount(sessions);
|
||||
|
||||
// We are good to end preview mode.
|
||||
if (sessions <= 0) {
|
||||
await this.#documentPreviewRepository.exit();
|
||||
}
|
||||
|
||||
if (this.#webSocket) {
|
||||
this.#webSocket.close();
|
||||
this.#webSocket = undefined;
|
||||
}
|
||||
|
||||
const url = this.#previewUrl.getValue() as string;
|
||||
window.location.replace(url);
|
||||
}
|
||||
|
||||
async exitSession() {
|
||||
let sessions = this.#getSessionCount();
|
||||
sessions--;
|
||||
this.exitPreview(sessions);
|
||||
}
|
||||
|
||||
iframeLoaded(iframe: HTMLIFrameElement) {
|
||||
if (!iframe) return;
|
||||
this.#configureWebSocket();
|
||||
this.#iframeReady.setValue(true);
|
||||
}
|
||||
|
||||
getIFrameWrapper(): HTMLElement | undefined {
|
||||
return this.getHostElement().shadowRoot?.querySelector('#wrapper') as HTMLElement;
|
||||
}
|
||||
|
||||
openWebsite() {
|
||||
const url = this.#previewUrl.getValue() as string;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
// TODO: [LK] Figure out how to make `iframe.contentDocument` works, as it's not from SameOrigin.
|
||||
reloadIFrame(iframe: HTMLIFrameElement) {
|
||||
const document = iframe.contentDocument;
|
||||
if (!document) return;
|
||||
|
||||
document.location.reload();
|
||||
}
|
||||
|
||||
async restartSession() {
|
||||
await this.#documentPreviewRepository.enter();
|
||||
this.startSession();
|
||||
}
|
||||
|
||||
startSession() {
|
||||
let sessions = this.#getSessionCount();
|
||||
sessions++;
|
||||
this.#setSessionCount(sessions);
|
||||
}
|
||||
|
||||
async updateIFrame(args?: { culture?: string; className?: string; height?: string; width?: string }) {
|
||||
if (!args) return;
|
||||
|
||||
const wrapper = this.getIFrameWrapper();
|
||||
if (!wrapper) return;
|
||||
|
||||
const scaleIFrame = () => {
|
||||
if (wrapper.className === 'fullsize') {
|
||||
wrapper.style.transform = '';
|
||||
} else {
|
||||
const wScale = document.body.offsetWidth / (wrapper.offsetWidth + 30);
|
||||
const hScale = document.body.offsetHeight / (wrapper.offsetHeight + 30);
|
||||
const scale = Math.min(wScale, hScale, 1); // get the lowest ratio, but not higher than 1
|
||||
wrapper.style.transform = `scale(${scale})`;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', scaleIFrame);
|
||||
wrapper.addEventListener('transitionend', scaleIFrame);
|
||||
|
||||
if (args.culture) {
|
||||
this.#iframeReady.setValue(false);
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('culture', args.culture);
|
||||
const newRelativePathQuery = window.location.pathname + '?' + params.toString();
|
||||
history.pushState(null, '', newRelativePathQuery);
|
||||
|
||||
this.#setPreviewUrl({ culture: args.culture });
|
||||
}
|
||||
|
||||
if (args.className) wrapper.className = args.className;
|
||||
if (args.height) wrapper.style.height = args.height;
|
||||
if (args.width) wrapper.style.width = args.width;
|
||||
}
|
||||
}
|
||||
|
||||
export const UMB_PREVIEW_CONTEXT = new UmbContextToken<UmbPreviewContext>('UmbPreviewContext');
|
||||
204
src/Umbraco.Web.UI.Client/src/apps/preview/preview.element.ts
Normal file
204
src/Umbraco.Web.UI.Client/src/apps/preview/preview.element.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { manifests as previewApps } from './apps/manifests.js';
|
||||
import { UmbPreviewContext } from './preview.context.js';
|
||||
import { css, customElement, html, nothing, state, when } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
|
||||
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
|
||||
|
||||
const elementName = 'umb-preview';
|
||||
|
||||
/**
|
||||
* @element umb-preview
|
||||
*/
|
||||
@customElement(elementName)
|
||||
export class UmbPreviewElement extends UmbLitElement {
|
||||
#context = new UmbPreviewContext(this);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (previewApps?.length) {
|
||||
umbExtensionsRegistry.registerMany(previewApps);
|
||||
}
|
||||
|
||||
this.observe(this.#context.iframeReady, (iframeReady) => (this._iframeReady = iframeReady));
|
||||
this.observe(this.#context.previewUrl, (previewUrl) => (this._previewUrl = previewUrl));
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('visibilitychange', this.#onVisibilityChange);
|
||||
window.addEventListener('beforeunload', () => this.#context.exitSession());
|
||||
this.#context.startSession();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('visibilitychange', this.#onVisibilityChange);
|
||||
// NOTE: Unsure how we remove an anonymous function from 'beforeunload' event listener.
|
||||
// The reason for the anonymous function is that if we used a named function,
|
||||
// `this` would be the `window` and would not have context to the class instance. [LK]
|
||||
//window.removeEventListener('beforeunload', () => this.#context.exitSession());
|
||||
this.#context.exitSession();
|
||||
}
|
||||
|
||||
@state()
|
||||
private _iframeReady?: boolean;
|
||||
|
||||
@state()
|
||||
private _previewUrl?: string;
|
||||
|
||||
#onIFrameLoad(event: Event & { target: HTMLIFrameElement }) {
|
||||
this.#context.iframeLoaded(event.target);
|
||||
}
|
||||
|
||||
#onVisibilityChange() {
|
||||
this.#context.checkSession();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._previewUrl) return nothing;
|
||||
return html`
|
||||
${when(!this._iframeReady, () => html`<div id="loading"><uui-loader-circle></uui-loader-circle></div>`)}
|
||||
<div id="wrapper">
|
||||
<div id="container">
|
||||
<iframe
|
||||
src=${this._previewUrl}
|
||||
title="Page preview"
|
||||
@load=${this.#onIFrameLoad}
|
||||
sandbox="allow-scripts"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
<div id="menu">
|
||||
<h4>Preview Mode</h4>
|
||||
<uui-button-group>
|
||||
<umb-extension-slot id="apps" type="previewApp"></umb-extension-slot>
|
||||
</uui-button-group>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
#loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
font-size: 6rem;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
#wrapper {
|
||||
transition: all 240ms cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#wrapper.fullsize {
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#wrapper.shadow {
|
||||
margin: 10px auto;
|
||||
background-color: white;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
box-shadow: 0 5px 20px 0 rgba(0, 0, 0, 0.26);
|
||||
}
|
||||
|
||||
#container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#menu {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
background-color: var(--uui-color-header-surface);
|
||||
height: 40px;
|
||||
|
||||
animation: menu-bar-animation 1.2s;
|
||||
animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
#menu > h4 {
|
||||
color: var(--uui-color-header-contrast-emphasis);
|
||||
margin: 0;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
#menu > uui-button-group {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
uui-icon.flip {
|
||||
rotate: 90deg;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
@keyframes menu-bar-animation {
|
||||
0% {
|
||||
bottom: -50px;
|
||||
}
|
||||
40% {
|
||||
bottom: -50px;
|
||||
}
|
||||
80% {
|
||||
bottom: 0px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
export default UmbPreviewElement;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[elementName]: UmbPreviewElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Meta } from '@storybook/web-components';
|
||||
import { html } from '@umbraco-cms/backoffice/external/lit';
|
||||
|
||||
export default {
|
||||
title: 'Apps/Preview',
|
||||
component: 'umb-preview',
|
||||
id: 'umb-preview',
|
||||
} satisfies Meta;
|
||||
|
||||
export const Preview = () => html`<umb-preview></umb-preview>`;
|
||||
14
src/Umbraco.Web.UI.Client/src/apps/preview/preview.test.ts
Normal file
14
src/Umbraco.Web.UI.Client/src/apps/preview/preview.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
import { UmbPreviewElement } from './preview.element.js';
|
||||
|
||||
describe('UmbPreview', () => {
|
||||
let element: UmbPreviewElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
element = await fixture(html`<umb-preview></umb-preview>`);
|
||||
});
|
||||
|
||||
it('is defined with its own instance', () => {
|
||||
expect(element).to.be.instanceOf(UmbPreviewElement);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import type { ManifestMenu } from './menu.model.js';
|
||||
import type { ManifestMenuItem, ManifestMenuItemTreeKind } from './menu-item.model.js';
|
||||
import type { ManifestModal } from './modal.model.js';
|
||||
import type { ManifestPackageView } from './package-view.model.js';
|
||||
import type { ManifestPreviewAppProvider } from './preview-app.model.js';
|
||||
import type { ManifestPropertyAction, ManifestPropertyActionDefaultKind } from './property-action.model.js';
|
||||
import type { ManifestPropertyEditorUi, ManifestPropertyEditorSchema } from './property-editor.model.js';
|
||||
import type { ManifestRepository } from './repository.model.js';
|
||||
@@ -97,6 +98,7 @@ export type * from './mfa-login-provider.model.js';
|
||||
export type * from './modal.model.js';
|
||||
export type * from './monaco-markdown-editor-action.model.js';
|
||||
export type * from './package-view.model.js';
|
||||
export type * from './preview-app.model.js';
|
||||
export type * from './property-action.model.js';
|
||||
export type * from './property-editor.model.js';
|
||||
export type * from './repository.model.js';
|
||||
@@ -183,6 +185,7 @@ export type ManifestTypes =
|
||||
| ManifestModal
|
||||
| ManifestMonacoMarkdownEditorAction
|
||||
| ManifestPackageView
|
||||
| ManifestPreviewAppProvider
|
||||
| ManifestPropertyActions
|
||||
| ManifestPropertyEditorSchema
|
||||
| ManifestPropertyEditorUi
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ManifestElement } from '@umbraco-cms/backoffice/extension-api';
|
||||
|
||||
/**
|
||||
* Preview apps are displayed in the menu of the preview window.
|
||||
*/
|
||||
export interface ManifestPreviewAppProvider extends ManifestElement {
|
||||
type: 'previewApp';
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export { UmbDocumentDetailRepository, UMB_DOCUMENT_DETAIL_REPOSITORY_ALIAS } from './detail/index.js';
|
||||
export { UmbDocumentItemRepository, UMB_DOCUMENT_ITEM_REPOSITORY_ALIAS } from './item/index.js';
|
||||
export { UmbDocumentPublishingRepository, UMB_DOCUMENT_PUBLISHING_REPOSITORY_ALIAS } from './publishing/index.js';
|
||||
export { UmbDocumentPreviewRepository } from './preview/index.js';
|
||||
|
||||
export type { UmbDocumentItemModel } from './item/types.js';
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository';
|
||||
import { PreviewService } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
import { tryExecute } from '@umbraco-cms/backoffice/resources';
|
||||
|
||||
export class UmbDocumentPreviewRepository extends UmbRepositoryBase {
|
||||
constructor(host: UmbControllerHost) {
|
||||
super(host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enters preview mode.
|
||||
* @return {Promise<void>}
|
||||
* @memberof UmbDocumentPreviewRepository
|
||||
*/
|
||||
async enter(): Promise<void> {
|
||||
await tryExecute(PreviewService.postPreview());
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exits preview mode.
|
||||
* @return {Promise<void>}
|
||||
* @memberof UmbDocumentPreviewRepository
|
||||
*/
|
||||
async exit(): Promise<void> {
|
||||
await tryExecute(PreviewService.deletePreview());
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { UmbDocumentPreviewRepository } from './document-preview.repository.js';
|
||||
@@ -1,8 +1,13 @@
|
||||
import { UmbDocumentUserPermissionCondition } from '../../user-permissions/document-user-permission.condition.js';
|
||||
import { UMB_DOCUMENT_WORKSPACE_CONTEXT } from '../document-workspace.context-token.js';
|
||||
import { UMB_USER_PERMISSION_DOCUMENT_UPDATE } from '../../user-permissions/index.js';
|
||||
import { UmbWorkspaceActionBase } from '@umbraco-cms/backoffice/workspace';
|
||||
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
|
||||
// TODO: Investigate how additional preview environments can be supported. [LK:2024-05-16]
|
||||
// https://docs.umbraco.com/umbraco-cms/reference/content-delivery-api/additional-preview-environments-support
|
||||
// In v13, they are registered on the server using `SendingContentNotification`, which is no longer available in v14.
|
||||
|
||||
export class UmbDocumentSaveAndPreviewWorkspaceAction extends UmbWorkspaceActionBase {
|
||||
constructor(host: UmbControllerHost, args: any) {
|
||||
super(host, args);
|
||||
@@ -24,7 +29,8 @@ export class UmbDocumentSaveAndPreviewWorkspaceAction extends UmbWorkspaceAction
|
||||
}
|
||||
|
||||
async execute() {
|
||||
alert('Save and preview');
|
||||
const workspaceContext = await this.getContext(UMB_DOCUMENT_WORKSPACE_CONTEXT);
|
||||
workspaceContext.saveAndPreview();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
UMB_EDIT_DOCUMENT_WORKSPACE_PATH_PATTERN,
|
||||
} from '../paths.js';
|
||||
import { UMB_DOCUMENTS_SECTION_PATH } from '../../paths.js';
|
||||
import { UmbDocumentPreviewRepository } from '../repository/preview/index.js';
|
||||
import { UMB_DOCUMENT_WORKSPACE_ALIAS } from './manifests.js';
|
||||
import { UmbEntityContext } from '@umbraco-cms/backoffice/entity';
|
||||
import { UMB_INVARIANT_CULTURE, UmbVariantId } from '@umbraco-cms/backoffice/variant';
|
||||
@@ -604,6 +605,28 @@ export class UmbDocumentWorkspaceContext
|
||||
}
|
||||
}
|
||||
|
||||
async #handleSaveAndPreview() {
|
||||
const unique = this.getUnique();
|
||||
if (!unique) throw new Error('Unique is missing');
|
||||
|
||||
let culture = UMB_INVARIANT_CULTURE;
|
||||
|
||||
// Save document (the active variant) before previewing.
|
||||
const { selected } = await this.#determineVariantOptions();
|
||||
if (selected.length > 0) {
|
||||
culture = selected[0];
|
||||
const variantId = UmbVariantId.FromString(culture);
|
||||
const saveData = this.#buildSaveData([variantId]);
|
||||
await this.#performSaveOrCreate(saveData);
|
||||
}
|
||||
|
||||
// Tell the server that we're entering preview mode.
|
||||
await new UmbDocumentPreviewRepository(this).enter();
|
||||
|
||||
const preview = window.open(`preview?id=${unique}&culture=${culture}`, 'umbpreview');
|
||||
preview?.focus();
|
||||
}
|
||||
|
||||
async #handleSaveAndPublish() {
|
||||
const unique = this.getUnique();
|
||||
if (!unique) throw new Error('Unique is missing');
|
||||
@@ -672,6 +695,7 @@ export class UmbDocumentWorkspaceContext
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async #performSaveAndPublish(variantIds: Array<UmbVariantId>, saveData: UmbDocumentDetailModel): Promise<void> {
|
||||
const unique = this.getUnique();
|
||||
if (!unique) throw new Error('Unique is missing');
|
||||
@@ -732,6 +756,10 @@ export class UmbDocumentWorkspaceContext
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
public async saveAndPreview(): Promise<void> {
|
||||
return this.#handleSaveAndPreview();
|
||||
}
|
||||
|
||||
public async saveAndPublish(): Promise<void> {
|
||||
return this.#handleSaveAndPublish();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user