Skip to content

Commit

Permalink
feat(preview): add isolated (iframe) mod support for preview area
Browse files Browse the repository at this point in the history
  • Loading branch information
ala-n committed Jan 22, 2024
1 parent e28fd08 commit e435b3c
Show file tree
Hide file tree
Showing 4 changed files with 118 additions and 5 deletions.
2 changes: 1 addition & 1 deletion pages/views/examples/example/form.njk
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ title: Example with form
</form>
</script>

<uip-preview class="centered-content" resizable></uip-preview>
<uip-preview class="centered-content" resizable isolation></uip-preview>

<uip-settings resizable vertical="@+sm" target=".demo-form">
<uip-bool-setting label="Email validation" target="#email" mode="append" attribute="class" value="validation-input"></uip-bool-setting>
Expand Down
6 changes: 6 additions & 0 deletions src/core/preview/preview.less
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@
max-height: 100%;
}

&-iframe {
width: 100%;
min-height: 100%;
border: none;
}

&.centered-content &-inner {
margin: auto;
}
Expand Down
76 changes: 72 additions & 4 deletions src/core/preview/preview.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import React from 'jsx-dom';

import {listen, memoize} from '@exadel/esl/modules/esl-utils/decorators';
import {attr, listen, memoize} from '@exadel/esl/modules/esl-utils/decorators';
import {ESLIntersectionTarget} from '@exadel/esl/modules/esl-event-listener/core';
import {parseBoolean, toBooleanAttribute} from '@exadel/esl/modules/esl-utils/misc';
import {afterNextRender, skipOneRender} from '@exadel/esl/modules/esl-utils/async';

import {UIPPlugin} from '../base/plugin';
import {UIPRenderingTemplatesService} from '../processors/templates';
import {UIPRenderingPreprocessorService} from '../processors/rendering';

import type {ESLIntersectionEvent} from '@exadel/esl/modules/esl-event-listener/core';

/**
* Preview {@link UIPPlugin} custom element definition.
* Mandatory for UI Playground rendering. Displays active playground content
*/
export class UIPPreview extends UIPPlugin {
static is = 'uip-preview';
static observedAttributes: string[] = ['dir', 'resizable'];
static observedAttributes: string[] = ['dir', 'resizable', 'isolation', 'isolation-template'];

/** Marker to use iframe isolated rendering */
@attr({parser: parseBoolean, serializer: toBooleanAttribute}) public isolation: boolean;
/** Template to use for isolated rendering */
@attr({defaultValue: 'default'}) public isolationTemplate: string;

protected _iframeResizeRAF: number = 0;

/** {@link UIPPlugin} section wrapper */
@memoize()
Expand All @@ -21,6 +33,11 @@ export class UIPPreview extends UIPPlugin {
return <div className={`${pluginType.is}-inner uip-plugin-inner esl-scrollable-content`}></div> as HTMLElement;
}

@memoize()
protected get $iframe(): HTMLIFrameElement {
return <iframe className="uip-preview-iframe" frameBorder="0"></iframe> as HTMLIFrameElement;
}

/** Extra element to animate decreasing height of content smoothly */
@memoize()
protected get $container(): HTMLElement {
Expand All @@ -34,11 +51,11 @@ export class UIPPreview extends UIPPlugin {
) as HTMLElement;
}

/** Changes preview markup from state changes */
/** Updates preview content from the model state changes */
@listen({event: 'uip:change', target: ($this: UIPPreview) => $this.$root})
protected _onRootStateChange(): void {
this.$container.style.minHeight = `${this.$inner.offsetHeight}px`;
this.$inner.innerHTML = UIPRenderingPreprocessorService.preprocess(this.model!.html);
this.isolation ? this.writeContentIsolated() : this.writeContent();

afterNextRender(() => this.$container.style.minHeight = '0px');
skipOneRender(() => {
Expand All @@ -57,9 +74,51 @@ export class UIPPreview extends UIPPlugin {
super.disconnectedCallback();
}

/** Writes the content directly to the inner area (non-isolated frame) */
protected writeContent(): void {
this.$inner.innerHTML = UIPRenderingPreprocessorService.preprocess(this.model!.html);
this.stopIframeResizeLoop();
}

/** Writes the content to the iframe inner (isolated frame) */
protected writeContentIsolated(): void {
if (this.$iframe.parentElement !== this.$inner) {
this.$inner.innerHTML = '';
this.$inner.appendChild(this.$iframe);
this.startIframeResizeLoop();
}

const title = this.model!.activeSnippet?.label || 'UI Playground';
const content = UIPRenderingPreprocessorService.preprocess(this.model!.html);
const html = UIPRenderingTemplatesService.render(this.isolationTemplate, {title, content});

this.$iframe.contentWindow?.document.open();
this.$iframe.contentWindow?.document.write(html);
this.$iframe.contentWindow?.document.close();
}

/** Start and do a resize sync-loop iteration. Recall itself on the next frame. */
protected startIframeResizeLoop(): void {
// Prevents multiple loops
if (this._iframeResizeRAF) cancelAnimationFrame(this._iframeResizeRAF);
// Addition loop fallback for iframe removal
if (this.$iframe.parentElement !== this.$inner) return;
// Reflect iframe height with inner content
this.$iframe.style.height = `${this.$iframe.contentWindow?.document.body.scrollHeight}px`;
this._iframeResizeRAF = requestAnimationFrame(this.startIframeResizeLoop.bind(this));
}

/** Stop resize loop iterations created by `startIframeResizeLoop` */
protected stopIframeResizeLoop(): void {
if (!this._iframeResizeRAF) return;
cancelAnimationFrame(this._iframeResizeRAF);
this._iframeResizeRAF = 0;
}

protected override attributeChangedCallback(attrName: string, oldVal: string, newVal: string): void {
if (attrName === 'resizable' && newVal === null) this.clearInlineSize();
if (attrName === 'dir') this.updateDir();
if (attrName === 'isolation' || attrName === 'isolation-template') this._onRootStateChange();
}

/** Resets element both inline height and width properties */
Expand All @@ -82,4 +141,13 @@ export class UIPPreview extends UIPPlugin {
if (e.propertyName !== 'min-height') return;
this.$container.style.removeProperty('min-height');
}

/** Handles visibility change of the preview are to limit resize sync-loops to the active preview area */
@listen({
event: 'intersects',
target: (preview: UIPPreview) => ESLIntersectionTarget.for(preview.$container, {threshold: 0.01}),
})
protected _onIntersects(e: ESLIntersectionEvent): void {
e.isIntersecting ? this.startIframeResizeLoop() : this.stopIframeResizeLoop();
}
}
39 changes: 39 additions & 0 deletions src/core/processors/templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {format} from '@exadel/esl/modules/esl-utils/misc';

interface UIPRenderingTemplateParams {
title?: string;
content: string;
[additional: string]: string | number | boolean | undefined | null;
}

export class UIPRenderingTemplatesService {
/** Template storage */
protected static templates = new Map<string, string>();

/** Register template */
public static add(name: string, template: string): void {
this.templates.set(name, template);
}
/** Get template */
public static get(name: string): string | undefined {
return this.templates.get(name);
}

/** Render template */
public static render(name: string, params: UIPRenderingTemplateParams): string {
const template = this.get(name);
if (!template) return params.content;
return format(template, params);
}
}

UIPRenderingTemplatesService.add('default', `
<html>
<head>
<title>{title}</title>
</head>
<body>
{content}
</body>
</html>
`);

0 comments on commit e435b3c

Please sign in to comment.