Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Breadcrumbs } from "./Breadcrumbs/index.js";
import { DocumentPreview } from "./Preview/DocumentPreview.js";
import { AddressBar } from "./AddressBar/AddressBar.js";
import { BreakpointSelector } from "./AddressBar/BreakpointSelector.js";
import { SampleFrontendBanner } from "./SampleFrontendBanner.js";

export const ContentPreviewConfig = () => {
const { Ui } = EditorConfig;
Expand All @@ -16,6 +17,11 @@ export const ContentPreviewConfig = () => {
element={<BreakpointSelector />}
/>
<Ui.Content.Element name="addressBar" element={<AddressBar />} />
<Ui.Content.Element
name="sampleFrontendBanner"
after="addressBar"
element={<SampleFrontendBanner />}
/>
<Ui.Content.Element name="iframe" element={<DocumentPreview />} />
<Ui.Content.Element
name={"breadcrumbs"}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ interface IframeProps {
showLoading: boolean;
viewportManager: ViewportManager;
onConnected: (messenger: Messenger) => void;
/**
* Rendered on top of the preview instead of the loader. Used to explain why the preview is
* empty, while keeping the iframe mounted so that a late connection still resolves on its own.
*/
overlay?: React.ReactNode;
}

export const Iframe = observer(({ url, timestamp, ...props }: IframeProps) => {
Expand Down Expand Up @@ -49,14 +54,15 @@ export const Iframe = observer(({ url, timestamp, ...props }: IframeProps) => {
return (
<PreviewContainer key={iframeUrl}>
<ConnectEditorToPreview iframeRef={iframeRef} onConnected={props.onConnected} />
{props.showLoading ? (
<OverlayLoader
size="lg"
variant="accent"
text="Loading preview..."
className={"bg-neutral-base"}
/>
) : null}
{props.overlay ??
(props.showLoading ? (
<OverlayLoader
size="lg"
variant="accent"
text="Loading preview..."
className={"bg-neutral-base"}
/>
) : null)}
{/* Content wrapper - sized by iframe content */}
<div
id={"preview-body"}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import React, { useCallback } from "react";
import { Button, EmptyState, Text } from "@webiny/admin-ui";
import { ReactComponent as BookIcon } from "@webiny/icons/menu_book.svg";
import { ReactComponent as RefreshIcon } from "@webiny/icons/refresh.svg";
import { ReactComponent as RocketIcon } from "@webiny/icons/rocket_launch.svg";
import { FRONTEND_SETUP_DOCS_URL, SAMPLE_FRONTEND_DOMAIN } from "../sampleFrontend.js";
import { usePreviewDomain } from "../usePreviewDomain.js";
import type { PreviewConnectionError } from "./usePreviewConnection.js";

interface NoFrontendConnectedProps {
// The origin the editor tried to load the preview from.
origin: string;
status: PreviewConnectionError;
onRetry: () => void;
}

const COPY: Record<PreviewConnectionError, { title: string; description: string }> = {
unreachable: {
title: "No frontend detected",
description:
"The editor renders your pages inside your own frontend, and nothing is currently running at"
},
unresponsive: {
title: "Frontend didn't connect",
description:
"Something is running, but it never connected to the editor. Make sure the Website Builder SDK is set up in the app running at"
}
};

export const NoFrontendConnected = ({ origin, status, onRetry }: NoFrontendConnectedProps) => {
const { previewDomain, setPreviewDomain } = usePreviewDomain();

const loadSampleFrontend = useCallback(() => {
setPreviewDomain(SAMPLE_FRONTEND_DOMAIN);
}, [setPreviewDomain]);

const openInstructions = useCallback(() => {
window.open(FRONTEND_SETUP_DOCS_URL, "_blank", "noopener,noreferrer");
}, []);

const copy = COPY[status];

// No point in offering the sample frontend when it's the domain that just failed to connect.
const canLoadSampleFrontend = previewDomain !== SAMPLE_FRONTEND_DOMAIN;

return (
<div
className={
"w-full h-full absolute inset-0 z-40 flex items-center justify-center overflow-auto bg-neutral-base"
}
>
<EmptyState
type={"layout"}
title={copy.title}
description={
<>
{copy.description} <span className={"font-semibold"}>{origin}</span>.
</>
}
actions={
<>
<Button
variant={canLoadSampleFrontend ? "secondary" : "primary"}
size={"md"}
icon={<BookIcon />}
text={"Read the instructions"}
onClick={openInstructions}
/>
{canLoadSampleFrontend ? (
<Button
variant={"primary"}
size={"md"}
icon={<RocketIcon />}
text={"Load sample frontend"}
onClick={loadSampleFrontend}
/>
) : null}
<Button
variant={"ghost"}
size={"md"}
icon={<RefreshIcon />}
text={"Try again"}
onClick={onRetry}
/>
</>
}
/>
{canLoadSampleFrontend ? (
<Text
as={"div"}
size={"sm"}
className={
"absolute bottom-0 left-0 right-0 px-lg py-md-plus text-center text-neutral-strong"
}
>
The sample frontend lets you build pages and see them here in the editor.
Viewing or previewing them outside the editor needs a frontend of your own, and
locally uploaded images and content-bound data won&apos;t render in the sample
either.
</Text>
) : null}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import type { Messenger } from "@webiny/website-builder-sdk";
import React, { useEffect, useMemo, useState } from "react";
import { ViewportManager } from "@webiny/website-builder-sdk";
import { mouseTracker } from "@webiny/website-builder-sdk";
import { useDocumentEditor } from "~/DocumentEditor/index.js";
import { Iframe } from "./Iframe.js";
import { PreviewFrame } from "./PreviewFrame.js";
import { DropZoneManager } from "./DropZoneManager.js";
import { DropZoneManagerProvider } from "./DropZoneManagerProvider.js";
import { KeyboardShortcuts } from "./KeyboardShortcuts.js";
Expand Down Expand Up @@ -82,21 +81,20 @@ export const Preview = () => {
};
}, []);

const onConnected = useCallback((messenger: Messenger) => {
previewEvents.onConnected(messenger);
}, []);

return (
<>
<ApplyTheme />
<DropZoneManagerProvider dropzoneManager={dropzoneManager}>
<AwaitIframeUrl>
{({ url }) => (
<Iframe
<PreviewFrame
// A new URL or timestamp means a new page load, so the connection with
// the frontend has to be established from scratch.
key={`${url}|${iframeTimestamp}`}
url={url}
timestamp={iframeTimestamp}
viewportManager={viewportManager}
onConnected={onConnected}
previewEvents={previewEvents}
showLoading={loadingPreview}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React, { useCallback, useMemo, useState } from "react";
import type { Messenger, ViewportManager } from "@webiny/website-builder-sdk";
import { Iframe } from "./Iframe.js";
import { NoFrontendConnected } from "./NoFrontendConnected.js";
import type { PreviewEvents } from "./PreviewEvents.js";
import { usePreviewConnection } from "./usePreviewConnection.js";

interface PreviewFrameProps {
url: string;
timestamp: number;
showLoading: boolean;
viewportManager: ViewportManager;
previewEvents: PreviewEvents;
}

/**
* Owns everything that belongs to a single load of the preview: the handshake with the frontend,
* and the resulting connection status. The parent remounts this component on every page load, so
* the connection state always starts over with the iframe.
*/
export const PreviewFrame = ({ url, timestamp, showLoading, ...props }: PreviewFrameProps) => {
const [connected, setConnected] = useState(false);
const { status, retry } = usePreviewConnection({ url, connected });

const onConnected = useCallback(
(messenger: Messenger) => {
setConnected(true);
props.previewEvents.onConnected(messenger);
},
[props.previewEvents]
);

const overlay = useMemo(() => {
if (status !== "unreachable" && status !== "unresponsive") {
return null;
}

return <NoFrontendConnected origin={new URL(url).origin} status={status} onRetry={retry} />;
}, [status, url, retry]);

return (
<Iframe
url={url}
timestamp={timestamp}
viewportManager={props.viewportManager}
onConnected={onConnected}
showLoading={showLoading}
overlay={overlay}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Commands } from "~/BaseEditor/index.js";
import { useDocumentEditor } from "~/DocumentEditor/index.js";

/**
* `connecting` - the preview was just (re)loaded, and we're still waiting for the handshake.
* `connected` - the frontend sent `preview.ready`, which means the editor can talk to it.
* `unreachable` - nothing is listening on the preview domain, usually because no frontend is running.
* `unresponsive` - something is listening, but it never sent `preview.ready`, which means it's not a
* Webiny frontend, or the Website Builder SDK is not set up in it.
*/
export type PreviewConnectionStatus = "connecting" | "connected" | "unreachable" | "unresponsive";

export type PreviewConnectionError = Extract<
PreviewConnectionStatus,
"unreachable" | "unresponsive"
>;

// How long we wait for the frontend's `preview.ready` message. This has to be generous, because a
// dev server that was just started compiles the page on the first request.
const HANDSHAKE_TIMEOUT_MS = 15000;

// How often an unreachable preview domain is re-checked, so that starting a dev server while the
// editor is open reloads the preview on its own.
const REACHABILITY_CHECK_INTERVAL_MS = 4000;

/**
* Checks whether anything is listening on the given origin. The response is opaque (`no-cors`),
* which is all we need: we only care about whether the browser managed to open a connection.
* A refused connection rejects almost immediately, which is what makes this a useful early signal.
*/
const isReachable = async (origin: string) => {
try {
await fetch(origin, { method: "HEAD", mode: "no-cors", cache: "no-store" });
return true;
} catch {
return false;
}
};

interface UsePreviewConnectionParams {
// The full preview URL. Only its origin is used for reachability checks.
url: string;
// Whether the frontend has completed the handshake with the editor.
connected: boolean;
}

interface UsePreviewConnectionResult {
status: PreviewConnectionStatus;
retry: () => void;
}

/**
* Tells us whether the editor is actually talking to a frontend, and if not, why not.
*
* There are two signals at play. The handshake (`preview.ready`) is the authoritative one: it only
* arrives from a frontend that has the Website Builder SDK set up. The reachability check is there
* for speed: waiting out the handshake timeout to tell someone that they have no frontend running
* is a poor first experience, and a refused connection gives us that answer in milliseconds.
*/
export const usePreviewConnection = (
params: UsePreviewConnectionParams
): UsePreviewConnectionResult => {
const { url, connected } = params;
const editor = useDocumentEditor();
const [status, setStatus] = useState<PreviewConnectionStatus>("connecting");

const origin = useMemo(() => new URL(url).origin, [url]);

const retry = useCallback(() => {
editor.executeCommand(Commands.RefreshPreview);
}, [editor]);

useEffect(() => {
if (connected) {
setStatus("connected");
return;
}

let disposed = false;

isReachable(origin).then(reachable => {
// When the origin is reachable, we keep waiting. Only a missing handshake can tell us
// that whatever answered isn't a frontend the editor can drive.
if (!disposed && !reachable) {
setStatus("unreachable");
}
});

const handshakeTimeout = setTimeout(() => {
if (!disposed) {
// `unreachable` describes the problem more accurately, so don't overwrite it.
setStatus(current => (current === "connecting" ? "unresponsive" : current));
}
}, HANDSHAKE_TIMEOUT_MS);

return () => {
disposed = true;
clearTimeout(handshakeTimeout);
};
}, [origin, connected]);

useEffect(() => {
if (status !== "unreachable") {
// We only poll while nothing is listening. A domain that answers but doesn't complete
// the handshake would otherwise reload the preview over and over.
return;
}

let disposed = false;

const interval = setInterval(async () => {
const reachable = await isReachable(origin);
if (!disposed && reachable) {
// The user started their frontend, so reload the preview for them.
retry();
}
}, REACHABILITY_CHECK_INTERVAL_MS);

return () => {
disposed = true;
clearInterval(interval);
};
}, [status, origin, retry]);

return { status, retry };
};
Loading
Loading