diff --git a/pages/views/examples/example/form.njk b/pages/views/examples/example/form.njk
index d22d44de..86f3a2b0 100644
--- a/pages/views/examples/example/form.njk
+++ b/pages/views/examples/example/form.njk
@@ -25,7 +25,7 @@ title: Example with form
-
+
diff --git a/src/core/preview/preview.less b/src/core/preview/preview.less
index 3a1012e9..f091f742 100644
--- a/src/core/preview/preview.less
+++ b/src/core/preview/preview.less
@@ -39,6 +39,12 @@
max-height: 100%;
}
+ &-iframe {
+ width: 100%;
+ min-height: 100%;
+ border: none;
+ }
+
&.centered-content &-inner {
margin: auto;
}
diff --git a/src/core/preview/preview.tsx b/src/core/preview/preview.tsx
index a98a8813..f1c658e5 100644
--- a/src/core/preview/preview.tsx
+++ b/src/core/preview/preview.tsx
@@ -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()
@@ -21,6 +33,11 @@ export class UIPPreview extends UIPPlugin {
return as HTMLElement;
}
+ @memoize()
+ protected get $iframe(): HTMLIFrameElement {
+ return as HTMLIFrameElement;
+ }
+
/** Extra element to animate decreasing height of content smoothly */
@memoize()
protected get $container(): HTMLElement {
@@ -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(() => {
@@ -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 */
@@ -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();
+ }
}
diff --git a/src/core/processors/templates.ts b/src/core/processors/templates.ts
new file mode 100644
index 00000000..7c47438c
--- /dev/null
+++ b/src/core/processors/templates.ts
@@ -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();
+
+ /** 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', `
+
+
+ {title}
+
+
+ {content}
+
+
+`);