-
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor(ui): implement an event-driven bidirectional communication s…
…ystem between for the iframe
- Loading branch information
Showing
17 changed files
with
464 additions
and
107 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
import { HTMLProps } from 'react' | ||
import { cn } from '@r1stack/core' | ||
|
||
import { ElementIds } from '@/types' | ||
|
||
import { useStoryLiteConfig } from '../../app/context/StoriesDataContext' | ||
import { useCanvasIframe } from './useCanvasIframe' | ||
|
||
const allowList = [ | ||
'autoplay', | ||
'camera', | ||
'encrypted-media', | ||
'fullscreen', | ||
'geolocation', | ||
'microphone', | ||
'midi', | ||
'payment', | ||
'picture-in-picture', | ||
'clipboard-write', | ||
'accelerometer', | ||
'gyroscope', | ||
'magnetometer', | ||
'xr-spatial-tracking', | ||
'usb', | ||
] | ||
|
||
// const sandboxAllowList = [ | ||
// 'allow-same-origin', | ||
// // 'allow-forms', | ||
// // 'allow-fullscreen', | ||
// // 'allow-modals', | ||
// // 'allow-orientation-lock', | ||
// // 'allow-pointer-lock', | ||
// // 'allow-popups', | ||
// // 'allow-popups-to-escape-sandbox', | ||
// // 'allow-presentation', | ||
// // 'allow-scripts', | ||
// // 'allow-top-navigation', | ||
// // 'allow-top-navigation-by-user-activation', | ||
// ] | ||
|
||
export type CanvasIframeProps = { | ||
story?: string | ||
exportName?: string | ||
} & Omit<HTMLProps<HTMLIFrameElement>, 'src'> | ||
|
||
const handleReceivedMessage = (message: any) => { | ||
// eslint-disable-next-line no-console | ||
console.log('[top] Message received:', message) | ||
} | ||
|
||
export function CanvasIframe(props: CanvasIframeProps) { | ||
const userConfig = useStoryLiteConfig() | ||
const userProps = userConfig?.iframeProps || {} | ||
const { className: userClassName, ...userRest } = userProps | ||
const { story, exportName, className, ...rest } = props | ||
|
||
const iframeSrc = | ||
story === undefined ? '/#/sandbox/dashboard' : `/#/sandbox/stories/${story}/${exportName || ''}` | ||
|
||
const { ref, isReady, sendMessage } = useCanvasIframe(handleReceivedMessage) | ||
|
||
const handleToolbarAction = () => { | ||
if (isReady) { | ||
sendMessage({ | ||
type: 'chat', | ||
payload: { | ||
message: 'Hello from top window', | ||
}, | ||
}) | ||
} | ||
} | ||
|
||
return ( | ||
<> | ||
<button onClick={handleToolbarAction}>Send message to iframe</button> | ||
<iframe | ||
ref={ref} | ||
id={ElementIds.Iframe} | ||
src={iframeSrc} | ||
title="StoryLite-iframe" | ||
className={cn('StoryFrame', className, userClassName)} | ||
allow={allowList.map(permission => `${permission} *`).join('; ')} | ||
allowFullScreen={true} | ||
{...rest} | ||
{...userRest} | ||
/> | ||
</> | ||
) | ||
} |
57 changes: 57 additions & 0 deletions
57
packages/storylite/src/components/canvas/CanvasIframeBody.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
import { HTMLProps, useEffect } from 'react' | ||
import { useSearchParams } from 'react-router-dom' | ||
import { cn } from '@r1stack/core' | ||
|
||
import { Story } from '../Story' | ||
import { CrossDocumentMessageSource, sendMessageToRoot } from './useCanvasIframe' | ||
|
||
export type CanvasIframeBodyProps = { | ||
story?: string | ||
exportName?: string | ||
} & HTMLProps<HTMLDivElement> | ||
|
||
export function CanvasIframeBody(props: CanvasIframeBodyProps) { | ||
const { story, exportName, ...rest } = props | ||
|
||
const [searchParams] = useSearchParams() | ||
const isStandalone = searchParams.has('standalone') | ||
|
||
useEffect(() => { | ||
const onMessage = (message: any) => { | ||
// eslint-disable-next-line no-console | ||
console.log('[iframe] Message received:', message) | ||
// do something, .e.g.: use global state manager | ||
} | ||
const handleReceivedMessage = (event: MessageEvent) => { | ||
const eventSource = event.data?.source | ||
|
||
// Verify that the message comes from the expected sender | ||
if (eventSource === CrossDocumentMessageSource.Root) { | ||
onMessage(event.data) | ||
} | ||
} | ||
window.addEventListener('message', handleReceivedMessage) | ||
|
||
return () => { | ||
window.removeEventListener('message', handleReceivedMessage) | ||
} | ||
}, []) | ||
|
||
return ( | ||
<div className={cn('SandboxLayout', [isStandalone, 'StandaloneSandboxLayout'])} {...rest}> | ||
<button | ||
onClick={() => { | ||
sendMessageToRoot({ | ||
type: 'chat', | ||
payload: { | ||
message: 'Hello from iframe', | ||
}, | ||
}) | ||
}} | ||
> | ||
Send message to main window | ||
</button> | ||
<Story story={story ?? 'index'} exportName={exportName ?? 'default'} /> | ||
</div> | ||
) | ||
} |
131 changes: 131 additions & 0 deletions
131
packages/storylite/src/components/canvas/useCanvasIframe.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
import React from 'react' | ||
|
||
import { act, render, renderHook, waitFor } from '@testing-library/react' | ||
|
||
import { | ||
CrossDocumentMessage, | ||
CrossDocumentMessageSource, | ||
useCanvasIframe, | ||
} from './useCanvasIframe' | ||
|
||
// Mock postMessage for testing | ||
window.postMessage = jest.fn() | ||
const iframeMock = { | ||
contentWindow: { | ||
postMessage: jest.fn(), | ||
}, | ||
addEventListener: jest.fn().mockImplementation((event, cb) => { | ||
if (event === 'load') { | ||
cb() | ||
} | ||
}), | ||
removeEventListener: jest.fn(), | ||
} | ||
|
||
describe('useCanvasIframe', () => { | ||
afterEach(() => { | ||
jest.clearAllMocks() | ||
}) | ||
|
||
const mockUseRef = () => { | ||
jest.spyOn(React, 'useRef').mockReturnValue({ current: iframeMock }) | ||
} | ||
|
||
const renderIframe = (args: Parameters<typeof useCanvasIframe> = []) => { | ||
const { result } = renderHook(() => useCanvasIframe(...args)) | ||
render( | ||
<div> | ||
<iframe | ||
data-testid="test-iframe" | ||
title="test" | ||
onLoad={() => { | ||
result.current.isReady = true | ||
// result.current.ref.current = iframeElementMock as unknown as HTMLIFrameElement | ||
}} | ||
/> | ||
</div>, | ||
) | ||
|
||
return result.current | ||
} | ||
|
||
const renderMockedIframe = async (args: Parameters<typeof useCanvasIframe> = []) => { | ||
mockUseRef() | ||
const result = renderIframe(args) | ||
await waitFor(() => { | ||
expect(result.isReady).toBe(true) | ||
}) | ||
|
||
return result | ||
} | ||
|
||
test('should set up iframe listeners correctly', async () => { | ||
const result = await renderMockedIframe() | ||
|
||
expect(result.isReady).toBe(true) | ||
}) | ||
|
||
test('should send message to iframe', async () => { | ||
const result = await renderMockedIframe() | ||
|
||
const message: CrossDocumentMessage = { | ||
type: 'test', | ||
payload: { | ||
test: 'Hello', | ||
}, | ||
} | ||
act(() => { | ||
result.sendMessage(message) | ||
}) | ||
|
||
expect(iframeMock.contentWindow.postMessage).toHaveBeenCalledWith( | ||
{ ...message, source: CrossDocumentMessageSource.Root }, | ||
'/', | ||
) | ||
}) | ||
|
||
test('should call onMessage callback when message received', async () => { | ||
const onMessage = jest.fn() | ||
await renderMockedIframe([onMessage]) | ||
|
||
const message: CrossDocumentMessage = { | ||
type: 'test', | ||
payload: { | ||
test: 'Hello', | ||
}, | ||
source: CrossDocumentMessageSource.Iframe, | ||
} | ||
const event = new MessageEvent('message', { | ||
source: iframeMock.contentWindow as unknown as Window, | ||
data: message, | ||
}) | ||
|
||
act(() => { | ||
window.dispatchEvent(event) | ||
}) | ||
|
||
expect(onMessage).toHaveBeenCalledWith(message) | ||
}) | ||
|
||
test('should not call onMessage callback if source is not the iframe', async () => { | ||
const onMessage = jest.fn() | ||
await renderMockedIframe([onMessage]) | ||
|
||
const event = new MessageEvent('message', { | ||
source: null, | ||
data: { | ||
source: 'other' as any, | ||
type: 'test', | ||
payload: { | ||
test: 'Hello', | ||
}, | ||
} satisfies CrossDocumentMessage, | ||
}) | ||
|
||
act(() => { | ||
window.dispatchEvent(event) | ||
}) | ||
|
||
expect(onMessage).not.toHaveBeenCalled() | ||
}) | ||
}) |
Oops, something went wrong.