keystatic icon indicating copy to clipboard operation
keystatic copied to clipboard

showcase: enhance live preview with an iframe rendered in the edit page

Open tresorama opened this issue 4 months ago • 1 comments

Screenshots

Disabled

Note the new Live Preview button in the top bar

Image

Enabled + Desktop

Image

Enabled + Mobile

Image

How it works

This implementation:

  • requires that Keystatic Official Live Preview is implemented
  • doesn't edit code of keystatic.
  • the code is written as "user-land" only:
    • using react portal to teleport components inside the existing keystatic DOM
    • using a monkey patch on fetch strategy to emit events (when keystatic admin does mutations) that react components can subscribe against

I will copy the code tomorrow in the next comment for anyone that want to use it as a base implementation.

tresorama avatar Aug 17 '25 19:08 tresorama

Next.js v15 project + App Router + Tailwind (update style if you don't use it).

  1. Install deps
npm i jotai #state
npm i @keystar/ui #ui components used by keystatic (for docs looks react aria compoents)
npm i @uidotdev/usehooks # react utility hooks (used for `useMeasure`)
  1. Add a new componants in the layout of Keystatic admin
import { KeystaticAdmin } from "./keystatic";
+ import { KeystaticAdminUiAddons } from "@/components/keystatic-admin-ui-addons";

export default function Layout() {
  return (
    <html lang="en">
      <body>
        <KeystaticAdmin />
+        <KeystaticAdminUiAddons />
      </body>
    </html>
  );
}
  1. Create all files of KeystaticAdminUiAddons
// @/components/keystatic-admin-ui-addons/index.tsx

'use client';

import React from "react";
import { usePathname } from "next/navigation";
import { KeystarProvider } from "@keystar/ui/core";
import "../../../app/(frontend)/tailwind.css";

import { PortalSafe } from "./components/portal-safe";
import { RootRouter } from "./root-router";

export const KeystaticAdminUiAddons = () => {
  // router (next js)
  const pathname = usePathname();

  return (
    <KeystarProvider>
      <PortalSafe
        rootElementCssSelector='body'
        reactTree={<RootRouter pathname={pathname} />}
      />
    </KeystarProvider>
  );
};

// @/components/keystatic-admin-ui-addons/components/portal-safe.tsx

'use client';

import { useEffect, useState } from "react";
import { createPortal } from "react-dom";

// constants
const MAX_RETRY = 80;


// errors
class MountError_DomElNotFound extends Error {
  code = 'DOM_EL_NOT_FOUND';
}

// main component

/**
 * `React Client Component`- Generic React Portal that requires a `cssSelector` and a `React.ReactNode` to mount.  
 * Internally handle the case when the element is not found, by retrying `MAX_RETRY` times with interval.
 */
export function PortalSafe({
  rootElementCssSelector,
  reactTree,
}: {
  rootElementCssSelector: string,
  reactTree: React.ReactNode,
}) {

  const [jsx, setJsx] = useState<null | React.ReactNode>(null);
  const [retryTryLeft, setRetryTryLeft] = useState(MAX_RETRY);

  // when react tree changes -> reset 
  // NOTE: this is required , otherwise the portal will not be updated when the react tree cahnges
  useEffect(() => {
    setRetryTryLeft(MAX_RETRY);
    setJsx(null);
  }, [reactTree]);

  // on mount and on state changes -> mount the react tree using createPortal
  useEffect(
    () => {

      const interval = setInterval(() => {
        console.log("KeystaticAdminUiAddons: mount retry", retryTryLeft);

        // if prev interval mounted then stop
        if (jsx) {
          clearInterval(interval);
          return;
        }
        // if no retries left then stop
        if (retryTryLeft <= 0) {
          clearInterval(interval);
          return;
        }

        // try to mount
        try {
          // try get dom el
          const domEl = document.querySelector(rootElementCssSelector);
          if (!domEl) throw new MountError_DomElNotFound();

          // try portal mount
          const newJsx = createPortal(reactTree, domEl);
          setJsx(newJsx);
          setRetryTryLeft(prev => prev - 1);

        } catch (_error) {

          if (_error instanceof MountError_DomElNotFound) {
            console.error(`KeystaticAdminUiAddons: "${rootElementCssSelector}" not found in DOM`);
          }
          else if (_error instanceof Error) {
            console.error(`KeystaticAdminUiAddons: "${rootElementCssSelector}" portal mount error`, _error);
          }

          setRetryTryLeft(prev => prev - 1);

        }
      }, 200);

      return () => {
        clearInterval(interval);
      };

    },
    [reactTree, rootElementCssSelector, jsx, retryTryLeft],
  );

  // if already mounted
  if (jsx) {
    return jsx;
  }

  // if not mounted but end retry
  if (retryTryLeft <= 0) {
    return <span>Mount error</span>;
  }

  return null;

}


// @/components/keystatic-admin-ui-addons/root-router.tsx

'use client';

import React, { useMemo } from "react";

import { CollectionItemEdit } from "./views/collection-item-edit";

/**
 * `React Client Component`- Root Router (similar to react router), that render the view based on pathname
 */
export const RootRouter = ({
  pathname,
}: {
  pathname: string,
}) => {
  const view = useMemo(() => viewsConfig.getMatchedView(pathname), [pathname]);
  if (!view) return null;
  return view.reactTree;
};


type ViewsConfigItem = {
  key: string,
  pathnameRegex: RegExp,
  reactTree: React.ReactElement;
};
type ViewsConfig = {
  /** The static map of pathname -> react tree */
  viewsMap: ViewsConfigItem[],
  /** getter for the matched view */
  getMatchedView: (pathname: string) => ViewsConfigItem | null,
};
const viewsConfig: ViewsConfig = {
  viewsMap: [
    {
      key: 'collection-item-edit',
      pathnameRegex: /^\/keystatic\/branch\/[^/]+\/collection\/[^/]+\/item\/[^/]+$/,
      reactTree: (
        <React.StrictMode>
          <CollectionItemEdit />
        </React.StrictMode>
      )
    }
  ],
  getMatchedView: (pathname) => {
    const found = viewsConfig.viewsMap.find((item) => {
      const isMatched = item.pathnameRegex.test(pathname);
      return isMatched;
    });
    return found ?? null;
  },
};

// @/components/keystatic-admin-ui-addons/views/collection-item-edit/index.tsx

'use client';

import { EventDispatcherInit } from "./event-dispatcher";
import { LivePreviewEnabler } from "./live-preview-enabler";

/**
 * `React Client Component`- View CollectionItemEdit
 */
export const CollectionItemEdit = () => {
  return (
    <>
      <EventDispatcherInit />
      <LivePreviewEnabler />
    </>
  );
};
// @/components/keystatic-admin-ui-addons/views/collection-item-edit/event-dispatcher.tsx

import { atom, useAtomValue } from "jotai";
import { useEffect } from "react";

type EventKey = (
  | "keystatic:collection-item:save:pending"
  | "keystatic:collection-item:save:success"
  | "keystatic:collection-item:save:error"
);

type EventDispatcherAPI = {
  /** Array of subscribed listeners, these are consumers of our events */
  subcribedListeners: Array<{ id: number, key: EventKey, callback: () => void; }>,
  /** Register a new subscribed listener */
  subscribe(eventKey: EventKey, callback: () => void): () => void,
  /** call every subscribed listener that matches the eventKey */
  dispatch(eventKey: EventKey): void,
  /** Internal state */
  privateBag: {
    unmountFns: Array<() => void>,
    onFetchFinishListeners: Array<{
      id: number,
      callback: (params: {
        requestArgs: {
          url: URL | string,
          options: RequestInit,
        },
        response: Response,
      }) => void;
    }>,
  },
  /** initialize events */
  init(): void,
  initMonkeyFetch(): void,
  initEventsEmitters(): void,
  /** destroy events */
  destroy(): void,
};

// gloabl state

export const atomEventDispatcher = atom<EventDispatcherAPI>(() => ({
  subcribedListeners: [],
  subscribe(eventKey, callback) {
    const subId = new Date().getTime();
    this.subcribedListeners.push({
      id: subId,
      key: eventKey,
      callback
    });
    const unsubscribe = () => {
      this.subcribedListeners = this.subcribedListeners.filter(({ id }) => id !== subId);
    };
    return unsubscribe;
  },
  /** call every subscribed listener that matches the eventKey */
  dispatch(eventKey) {
    this.subcribedListeners.forEach(({ key, callback }) => {
      if (key === eventKey) {
        callback();
      }
    });
  },
  privateBag: {
    unmountFns: [],
    onFetchFinishListeners: [],
  },
  init() {
    // 1. monkey featch fetch to interceptc api calls
    this.initMonkeyFetch();

    // 2. listen to dom or fetch and trigger events
    this.initEventsEmitters();

  },
  initMonkeyFetch() {
    const originalFetch = window.fetch;
    window.fetch = async (...args) => {

      // create request data
      const requestUrl = typeof args[0] === 'string' || args[0] instanceof URL ? args[0] : null;
      const requestOptions: RequestInit = typeof args[1] === 'object' ? args[1] : {};

      // early abort if no url
      if (requestUrl === null) {
        return originalFetch(...args);
      }

      // call fetch
      const response = await originalFetch(...args);

      // trigger listeners
      this.privateBag.onFetchFinishListeners.forEach(item => {
        item.callback({
          requestArgs: {
            url: requestUrl,
            options: requestOptions,
          },
          response,
        });
      });

      // return response to ooriginal fetch
      return response;
    };
    this.privateBag.unmountFns.push(() => {
      window.fetch = originalFetch;
    });

  },
  initEventsEmitters() {

    // utils
    const registerEventListener = (
      cssSelector: string,
      handler: () => void
    ) => {
      // listen
      document.querySelector(cssSelector)?.addEventListener('click', handler);
      // unlisten
      this.privateBag.unmountFns.push(() => {
        document.querySelector(cssSelector)?.removeEventListener('click', handler);
      });
    };

    const registerFetchListener = (
      handler: EventDispatcherAPI['privateBag']['onFetchFinishListeners'][number]['callback']
    ) => {
      // listen
      const subId = new Date().getTime();
      this.privateBag.onFetchFinishListeners.push({
        id: subId,
        callback: handler,
      });
      // unlisten
      this.privateBag.unmountFns.push(() => {
        this.privateBag.onFetchFinishListeners = this.privateBag.onFetchFinishListeners.filter(({ id }) => id !== subId);
      });
    };

    // events

    registerEventListener(
      'main > header > div > div > button[form=item-edit-form]',
      () => this.dispatch("keystatic:collection-item:save:pending")
    );
    registerFetchListener(
      ({ requestArgs, response }) => {

        // if is not my fetch call abort
        const isMyCall = (
          requestArgs.url.toString() === 'https://api.github.com/graphql'
          &&
          requestArgs.options.body?.toString().includes('CreateCommit')
        );
        if (!isMyCall) return;

        // trigger events based on response
        if (response.ok) {
          this.dispatch("keystatic:collection-item:save:success");
        }
        else {
          this.dispatch("keystatic:collection-item:save:error");
        }
      }
    );

  },
  destroy() {
    if (!this) return;
    this.privateBag.unmountFns.forEach(fn => fn());
  }
}));

// component

/**
 * `React Client Component`- initialize the event dispatcher of this Keystatic admin page
 */
export const EventDispatcherInit = () => {
  // global state
  const eventDispatcher = useAtomValue(atomEventDispatcher);

  // on mount -> init event dispatcher so it register listeners that dispatch events
  useEffect(() => {
    eventDispatcher.init();
    return eventDispatcher.destroy;
  }, [eventDispatcher]);

  return null;
};
// @/components/keystatic-admin-ui-addons/views/collection-item-edit/live-preview-enabler.tsx

'use client';

import { atom, useAtom } from "jotai";

import { PortalSafe } from "../../components/portal-safe";
import { LivePreviewButton } from "./live-preview-button";
import { LivePreviewIframe } from "./live-preview-iframe";


// gloabl state
export const atomLivePreviewEnabled = atom<boolean>(false);

/**
 * `React Client Component`- Component that render every piece cof UI needed for Live Preview
 */
export const LivePreviewEnabler = () => {

  // global state
  const [isEnabled, setIsEnabled] = useAtom(atomLivePreviewEnabled);

  return (
    <>
      <PortalSafe
        rootElementCssSelector='main > header > div:has(>button, >nav, >div) > div'
        reactTree={<LivePreviewButton />}
      />
      {isEnabled && (
        <PortalSafe
          rootElementCssSelector='main:has(>header, >form) > form'
          reactTree={<LivePreviewIframe />}
        />
      )}
    </>
  );
};


// @/components/keystatic-admin-ui-addons/views/collection-item-edit/live-preview-button.tsx

import { useAtom } from 'jotai';
import { Button } from '@keystar/ui/button';

import { atomLivePreviewEnabled } from './live-preview-enabler';

export const LivePreviewButton = () => {

  // global state
  const [isLivePreviewEnabled, setIsLivePreviewEnabled] = useAtom(atomLivePreviewEnabled);

  return (
    <Button
      type="button"
      onClick={() => setIsLivePreviewEnabled(prev => !prev)}
      UNSAFE_className="order-[-1]"
    >
      <span className='mr-2'>{isLivePreviewEnabled ? "🟢" : "🔴"}</span>
      <span>Live Preview</span>
    </Button>
  );
};
// @/components/keystatic-admin-ui-addons/views/collection-item-edit/live-preview-iframe.tsx
import { useEffect, useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import { useAtomValue } from "jotai";
import { useMeasure } from '@uidotdev/usehooks';
import { MenuTrigger, Menu, Item } from '@keystar/ui/menu';
import { Button } from "@keystar/ui/button";


import { keystaticConfig } from "@/lib/keystatic/config/keystatic.config";
import { atomEventDispatcher } from "./event-dispatcher";


// main component

export const LivePreviewIframe = () => {

  // router
  const pathname = usePathname();

  // global state
  const eventDispatcher = useAtomValue(atomEventDispatcher);

  // local state
  const [fetchTime, setFetchTime] = useState(new Date().getTime());

  // derived state
  const livePreviewUrl = useMemo(() => calculatePreviewUrl(pathname, fetchTime), [pathname, fetchTime]);

  // on mount -> subscribe to events
  useEffect(() => {
    const unmountFns: Array<() => void> = [];
    unmountFns.push(
      eventDispatcher.subscribe('keystatic:collection-item:save:success', () => setFetchTime(new Date().getTime())),
    );
    return () => {
      unmountFns.forEach(fn => fn());
    };
  }, [eventDispatcher, setFetchTime]);

  // render

  return (
    !livePreviewUrl ? (
      <p>Cannot calculate live preview url</p>
    ) : (
      <Iframe livePreviewUrl={livePreviewUrl} />
    )
  );
};


/**
 * Function that calculate the preview url usign `pathname` and `keystaticConfig`.  
 * If is not possible to calculate the preview url, returns `null`.
 */
const calculatePreviewUrl = (
  pathname: string,
  /** A random string that is used to invalidate the cache and refreh the preview */
  fetchTime: number,
) => {
  if (!pathname) {
    throw new Error('Unexpected that usePathname() returned null. Are you sure the react tre is under NExt.js tree?');
  }

  // parse route path params
  const pathnameParts = pathname.split("/").filter(Boolean);
  const branchSlug = pathnameParts[2];
  const collectionSlug = pathnameParts[4];
  const documentSlug = pathnameParts[6];

  // try to find the collection
  const collectionEntry = Object.entries(keystaticConfig.collections).find(([collectionKey]) => {
    return collectionKey === collectionSlug;
  });
  if (!collectionEntry) return null;

  // calculate preview url
  const collectionConfig = collectionEntry[1];
  const previewUrlTemplate = collectionConfig.previewUrl;
  if (!previewUrlTemplate) {
    return null;
  }

  const previewUrl = previewUrlTemplate
    .replace("{branch}", branchSlug)
    .replace("{slug}", documentSlug)
    .concat(`?fetchTime=${fetchTime}`);
  return previewUrl;
};


// subcomponents

type WidthHeight = {
  width: number;
  height: number;
};


const Iframe = ({
  livePreviewUrl,
}: {
  livePreviewUrl: string;
}) => {

  // local state
  const [pageRealWidthHeight, setPageRealWidthHeight] = useState<WidthHeight>({ width: 0, height: 0 });

  return (
    <>
      <style>
        {`
        main:has(>header, >form) > form {
          display: flex;
          flex-direction: row;
        }
        main:has(>header, >form) > form > *:nth-child(1) {
          max-width: 40vw;
        }
        main:has(>header, >form) > form > .KAA--LIVE-PREVIEW-IFRAME {
          flex: 1 1 0px;
          min-width: 0px;
        }
        `}
      </style>
      <div className="KAA--LIVE-PREVIEW-IFRAME min-h-0 h-full flex flex-col border-l border-[var(--kui-color-border-muted)]">
        {/* Resizer Bar */}
        <div className="KAA--LIVE-PREVIEW-IFRAME--RESIZER sticky top-0 left-0 right-0">
          <IframeResizerBar
            setPageRealWidthHeight={setPageRealWidthHeight}
          />
        </div>
        {/* Iframe */}
        <div className="KAA--LIVE-PREVIEW-IFRAME--IFRAME-CONTAINER w-full min-h-0 flex-1">
          <IframeRendererNative
            pageRealWidthHeight={pageRealWidthHeight}
            iframeUrl={livePreviewUrl}
          />
        </div>
      </div>
    </>
  );
};

const DEVICE_PRESETS = {
  // Apple iPhones
  'iPhone 14': { width: 390, height: 844, label: 'iPhone 14 (390×844)' },
  'iPhone 14 Pro': { width: 393, height: 852, label: 'iPhone 14 Pro (393×852)' },
  'iPhone 14 Plus': { width: 428, height: 926, label: 'iPhone 14 Plus (428×926)' },
  'iPhone 14 Pro Max': { width: 430, height: 932, label: 'iPhone 14 Pro Max (430×932)' },
  'iPhone 13 Pro': { width: 390, height: 844, label: 'iPhone 13 Pro (390×844)' },
  'iPhone 13 Pro Max': { width: 428, height: 926, label: 'iPhone 13 Pro Max (428×926)' },
  'iPhone 13 Mini': { width: 360, height: 780, label: 'iPhone 13 Mini (360×780)' },
  'iPhone 12 Pro Max': { width: 428, height: 926, label: 'iPhone 12 Pro Max (428×926)' },
  'iPhone 12 Pro': { width: 390, height: 844, label: 'iPhone 12 Pro (390×844)' },
  'iPhone 12 Mini': { width: 375, height: 812, label: 'iPhone 12 Mini (375×812)' },
  'iPhone SE 2022': { width: 375, height: 667, label: 'iPhone SE (2022) (375×667)' },

  // Android / Samsung
  'Galaxy S22': { width: 360, height: 800, label: 'Galaxy S22 (360×800)' }, // media comune
  'Galaxy Note 9': { width: 360, height: 740, label: 'Galaxy Note 9 (360×740)' },

  // Tablets
  'iPad (portrait)': { width: 768, height: 1024, label: 'iPad (768×1024)' },
  'iPad Pro 12.9"': { width: 1024, height: 1366, label: 'iPad Pro 12.9″ (1024×1366)' },

  // Desktop / Laptop common
  'Laptop 1366×768': { width: 1366, height: 768, label: 'Laptop 1366×768' },
  'Laptop 1440×900': { width: 1440, height: 900, label: 'Laptop 1440×900' },
  'Desktop 1920×1080': { width: 1920, height: 1080, label: 'Desktop 1920×1080' },
  '4K UHD 3840×2160': { width: 3840, height: 2160, label: '4K UHD (3840×2160)' },
} as const satisfies Record<string, { width: number; height: number; label: string; }>;

type DevicePresetKey = keyof typeof DEVICE_PRESETS;

const IframeResizerBar = ({
  setPageRealWidthHeight,
}: {
  setPageRealWidthHeight: (params: WidthHeight) => void;
}) => {

  const [selectedDevicePresetKey, setSelectedDevicePresetKey] = useState<DevicePresetKey>('Laptop 1440×900');
  const selectedDevicePreset = DEVICE_PRESETS[selectedDevicePresetKey];

  useEffect(() => {
    setPageRealWidthHeight({
      width: selectedDevicePreset.width,
      height: selectedDevicePreset.height
    });
  }, [selectedDevicePreset, setPageRealWidthHeight]);

  return (
    <div className="p-2 flex justify-center items-center gap-2 border-b border-[var(--kui-color-border-muted)]">
      {/* <select
        value={selectedDevicePresetKey}
        onChange={e => setSelectedDevicePresetKey(e.target.value as DevicePresetKey)}
      >
        {Object.entries(DEVICE_PRESETS).map(([name, { width, height }]) => (
          <option key={name} value={name}>{name} ({width}x{height})</option>
        ))}
      </select> */}

      <MenuTrigger>
        <Button>
          {selectedDevicePreset.label}
        </Button>
        <Menu
          selectionMode="single"
          items={Object.entries(DEVICE_PRESETS).map(([name, data]) => ({ key: name, data }))}
          selectedKeys={[selectedDevicePresetKey]}
          onSelectionChange={([key]) => setSelectedDevicePresetKey(key as DevicePresetKey)}
          disallowEmptySelection
        >
          {item => (
            <Item key={item.key} textValue={item.data.label}>
              {item.data.label}
            </Item>
          )}
        </Menu>
      </MenuTrigger>

      {/* <input
          type="number"
          value={pageRealWidth}
          onChange={e => setPageRealWidth(Number(e.target.value))}
          style={{ width: "80px" }}
        /> */}
    </div>
  );

};



const IframeRendererNative = ({
  pageRealWidthHeight,
  iframeUrl,
}: {
  pageRealWidthHeight: WidthHeight,
  iframeUrl: string;
}) => {

  // local state
  const [refIframeContainer, iframeContainerMeasures] = useMeasure();

  // derived state
  const iframeZoom = useMemo<{
    transformScale: number,
    transformOrigin: "left top" | "center top";
  }>(
    () => {
      // 1. if width is null -> no zoom + align top-left ...
      if (iframeContainerMeasures.width === null) {
        return {
          transformScale: 1,
          transformOrigin: "left top",
        };
      }

      // calculate zoom
      const zoom = iframeContainerMeasures.width / pageRealWidthHeight.width;

      // 2. if zoom is more than 1 means that the width is less the iframe container -> clamp zoom at 0.75 + align center
      if (zoom > 1) {
        return {
          transformScale: 0.75,
          transformOrigin: "center top",
        };
      }

      // 3. if zoom is less than 1 means that the width is more the iframe container -> zoom + align top-left
      return {
        transformScale: zoom,
        transformOrigin: "left top",
      };
    },
    [iframeContainerMeasures.width, pageRealWidthHeight.width]
  );

  return (
    <div
      ref={refIframeContainer}
      className="relative h-full"
    >
      <iframe
        src={iframeUrl}
        width={pageRealWidthHeight.width}
        height={pageRealWidthHeight.height}
        style={{
          overflow: 'auto',
          margin: '0 auto',
          // height: "100%",
          transform: `scale(${iframeZoom.transformScale})`,
          transformOrigin: iframeZoom.transformOrigin,
        }}
      />
      {/* <div className="absolute bottom-0 left-0 right-0 p-4 flex gap-6 bg-[var(--kui-color-alias-foreground-disabled)]">
        <div>
          <p>Iframe Inner</p>
          <p>Width: {pageRealWidthHeight.width}px</p>
          <p>Height: {pageRealWidthHeight.height}px</p>
        </div>
        <div>
          <p>Iframe Container</p>
          <p>Width: {iframeContainerMeasures.width}px</p>
          <p>Height: {iframeContainerMeasures.height}px</p>
        </div>
      </div> */}
    </div>
  );
};



tresorama avatar Aug 18 '25 09:08 tresorama