214 lines
6.8 KiB
TypeScript
214 lines
6.8 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { toast } from "sonner";
|
|
import { previewFrameTrustsMessage } from "@/components/project/preview-frame-trust";
|
|
import { dashboardBridgeScriptUrl } from "@/lib/dashboard-bridge-url";
|
|
|
|
/** Must match public/vibn-preview-bridge.js */
|
|
export const PREVIEW_BRIDGE_SOURCE = "vibn-preview";
|
|
|
|
export type PreviewPickPayload = {
|
|
selector: string;
|
|
tagName: string;
|
|
textSnippet: string;
|
|
outerHtmlSnippet?: string;
|
|
};
|
|
|
|
/**
|
|
* Set by PreviewBridgeProvider. Unified project shell uses this to prepend the
|
|
* latest preview element pick to the outbound chat message (then clears the pick).
|
|
*/
|
|
export const previewMessagePrepRef: { current: ((text: string) => string) | null } = {
|
|
current: null,
|
|
};
|
|
|
|
type PreviewBridgeContextValue = {
|
|
selectMode: boolean;
|
|
setSelectMode: (v: boolean) => void;
|
|
picked: PreviewPickPayload | null;
|
|
clearPick: () => void;
|
|
/** Preview iframe calls this when URL / element changes */
|
|
registerPreviewIframe: (el: HTMLIFrameElement | null, src: string | null) => void;
|
|
/** After iframe load so CMD enable/disable is delivered to the bridge */
|
|
notifyPreviewIframeLoaded: () => void;
|
|
};
|
|
|
|
const PreviewBridgeContext = createContext<PreviewBridgeContextValue | null>(null);
|
|
|
|
/**
|
|
* Pick targetOrigin for parent → iframe postMessage.
|
|
*
|
|
* - Same-origin or redirected frames: use the live document origin when href is real.
|
|
* - Cross-origin tunnel: cannot read location → '*' (safe here: we only message this iframe's contentWindow).
|
|
* - about:blank / srcdoc: never trust iframe.src's origin (often mismatches inherited / opaque origin).
|
|
*/
|
|
function postCmdTargetOrigin(iframe: HTMLIFrameElement): string {
|
|
try {
|
|
const cw = iframe.contentWindow;
|
|
if (!cw) return "*";
|
|
const href = cw.location.href;
|
|
if (href && href !== "about:blank" && href !== "about:srcdoc") {
|
|
return cw.location.origin;
|
|
}
|
|
} catch {
|
|
/* Cross-origin — location not readable */
|
|
}
|
|
return "*";
|
|
}
|
|
|
|
function postCmdToIframe(win: Window, iframe: HTMLIFrameElement, msg: unknown) {
|
|
const targetOrigin = postCmdTargetOrigin(iframe);
|
|
try {
|
|
win.postMessage(msg, targetOrigin);
|
|
} catch {
|
|
try {
|
|
win.postMessage(msg, "*");
|
|
} catch {
|
|
/* ignore — iframe may not be ready */
|
|
}
|
|
}
|
|
}
|
|
|
|
export function PreviewBridgeProvider({ children }: { children: ReactNode }) {
|
|
const [selectMode, setSelectMode] = useState(false);
|
|
const [picked, setPicked] = useState<PreviewPickPayload | null>(null);
|
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
const [targetOrigin, setTargetOrigin] = useState<string | null>(null);
|
|
const [iframeLoadGen, setIframeLoadGen] = useState(0);
|
|
const bridgeReadyRef = useRef(false);
|
|
/** Avoid stacking duplicate toasts (Strict Mode / rapid toggles). */
|
|
const noBridgeWarnedRef = useRef(false);
|
|
|
|
const registerPreviewIframe = useCallback((el: HTMLIFrameElement | null, src: string | null) => {
|
|
iframeRef.current = el;
|
|
bridgeReadyRef.current = false;
|
|
// New frame — allow one no-bridge toast again if Select stays on.
|
|
if (el) noBridgeWarnedRef.current = false;
|
|
// React passes ref(el=null) on unmount but `src` is still the last URL — must clear
|
|
// origin so we don't pair a stale targetOrigin with a detached iframe (breaks soft nav;
|
|
// hard refresh remounts the provider so it masks the bug).
|
|
if (!el) {
|
|
setTargetOrigin(null);
|
|
return;
|
|
}
|
|
if (!src) {
|
|
setTargetOrigin(null);
|
|
return;
|
|
}
|
|
try {
|
|
setTargetOrigin(new URL(src, window.location.href).origin);
|
|
} catch {
|
|
setTargetOrigin(null);
|
|
}
|
|
}, []);
|
|
|
|
const notifyPreviewIframeLoaded = useCallback(() => {
|
|
setIframeLoadGen((n) => n + 1);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onMsg = (ev: MessageEvent) => {
|
|
const d = ev.data;
|
|
if (!d || d.source !== PREVIEW_BRIDGE_SOURCE) return;
|
|
const iframeSrc = iframeRef.current?.src;
|
|
|
|
if (d.type === "BRIDGE_READY") {
|
|
if (!previewFrameTrustsMessage(ev.origin, targetOrigin, iframeSrc)) return;
|
|
bridgeReadyRef.current = true;
|
|
noBridgeWarnedRef.current = false;
|
|
toast.dismiss("vibn-preview-no-bridge");
|
|
return;
|
|
}
|
|
|
|
if (d.type !== "PICK") return;
|
|
if (!previewFrameTrustsMessage(ev.origin, targetOrigin, iframeSrc)) return;
|
|
setPicked(d.payload as PreviewPickPayload);
|
|
setSelectMode(false);
|
|
};
|
|
window.addEventListener("message", onMsg);
|
|
return () => window.removeEventListener("message", onMsg);
|
|
}, [targetOrigin]);
|
|
|
|
useEffect(() => {
|
|
if (!selectMode) {
|
|
noBridgeWarnedRef.current = false;
|
|
toast.dismiss("vibn-preview-no-bridge");
|
|
return;
|
|
}
|
|
const t = window.setTimeout(() => {
|
|
if (!bridgeReadyRef.current && !noBridgeWarnedRef.current) {
|
|
noBridgeWarnedRef.current = true;
|
|
const scriptUrl = dashboardBridgeScriptUrl();
|
|
const explicit =
|
|
(process.env.NEXT_PUBLIC_VIBN_BRIDGE_URL ?? "").trim().length > 0;
|
|
const hint = explicit
|
|
? `Load this script in the preview app root layout: ${scriptUrl}`
|
|
: `Load this script in the preview app root layout (served from this dashboard): ${scriptUrl}`;
|
|
toast.dismiss("vibn-preview-no-bridge");
|
|
toast.error("Preview picker isn't running inside this frame", {
|
|
id: "vibn-preview-no-bridge",
|
|
description: hint,
|
|
duration: 12_000,
|
|
});
|
|
}
|
|
}, 1600);
|
|
return () => window.clearTimeout(t);
|
|
}, [selectMode]);
|
|
|
|
useEffect(() => {
|
|
const iframe = iframeRef.current;
|
|
const win = iframe?.contentWindow;
|
|
if (!win || !iframe) return;
|
|
const msg = {
|
|
source: PREVIEW_BRIDGE_SOURCE,
|
|
type: "CMD",
|
|
cmd: selectMode ? "enable-select" : "disable-select",
|
|
};
|
|
postCmdToIframe(win, iframe, msg);
|
|
}, [selectMode, targetOrigin, iframeLoadGen]);
|
|
|
|
const clearPick = useCallback(() => setPicked(null), []);
|
|
|
|
useEffect(() => {
|
|
previewMessagePrepRef.current = (text: string) => {
|
|
if (!picked) return text;
|
|
const block = [
|
|
"[Preview selection]",
|
|
`Selector: ${picked.selector}`,
|
|
`Tag: ${picked.tagName}`,
|
|
...(picked.textSnippet ? [`Text: ${picked.textSnippet}`] : []),
|
|
"---",
|
|
].join("\n");
|
|
setPicked(null);
|
|
return `${block}\n\n${text}`;
|
|
};
|
|
return () => {
|
|
previewMessagePrepRef.current = null;
|
|
};
|
|
}, [picked]);
|
|
|
|
const value: PreviewBridgeContextValue = {
|
|
selectMode,
|
|
setSelectMode,
|
|
picked,
|
|
clearPick,
|
|
registerPreviewIframe,
|
|
notifyPreviewIframeLoaded,
|
|
};
|
|
|
|
return <PreviewBridgeContext.Provider value={value}>{children}</PreviewBridgeContext.Provider>;
|
|
}
|
|
|
|
export function usePreviewBridge(): PreviewBridgeContextValue | null {
|
|
return useContext(PreviewBridgeContext);
|
|
}
|