import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit'; import {MutationController} from '@lit-labs/observers/mutation-controller.js'; import {property, query, state} from 'lit/decorators.js'; import {styleMap} from 'lit/directives/style-map.js'; import ZincElement from '../../internal/zinc-element'; import styles from './preview-frame.scss'; const DEVICE_WIDTHS = { desktop: '100%', tablet: '768px', mobile: '390px', } as const; export type PreviewFrameDevice = keyof typeof DEVICE_WIDTHS; /** * @summary Embeds a live preview iframe and drives the hp-preview postMessage * protocol: answers the frame's ready handshake with a config payload fetched * from data-uri, auto-saves watched forms on change, refreshes the preview * after each save (its own, or a shell-driven save of a `refresh-on` form), * accepts a theme payload via setTheme() that is retained and replayed * after every ready handshake, and grows the frame to a content height the * embed reports so the panel scrolls an overflowing page. * * @documentation https://zinc.style/components/preview-frame * @status experimental * @since 1.0 * * @event zn-error - Emitted when the preview reports a render error or a save fails. * * @csspart base - The component's base wrapper. * @csspart stage - The device-width wrapper around the iframe. * @csspart iframe - The preview iframe. * @csspart error - The error overlay. * * @cssproperty --zn-preview-frame-dot-spacing - Spacing of the backdrop dot grid (`backdrop="dots"`). Defaults to 20px. * @cssproperty --zn-preview-frame-dot-opacity - Opacity of the backdrop dots (`backdrop="dots"`). Defaults to 0.08. */ export default class ZnPreviewFrame extends ZincElement { static styles: CSSResultGroup = unsafeCSS(styles); /** URL of the preview shell page (tokened embed URL). */ @property() src = ''; /** Expected origin of the iframe; all postMessage traffic is checked against it. */ @property({attribute: 'frame-origin'}) frameOrigin = ''; /** Endpoint returning the hp-preview:config payload JSON. The console proxy rewrites this attribute to an app-prefixed path for proper fetch resolution. */ @property({attribute: 'data-uri'}) dataUri = ''; /** * Selector (resolved against the component's root node) for the forms to watch. * Defaults to only forms explicitly opted in via a `data-auto-save` attribute — * unmarked forms keep normal submit behavior and are never intercepted, * auto-saved, or used to trigger a preview refresh. Override to widen the scope. */ @property() watch = 'form[data-auto-save]'; /** * Selector for forms whose saves are left to the shell but should still * refresh the preview. These are never intercepted: the shell submits them * (so its own response handling — alerts, refreshes — runs as normal) and the * preview re-fetches its config once the shell reports the save complete. * Matched by delegation on the shell's bubbled `complete` event rather than * by attaching to the forms themselves, so a save anywhere on the page * refreshes the preview — including forms in a different DOM root, e.g. a * page-level form saved while the preview sits inside a tab panel's shadow * root (a page's Template select lives on one tab, its preview on another). * Set empty to disable. */ @property({attribute: 'refresh-on'}) refreshOn = 'form'; /** Debounce in ms between a form change and its auto-save. */ @property({type: Number}) debounce = 400; /** * Zooms the previewed page out (0–1]. The frame always fills the panel; * zoom shrinks the content browser-style, so 0.4 shows the page at 40% * size with correspondingly more of it visible. 1 = natural size. * Ignored when `fill` is set. */ @property({type: Number}) zoom = 1; /** * The visible height (in CSS pixels) of the preview panel. Fixed rather * than measured, because a measured height would feed back into the * scaled iframe's layout box and grow unbounded. With `fill` set, this * becomes a `min-height` floor instead of the height. */ @property({type: Number, attribute: 'min-height'}) minHeight = 480; /** * Fills the panel's own column height instead of using a fixed * `min-height` pixel height — for hosts (like zn-theme-editor) whose * layout already stretches the column to match a taller sibling. * `zoom` is ignored when set: its oversize maths depends on a known * pixel height, which `fill` deliberately doesn't have. */ @property({type: Boolean}) fill = false; /** * Constrains and centres the preview to a device width: `desktop` (100%), * `tablet` (768px) or `mobile` (390px). The iframe element itself is * narrowed, so the embedded page's own media queries fire. */ @property({reflect: true}) device: PreviewFrameDevice = 'desktop'; /** Backdrop behind the stage: `dots` (default) is the canvas dot grid; `panel` is a plain `rgb(var(--zn-panel))` fill. */ @property({reflect: true}) backdrop: 'dots' | 'panel' = 'dots'; /** * Lets pointer input through to the embedded page. The preview is inert by * default: clicks never reach the frame, so the previewed page can't be * navigated or submitted from inside the preview. Cross-origin content can't * be reached from here to cancel its own handlers, so this blocks pointer * input entirely — hover goes with it. Scrolling doesn't: an overflowing * page is scrolled by the panel rather than by the frame (see _contentHeight). * * Set, the frame becomes a real viewport instead: it stays the panel's own * height and the embed scrolls itself, so there is one scrollbar rather than * a panel scrolling an oversized frame, and the embed's viewport-relative * layout (`100vh`, `position: fixed`, sticky headers) sizes to what's on * screen. _contentHeight is ignored while this is set. */ @property({type: Boolean, reflect: true}) interactive = false; @query('iframe') frame: HTMLIFrameElement; @state() private error = ''; /** * Height of the embedded page's content, when it's known: reported by the * embed (`height` on hp-preview:rendered, or an hp-preview:height message) or * measured directly for a same-origin embed. The frame is laid out at this * height rather than the panel's, so the page never scrolls inside the frame * — the panel scrolls instead, which is what makes an overflowing preview * reachable while pointer input to the frame is blocked. 0 = unknown, and the * frame falls back to filling the panel. Kept up to date either way, but only * laid out when the frame is inert: an `interactive` frame scrolls itself. */ @state() private _contentHeight = 0; private _generation = 0; private _theme: Record | undefined; private _contentObserver: ResizeObserver | undefined; private readonly _watchedForms = new Set(); private readonly _debounceTimers = new Map(); // 'complete' delegation targets: the document always, plus the component's // own shadow root when it has one (a non-composed event never reaches the // document). A composed event inside the shadow root hits both, so the // handler dedupes by event object. private _shellSaveRoots: EventTarget[] = []; private _lastShellSave: Event | undefined; // Forms are siblings in light DOM and get replaced when other content // re-renders; re-resolve them whenever the surrounding DOM changes. // eslint-disable-next-line @typescript-eslint/no-unused-vars private readonly _formObserver = new MutationController(this, { target: null, config: {subtree: true, childList: true}, callback: () => this._attachForms(), }); connectedCallback() { super.connectedCallback(); window.addEventListener('message', this._onMessage); this._attachForms(); const root = this.getRootNode(); this._shellSaveRoots = root instanceof ShadowRoot ? [document, root] : [document]; this._shellSaveRoots.forEach(target => target.addEventListener('complete', this._onShellSave)); if (root instanceof Document) { this._formObserver.observe(root.body); } else if (root instanceof ShadowRoot) { // MutationController's type only accepts Element, but MutationObserver.observe() // accepts any Node at runtime, including a ShadowRoot. this._formObserver.observe(root as unknown as Element); } } protected willUpdate(changed: PropertyValues) { if (changed.has('src')) this._resetContentHeight(); } disconnectedCallback() { super.disconnectedCallback(); window.removeEventListener('message', this._onMessage); this._contentObserver?.disconnect(); this._contentObserver = undefined; this._watchedForms.forEach(form => this._detachForm(form)); this._watchedForms.clear(); this._shellSaveRoots.forEach(target => target.removeEventListener('complete', this._onShellSave)); this._shellSaveRoots = []; } private readonly _onMessage = (e: MessageEvent) => { // Fail closed: with frame-origin unset, every message is rejected. if (e.origin !== this.frameOrigin) return; if (!this.frame || e.source !== this.frame.contentWindow) return; const data = e.data as { type?: string; message?: string; height?: unknown } | undefined; switch (data?.type) { case 'hp-preview:ready': // config first: the embed applies the theme on top of a rendered page void this._sendConfig().then(() => this._postTheme()); break; case 'hp-preview:rendered': this.error = ''; this._applyContentHeight(data.height); break; case 'hp-preview:height': this._applyContentHeight(data.height); break; case 'hp-preview:error': this._fail(String(data.message ?? 'Preview failed to render')); break; } }; private _applyContentHeight(height: unknown) { const value = Math.round(Number(height)); if (!Number.isFinite(value) || value <= 0) return; this._contentHeight = value; } private _resetContentHeight() { this._contentHeight = 0; this._contentObserver?.disconnect(); this._contentObserver = undefined; } // Same-origin embeds don't need to implement the height half of the protocol — // their document can be measured from here. private readonly _onFrameLoad = () => { this._contentObserver?.disconnect(); this._contentObserver = undefined; let root: HTMLElement | null | undefined; try { root = this.frame?.contentDocument?.documentElement; } catch { return; // cross-origin: the embed has to report its own height } if (!root) return; const measure = () => { const measured = root.getBoundingClientRect().height; if (measured > (this.frame?.clientHeight ?? 0)) this._applyContentHeight(measured); }; measure(); this._contentObserver = new ResizeObserver(measure); this._contentObserver.observe(root); }; /** Re-fetches the payload and pushes a fresh config to the preview. */ refresh() { return this._sendConfig(); } /** * Pushes a theme payload into the preview. The payload is retained and * re-posted after every ready handshake, so a frame reload doesn't drop an * in-progress theme. */ setTheme(theme: Record) { this._theme = theme; this._postTheme(); } private _postTheme() { if (!this._theme || !this.frameOrigin) return; this.frame?.contentWindow?.postMessage( {type: 'hp-preview:theme', ...this._theme}, this.frameOrigin ); } private async _sendConfig() { // A theme-editor-only setup has no config endpoint; fetch('') would return // the host page's HTML and fail JSON parsing into the error overlay. if (!this.dataUri || !this.frameOrigin) return; const generation = ++this._generation; try { const response = await fetch(this.dataUri, { credentials: 'same-origin', // The console proxy pagelet-wraps app responses; 'download' streams // the endpoint's raw JSON through verbatim. headers: {'x-kx-fetch-style': 'download'}, }); if (!response.ok) { throw new Error(await response.text() || response.statusText); } const payload = await response.json() as Record; if (generation !== this._generation) return; // a newer refresh is in flight this.frame?.contentWindow?.postMessage( {type: 'hp-preview:config', ...payload}, this.frameOrigin ); } catch (err) { if (generation === this._generation) { this._fail(err instanceof Error ? err.message : String(err)); } } } private _attachForms() { const root = this.getRootNode() as Document | ShadowRoot; const matched = new Set(); root.querySelectorAll(this.watch).forEach(node => { if (node instanceof HTMLFormElement) matched.add(node); }); this._watchedForms.forEach(form => { if (!matched.has(form)) { this._detachForm(form); this._watchedForms.delete(form); } }); matched.forEach(form => { if (this._watchedForms.has(form)) return; this._watchedForms.add(form); form.addEventListener('submit', this._onSubmit, {capture: true}); form.addEventListener('zn-change', this._onChange); form.addEventListener('zn-input', this._onChange); form.addEventListener('change', this._onChange); }); } // Shell-saved forms: the shell fires a bubbling 'complete' on the form it // submitted. composedPath()[0] recovers the form when the event was // retargeted crossing a shadow boundary on its way to the document. private readonly _onShellSave = (e: Event) => { if (e === this._lastShellSave) return; this._lastShellSave = e; if (!this.refreshOn) return; const origin = e.composedPath()[0]; if (!(origin instanceof HTMLFormElement) || !origin.matches(this.refreshOn)) return; if (this._watchedForms.has(origin)) return; // auto-saved forms refresh via _save void this._sendConfig(); }; private _detachForm(form: HTMLFormElement) { form.removeEventListener('submit', this._onSubmit, {capture: true}); form.removeEventListener('zn-change', this._onChange); form.removeEventListener('zn-input', this._onChange); form.removeEventListener('change', this._onChange); const timer = this._debounceTimers.get(form); if (timer) window.clearTimeout(timer); this._debounceTimers.delete(form); } private readonly _onChange = (e: Event) => { const form = e.currentTarget as HTMLFormElement; const existing = this._debounceTimers.get(form); if (existing) window.clearTimeout(existing); this._debounceTimers.set(form, window.setTimeout(() => { this._debounceTimers.delete(form); if (!form.isConnected) return; form.requestSubmit(); }, this.debounce)); }; private readonly _onSubmit = (e: SubmitEvent) => { e.preventDefault(); e.stopImmediatePropagation(); void this._save(e.currentTarget as HTMLFormElement); }; private async _save(form: HTMLFormElement) { try { const response = await fetch(form.getAttribute('action') || '', { method: 'POST', credentials: 'same-origin', // 'download' streams the app's own status and headers through verbatim; // without it the console proxy pagelet-wraps the response and a failed // save is indistinguishable from a successful one. headers: {'x-kx-fetch-style': 'download'}, body: new FormData(form), }); // Apps report a failed save as 204 plus an alert header, which is `ok`. const alert = response.headers.get('x-kubex-alert-danger'); if (alert) throw new Error(alert); if (!response.ok) { throw new Error(await response.text() || response.statusText); } await this._sendConfig(); } catch (err) { this._fail(err instanceof Error ? err.message : String(err)); } } private _fail(message: string) { this.error = message; this.emit('zn-error', {detail: {message}}); } render() { const zoom = this.zoom > 0 && this.zoom <= 1 ? this.zoom : 1; // Browser-style zoom-out: the iframe lays out oversized (1/zoom) and is // transformed back down, so the frame fills the panel while the content // renders smaller and more of the page is visible. Percentage width means // nothing is measured — no layout feedback loop. // Growing the frame past the panel is the workaround for an inert frame not // being scrollable; interactive, that trades one scrollbar for two and hands // the embed a viewport taller than the panel it's shown in. const content = this.error || this.interactive ? 0 : this._contentHeight; const iframeStyles = this.fill ? {width: '100%', height: content ? `max(${content}px, 100%)` : '100%'} : { width: `${100 / zoom}%`, height: `${Math.max(content, this.minHeight / zoom)}px`, transform: `scale(${zoom})`, transformOrigin: '0 0', }; const containerStyles = this.fill ? {height: '100%', minHeight: `${this.minHeight}px`} : {height: `${this.minHeight}px`}; const stageStyles: Record = { width: DEVICE_WIDTHS[this.device] ?? DEVICE_WIDTHS.desktop, }; if (content) { stageStyles.height = this.fill ? `max(${content}px, 100%)` : `${Math.max(content * zoom, this.minHeight)}px`; } return html`
${this.error ? html`
${this.error}
` : ''}
`; } }