keystatic
keystatic copied to clipboard
showcase: enhance live preview with an iframe rendered in the edit page
Screenshots
Disabled
Note the new Live Preview button in the top bar
Enabled + Desktop
Enabled + Mobile
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
fetchstrategy 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.
Next.js v15 project + App Router + Tailwind (update style if you don't use it).
- 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`)
- 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>
);
}
- 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>
);
};