Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions apps/start/public/openpanel-embed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* 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:
* <iframe data-openpanel-embed src="https://…/share/overview/…" …></iframe>
* <script async src="https://…/openpanel-embed.js"></script>
*/
(() => {
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.min(rect.height, Math.max(0, -rect.top));
const visibleBottom = Math.min(rect.height, window.innerHeight - rect.top);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
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));
}
if (event.source) {
event.source.postMessage(
{ source: SOURCE, type: 'resize-ack', height: 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);
}
}
}

function pingIframes() {
for (const iframe of iframes()) {
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(
{ source: SOURCE, type: 'request-resize' },
'*',
);
}
}
}

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';
}
iframe.addEventListener('load', () => {
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(
{ source: SOURCE, type: 'request-resize' },
'*',
);
}
});
}
pingIframes();
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
57 changes: 52 additions & 5 deletions apps/start/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,26 @@ 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';

const overlayClassName =
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50';

function Dialog({
modal,
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
// Scroll-lock + focus trap fight iframe auto-height (parent owns the scroll).
const embedded = typeof window !== 'undefined' && isInIframe();
return (
<DialogPrimitive.Root
data-slot="dialog"
modal={embedded ? false : modal}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{...props}
/>
);
}

function DialogTrigger({
Expand All @@ -30,15 +44,37 @@ function DialogClose({

function DialogOverlay({
className,
style,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
const embedded = typeof window !== 'undefined' && isInIframe();
const viewport = useEmbedViewport();
const embeddedStyle =
viewport != null && viewport.visibleHeight > 0
? {
top: viewport.visibleTop,
height: viewport.visibleHeight,
bottom: 'auto' as const,
}
: undefined;

// Radix drops Overlay when modal={false}; paint our own so embeds keep a backdrop.
if (embedded) {
return (
<div
data-slot="dialog-overlay"
aria-hidden
className={cn(overlayClassName, className)}
style={{ ...embeddedStyle, ...style }}
/>
);
}

return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
className={cn(overlayClassName, className)}
style={style}
{...props}
/>
);
Expand All @@ -48,10 +84,20 @@ function DialogContent({
className,
children,
showCloseButton = false,
style,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
const viewport = useEmbedViewport();
const embeddedStyle =
viewport != null && viewport.visibleHeight > 0
? {
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 (
<div data-slot="dialog-portal">
Expand All @@ -66,6 +112,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}
Expand Down
15 changes: 15 additions & 0 deletions apps/start/src/hooks/measure-embed-content-height.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { measureEmbedContentHeight } from './use-embed-viewport';

describe('measureEmbedContentHeight', () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it('returns 0 without reading HTMLElement when document is undefined', () => {
vi.stubGlobal('document', undefined);
vi.stubGlobal('HTMLElement', undefined);

expect(measureEmbedContentHeight()).toBe(0);
});
});
Loading