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} + + +`);