From b1d9836d3e44cf5c34a5fc170c5faa0340f8a1b6 Mon Sep 17 00:00:00 2001 From: grootbro Date: Mon, 7 Sep 2026 20:14:14 +0700 Subject: [PATCH 1/4] feat(embed): auto-resize share iframes and keep modals in view Ship openpanel-embed.js (Plausible-style height sync via postMessage) for share overview/dashboard embeds, and pin Radix dialogs to the visible iframe slice so they no longer center mid-document or lock parent scroll. --- apps/start/public/openpanel-embed.js | 91 +++++++++++++++ apps/start/src/components/ui/dialog.tsx | 36 +++++- apps/start/src/hooks/use-embed-viewport.ts | 108 ++++++++++++++++++ .../start/src/modals/share-overview-modal.tsx | 39 ++++++- apps/start/src/routeTree.gen.ts | 21 ++++ apps/start/src/routes/iframe-test.tsx | 35 ++++++ .../src/routes/share.dashboard.$shareId.tsx | 2 + .../src/routes/share.overview.$shareId.tsx | 2 + apps/start/src/utils/embed-viewport.test.ts | 28 +++++ apps/start/src/utils/embed-viewport.ts | 45 ++++++++ 10 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 apps/start/public/openpanel-embed.js create mode 100644 apps/start/src/hooks/use-embed-viewport.ts create mode 100644 apps/start/src/routes/iframe-test.tsx create mode 100644 apps/start/src/utils/embed-viewport.test.ts create mode 100644 apps/start/src/utils/embed-viewport.ts diff --git a/apps/start/public/openpanel-embed.js b/apps/start/public/openpanel-embed.js new file mode 100644 index 000000000..bd1ed4a89 --- /dev/null +++ b/apps/start/public/openpanel-embed.js @@ -0,0 +1,91 @@ +/** + * OpenPanel share embed host — auto-sizes iframes marked with + * data-openpanel-embed and answers viewport queries so in-frame dialogs + * can center on the visible slice (parent scroll ≠ iframe mid-point). + * + * Usage: + * + * + */ +(() => { + const SOURCE = 'openpanel-embed'; + const SELECTOR = 'iframe[data-openpanel-embed]'; + + function iframes() { + return Array.from(document.querySelectorAll(SELECTOR)); + } + + function viewportFor(iframe) { + const rect = iframe.getBoundingClientRect(); + const visibleTop = Math.max(0, -rect.top); + const visibleBottom = Math.min(rect.height, window.innerHeight - rect.top); + const visibleHeight = Math.max( + 0, + visibleBottom - visibleTop, + ) || Math.min(rect.height, window.innerHeight); + return { visibleTop, visibleHeight }; + } + + function replyViewport(iframe, source) { + const { visibleTop, visibleHeight } = viewportFor(iframe); + source.postMessage( + { source: SOURCE, type: 'viewport', visibleTop, visibleHeight }, + '*', + ); + } + + function onMessage(event) { + const data = event.data; + if (!data || data.source !== SOURCE) { + return; + } + + const iframe = iframes().find((el) => el.contentWindow === event.source); + if (!iframe) { + return; + } + + if (data.type === 'resize' && typeof data.height === 'number') { + const next = Math.max(0, Math.ceil(data.height)); + if (String(iframe.height) !== String(next)) { + iframe.style.height = `${next}px`; + iframe.setAttribute('height', String(next)); + } + return; + } + + if (data.type === 'request-viewport' && event.source) { + replyViewport(iframe, event.source); + } + } + + function broadcastViewport() { + for (const iframe of iframes()) { + if (iframe.contentWindow) { + replyViewport(iframe, iframe.contentWindow); + } + } + } + + window.addEventListener('message', onMessage); + window.addEventListener('scroll', broadcastViewport, { passive: true }); + window.addEventListener('resize', broadcastViewport); + + function init() { + for (const iframe of iframes()) { + iframe.setAttribute('scrolling', 'no'); + if (!iframe.style.width) { + iframe.style.width = '100%'; + } + if (!iframe.style.border) { + iframe.style.border = '0'; + } + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/apps/start/src/components/ui/dialog.tsx b/apps/start/src/components/ui/dialog.tsx index 1b96fca1a..557639473 100644 --- a/apps/start/src/components/ui/dialog.tsx +++ b/apps/start/src/components/ui/dialog.tsx @@ -2,12 +2,23 @@ import * as DialogPrimitive from '@radix-ui/react-dialog'; import { XIcon } from 'lucide-react'; import type * as React from 'react'; +import { useEmbedViewport, isInIframe } from '@/hooks/use-embed-viewport'; +import { embeddedDialogCenterY } from '@/utils/embed-viewport'; import { cn } from '@/lib/utils'; function Dialog({ + modal, ...props }: React.ComponentProps) { - return ; + // Scroll-lock + focus trap fight iframe auto-height (parent owns the scroll). + const embedded = typeof window !== 'undefined' && isInIframe(); + return ( + + ); } function DialogTrigger({ @@ -30,8 +41,19 @@ function DialogClose({ function DialogOverlay({ className, + style, ...props }: React.ComponentProps) { + const viewport = useEmbedViewport(); + const embeddedStyle = + viewport != null + ? { + top: viewport.visibleTop, + height: viewport.visibleHeight, + bottom: 'auto' as const, + } + : undefined; + return ( ); @@ -48,10 +71,20 @@ function DialogContent({ className, children, showCloseButton = false, + style, ...props }: React.ComponentProps & { showCloseButton?: boolean; }) { + const viewport = useEmbedViewport(); + const embeddedStyle = + viewport != null + ? { + top: embeddedDialogCenterY(viewport), + maxHeight: Math.min(viewport.visibleHeight * 0.9, 720), + } + : undefined; + // Not using DialogPortal because it's breaking useRef's for some reason return (
@@ -66,6 +99,7 @@ function DialogContent({ 'bg-def-100 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg [&>*]:min-w-0', className, )} + style={{ ...embeddedStyle, ...style }} {...props} > {children} diff --git a/apps/start/src/hooks/use-embed-viewport.ts b/apps/start/src/hooks/use-embed-viewport.ts new file mode 100644 index 000000000..2c3749438 --- /dev/null +++ b/apps/start/src/hooks/use-embed-viewport.ts @@ -0,0 +1,108 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + type EmbedViewport, + isInIframe, + viewportFromIframeRect, +} from '@/utils/embed-viewport'; + +const SOURCE = 'openpanel-embed'; + +type ViewportMessage = { + source: typeof SOURCE; + type: 'viewport'; + visibleTop: number; + visibleHeight: number; +}; + +function isViewportMessage(data: unknown): data is ViewportMessage { + if (!data || typeof data !== 'object') { + return false; + } + const msg = data as Record; + return ( + msg.source === SOURCE && + msg.type === 'viewport' && + typeof msg.visibleTop === 'number' && + typeof msg.visibleHeight === 'number' + ); +} + +/** + * While framed by openpanel-embed.js, keep the visible slice in sync so + * overlays can pin to what the user actually sees (not mid-document). + */ +export function useEmbedViewport(): EmbedViewport | null { + const [viewport, setViewport] = useState(null); + + useEffect(() => { + if (!isInIframe()) { + return; + } + + const onMessage = (event: MessageEvent) => { + if (!isViewportMessage(event.data)) { + return; + } + setViewport({ + visibleTop: event.data.visibleTop, + visibleHeight: event.data.visibleHeight, + }); + }; + + window.addEventListener('message', onMessage); + window.parent.postMessage({ source: SOURCE, type: 'request-viewport' }, '*'); + + const interval = window.setInterval(() => { + window.parent.postMessage( + { source: SOURCE, type: 'request-viewport' }, + '*', + ); + }, 250); + + return () => { + window.removeEventListener('message', onMessage); + window.clearInterval(interval); + }; + }, []); + + return viewport; +} + +/** Report document height to the parent embed host. */ +export function useReportEmbedHeight(enabled = true) { + useEffect(() => { + if (!enabled || !isInIframe()) { + return; + } + + const publish = () => { + const height = Math.ceil( + Math.max( + document.documentElement.scrollHeight, + document.body?.scrollHeight ?? 0, + ), + ); + window.parent.postMessage( + { source: SOURCE, type: 'resize', height }, + '*', + ); + }; + + publish(); + const ro = new ResizeObserver(publish); + ro.observe(document.documentElement); + if (document.body) { + ro.observe(document.body); + } + window.addEventListener('load', publish); + + return () => { + ro.disconnect(); + window.removeEventListener('load', publish); + }; + }, [enabled]); +} + +export { isInIframe, viewportFromIframeRect }; diff --git a/apps/start/src/modals/share-overview-modal.tsx b/apps/start/src/modals/share-overview-modal.tsx index ced7a9660..27439c960 100644 --- a/apps/start/src/modals/share-overview-modal.tsx +++ b/apps/start/src/modals/share-overview-modal.tsx @@ -27,6 +27,7 @@ export default function ShareOverviewModal() { const { projectId, organizationId } = useAppParams(); const navigate = useNavigate(); const [copied, setCopied] = useState(false); + const [copiedEmbed, setCopiedEmbed] = useState(false); const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -43,7 +44,10 @@ export default function ShareOverviewModal() { const shareUrl = existingShare?.id ? `${window.location.origin}/share/overview/${existingShare.id}` : ''; - + const embedScriptUrl = `${window.location.origin}/openpanel-embed.js`; + const embedCode = shareUrl + ? `\n` + : ''; const { register, handleSubmit, watch } = useForm({ resolver: zodResolver(validator), defaultValues: { @@ -90,6 +94,13 @@ export default function ShareOverviewModal() { toast('Link copied to clipboard'); }; + const handleCopyEmbed = () => { + navigator.clipboard.writeText(embedCode); + setCopiedEmbed(true); + setTimeout(() => setCopiedEmbed(false), 2000); + toast('Embed code copied to clipboard'); + }; + const handleMakePrivate = () => { mutation.mutate({ public: false, @@ -152,6 +163,32 @@ export default function ShareOverviewModal() {
+
+

+ Embed (auto-resizes to content height): +

+
+