diff --git a/web/src/components/molecules/PluginEditor/core/hooks.ts b/web/src/components/molecules/PluginEditor/core/hooks.ts
index 8d66590d8c..15460c6bfc 100644
--- a/web/src/components/molecules/PluginEditor/core/hooks.ts
+++ b/web/src/components/molecules/PluginEditor/core/hooks.ts
@@ -187,6 +187,11 @@ export default () => {
[],
);
+ const useExperimentalSandbox = useMemo(
+ () => new URLSearchParams(window.location.search).has("useSandbox"),
+ [],
+ );
+
return {
sourceCode,
layers,
@@ -197,6 +202,7 @@ export default () => {
showAlignSystem,
showInfobox,
engineMeta,
+ useExperimentalSandbox,
setSourceCode,
setMode,
setInfoboxSize,
diff --git a/web/src/components/molecules/PluginEditor/core/index.tsx b/web/src/components/molecules/PluginEditor/core/index.tsx
index 80aa201044..9511c7b655 100644
--- a/web/src/components/molecules/PluginEditor/core/index.tsx
+++ b/web/src/components/molecules/PluginEditor/core/index.tsx
@@ -19,6 +19,7 @@ const PluginEditor: React.FC = () => {
showInfobox,
layers,
engineMeta,
+ useExperimentalSandbox,
setSourceCode,
setMode,
setInfoboxSize,
@@ -48,6 +49,7 @@ const PluginEditor: React.FC = () => {
widgetAlignSystem={widgets.alignSystem}
layers={layers}
meta={engineMeta}
+ useExperimentalSandbox={useExperimentalSandbox}
/>
{
[],
);
+ const useExperimentalSandbox = useMemo(() => {
+ return !!sceneProperty?.experimental?.experimental_sandbox;
+ }, [sceneProperty]);
+
return {
sceneId,
rootLayerId,
@@ -319,6 +323,7 @@ export default (isBuilt?: boolean) => {
widgetAlignEditorActivated,
engineMeta,
layerSelectionReason,
+ useExperimentalSandbox,
selectLayer,
selectBlock,
onBlockChange,
diff --git a/web/src/components/organisms/EarthEditor/core/CanvasArea/index.tsx b/web/src/components/organisms/EarthEditor/core/CanvasArea/index.tsx
index 6d62c751d1..bbc3a71e60 100644
--- a/web/src/components/organisms/EarthEditor/core/CanvasArea/index.tsx
+++ b/web/src/components/organisms/EarthEditor/core/CanvasArea/index.tsx
@@ -32,6 +32,7 @@ const CanvasArea: React.FC
= ({ isBuilt, inEditor }) => {
widgetAlignEditorActivated,
engineMeta,
layerSelectionReason,
+ useExperimentalSandbox,
selectLayer,
selectBlock,
onBlockChange,
@@ -83,6 +84,7 @@ const CanvasArea: React.FC = ({ isBuilt, inEditor }) => {
widgetAlignSystemEditing={widgetAlignEditorActivated}
meta={engineMeta}
layerSelectionReason={layerSelectionReason}
+ useExperimentalSandbox={useExperimentalSandbox}
onLayerSelect={selectLayer}
onCameraChange={onCameraChange}
onWidgetLayoutUpdate={onWidgetUpdate}
diff --git a/web/src/core/Crust/Plugins/PluginFrame/PluginIFrame/index.tsx b/web/src/core/Crust/Plugins/PluginFrame/PluginIFrame/index.tsx
index 62d3ada2fb..f01b273243 100644
--- a/web/src/core/Crust/Plugins/PluginFrame/PluginIFrame/index.tsx
+++ b/web/src/core/Crust/Plugins/PluginFrame/PluginIFrame/index.tsx
@@ -2,7 +2,9 @@ import { forwardRef, ForwardRefRenderFunction, IframeHTMLAttributes, ReactNode,
import type { RefObject } from "react";
import { createPortal } from "react-dom";
+import useExperimentalSandbox from "../../useExperimentalSandbox";
import IFrame, { AutoResize } from "../IFrame";
+import SafeIframe from "../SafeIFrame";
import useHooks, { Ref } from "./hooks";
@@ -50,21 +52,38 @@ const PluginIFrame: ForwardRefRenderFunction[ = (
handleLoad,
} = useHooks({ ready, ref, visible, type, enabled, onRender });
+ const experimentalSandbox = useExperimentalSandbox();
+
const children = (
<>
{html ? (
-
+ experimentalSandbox ? (
+
+ ) : (
+
+ )
) : renderPlaceholder ? (
<>{renderPlaceholder}>
) : null}
diff --git a/web/src/core/Crust/Plugins/PluginFrame/SafeIFrame/hooks.ts b/web/src/core/Crust/Plugins/PluginFrame/SafeIFrame/hooks.ts
new file mode 100644
index 0000000000..1e157ec81d
--- /dev/null
+++ b/web/src/core/Crust/Plugins/PluginFrame/SafeIFrame/hooks.ts
@@ -0,0 +1,171 @@
+import {
+ IframeHTMLAttributes,
+ Ref,
+ RefObject,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import { insertToBody } from "./utils";
+
+export type RefType = {
+ postMessage: (message: any) => void;
+ resize: (width: string | number | undefined, height: string | number | undefined) => void;
+};
+
+export type AutoResize = "both" | "width-only" | "height-only";
+
+export default function useHook({
+ autoResizeMessageKey = "___iframe_auto_resize___",
+ width,
+ height,
+ html,
+ ref,
+ autoResize,
+ visible,
+ iFrameProps,
+ onLoad,
+ onMessage,
+ onClick,
+ onAutoResized,
+}: {
+ width?: number | string;
+ height?: number | string;
+ autoResizeMessageKey?: string;
+ html?: string;
+ ref?: Ref;
+ autoResize?: AutoResize;
+ visible?: boolean;
+ iFrameProps?: IframeHTMLAttributes;
+ onLoad?: () => void;
+ onMessage?: (message: any) => void;
+ onClick?: () => void;
+ onAutoResized?: () => void;
+} = {}): {
+ ref: RefObject;
+ props: IframeHTMLAttributes;
+ srcDoc: string;
+ onLoad?: () => void;
+} {
+ const loaded = useRef(false);
+ const iFrameRef = useRef(null);
+ const [iFrameSize, setIFrameSize] = useState<[string | undefined, string | undefined]>();
+ const pendingMesages = useRef([]);
+
+ useImperativeHandle(
+ ref,
+ (): RefType => ({
+ postMessage: message => {
+ if (!loaded.current || !iFrameRef.current?.contentWindow) {
+ pendingMesages.current.push(message);
+ return;
+ }
+ iFrameRef.current.contentWindow.postMessage(message, "*");
+ },
+ resize: (width, height) => {
+ const width2 = typeof width === "number" ? width + "px" : width ?? undefined;
+ const height2 = typeof height === "number" ? height + "px" : height ?? undefined;
+ setIFrameSize(width2 || height2 ? [width2, height2] : undefined);
+ },
+ }),
+ [],
+ );
+
+ useEffect(() => {
+ const cb = (ev: MessageEvent) => {
+ if (!iFrameRef.current || ev.source !== iFrameRef.current.contentWindow) return;
+ if (ev.data?.[autoResizeMessageKey]) {
+ const { width, height } = ev.data[autoResizeMessageKey];
+ if (typeof width !== "number" || typeof height !== "number") return;
+ setIFrameSize([width + "px", height + "px"]);
+ onAutoResized?.();
+ } else {
+ onMessage?.(ev.data);
+ }
+ };
+ window.addEventListener("message", cb);
+ return () => {
+ window.removeEventListener("message", cb);
+ };
+ }, [autoResize, autoResizeMessageKey, onMessage, onAutoResized]);
+
+ const onIframeLoad = useCallback(() => {
+ loaded.current = true;
+ onLoad?.();
+ }, [onLoad]);
+
+ const props = useMemo>(
+ () => ({
+ ...iFrameProps,
+ style: {
+ display: visible ? "block" : "none",
+ width: visible
+ ? !autoResize || autoResize == "height-only"
+ ? "100%"
+ : iFrameSize?.[0]
+ : "0px",
+ height: visible
+ ? !autoResize || autoResize == "width-only"
+ ? "100%"
+ : iFrameSize?.[1]
+ : "0px",
+ ...iFrameProps?.style,
+ },
+ }),
+ [autoResize, iFrameProps, iFrameSize, visible],
+ );
+
+ useEffect(() => {
+ const handleBlur = () => {
+ if (iFrameRef.current && iFrameRef.current === document.activeElement) {
+ onClick?.();
+ }
+ };
+ window.addEventListener("blur", handleBlur);
+ return () => {
+ window.removeEventListener("blur", handleBlur);
+ };
+ }, [onClick]);
+
+ useEffect(() => {
+ const w = typeof width === "number" ? width + "px" : width;
+ const h = typeof height === "number" ? height + "px" : height;
+ setIFrameSize(w || h ? [w, h] : undefined);
+ }, [width, height]);
+
+ const autoResizeScript = useMemo(() => {
+ return ``;
+ }, [autoResizeMessageKey]);
+
+ const srcDoc = useMemo(() => {
+ return insertToBody(html, autoResizeScript);
+ }, [html, autoResizeScript]);
+
+ return {
+ ref: iFrameRef,
+ props,
+ srcDoc,
+ onLoad: onIframeLoad,
+ };
+}
diff --git a/web/src/core/Crust/Plugins/PluginFrame/SafeIFrame/index.stories.tsx b/web/src/core/Crust/Plugins/PluginFrame/SafeIFrame/index.stories.tsx
new file mode 100644
index 0000000000..fed5416cde
--- /dev/null
+++ b/web/src/core/Crust/Plugins/PluginFrame/SafeIFrame/index.stories.tsx
@@ -0,0 +1,49 @@
+import { Meta, Story } from "@storybook/react";
+import { useRef } from "react";
+
+import Component, { Props, Ref } from ".";
+
+export default {
+ title: "atoms/Plugin/SafeIFrame",
+ component: Component,
+ argTypes: {
+ onLoad: { action: "onLoad" },
+ onMessage: { action: "onMessage" },
+ },
+ // parameters: { actions: { argTypesRegex: "^on.*" } },
+} as Meta;
+
+export const Default: Story = args => {
+ const ref = useRef][(null);
+ const postMessage = () => {
+ ref.current?.postMessage({ foo: new Date().toISOString() });
+ };
+ return (
+
+ );
+};
+
+Default.args = {
+ visible: true,
+ iFrameProps: {
+ style: {
+ width: "400px",
+ height: "300px",
+ },
+ },
+ html: `]iframe
`,
+};
diff --git a/web/src/core/Crust/Plugins/PluginFrame/SafeIFrame/index.tsx b/web/src/core/Crust/Plugins/PluginFrame/SafeIFrame/index.tsx
new file mode 100644
index 0000000000..2f3b03b423
--- /dev/null
+++ b/web/src/core/Crust/Plugins/PluginFrame/SafeIFrame/index.tsx
@@ -0,0 +1,79 @@
+import composeRefs from "@seznam/compose-react-refs";
+import React, { IframeHTMLAttributes } from "react";
+import type { RefObject } from "react";
+
+import useHook, { RefType, AutoResize as AutoResizeType } from "./hooks";
+
+export type Ref = RefType;
+
+export type AutoResize = AutoResizeType;
+
+export type Props = {
+ autoResize?: AutoResize;
+ className?: string;
+ html?: string;
+ visible?: boolean;
+ iFrameProps?: IframeHTMLAttributes;
+ width?: string | number;
+ height?: string | number;
+ externalRef?: RefObject;
+ onLoad?: () => void;
+ onMessage?: (message: any) => void;
+ onClick?: () => void;
+ onAutoResized?: () => void;
+};
+
+const IFrame: React.ForwardRefRenderFunction[ = (
+ {
+ autoResize,
+ className,
+ html,
+ visible,
+ iFrameProps,
+ width,
+ height,
+ externalRef,
+ onLoad,
+ onMessage,
+ onClick,
+ onAutoResized,
+ },
+ ref,
+) => {
+ const {
+ ref: iFrameRef,
+ props,
+ onLoad: onIFrameLoad,
+ srcDoc,
+ } = useHook({
+ width,
+ height,
+ visible,
+ iFrameProps,
+ autoResize,
+ html,
+ ref,
+ onLoad,
+ onMessage,
+ onClick,
+ onAutoResized,
+ });
+
+ return html ? (
+
+ ) : null;
+};
+
+export default React.forwardRef(IFrame);
diff --git a/web/src/core/Crust/Plugins/PluginFrame/SafeIFrame/utils.test.ts b/web/src/core/Crust/Plugins/PluginFrame/SafeIFrame/utils.test.ts
new file mode 100644
index 0000000000..190e5869b8
--- /dev/null
+++ b/web/src/core/Crust/Plugins/PluginFrame/SafeIFrame/utils.test.ts
@@ -0,0 +1,24 @@
+import { expect, test } from "vitest";
+
+import { insertToBody } from "./utils";
+
+test("insertScriptToBody", () => {
+ const script = ``;
+ const html1 = ``;
+ expect(insertToBody(html1, script)).toEqual(``);
+
+ const html2 = ``;
+ expect(insertToBody(html2, script)).toEqual(
+ ``,
+ );
+
+ const html3 = ``;
+ expect(insertToBody(html3, script)).toEqual(
+ ``,
+ );
+
+ const html4 = ``;
+ expect(insertToBody(html4, script)).toEqual(
+ ``,
+ );
+});
diff --git a/web/src/core/Crust/Plugins/PluginFrame/SafeIFrame/utils.ts b/web/src/core/Crust/Plugins/PluginFrame/SafeIFrame/utils.ts
new file mode 100644
index 0000000000..1d37134684
--- /dev/null
+++ b/web/src/core/Crust/Plugins/PluginFrame/SafeIFrame/utils.ts
@@ -0,0 +1,8 @@
+export const insertToBody = (html: string | undefined, insertStr: string) => {
+ if (html === undefined) return "";
+ let lastBodyIndex = html.lastIndexOf("]