diff --git a/chainforge/react-server/src/App.tsx b/chainforge/react-server/src/App.tsx index e1b5a4f54..5868abcd4 100644 --- a/chainforge/react-server/src/App.tsx +++ b/chainforge/react-server/src/App.tsx @@ -43,6 +43,7 @@ import { IconHeart, IconCheckbox, IconTransform, + IconWorldWww, } from "@tabler/icons-react"; import RemoveEdge from "./RemoveEdge"; import TextFieldsNode from "./TextFieldsNode"; // Import a custom node @@ -72,6 +73,7 @@ import axios from "axios"; import LZString from "lz-string"; import { EXAMPLEFLOW_1 } from "./example_flows"; import MediaNode from "./MediaNode"; +import IframeNode from "./IframeNode"; // Styling import "reactflow/dist/style.css"; // reactflow @@ -218,6 +220,7 @@ const nodeTypes = { split: SplitNode, processor: CodeEvaluatorNode, media: MediaNode, + iframe: IframeNode, }; const nodeEmojis = { @@ -237,6 +240,7 @@ const nodeEmojis = { join: , split: , media: "📺", + iframe: , }; const edgeTypes = { @@ -359,6 +363,9 @@ const App = () => { x: x - 200 + (offsetX || 0), y: y - 100 + (offsetY || 0), }, + ...(type === "iframe" + ? { style: { width: 520, height: 400 } as React.CSSProperties } + : {}), }); }, [addNodeToStore, rfInstance], @@ -570,6 +577,18 @@ const App = () => { tooltip: "Make a comment about your flow.", onClick: () => addNode("comment"), }, + { + key: "iframe", + title: "iFrame Node", + icon: nodeEmojis.iframe, + tooltip: + "Embed a web page in an iframe on the canvas (for reference; not connected to the flow).", + onClick: () => + addNode("iframeNode", "iframe", { + url: "", + title: "iFrame Node", + }), + }, { key: "script", title: "Global Python Scripts", diff --git a/chainforge/react-server/src/IframeNode.tsx b/chainforge/react-server/src/IframeNode.tsx new file mode 100644 index 000000000..ff1bea474 --- /dev/null +++ b/chainforge/react-server/src/IframeNode.tsx @@ -0,0 +1,257 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Handle, NodeProps, Position } from "reactflow"; +import { ActionIcon, Text, TextInput, Tooltip } from "@mantine/core"; +import { + IconEye, + IconEyeOff, + IconRefresh, + IconWorldWww, +} from "@tabler/icons-react"; +import { NodeResizer } from "@reactflow/node-resizer"; +import "@reactflow/node-resizer/dist/style.css"; +import useStore from "./store"; +import NodeLabel from "./NodeLabelComponent"; +import BaseNode from "./BaseNode"; +import { TemplateVarInfo } from "./backend/typing"; + +export type IframeNodeData = { + title?: string; + url?: string; + input?: string; + refresh?: boolean; + /** When false, the iframe is not mounted (saves load on heavy pages). Default true. */ + renderEmbed?: boolean; +}; + +function safeEmbedUrl(raw: string): string | null { + const t = raw.trim(); + if (!t) return null; + try { + const u = new URL(t.includes("://") ? t : `https://${t}`); + if (u.protocol !== "http:" && u.protocol !== "https:") return null; + return u.href; + } catch { + return null; + } +} + +function firstOutputToUrlString( + out: (string | TemplateVarInfo)[] | null, +): string | null { + if (!out || out.length === 0) return null; + const x = out[0]; + if (typeof x === "string") return x.trim(); + if (x && typeof x === "object" && "text" in x && x.text !== undefined) { + const t = x.text; + return typeof t === "string" ? t.trim() : String(t).trim(); + } + return null; +} + +type IframeNodeProps = NodeProps; + +/** + * Embeds a web page on the canvas for reference. Resize the node to change the viewport. + * The left target handle accepts a connection whose text output sets the iframe URL. + */ +const IframeNode: React.FC = ({ data, id, selected }) => { + const edges = useStore((s) => s.edges); + const nodes = useStore((s) => s.nodes); + const output = useStore((s) => s.output); + const setDataPropsForNode = useStore((state) => state.setDataPropsForNode); + + const [urlDraft, setUrlDraft] = useState(data.url ?? ""); + const [committedManual, setCommittedManual] = useState(data.url ?? ""); + const [reloadKey, setReloadKey] = useState(0); + + const urlEdge = useMemo( + () => edges.find((e) => e.target === id && e.targetHandle === "url"), + [edges, id], + ); + + const wiredUrl = useMemo(() => { + if (!urlEdge) return null; + const sh = urlEdge.sourceHandle ?? "output"; + const out = output(urlEdge.source, sh, id, "url"); + return firstOutputToUrlString(out); + }, [urlEdge, output, id, nodes]); + + const isWired = Boolean(urlEdge); + + useEffect(() => { + setUrlDraft(data.url ?? ""); + setCommittedManual(data.url ?? ""); + }, [data.url]); + + useEffect(() => { + if (data.refresh) { + setDataPropsForNode(id, { refresh: false }); + setReloadKey((k) => k + 1); + } + }, [data.refresh, id, setDataPropsForNode]); + + // While connected, upstream output overrides manual URL (persisted on the node). + useEffect(() => { + if (!isWired) return; + const w = wiredUrl ?? ""; + setUrlDraft(w); + setCommittedManual(w); + if (w !== (data.url ?? "")) { + setDataPropsForNode(id, { url: w }); + } + }, [isWired, wiredUrl, id, setDataPropsForNode, data.url]); + + const effectiveCommitted = isWired ? wiredUrl ?? "" : committedManual; + const embedSrc = useMemo( + () => safeEmbedUrl(effectiveCommitted), + [effectiveCommitted], + ); + + const commitManualUrl = useCallback(() => { + if (isWired) return; + const next = urlDraft.trim(); + setCommittedManual(next); + setDataPropsForNode(id, { url: next }); + setReloadKey((k) => k + 1); + }, [id, isWired, setDataPropsForNode, urlDraft]); + + const handleReload = useCallback(() => { + setReloadKey((k) => k + 1); + }, []); + + const showEmbed = data.renderEmbed !== false; + + const toggleRenderEmbed = useCallback(() => { + setDataPropsForNode(id, { renderEmbed: !showEmbed }); + }, [id, setDataPropsForNode, showEmbed]); + + return ( + + +
+ } + customButtons={[ + + + , + ]} + /> +
+ + + + + + { + if (!isWired) setUrlDraft(e.currentTarget.value); + }} + onBlur={commitManualUrl} + onKeyDown={(e) => { + if (!isWired && e.key === "Enter") commitManualUrl(); + }} + readOnly={isWired} + size="xs" + variant="unstyled" + styles={{ + root: { flex: 1, minWidth: 0 }, + input: { + fontFamily: + 'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', + fontSize: 13, + }, + }} + /> + + + + + +
+
+ {embedSrc && showEmbed ? ( +