Skip to content

Commit

Permalink
[FEATURE] Allow scroll update on resize (#6)
Browse files Browse the repository at this point in the history
Allows specifying an onResize handler, a `updateParentScrollOnResize` handler is usable if we want to try to keep the iframe in the viewport after a resize
  • Loading branch information
Lemick authored Nov 11, 2024
1 parent 234fe3b commit 3e3a26e
Show file tree
Hide file tree
Showing 37 changed files with 313 additions and 60 deletions.
9 changes: 6 additions & 3 deletions .github/workflows/develop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,14 @@ jobs:
run: npm run build

- name: Run tests and code coverage
id: test
run: npm run test

- uses: actions/upload-artifact@v4
if: always()
if: failure() && steps.test.outcome == 'failure'
with:
name: playwright-report
path: playwright-report/
retention-days: 30
path: |
packages/core/playwright-report/
packages/core/e2e/usecases.spec.ts-snapshots/
retention-days: 7
46 changes: 33 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
# Open Iframe Resizer
# Open Iframe Resizer

## Overview

A modern, lightweight alternative for resizing iframes dynamically. It is shipped under the MIT license, making it usable in commercial projects.

If you found this plugin helpful, please consider starring the repository!
If you found this plugin helpful, please consider starring the repository!

## Getting Started

### Browser (ES6 modules)

```html

<script type="module">
import { initialize } from "https://cdn.jsdelivr.net/npm/@open-iframe-resizer/core@latest/dist/index.js";
Expand All @@ -21,38 +22,57 @@ If you found this plugin helpful, please consider starring the repository!
You can found a working example [here](https://codesandbox.io/p/sandbox/open-iframe-resize-browser-m655zt)

### Package

Note you can also install the core package through [npm](https://www.npmjs.com/package/@open-iframe-resizer/core):

```bash
npm install @open-iframe-resizer/core
```

### React

A React component is also available:

```bash
npm install @open-iframe-resizer/react
```

## Notes

### Comparison with iframe-resizer
This library is very good, but it has changed its license, so it is no longer usable in closed-source projects for free.
I decided to replicate some parts of the API, as it may facilitate migration to this project.
### Performing actions after a resize

Some features from this library are missing, but they could be implemented in future versions.
You can execute a custom function after an iframe has been resized. Also, you can use built-in functions
like `updateParentScrollOnResize` to help keep the iframe within the viewport after resizing:

```javascript
import { initialize, updateParentScrollOnResize } from "https://cdn.jsdelivr.net/npm/@open-iframe-resizer/core@latest/dist/index.js";

initialize({ onIframeResize: updateParentScrollOnResize }, "#myIframe");
```

### Resize iframes from a different origin
- If you have control over the embedded page, you need to load the script on your child page to enable messaging between the two windows (you do not need to call the initialize function in the child; loading the module is sufficient).

- If you have control over the embedded page, you need to load the script on your child page to enable messaging between the two windows (you do not need to call the initialize function in the child;
loading the module is sufficient).

Here is an example of the [parent page](https://codesandbox.io/p/sandbox/xj24pg) and the [child](https://codesandbox.io/p/sandbox/growing-iframe-msv4hr).

- If you have no control over the child iframe domain, and, by chance, the child page loads the legacy *iframe-resizer* script, you can initialize the library with the compatibility mode; it will try to connect to the child iframe:

```javascript
initialize({ enableLegacyLibSupport: true }, "#my-iframe");
```
- If you have no control over the child iframe domain, and, by chance, the child page loads the legacy *iframe-resizer* script, you can initialize the library with the compatibility mode; it will try
to connect to the child iframe:
```javascript
initialize({ enableLegacyLibSupport: true }, "#my-iframe");
```

### Comparison with iframe-resizer

This library is very good, but it has changed its license, so it is no longer usable in closed-source projects for free.
I decided to replicate some parts of the API, as it may facilitate migration to this project.

Some features from this library are missing, but they could be implemented in future versions.

## Browser support

| Chrome | Safari | Firefox | Opera | IE |
|--------|--------|---------|-------|-----------|
| Chrome | Safari | Firefox | Opera | IE |
|--------|--------|---------|-------|-------|
| 64+ | 13.1+ | 69+ | 51+ | 🙅‍♂️ |
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion packages/core/e2e/usecases.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test } from "@playwright/test";
import { expect, test } from "@playwright/test";

type AssertArg = { comparator: "greaterThan" | "lesserThan"; threshold: number };

Expand Down Expand Up @@ -77,3 +77,14 @@ test("Should not resize iframe when unsubscribed", async ({ page }) => {

await page.waitForFunction<boolean, AssertArg>(allIframesOffsetHeightMatch, { comparator: "lesserThan", threshold: 700 });
});

test("Should scroll in the parent window when an iframe is resized to keep the iframe in the viewport", async ({ page }) => {
await page.goto("/usecases/13-update-parent-scroll-on-resize/index.html");
await expect(page).toHaveScreenshot("01-initial-state.png");

await page.locator("#myIframe").contentFrame().getByRole("button", { name: "Add content to the iframe" }).click();
await expect(page).toHaveScreenshot("02-after-iframe-height-increased.png");

await page.locator("#myIframe").contentFrame().getByRole("button", { name: "Shrink the iframe" }).click();
await expect(page).toHaveScreenshot("03-after-iframe-height-decreased.png");
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@open-iframe-resizer/core",
"private": false,
"version": "1.1.3",
"version": "1.2.0",
"description": "Open-source modern iframe resizer",
"license": "MIT",
"repository": {
Expand Down Expand Up @@ -39,10 +39,10 @@
"README.md"
],
"scripts": {
"docs": "npm run typedoc",
"docs": "typedoc --options typedoc.json src/",
"lint": "biome lint ./src",
"build": "tsc && npm run lint && vite build && typedoc",
"build:watch": "vite build --watch && typedoc",
"build": "tsc && npm run lint && vite build && npm run docs",
"build:watch": "vite build --watch && npm run docs",
"dev": "concurrently \"vite build --watch\" \"npm run serve\"",
"serve": "concurrently \"vite --port 5550\" \"vite --port 5551\"",
"test": "npm run test:unit && npm run test:e2e",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default defineConfig({
baseURL: "http://localhost:5550",

/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on",
trace: "on-first-retry",
},

/* Configure projects for major browsers */
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/child.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function initializeChildListener() {
const data: IframeResizeEventData = {
type: "iframe-resized",
width: document.documentElement.scrollWidth,
height: getBoundingRectHeight(document) ?? undefined,
height: getBoundingRectHeight(document),
};
window.parent.postMessage(data, "*");
};
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,6 @@ export const removeUndefinedProperties = <T extends { [key: string]: unknown }>(
};

export const getBoundingRectHeight = (document: Document) => {
const { height } = document.documentElement.getBoundingClientRect() ?? {};
return height ? Math.ceil(height) : undefined;
const { height } = document.documentElement.getBoundingClientRect();
return Math.ceil(height);
};
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./parent";
export * from "./child";
export * from "./resize-handlers";
export type * from "./type";
95 changes: 72 additions & 23 deletions packages/core/src/parent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,27 @@ import {
removeUndefinedProperties,
} from "~/common";
import { handleLegacyLibResizeMessage, sendLegacyLibInitMessageOnIframeLoad } from "~/compat";
import type { IframeResizeEvent, InitializeFunction, Settings } from "./type";
import type { IframeResizeEvent, InitializeFunction, InteractionState, ResizeContext, Settings } from "./type";

type RegisteredElement = { iframe: HTMLIFrameElement; settings: Settings; interactionState: InteractionState };

const resizeObserver = createResizeObserver();
let registeredIframes: Array<{ iframe: HTMLIFrameElement; settings: Settings }> = [];
let registeredElements: Array<RegisteredElement> = [];

const initialize: InitializeFunction = (clientSettings, selector) => {
const finalSettings = { ...getDefaultSettings(), ...removeUndefinedProperties(clientSettings ?? {}) };
const iframes = resolveIframesToRegister(selector);
const allowedOrigins = registerIframesAllowOrigins(finalSettings, iframes);

return iframes.map((iframe) => {
registeredIframes.push({ iframe, settings: finalSettings });
const unsubscribeResizeListener = addChildResizeListener(iframe, finalSettings, allowedOrigins);
const registeredElement: RegisteredElement = { iframe, settings: finalSettings, interactionState: { isHovered: false } };
const unsubscribe = addChildResizeListener(registeredElement, allowedOrigins);
registeredElements.push(registeredElement);

return {
unsubscribe: () => {
unsubscribeResizeListener();
registeredIframes = registeredIframes.filter((entry) => entry.iframe !== iframe);
unsubscribe();
registeredElements = registeredElements.filter((entry) => entry.iframe !== iframe);
},
};
});
Expand Down Expand Up @@ -59,14 +63,21 @@ function registerIframesAllowOrigins(settings: Settings, iframes: HTMLIFrameElem
return allowedOrigins;
}

function addChildResizeListener(iframe: HTMLIFrameElement, settings: Settings, allowedOrigins: string[]) {
if (isIframeSameOrigin(iframe)) {
return addSameOriginChildResizeListener(iframe);
}
return addCrossOriginChildResizeListener(iframe, settings, allowedOrigins);
function addChildResizeListener(registeredElement: RegisteredElement, allowedOrigins: string[]) {
const removeResizeListener = isIframeSameOrigin(registeredElement.iframe)
? addSameOriginChildResizeListener(registeredElement)
: addCrossOriginChildResizeListener(registeredElement, allowedOrigins);

const removeInteractionListeners = addInteractionListeners(registeredElement);

return () => {
removeResizeListener();
removeInteractionListeners();
};
}

function addCrossOriginChildResizeListener(iframe: HTMLIFrameElement, settings: Settings, allowedOrigins: string[]) {
function addCrossOriginChildResizeListener(registeredElement: RegisteredElement, allowedOrigins: string[]) {
const { iframe, settings } = registeredElement;
const handleIframeResizedMessage = (event: MessageEvent) => {
const isOriginValid = !settings.checkOrigin || allowedOrigins.includes(event.origin);
const isIframeTarget = iframe.contentWindow === event.source;
Expand All @@ -77,13 +88,13 @@ function addCrossOriginChildResizeListener(iframe: HTMLIFrameElement, settings:

if (event.data?.type === "iframe-resized") {
const { height } = (event as IframeResizeEvent).data;
height && updateIframeDimensions({ height, iframe, settings });
height && resizeIframe({ newHeight: height, registeredElement });
return;
}

if (settings.enableLegacyLibSupport) {
const height = handleLegacyLibResizeMessage(event);
height !== null && updateIframeDimensions({ height, iframe, settings });
height !== null && resizeIframe({ newHeight: height, registeredElement });
return;
}
};
Expand All @@ -97,7 +108,8 @@ function addCrossOriginChildResizeListener(iframe: HTMLIFrameElement, settings:
return () => window.removeEventListener("message", handleIframeResizedMessage);
}

function addSameOriginChildResizeListener(iframe: HTMLIFrameElement) {
function addSameOriginChildResizeListener(registeredElement: RegisteredElement) {
const { iframe } = registeredElement;
const startListener = () => {
const contentBody = iframe.contentDocument?.body;
if (!contentBody) {
Expand All @@ -118,28 +130,65 @@ function addSameOriginChildResizeListener(iframe: HTMLIFrameElement) {
};
}

function addInteractionListeners(registeredElement: RegisteredElement) {
const { iframe, interactionState } = registeredElement;

const onMouseEnter = () => {
interactionState.isHovered = true;
};

const onMouseLeave = () => {
interactionState.isHovered = false;
};

iframe.addEventListener("mouseenter", onMouseEnter);
iframe.addEventListener("mouseleave", onMouseLeave);

return () => {
iframe.removeEventListener("mouseenter", onMouseEnter);
iframe.removeEventListener("mouseleave", onMouseLeave);
};
}

function createResizeObserver() {
const handleEntry = ({ target }: ResizeObserverEntry) => {
const matchingRegisteredIframe = registeredIframes.find((value) => value.iframe.contentDocument?.body === target);
if (!matchingRegisteredIframe) {
const matchingRegisteredElement = registeredElements.find((value) => value.iframe.contentDocument?.body === target);
if (!matchingRegisteredElement) {
return;
}
const { iframe, settings } = matchingRegisteredIframe;
const { iframe } = matchingRegisteredElement;
if (!iframe.contentDocument) {
return;
}
const calculatedHeight = getBoundingRectHeight(iframe.contentDocument);
if (!calculatedHeight) {
const height = getBoundingRectHeight(iframe.contentDocument);
if (!height) {
return;
}
updateIframeDimensions({ height: calculatedHeight, iframe, settings });
resizeIframe({ newHeight: height, registeredElement: matchingRegisteredElement });
};

return new ResizeObserver((entries) => entries.forEach(handleEntry));
}

function updateIframeDimensions({ height, iframe, settings }: { iframe: HTMLIFrameElement; height: number; settings: Settings }) {
iframe.style.height = `${height + settings.offsetSize}px`;
function resizeIframe({ registeredElement, newHeight }: { registeredElement: RegisteredElement; newHeight: number }) {
const { iframe, settings, interactionState } = registeredElement;

const previousBoundingRect = iframe.getBoundingClientRect();
const newCalculatedHeight = newHeight + settings.offsetSize;
iframe.style.height = `${newCalculatedHeight}px`;

if (!settings.onIframeResize) {
return;
}

const resizeContext: ResizeContext = {
iframe,
settings: { ...settings },
interactionState: { ...interactionState },
previousRenderState: { rect: previousBoundingRect },
nextRenderState: { rect: iframe.getBoundingClientRect() },
};
settings.onIframeResize(resizeContext);
}

export { initialize };
13 changes: 13 additions & 0 deletions packages/core/src/resize-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ResizeContext } from "~/type";

/**
* Resize handler that scrolls to restore the iframe's position in the viewport as it was before the resize.
*
* *Note:* This behavior only triggers if the iframe is currently being hovered by the user,
* in order to try to limit the number of scroll as it can affect the user experience.
*/
export const updateParentScrollOnResize = ({ previousRenderState, nextRenderState, interactionState }: ResizeContext) => {
if (interactionState.isHovered) {
window.scrollBy(0, nextRenderState.rect.bottom - previousRenderState.rect.bottom);
}
};
Loading

0 comments on commit 3e3a26e

Please sign in to comment.