cal.pub0.org/packages/embeds/embed-core/src/embed-iframe.ts

241 lines
8.6 KiB
TypeScript

import { useState, useEffect, CSSProperties } from "react";
import { sdkActionManager } from "./sdk-event";
let isSafariBrowser = false;
if (typeof window !== "undefined") {
const ua = navigator.userAgent.toLowerCase();
isSafariBrowser = ua.includes("safari") && !ua.includes("chrome");
if (isSafariBrowser) {
log("Safari Detected: Using setTimeout instead of rAF");
}
}
function keepRunningAsap(fn: (...arg: any) => void) {
if (isSafariBrowser) {
// https://adpiler.com/blog/the-full-solution-why-do-animations-run-slower-in-safari/
return setTimeout(fn, 50);
}
return requestAnimationFrame(fn);
}
declare global {
interface Window {
CalEmbed: {
__logQueue?: any[];
};
CalComPlan: string;
}
}
function log(...args: any[]) {
let namespace;
if (typeof window !== "undefined") {
const searchParams = new URL(document.URL).searchParams;
namespace = typeof searchParams.get("embed") !== "undefined" ? "" : "_unknown_";
//TODO: Send postMessage to parent to get all log messages in the same queue.
window.CalEmbed = window.CalEmbed || {};
const logQueue = (window.CalEmbed.__logQueue = window.CalEmbed.__logQueue || []);
args.push({
ns: namespace,
});
args.unshift("CAL:");
logQueue.push(args);
if (searchParams.get("debug")) {
console.log(...args);
}
}
}
// Only allow certain styles to be modified so that when we make any changes to HTML, we know what all embed styles might be impacted.
// Keep this list to minimum, only adding those styles which are really needed.
interface EmbedStyles {
body?: Pick<CSSProperties, "background" | "backgroundColor">;
eventTypeListItem?: Pick<CSSProperties, "background" | "color" | "backgroundColor">;
enabledDateButton?: Pick<CSSProperties, "background" | "color" | "backgroundColor">;
disabledDateButton?: Pick<CSSProperties, "background" | "color" | "backgroundColor">;
}
type ElementName = keyof EmbedStyles;
type ReactEmbedStylesSetter = React.Dispatch<React.SetStateAction<EmbedStyles>>;
export interface UiConfig {
theme: string;
styles: EmbedStyles;
}
const embedStore = {
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
styles: {},
// Store all React State setters here.
reactStylesStateSetters: {} as Record<ElementName, ReactEmbedStylesSetter>,
};
const setEmbedStyles = (stylesConfig: UiConfig["styles"]) => {
embedStore.styles = stylesConfig;
for (let [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
setEmbedStyle((styles) => {
return {
...styles,
...stylesConfig,
};
});
}
};
const registerNewSetter = (elementName: ElementName, setStyles: ReactEmbedStylesSetter) => {
embedStore.reactStylesStateSetters[elementName] = setStyles;
// It's possible that 'ui' instruction has already been processed and the registration happened due to some action by the user in iframe.
// So, we should call the setter immediately with available embedStyles
setStyles(embedStore.styles);
};
const removeFromEmbedStylesSetterMap = (elementName: ElementName) => {
delete embedStore.reactStylesStateSetters[elementName];
};
// TODO: Make it usable as an attribute directly instead of styles value. It would allow us to go beyond styles e.g. for debugging we can add a special attribute indentifying the element on which UI config has been applied
export const useEmbedStyles = (elementName: ElementName) => {
const [styles, setStyles] = useState({} as EmbedStyles);
useEffect(() => {
registerNewSetter(elementName, setStyles);
// It's important to have an element's embed style be required in only one component. If due to any reason it is required in multiple components, we would override state setter.
return () => {
// Once the component is unmounted, we can remove that state setter.
removeFromEmbedStylesSetterMap(elementName);
};
}, []);
return styles[elementName] || {};
};
function unhideBody() {
document.body.style.display = "block";
}
// If you add a method here, give type safety to parent manually by adding it to embed.ts. Look for "parentKnowsIframeReady" in it
export const methods = {
ui: function style(uiConfig: UiConfig) {
// TODO: Create automatic logger for all methods. Useful for debugging.
log("Method: ui called", uiConfig);
if (window.CalComPlan && window.CalComPlan !== "PRO") {
log(`Upgrade to PRO for "ui" instruction to work`, window.CalComPlan);
return;
}
const stylesConfig = uiConfig.styles;
// In case where parent gives instructions before CalComPlan is set.
// This is easily possible as React takes time to initialize and render components where this variable is set.
if (!window.CalComPlan) {
return requestAnimationFrame(() => {
style(uiConfig);
});
}
// body can't be styled using React state hook as it is generated by _document.tsx which doesn't support hooks.
if (stylesConfig.body?.background) {
document.body.style.background = stylesConfig.body.background as string;
}
setEmbedStyles(stylesConfig);
},
parentKnowsIframeReady: () => {
log("Method: `parentKnowsIframeReady` called");
unhideBody();
sdkActionManager?.fire("linkReady", {});
},
};
const messageParent = (data: any) => {
parent.postMessage(
{
originator: "CAL",
...data,
},
"*"
);
};
function keepParentInformedAboutDimensionChanges() {
console.log("keepParentInformedAboutDimensionChanges executed");
let knownIframeHeight: Number | null = null;
let numDimensionChanges = 0;
let isFirstTime = true;
let isWindowLoadComplete = false;
keepRunningAsap(function informAboutScroll() {
if (document.readyState !== "complete") {
// Wait for window to load to correctly calculate the initial scroll height.
keepRunningAsap(informAboutScroll);
return;
}
if (!isWindowLoadComplete) {
// On Safari, even though document.readyState is complete, still the page is not rendered and we can't compute documentElement.scrollHeight correctly
// Postponing to just next cycle allow us to fix this.
setTimeout(() => {
isWindowLoadComplete = true;
informAboutScroll();
}, 10);
return;
}
const documentScrollHeight = document.documentElement.scrollHeight;
const contentHeight = document.documentElement.offsetHeight;
// During first render let iframe tell parent that how much is the expected height to avoid scroll.
// Parent would set the same value as the height of iframe which would prevent scroll.
// On subsequent renders, consider html height as the height of the iframe. If we don't do this, then if iframe get's bigger in height, it would never shrink
let iframeHeight = isFirstTime ? documentScrollHeight : contentHeight;
isFirstTime = false;
// TODO: Handle width as well.
if (knownIframeHeight !== iframeHeight) {
knownIframeHeight = iframeHeight;
numDimensionChanges++;
// FIXME: This event shouldn't be subscribable by the user. Only by the SDK.
sdkActionManager?.fire("dimension-changed", {
iframeHeight,
});
}
// Parent Counterpart would change the dimension of iframe and thus page's dimension would be impacted which is recursive.
// It should stop ideally by reaching a hiddenHeight value of 0.
// FIXME: If 0 can't be reached we need to just abandon our quest for perfect iframe and let scroll be there. Such case can be logged in the wild and fixed later on.
if (numDimensionChanges > 50) {
console.warn("Too many dimension changes detected.");
return;
}
keepRunningAsap(informAboutScroll);
});
}
if (typeof window !== "undefined") {
const url = new URL(document.URL);
if (url.searchParams.get("prerender") !== "true" && typeof url.searchParams.get("embed") !== "undefined") {
log("Initializing embed-iframe");
// If embed link is opened in top, and not in iframe. Let the page be visible.
if (top === window) {
unhideBody();
}
sdkActionManager?.on("*", (e) => {
const detail = e.detail;
//console.log(detail.fullType, detail.type, detail.data);
log(detail);
messageParent(detail);
});
window.addEventListener("message", (e) => {
const data: Record<string, any> = e.data;
if (!data) {
return;
}
const method: keyof typeof methods = data.method;
if (data.originator === "CAL" && typeof method === "string") {
methods[method]?.(data.arg);
}
});
keepParentInformedAboutDimensionChanges();
sdkActionManager?.fire("iframeReady", {});
}
}