diff --git a/.env.development b/.env.development index bee5d8944..44955884f 100644 --- a/.env.development +++ b/.env.development @@ -13,6 +13,8 @@ VITE_APP_PORTAL_URL= VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_APP=https://app.excalidraw.com +VITE_APP_AI_BACKEND=http://localhost:3015 + VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}' # put these in your .env.local, or make sure you don't commit! diff --git a/.env.production b/.env.production index 19df4b96e..26b46a52a 100644 --- a/.env.production +++ b/.env.production @@ -9,6 +9,8 @@ VITE_APP_PORTAL_URL=https://portal.excalidraw.com VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_APP=https://app.excalidraw.com +VITE_APP_AI_BACKEND=https://oss-ai.excalidraw.com + # Fill to set socket server URL used for collaboration. # Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow VITE_APP_WS_SERVER_URL= diff --git a/excalidraw-app/index.tsx b/excalidraw-app/index.tsx index 6742a18d3..d6158af07 100644 --- a/excalidraw-app/index.tsx +++ b/excalidraw-app/index.tsx @@ -26,6 +26,8 @@ import { Excalidraw, defaultLang, LiveCollaborationTrigger, + TTDDialog, + TTDDialogTrigger, } from "../src/packages/excalidraw/index"; import { AppState, @@ -776,6 +778,64 @@ const ExcalidrawWrapper = () => { )} + { + try { + const response = await fetch( + `${ + import.meta.env.VITE_APP_AI_BACKEND + }/v1/ai/text-to-diagram/generate`, + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ prompt: input }), + }, + ); + + const rateLimit = response.headers.has("X-Ratelimit-Limit") + ? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10) + : undefined; + + const rateLimitRemaining = response.headers.has( + "X-Ratelimit-Remaining", + ) + ? parseInt( + response.headers.get("X-Ratelimit-Remaining") || "0", + 10, + ) + : undefined; + + const json = await response.json(); + + if (!response.ok) { + if (response.status === 429) { + return { + rateLimit, + rateLimitRemaining, + error: new Error( + "Too many requests today, please try again tomorrow!", + ), + }; + } + + throw new Error(json.message || "Generation failed..."); + } + + const generatedResponse = json.generatedResponse; + if (!generatedResponse) { + throw new Error("Generation failed..."); + } + + return { generatedResponse, rateLimit, rateLimitRemaining }; + } catch (err: any) { + throw new Error("Request failed"); + } + }} + /> + {isCollaborating && isOffline && (
{t("alerts.collabOfflineWarning")} diff --git a/src/actions/actionAlign.tsx b/src/actions/actionAlign.tsx index 5697a707e..137f68ae9 100644 --- a/src/actions/actionAlign.tsx +++ b/src/actions/actionAlign.tsx @@ -9,6 +9,7 @@ import { } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; import { getNonDeletedElements } from "../element"; +import { isFrameLikeElement } from "../element/typeChecks"; import { ExcalidrawElement } from "../element/types"; import { updateFrameMembershipOfSelectedElements } from "../frame"; import { t } from "../i18n"; @@ -28,7 +29,7 @@ const alignActionsPredicate = ( return ( selectedElements.length > 1 && // TODO enable aligning frames when implemented properly - !selectedElements.some((el) => el.type === "frame") + !selectedElements.some((el) => isFrameLikeElement(el)) ); }; diff --git a/src/actions/actionDeleteSelected.tsx b/src/actions/actionDeleteSelected.tsx index 4d7ec6a7c..de25ed898 100644 --- a/src/actions/actionDeleteSelected.tsx +++ b/src/actions/actionDeleteSelected.tsx @@ -10,7 +10,7 @@ import { newElementWith } from "../element/mutateElement"; import { getElementsInGroup } from "../groups"; import { LinearElementEditor } from "../element/linearElementEditor"; import { fixBindingsAfterDeletion } from "../element/binding"; -import { isBoundToContainer } from "../element/typeChecks"; +import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks"; import { updateActiveTool } from "../utils"; import { TrashIcon } from "../components/icons"; @@ -20,7 +20,7 @@ const deleteSelectedElements = ( ) => { const framesToBeDeleted = new Set( getSelectedElements( - elements.filter((el) => el.type === "frame"), + elements.filter((el) => isFrameLikeElement(el)), appState, ).map((el) => el.id), ); diff --git a/src/actions/actionDistribute.tsx b/src/actions/actionDistribute.tsx index d3cdb5c9c..bf51bedf4 100644 --- a/src/actions/actionDistribute.tsx +++ b/src/actions/actionDistribute.tsx @@ -5,6 +5,7 @@ import { import { ToolButton } from "../components/ToolButton"; import { distributeElements, Distribution } from "../distribute"; import { getNonDeletedElements } from "../element"; +import { isFrameLikeElement } from "../element/typeChecks"; import { ExcalidrawElement } from "../element/types"; import { updateFrameMembershipOfSelectedElements } from "../frame"; import { t } from "../i18n"; @@ -19,7 +20,7 @@ const enableActionGroup = (appState: AppState, app: AppClassProperties) => { return ( selectedElements.length > 1 && // TODO enable distributing frames when implemented properly - !selectedElements.some((el) => el.type === "frame") + !selectedElements.some((el) => isFrameLikeElement(el)) ); }; diff --git a/src/actions/actionDuplicateSelection.tsx b/src/actions/actionDuplicateSelection.tsx index 060a28680..ba079168e 100644 --- a/src/actions/actionDuplicateSelection.tsx +++ b/src/actions/actionDuplicateSelection.tsx @@ -20,7 +20,7 @@ import { bindTextToShapeAfterDuplication, getBoundTextElement, } from "../element/textElement"; -import { isBoundToContainer, isFrameElement } from "../element/typeChecks"; +import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks"; import { normalizeElementOrder } from "../element/sortElements"; import { DuplicateIcon } from "../components/icons"; import { @@ -140,11 +140,11 @@ const duplicateElements = ( } const boundTextElement = getBoundTextElement(element); - const isElementAFrame = isFrameElement(element); + const isElementAFrameLike = isFrameLikeElement(element); if (idsOfElementsToDuplicate.get(element.id)) { // if a group or a container/bound-text or frame, duplicate atomically - if (element.groupIds.length || boundTextElement || isElementAFrame) { + if (element.groupIds.length || boundTextElement || isElementAFrameLike) { const groupId = getSelectedGroupForElement(appState, element); if (groupId) { // TODO: @@ -154,7 +154,7 @@ const duplicateElements = ( sortedElements, groupId, ).flatMap((element) => - isFrameElement(element) + isFrameLikeElement(element) ? [...getFrameChildren(elements, element.id), element] : [element], ); @@ -180,7 +180,7 @@ const duplicateElements = ( ); continue; } - if (isElementAFrame) { + if (isElementAFrameLike) { const elementsInFrame = getFrameChildren(sortedElements, element.id); elementsWithClones.push( diff --git a/src/actions/actionElementLock.ts b/src/actions/actionElementLock.ts index cd539c5a3..164240b29 100644 --- a/src/actions/actionElementLock.ts +++ b/src/actions/actionElementLock.ts @@ -1,4 +1,5 @@ import { newElementWith } from "../element/mutateElement"; +import { isFrameLikeElement } from "../element/typeChecks"; import { ExcalidrawElement } from "../element/types"; import { KEYS } from "../keys"; import { arrayToMap } from "../utils"; @@ -51,7 +52,7 @@ export const actionToggleElementLock = register({ selectedElementIds: appState.selectedElementIds, includeBoundTextElement: false, }); - if (selected.length === 1 && selected[0].type !== "frame") { + if (selected.length === 1 && !isFrameLikeElement(selected[0])) { return selected[0].locked ? "labels.elementLock.unlock" : "labels.elementLock.lock"; diff --git a/src/actions/actionFrame.ts b/src/actions/actionFrame.ts index 9e8c16c23..4cddb2ac0 100644 --- a/src/actions/actionFrame.ts +++ b/src/actions/actionFrame.ts @@ -7,23 +7,27 @@ import { AppClassProperties, AppState } from "../types"; import { updateActiveTool } from "../utils"; import { setCursorForShape } from "../cursor"; import { register } from "./register"; +import { isFrameLikeElement } from "../element/typeChecks"; const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => { const selectedElements = app.scene.getSelectedElements(appState); - return selectedElements.length === 1 && selectedElements[0].type === "frame"; + return ( + selectedElements.length === 1 && isFrameLikeElement(selectedElements[0]) + ); }; export const actionSelectAllElementsInFrame = register({ name: "selectAllElementsInFrame", trackEvent: { category: "canvas" }, perform: (elements, appState, _, app) => { - const selectedFrame = app.scene.getSelectedElements(appState)[0]; + const selectedElement = + app.scene.getSelectedElements(appState).at(0) || null; - if (selectedFrame && selectedFrame.type === "frame") { + if (isFrameLikeElement(selectedElement)) { const elementsInFrame = getFrameChildren( getNonDeletedElements(elements), - selectedFrame.id, + selectedElement.id, ).filter((element) => !(element.type === "text" && element.containerId)); return { @@ -54,15 +58,20 @@ export const actionRemoveAllElementsFromFrame = register({ name: "removeAllElementsFromFrame", trackEvent: { category: "history" }, perform: (elements, appState, _, app) => { - const selectedFrame = app.scene.getSelectedElements(appState)[0]; + const selectedElement = + app.scene.getSelectedElements(appState).at(0) || null; - if (selectedFrame && selectedFrame.type === "frame") { + if (isFrameLikeElement(selectedElement)) { return { - elements: removeAllElementsFromFrame(elements, selectedFrame, appState), + elements: removeAllElementsFromFrame( + elements, + selectedElement, + appState, + ), appState: { ...appState, selectedElementIds: { - [selectedFrame.id]: true, + [selectedElement.id]: true, }, }, commitToHistory: true, diff --git a/src/actions/actionGroup.tsx b/src/actions/actionGroup.tsx index 219f1444c..e6cb05840 100644 --- a/src/actions/actionGroup.tsx +++ b/src/actions/actionGroup.tsx @@ -22,8 +22,8 @@ import { AppClassProperties, AppState } from "../types"; import { isBoundToContainer } from "../element/typeChecks"; import { getElementsInResizingFrame, - getFrameElements, - groupByFrames, + getFrameLikeElements, + groupByFrameLikes, removeElementsFromFrame, replaceAllElementsInFrame, } from "../frame"; @@ -102,7 +102,7 @@ export const actionGroup = register({ // when it happens, we want to remove elements that are in the frame // and are going to be grouped from the frame (mouthful, I know) if (groupingElementsFromDifferentFrames) { - const frameElementsMap = groupByFrames(selectedElements); + const frameElementsMap = groupByFrameLikes(selectedElements); frameElementsMap.forEach((elementsInFrame, frameId) => { nextElements = removeElementsFromFrame( @@ -219,7 +219,7 @@ export const actionUngroup = register({ .map((element) => element.frameId!), ); - const targetFrames = getFrameElements(elements).filter((frame) => + const targetFrames = getFrameLikeElements(elements).filter((frame) => selectedElementFrameIds.has(frame.id), ); diff --git a/src/actions/actionMenu.tsx b/src/actions/actionMenu.tsx index b259d7267..fa8dcbea7 100644 --- a/src/actions/actionMenu.tsx +++ b/src/actions/actionMenu.tsx @@ -56,13 +56,18 @@ export const actionShortcuts = register({ viewMode: true, trackEvent: { category: "menu", action: "toggleHelpDialog" }, perform: (_elements, appState, _, { focusContainer }) => { - if (appState.openDialog === "help") { + if (appState.openDialog?.name === "help") { focusContainer(); } return { appState: { ...appState, - openDialog: appState.openDialog === "help" ? null : "help", + openDialog: + appState.openDialog?.name === "help" + ? null + : { + name: "help", + }, }, commitToHistory: false, }; diff --git a/src/actions/actionStyles.ts b/src/actions/actionStyles.ts index 2b656b050..9c6589bbc 100644 --- a/src/actions/actionStyles.ts +++ b/src/actions/actionStyles.ts @@ -20,7 +20,7 @@ import { hasBoundTextElement, canApplyRoundnessTypeToElement, getDefaultRoundnessTypeForElement, - isFrameElement, + isFrameLikeElement, isArrowElement, } from "../element/typeChecks"; import { getSelectedElements } from "../scene"; @@ -138,7 +138,7 @@ export const actionPasteStyles = register({ }); } - if (isFrameElement(element)) { + if (isFrameLikeElement(element)) { newElement = newElementWith(newElement, { roundness: null, backgroundColor: "transparent", diff --git a/src/analytics.ts b/src/analytics.ts index c7b4f844d..671f59202 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -1,3 +1,7 @@ +// place here categories that you want to track. We want to track just a +// small subset of categories at a given time. +const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[]; + export const trackEvent = ( category: string, action: string, @@ -5,13 +9,13 @@ export const trackEvent = ( value?: number, ) => { try { - // place here categories that you want to track as events - // KEEP IN MIND THE PRICING - const ALLOWED_CATEGORIES_TO_TRACK = [] as string[]; - // Uncomment the next line to track locally - // console.log("Track Event", { category, action, label, value }); - - if (typeof window === "undefined" || import.meta.env.VITE_WORKER_ID) { + // prettier-ignore + if ( + typeof window === "undefined" + || import.meta.env.VITE_WORKER_ID + // comment out to debug locally + || import.meta.env.PROD + ) { return; } @@ -19,6 +23,10 @@ export const trackEvent = ( return; } + if (!import.meta.env.PROD) { + console.info("trackEvent", { category, action, label, value }); + } + if (window.sa_event) { window.sa_event(action, { category, diff --git a/src/clipboard.ts b/src/clipboard.ts index 7934f3891..a8bc21562 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -9,7 +9,10 @@ import { EXPORT_DATA_TYPES, MIME_TYPES, } from "./constants"; -import { isInitializedImageElement } from "./element/typeChecks"; +import { + isFrameLikeElement, + isInitializedImageElement, +} from "./element/typeChecks"; import { deepCopyElement } from "./element/newElement"; import { mutateElement } from "./element/mutateElement"; import { getContainingFrame } from "./frame"; @@ -124,7 +127,7 @@ export const serializeAsClipboardJSON = ({ files: BinaryFiles | null; }) => { const framesToCopy = new Set( - elements.filter((element) => element.type === "frame"), + elements.filter((element) => isFrameLikeElement(element)), ); let foundFile = false; diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 17b7db28a..675a79f84 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { ActionManager } from "../actions/manager"; import { getNonDeletedElements } from "../element"; -import { ExcalidrawElement } from "../element/types"; +import { ExcalidrawElement, ExcalidrawElementType } from "../element/types"; import { t } from "../i18n"; import { useDevice } from "../components/App"; import { @@ -37,8 +37,11 @@ import { frameToolIcon, mermaidLogoIcon, laserPointerToolIcon, + OpenAIIcon, + MagicIcon, } from "./icons"; import { KEYS } from "../keys"; +import { useTunnels } from "../context/tunnels"; export const SelectedShapeActions = ({ appState, @@ -80,7 +83,8 @@ export const SelectedShapeActions = ({ const showLinkIcon = targetElements.length === 1 || isSingleElementBoundContainer; - let commonSelectedType: string | null = targetElements[0]?.type || null; + let commonSelectedType: ExcalidrawElementType | null = + targetElements[0]?.type || null; for (const element of targetElements) { if (element.type !== commonSelectedType) { @@ -95,7 +99,8 @@ export const SelectedShapeActions = ({ {((hasStrokeColor(appState.activeTool.type) && appState.activeTool.type !== "image" && commonSelectedType !== "image" && - commonSelectedType !== "frame") || + commonSelectedType !== "frame" && + commonSelectedType !== "magicframe") || targetElements.some((element) => hasStrokeColor(element.type))) && renderAction("changeStrokeColor")}
@@ -233,6 +238,8 @@ export const ShapesSwitcher = ({ const laserToolSelected = activeTool.type === "laser"; const embeddableToolSelected = activeTool.type === "embeddable"; + const { TTDDialogTriggerTunnel } = useTunnels(); + return ( <> {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { @@ -333,13 +340,43 @@ export const ShapesSwitcher = ({ > {t("toolBar.laser")} +
+ Generate +
+ {app.props.aiEnabled !== false && } app.setOpenDialog("mermaid")} + onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })} icon={mermaidLogoIcon} data-testid="toolbar-embeddable" > {t("toolBar.mermaidToExcalidraw")} + {app.props.aiEnabled !== false && ( + <> + app.onMagicframeToolSelect()} + icon={MagicIcon} + data-testid="toolbar-magicframe" + > + {t("toolBar.magicframe")} + AI + + { + trackEvent("ai", "open-settings", "d2c"); + app.setOpenDialog({ + name: "settings", + source: "settings", + tab: "diagram-to-code", + }); + }} + icon={OpenAIIcon} + data-testid="toolbar-magicSettings" + > + {t("toolBar.magicSettings")} + + + )} diff --git a/src/components/App.tsx b/src/components/App.tsx index 80fc50bd2..502ec1f5c 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -47,12 +47,15 @@ import { isEraserActive, isHandToolActive, } from "../appState"; -import { PastedMixedContent, parseClipboard } from "../clipboard"; +import { + PastedMixedContent, + copyTextToSystemClipboard, + parseClipboard, +} from "../clipboard"; import { APP_NAME, CURSOR_TYPE, DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, - DEFAULT_UI_OPTIONS, DEFAULT_VERTICAL_ALIGN, DRAGGING_THRESHOLD, ELEMENT_READY_TO_ERASE_OPACITY, @@ -86,6 +89,8 @@ import { YOUTUBE_STATES, ZOOM_STEP, POINTER_EVENTS, + TOOL_TYPE, + EDITOR_LS_KEYS, } from "../constants"; import { ExportedElements, exportCanvas, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; @@ -139,6 +144,8 @@ import { newFrameElement, newFreeDrawElement, newEmbeddableElement, + newMagicFrameElement, + newIframeElement, } from "../element/newElement"; import { hasBoundTextElement, @@ -146,13 +153,17 @@ import { isBindingElement, isBindingElementType, isBoundToContainer, - isFrameElement, + isFrameLikeElement, isImageElement, isEmbeddableElement, isInitializedImageElement, isLinearElement, isLinearElementType, isUsingAdaptiveRadius, + isFrameElement, + isIframeElement, + isIframeLikeElement, + isMagicFrameElement, } from "../element/typeChecks"; import { ExcalidrawBindableElement, @@ -167,8 +178,11 @@ import { FileId, NonDeletedExcalidrawElement, ExcalidrawTextContainer, - ExcalidrawFrameElement, - ExcalidrawEmbeddableElement, + ExcalidrawFrameLikeElement, + ExcalidrawMagicFrameElement, + ExcalidrawIframeLikeElement, + IframeData, + ExcalidrawIframeElement, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -256,6 +270,7 @@ import { easeOut, } from "../utils"; import { + createSrcDoc, embeddableURLValidator, extractSrc, getEmbedLink, @@ -338,6 +353,7 @@ import { elementOverlapsWithFrame, updateFrameMembershipOfSelectedElements, isElementInFrame, + getFrameLikeTitle, } from "../frame"; import { excludeElementsInFramesFromSelection, @@ -375,7 +391,6 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { StaticCanvas, InteractiveCanvas } from "./canvases"; import { Renderer } from "../scene/Renderer"; import { ShapeCache } from "../scene/ShapeCache"; -import MermaidToExcalidraw from "./MermaidToExcalidraw"; import { LaserToolOverlay } from "./LaserTool/LaserTool"; import { LaserPathManager } from "./LaserTool/LaserPathManager"; import { @@ -385,6 +400,13 @@ import { setCursorForShape, } from "../cursor"; import { Emitter } from "../emitter"; +import { ElementCanvasButtons } from "../element/ElementCanvasButtons"; +import { MagicCacheData, diagramToHTML } from "../data/magic"; +import { elementsOverlappingBBox, exportToBlob } from "../packages/utils"; +import { COLOR_PALETTE } from "../colors"; +import { ElementCanvasButton } from "./MagicButton"; +import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; +import { EditorLocalStorage } from "../data/EditorLocalStorage"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -491,11 +513,6 @@ class App extends React.Component { private excalidrawContainerRef = React.createRef(); - public static defaultProps: Partial = { - // needed for tests to pass since we directly render App in many tests - UIOptions: DEFAULT_UI_OPTIONS, - }; - public scene: Scene; public renderer: Renderer; private fonts: Fonts; @@ -716,22 +733,22 @@ class App extends React.Component { } } - private updateEmbeddableRef( - id: ExcalidrawEmbeddableElement["id"], + private cacheEmbeddableRef( + element: ExcalidrawIframeLikeElement, ref: HTMLIFrameElement | null, ) { if (ref) { - this.iFrameRefs.set(id, ref); + this.iFrameRefs.set(element.id, ref); } } private getHTMLIFrameElement( - id: ExcalidrawEmbeddableElement["id"], + element: ExcalidrawIframeLikeElement, ): HTMLIFrameElement | undefined { - return this.iFrameRefs.get(id); + return this.iFrameRefs.get(element.id); } - private handleEmbeddableCenterClick(element: ExcalidrawEmbeddableElement) { + private handleEmbeddableCenterClick(element: ExcalidrawIframeLikeElement) { if ( this.state.activeEmbeddable?.element === element && this.state.activeEmbeddable?.state === "active" @@ -754,7 +771,11 @@ class App extends React.Component { }); }, 100); - const iframe = this.getHTMLIFrameElement(element.id); + if (isIframeElement(element)) { + return; + } + + const iframe = this.getHTMLIFrameElement(element); if (!iframe?.contentWindow) { return; @@ -806,8 +827,8 @@ class App extends React.Component { } } - private isEmbeddableCenter( - el: ExcalidrawEmbeddableElement | null, + private isIframeLikeElementCenter( + el: ExcalidrawIframeLikeElement | null, event: React.PointerEvent | PointerEvent, sceneX: number, sceneY: number, @@ -829,12 +850,12 @@ class App extends React.Component { } private updateEmbeddables = () => { - const embeddableElements = new Map(); + const iframeLikes = new Set(); let updated = false; this.scene.getNonDeletedElements().filter((element) => { if (isEmbeddableElement(element)) { - embeddableElements.set(element.id, true); + iframeLikes.add(element.id); if (element.validated == null) { updated = true; @@ -846,6 +867,8 @@ class App extends React.Component { mutateElement(element, { validated }, false); ShapeCache.delete(element); } + } else if (isIframeElement(element)) { + iframeLikes.add(element.id); } return false; }); @@ -856,7 +879,7 @@ class App extends React.Component { // GC this.iFrameRefs.forEach((ref, id) => { - if (!embeddableElements.has(id)) { + if (!iframeLikes.has(id)) { this.iFrameRefs.delete(id); } }); @@ -870,8 +893,8 @@ class App extends React.Component { const embeddableElements = this.scene .getNonDeletedElements() .filter( - (el): el is NonDeleted => - isEmbeddableElement(el) && !!el.validated, + (el): el is NonDeleted => + (isEmbeddableElement(el) && !!el.validated) || isIframeElement(el), ); return ( @@ -881,7 +904,150 @@ class App extends React.Component { { sceneX: el.x, sceneY: el.y }, this.state, ); - const embedLink = getEmbedLink(toValidURL(el.link || "")); + + let src: IframeData | null; + + if (isIframeElement(el)) { + src = null; + + const data: MagicCacheData = (el.customData?.generationData ?? + this.magicGenerations.get(el.id)) || { + status: "error", + message: "No generation data", + code: "ERR_NO_GENERATION_DATA", + }; + + if (data.status === "done") { + const html = data.html; + src = { + intrinsicSize: { w: el.width, h: el.height }, + type: "document", + srcdoc: () => { + return html; + }, + } as const; + } else if (data.status === "pending") { + src = { + intrinsicSize: { w: el.width, h: el.height }, + type: "document", + srcdoc: () => { + return createSrcDoc(` + +
+ + + +
+
Generating...
+ `); + }, + } as const; + } else { + let message: string; + if (data.code === "ERR_GENERATION_INTERRUPTED") { + message = "Generation was interrupted..."; + } else { + message = data.message || "Generation failed"; + } + src = { + intrinsicSize: { w: el.width, h: el.height }, + type: "document", + srcdoc: () => { + return createSrcDoc(` + +

Error!

+

${message}

+ `); + }, + } as const; + } + } else { + src = getEmbedLink(toValidURL(el.link || "")); + } + + // console.log({ src }); + const isVisible = isElementInViewport( el, normalizedWidth, @@ -953,19 +1119,19 @@ class App extends React.Component { padding: `${el.strokeWidth}px`, }} > - {this.props.renderEmbeddable?.(el, this.state) ?? ( + {(isEmbeddableElement(el) + ? this.props.renderEmbeddable?.(el, this.state) + : null) ?? (