diff --git a/build/banner.js b/build/banner.js index 779db6249..a25292acc 100644 --- a/build/banner.js +++ b/build/banner.js @@ -6,19 +6,19 @@ const today = date.toISOString().split('T')[0] export default (file, type) => `/*! * @preserve - * + * * @module iframe-resizer/${file} ${pkg.version} (${type}) ${type === 'iife' ? '' : `- ${today}`} * - * @license ${pkg.license} for non-commercial use only. - * For commercial use, you must purchase a license from + * @license ${pkg.license} For use with GPL compliant sites (fully published front & backend source code) + * Alternatively for commercial use, you can purchase a license from * ${pkg.homepage}/pricing - * - * @description Keep same and cross domain iFrames sized to their content + * + * @description Keep same and cross domain iFrames sized to their content * * @author ${pkg.author.name} <${pkg.author.email}> - * + * * @see {@link ${pkg.homepage}} - * + * * @copyright (c) 2013 - ${year}, ${pkg.author.name}. All rights reserved. */ diff --git a/packages/angular/directive.ts b/packages/angular/directive.ts index 1c8a53a1f..fb1704798 100644 --- a/packages/angular/directive.ts +++ b/packages/angular/directive.ts @@ -34,7 +34,7 @@ export type iframeResizerOptions = { bodyMargin?: string | number | null bodyPadding?: string | number | null checkOrigin?: boolean | string[] - direction?: 'vertical' | 'horizontal' | 'none' + direction?: 'vertical' | 'horizontal' | 'both' | 'none' inPageLinks?: boolean license: string offsetSize?: number diff --git a/packages/child/check-blocking-css.js b/packages/child/check-blocking-css.js index c1f521683..4ab4877d3 100644 --- a/packages/child/check-blocking-css.js +++ b/packages/child/check-blocking-css.js @@ -1,4 +1,4 @@ -import { AUTO } from '../common/consts' +import { AUTO, NONE } from '../common/consts' import { advise, log } from './console' const nodes = () => [document.documentElement, document.body] @@ -7,7 +7,7 @@ const properties = ['min-height', 'min-width', 'max-height', 'max-width'] const blockedStyleSheets = new Set() const hasCssValue = (value) => - value && value !== '0px' && value !== AUTO && value !== 'none' + value && value !== '0px' && value !== AUTO && value !== NONE const getElementName = (node) => node.tagName ? node.tagName.toLowerCase() : 'unknown' diff --git a/packages/child/observers/overflow.js b/packages/child/observers/overflow.js index 2a06e79ba..7f594d0d2 100644 --- a/packages/child/observers/overflow.js +++ b/packages/child/observers/overflow.js @@ -1,6 +1,6 @@ import { HIGHLIGHT } from 'auto-console-group' -import { HEIGHT_EDGE, OVERFLOW_ATTR } from '../../common/consts' +import { HEIGHT_EDGE, NONE, OVERFLOW_ATTR } from '../../common/consts' import { id } from '../../common/utils' import { info } from '../console' import { @@ -17,7 +17,7 @@ const logNewlyObserved = createLogNewlyObserved(OVERFLOW) const warnAlreadyObserved = createWarnAlreadyObserved(OVERFLOW) const isHidden = (node) => - node.hidden || node.offsetParent === null || node.style.display === 'none' + node.hidden || node.offsetParent === null || node.style.display === NONE const createOverflowObserver = (callback, options) => { const side = options.side || HEIGHT_EDGE diff --git a/packages/child/observers/perf.js b/packages/child/observers/perf.js index 6704e6eb9..8c92b16df 100644 --- a/packages/child/observers/perf.js +++ b/packages/child/observers/perf.js @@ -14,9 +14,6 @@ export const PREF_END = '--ifr-end' const PREF_MEASURE = '--ifr-measure' const timings = [] -// const usedTags = new WeakSet() - -// const addUsedTag = (el) => typeof el === OBJECT && usedTags.add(el) let detail = {} let oldAverage = 0 @@ -98,9 +95,6 @@ export default function createPerformanceObserver() { const observer = new PerformanceObserver(perfObserver) observer.observe({ entryTypes: ['mark'] }) - // addUsedTag(document.documentElement) - // addUsedTag(document.body) - startTimingCheck() return { diff --git a/packages/common/consts.js b/packages/common/consts.js index dded1e5af..c3c73a498 100644 --- a/packages/common/consts.js +++ b/packages/common/consts.js @@ -3,8 +3,6 @@ export const LABEL = 'iframeResizer' export const SEPARATOR = ':' export const CHILD_READY_MESSAGE = '[iFrameResizerChild]Ready' -export const AFTER_EVENT_STACK = 1 - export const AUTO_RESIZE = 'autoResize' export const BEFORE_UNLOAD = 'beforeUnload' export const CLOSE = 'close' diff --git a/packages/common/utils.js b/packages/common/utils.js index a6a167adb..66d4cb14e 100644 --- a/packages/common/utils.js +++ b/packages/common/utils.js @@ -1,9 +1,8 @@ -import { STRING } from './consts' +import { OBJECT, STRING } from './consts' export const isElement = (node) => node.nodeType === Node.ELEMENT_NODE - export const isNumber = (value) => !Number.isNaN(value) - +export const isObject = (value) => typeof value === OBJECT export const isString = (value) => typeof value === STRING export const isSafari = /^((?!chrome|android).)*safari/i.test( diff --git a/packages/core/checks/id.js b/packages/core/checks/id.js new file mode 100644 index 000000000..407ada14c --- /dev/null +++ b/packages/core/checks/id.js @@ -0,0 +1,27 @@ +import { isString } from '../../common/utils' +import { event as consoleEvent, log } from '../console' +import defaults from '../values/defaults' + +let count = 0 + +function newId(options) { + const id = options?.id || defaults.id + count++ + return document.getElementById(id) === null ? id : `${id}${count++}` +} + +export default function ensureHasId(iframe, options) { + let { id } = iframe + + if (id && !isString(id)) { + throw new TypeError('Invalid id for iFrame. Expected String') + } + + if (!id || id === '') { + id = newId(options) + iframe.id = id + consoleEvent(id, 'assignId') + log(id, `Added missing iframe ID: ${id} (${iframe.src})`) + } + + return id +} diff --git a/packages/core/checks/manual-logging.js b/packages/core/checks/manual-logging.js new file mode 100644 index 000000000..c8b8f2f81 --- /dev/null +++ b/packages/core/checks/manual-logging.js @@ -0,0 +1,10 @@ +import { COLLAPSE } from '../../common/consts' + +export default function (options) { + const { search } = window.location + + if (search.includes('ifrlog')) { + options.log = COLLAPSE + options.logExpand = search.includes('ifrlog=expanded') + } +} diff --git a/packages/core/checks/mode.js b/packages/core/checks/mode.js new file mode 100644 index 000000000..c698b2ee6 --- /dev/null +++ b/packages/core/checks/mode.js @@ -0,0 +1,38 @@ +import { VERSION } from '../../common/consts' +import { getModeData, getModeLabel } from '../../common/mode' +import { advise, purge as consoleClear, vInfo } from '../console' +import settings from '../values/settings' + +let vAdvised = false +let vInfoDisable = false + +export default function checkMode(id, childMode = -3) { + if (vAdvised) return + const mode = Math.max(settings[id].mode, childMode) + if (mode > settings[id].mode) settings[id].mode = mode + if (mode < 0) { + consoleClear(id) + if (!settings[id].vAdvised) + advise(id || 'Parent', `${getModeData(mode + 2)}${getModeData(2)}`) + settings[id].vAdvised = true + throw getModeData(mode + 2).replace(/<\/?[a-z][^>]*>|<\/>/gi, '') + } + if (!(mode > 0 && vInfoDisable)) { + vInfo(`v${VERSION} (${getModeLabel(mode)})`, mode) + } + if (mode < 1) advise('Parent', getModeData(3)) + vAdvised = true +} + +export function preModeCheck(id) { + if (vAdvised) return + const { mode } = settings[id] + if (mode !== -1) checkMode(id, mode) +} + +export function enableVInfo(options) { + if (options?.log === -1) { + options.log = false + vInfoDisable = true + } +} diff --git a/packages/core/checks/options.js b/packages/core/checks/options.js new file mode 100644 index 000000000..d6d81cc86 --- /dev/null +++ b/packages/core/checks/options.js @@ -0,0 +1,28 @@ +import { + AUTO_RESIZE, + BOTH, + HORIZONTAL, + NONE, + VERTICAL, +} from '../../common/consts' +import { advise } from '../console' + +export default function checkOptions(id, options) { + if (!options) return {} + + if ( + 'sizeWidth' in options || + 'sizeHeight' in options || + AUTO_RESIZE in options + ) { + advise( + id, + `Deprecated Option + +The sizeWidth, sizeHeight and autoResize options have been replaced with new direction option which expects values of ${VERTICAL}, ${HORIZONTAL}, ${BOTH} or ${NONE}. +`, + ) + } + + return options +} diff --git a/packages/core/checks/origin.js b/packages/core/checks/origin.js new file mode 100644 index 000000000..265c32eb1 --- /dev/null +++ b/packages/core/checks/origin.js @@ -0,0 +1,15 @@ +import { HIGHLIGHT } from 'auto-console-group' + +import { log } from '../console' +import settings from '../values/settings' + +export default function checkSameDomain(id) { + try { + settings[id].sameOrigin = + !!settings[id]?.iframe?.contentWindow?.iframeChildListener + } catch (error) { + settings[id].sameOrigin = false + } + + log(id, `sameOrigin: %c${settings[id].sameOrigin}`, HIGHLIGHT) +} diff --git a/packages/core/unique.js b/packages/core/checks/unique.js similarity index 90% rename from packages/core/unique.js rename to packages/core/checks/unique.js index b08be45f9..efcce5630 100644 --- a/packages/core/unique.js +++ b/packages/core/checks/unique.js @@ -1,5 +1,5 @@ -import { NEW_LINE } from '../common/consts' -import { advise } from './console' +import { NEW_LINE } from '../../common/consts' +import { advise } from '../console' const shownDuplicateIdWarning = {} diff --git a/packages/core/checks/version.js b/packages/core/checks/version.js new file mode 100644 index 000000000..3286f253d --- /dev/null +++ b/packages/core/checks/version.js @@ -0,0 +1,27 @@ +import { FOREGROUND, HIGHLIGHT } from 'auto-console-group' + +import { VERSION } from '../../common/consts' +import { advise, log } from '../console' + +export default function checkVersion(id, version) { + if (version === VERSION) return + if (version === undefined) { + advise( + id, + `Legacy version detected in iframe + +Detected legacy version of child page script. It is recommended to update the page in the iframe to use @iframe-resizer/child. + +See https://iframe-resizer.com/setup/#child-page-setup for more details. +`, + ) + return + } + log( + id, + `Version mismatch (Child: %c${version}%c !== Parent: %c${VERSION})`, + HIGHLIGHT, + FOREGROUND, + HIGHLIGHT, + ) +} diff --git a/packages/core/checks/warning-timeout.js b/packages/core/checks/warning-timeout.js new file mode 100644 index 000000000..d79939de6 --- /dev/null +++ b/packages/core/checks/warning-timeout.js @@ -0,0 +1,10 @@ +import { HIGHLIGHT } from 'auto-console-group' + +import { info } from '../console' +import settings from '../values/settings' + +export default function checkWarningTimeout(id) { + if (!settings[id].warningTimeout) { + info(id, 'warningTimeout:%c disabled', HIGHLIGHT) + } +} diff --git a/packages/core/events/message.js b/packages/core/events/message.js new file mode 100644 index 000000000..c7e418e30 --- /dev/null +++ b/packages/core/events/message.js @@ -0,0 +1,22 @@ +import { FOREGROUND, HIGHLIGHT } from 'auto-console-group' + +import { log } from '../console' +import on from './wrapper' + +export default function onMessage(messageData, messageBody) { + const { id, iframe } = messageData + + log( + id, + `onMessage passed: {iframe: %c${id}%c, message: %c${messageBody}%c}`, + HIGHLIGHT, + FOREGROUND, + HIGHLIGHT, + FOREGROUND, + ) + + on(id, 'onMessage', { + iframe, + message: JSON.parse(messageBody), + }) +} diff --git a/packages/core/events/mouse.js b/packages/core/events/mouse.js new file mode 100644 index 000000000..4773af99b --- /dev/null +++ b/packages/core/events/mouse.js @@ -0,0 +1,28 @@ +import { SEPARATOR } from '../../common/consts' +import getMessageBody from '../received/message' +import on from './wrapper' + +export default function onMouse(event, messageData) { + const { id, iframe, height, type, width } = messageData + let mousePos = {} + + if (width === 0 && height === 0) { + const coords = getMessageBody(id, 9).split(SEPARATOR) + mousePos = { + x: coords[1], + y: coords[0], + } + } else { + mousePos = { + x: width, + y: height, + } + } + + on(id, event, { + iframe, + screenX: Number(mousePos.x), + screenY: Number(mousePos.y), + type, + }) +} diff --git a/packages/core/events/resize.js b/packages/core/events/resize.js new file mode 100644 index 000000000..f8e2cc878 --- /dev/null +++ b/packages/core/events/resize.js @@ -0,0 +1,10 @@ +import { setPagePosition } from '../page/position' +import setSize from './size' +import on from './wrapper' + +export default function resizeIframe(messageData) { + const { id } = messageData + setSize(messageData) + setPagePosition(id) + on(id, 'onResized', messageData) +} diff --git a/packages/core/events/size.js b/packages/core/events/size.js new file mode 100644 index 000000000..b7f7a7664 --- /dev/null +++ b/packages/core/events/size.js @@ -0,0 +1,20 @@ +import { HIGHLIGHT } from 'auto-console-group' + +import { HEIGHT, WIDTH } from '../../common/consts' +import { info } from '../console' +import settings from '../values/settings' + +function setDimension(dimension, messageData) { + const { id } = messageData + const size = `${messageData[dimension]}px` + messageData.iframe.style[dimension] = size + info(id, `Set ${dimension}: %c${size}`, HIGHLIGHT) +} + +export default function setSize(messageData) { + const { id } = messageData + const { sizeHeight, sizeWidth } = settings[id] + + if (sizeHeight) setDimension(HEIGHT, messageData) + if (sizeWidth) setDimension(WIDTH, messageData) +} diff --git a/packages/core/events/visible.js b/packages/core/events/visible.js new file mode 100644 index 000000000..1ae5c66ad --- /dev/null +++ b/packages/core/events/visible.js @@ -0,0 +1,13 @@ +import { RESIZE } from '../../common/consts' +import trigger from '../send/trigger' +import settings from '../values/settings' + +const sendTriggerMsg = (eventName, event) => + Object.values(settings) + .filter(({ autoResize, firstRun }) => autoResize && !firstRun) + .forEach(({ iframe }) => trigger(eventName, event, iframe.id)) + +export default function tabVisible() { + if (document.hidden === true) return + sendTriggerMsg('tabVisible', RESIZE) +} diff --git a/packages/core/events/wrapper.js b/packages/core/events/wrapper.js new file mode 100644 index 000000000..2d477d4db --- /dev/null +++ b/packages/core/events/wrapper.js @@ -0,0 +1,27 @@ +import { FUNCTION } from '../../common/consts' +import { isolateUserCode } from '../../common/utils' +import { warn } from '../console' +import settings from '../values/settings' + +function on(iframeId, funcName, val) { + if (!settings[iframeId]) return null + + const func = settings[iframeId][funcName] + + if (typeof func !== FUNCTION) + throw new TypeError(`${funcName} on iframe[${iframeId}] is not a function`) + + if (funcName !== 'onBeforeClose' && funcName !== 'onScroll') + return isolateUserCode(func, val) + + try { + return func(val) + } catch (error) { + // eslint-disable-next-line no-console + console.error(error) + warn(iframeId, `Error in ${funcName} callback`) + return null + } +} + +export default on diff --git a/packages/core/index.js b/packages/core/index.js index e56f2f7d7..65d8ba7f1 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -1,1385 +1,29 @@ -import { FOREGROUND, HIGHLIGHT } from 'auto-console-group' - -import { - AFTER_EVENT_STACK, - AUTO, - AUTO_RESIZE, - BEFORE_UNLOAD, - BOTH, - CHILD, - CHILD_READY_MESSAGE, - CLOSE, - COLLAPSE, - EXPAND, - FUNCTION, - HEIGHT, - HIDDEN, - HORIZONTAL, - IN_PAGE_LINK, - INIT, - INIT_EVENTS, - INIT_FROM_IFRAME, - LABEL, - LAZY, - LOAD, - LOG_OPTIONS, - MESSAGE, - MESSAGE_HEADER_LENGTH, - MESSAGE_ID, - MESSAGE_ID_LENGTH, - MIN_SIZE, - MOUSE_ENTER, - MOUSE_LEAVE, - NONE, - NULL, - NUMBER, - OBJECT, - OFFSET, - OFFSET_SIZE, - ONLOAD, - PAGE_INFO, - PAGE_INFO_STOP, - PARENT, - PARENT_INFO, - PARENT_INFO_STOP, - REMOVED_NEXT_VERSION, - RESET, - RESET_REQUIRED_METHODS, - RESIZE, - SCROLL, - SCROLL_BY, - SCROLL_TO, - SCROLL_TO_OFFSET, - SEPARATOR, - STRING, - TITLE, - VERSION, - VERTICAL, - WIDTH, -} from '../common/consts' -import { addEventListener, removeEventListener } from '../common/listeners' -import setMode, { getModeData, getModeLabel } from '../common/mode' -import { hasOwn, isolateUserCode, once, typeAssert } from '../common/utils' -import { - advise, - debug, - endAutoGroup, - error, - errorBoundary, - event as consoleEvent, - info, - log, - purge as consoleClear, - setupConsole, - vInfo, - warn, -} from './console' -import warnOnNoResponse from './timeout' -import checkUniqueId from './unique' -import defaults from './values/defaults' -import page from './values/page' -import settings from './values/settings' - -function iframeListener(event) { - function resizeIframe() { - setSize(messageData) - setPagePosition(iframeId) - - on('onResized', messageData) - } - - function getPaddingEnds(compStyle) { - if (compStyle.boxSizing !== 'border-box') return 0 - - const top = compStyle.paddingTop ? parseInt(compStyle.paddingTop, 10) : 0 - const bot = compStyle.paddingBottom - ? parseInt(compStyle.paddingBottom, 10) - : 0 - - return top + bot - } - - function getBorderEnds(compStyle) { - if (compStyle.boxSizing !== 'border-box') return 0 - - const top = compStyle.borderTopWidth - ? parseInt(compStyle.borderTopWidth, 10) - : 0 - - const bot = compStyle.borderBottomWidth - ? parseInt(compStyle.borderBottomWidth, 10) - : 0 - - return top + bot - } - - function processMessage(msg) { - const data = msg.slice(MESSAGE_ID_LENGTH).split(SEPARATOR) - const height = data[1] ? Number(data[1]) : 0 - const iframe = settings[data[0]]?.iframe - const compStyle = getComputedStyle(iframe) - - const messageData = { - iframe, - id: data[0], - height: height + getPaddingEnds(compStyle) + getBorderEnds(compStyle), - width: Number(data[2]), - type: data[3], - msg: data[4], - } - - // eslint-disable-next-line prefer-destructuring - if (data[5]) messageData.mode = data[5] - - return messageData - } - - function isMessageFromIframe() { - function checkAllowedOrigin() { - function checkList() { - let i = 0 - let retCode = false - - log( - iframeId, - `Checking connection is from allowed list of origins: %c${checkOrigin}`, - HIGHLIGHT, - ) - - for (; i < checkOrigin.length; i++) { - if (checkOrigin[i] === origin) { - retCode = true - break - } - } - - return retCode - } - - function checkSingle() { - const remoteHost = settings[iframeId]?.remoteHost - log(iframeId, `Checking connection is from: %c${remoteHost}`, HIGHLIGHT) - return origin === remoteHost - } - - return checkOrigin.constructor === Array ? checkList() : checkSingle() - } - - const { origin, sameOrigin } = event - - if (sameOrigin) return true - - let checkOrigin = settings[iframeId]?.checkOrigin - - if (checkOrigin && `${origin}` !== NULL && !checkAllowedOrigin()) { - throw new Error( - `Unexpected message received from: ${origin} for ${messageData.iframe.id}. Message was: ${event.data}. This error can be disabled by setting the checkOrigin: false option or by providing of array of trusted domains.`, - ) - } - - return true - } - - const isMessageForUs = (msg) => - MESSAGE_ID === `${msg}`.slice(0, MESSAGE_ID_LENGTH) && - msg.slice(MESSAGE_ID_LENGTH).split(SEPARATOR)[0] in settings - - function isMessageFromMetaParent() { - // Test if this message is from a parent above us. This is an ugly test, however, updating - // the message format would break backwards compatibility. - const retCode = messageData.type in { true: 1, false: 1, undefined: 1 } - - if (retCode) { - log(iframeId, 'Ignoring init message from meta parent page') - } - - return retCode - } - - const getMsgBody = (offset) => - msg.slice(msg.indexOf(SEPARATOR) + MESSAGE_HEADER_LENGTH + offset) - - function forwardMsgFromIframe(msgBody) { - log( - iframeId, - `onMessage passed: {iframe: %c${messageData.iframe.id}%c, message: %c${msgBody}%c}`, - HIGHLIGHT, - FOREGROUND, - HIGHLIGHT, - FOREGROUND, - ) - - on('onMessage', { - iframe: messageData.iframe, - message: JSON.parse(msgBody), - }) - } - - function getPageInfo() { - const bodyPosition = document.body.getBoundingClientRect() - const iFramePosition = messageData.iframe.getBoundingClientRect() - const { scrollY, scrollX, innerHeight, innerWidth } = window - const { clientHeight, clientWidth } = document.documentElement - - return JSON.stringify({ - iframeHeight: iFramePosition.height, - iframeWidth: iFramePosition.width, - clientHeight: Math.max(clientHeight, innerHeight || 0), - clientWidth: Math.max(clientWidth, innerWidth || 0), - offsetTop: parseInt(iFramePosition.top - bodyPosition.top, 10), - offsetLeft: parseInt(iFramePosition.left - bodyPosition.left, 10), - scrollTop: scrollY, - scrollLeft: scrollX, - documentHeight: clientHeight, - documentWidth: clientWidth, - windowHeight: innerHeight, - windowWidth: innerWidth, - }) - } - - function getParentProps() { - const { iframe } = messageData - const { scrollWidth, scrollHeight } = document.documentElement - const { width, height, offsetLeft, offsetTop, pageLeft, pageTop, scale } = - window.visualViewport - - return JSON.stringify({ - iframe: iframe.getBoundingClientRect(), - document: { - scrollWidth, - scrollHeight, - }, - viewport: { - width, - height, - offsetLeft, - offsetTop, - pageLeft, - pageTop, - scale, - }, - }) - } - - const sendInfoToIframe = (type, infoFunction) => (requestType, iframeId) => { - const gate = {} - - function throttle(func, frameId) { - if (!gate[frameId]) { - func() - gate[frameId] = requestAnimationFrame(() => { - gate[frameId] = null - }) - } - } - - function gatedTrigger() { - trigger(`${requestType} (${type})`, `${type}:${infoFunction()}`, iframeId) - } - - throttle(gatedTrigger, iframeId) - } - - const startInfoMonitor = (sendInfoToIframe, type) => () => { - let pending = false - - const sendInfo = (requestType) => () => { - if (settings[id]) { - if (!pending || pending === requestType) { - sendInfoToIframe(requestType, id) - - pending = requestType - requestAnimationFrame(() => { - pending = false - }) - } - } else { - stop() - } - } - - const sendScroll = sendInfo(SCROLL) - const sendResize = sendInfo('resize window') - - function setListener(requestType, listener) { - log(id, `${requestType}listeners for send${type}`) - listener(window, SCROLL, sendScroll) - listener(window, RESIZE, sendResize) - } - - function stop() { - consoleEvent(id, `stop${type}`) - setListener('Remove ', removeEventListener) - pageObserver.disconnect() - iframeObserver.disconnect() - removeEventListener(settings[id].iframe, LOAD, stop) - } - - function start() { - setListener('Add ', addEventListener) - - pageObserver.observe(document.body, { - attributes: true, - childList: true, - subtree: true, - }) - - iframeObserver.observe(settings[id].iframe, { - attributes: true, - childList: false, - subtree: false, - }) - } - - const id = iframeId // Create locally scoped copy of iFrame ID - - const pageObserver = new ResizeObserver(sendInfo('pageObserver')) - const iframeObserver = new ResizeObserver(sendInfo('iframeObserver')) - - if (settings[id]) { - settings[id][`stop${type}`] = stop - addEventListener(settings[id].iframe, LOAD, stop) - start() - } - } - - const stopInfoMonitor = (stopFunction) => () => { - if (stopFunction in settings[iframeId]) { - settings[iframeId][stopFunction]() - delete settings[iframeId][stopFunction] - } - } - - const sendPageInfoToIframe = sendInfoToIframe(PAGE_INFO, getPageInfo) - const sendParentInfoToIframe = sendInfoToIframe(PARENT_INFO, getParentProps) - - const startPageInfoMonitor = startInfoMonitor( - sendPageInfoToIframe, - 'PageInfo', - ) - const startParentInfoMonitor = startInfoMonitor( - sendParentInfoToIframe, - 'ParentInfo', - ) - - const stopPageInfoMonitor = stopInfoMonitor('stopPageInfo') - const stopParentInfoMonitor = stopInfoMonitor('stopParentInfo') - - function checkIframeExists() { - if (messageData.iframe === null) { - warn(iframeId, `The iframe (${messageData.id}) was not found.`) - return false - } - - return true - } - - function getElementPosition(target) { - const iFramePosition = target.getBoundingClientRect() - - getPagePosition(iframeId) - - return { - x: Number(iFramePosition.left) + Number(page.position.x), - y: Number(iFramePosition.top) + Number(page.position.y), - } - } - - function scrollBy() { - const x = messageData.width - const y = messageData.height - - // Check for V4 as well - const target = window.parentIframe || window.parentIFrame || window - - info( - iframeId, - `scrollBy: x: %c${x}%c y: %c${y}`, - HIGHLIGHT, - FOREGROUND, - HIGHLIGHT, - ) - - target.scrollBy(x, y) - } - - function scrollRequestFromChild(addOffset) { - /* istanbul ignore next */ // Not testable in Karma - function reposition(newPosition) { - page.position = newPosition - scrollTo(iframeId) - } - - function scrollParent(target, newPosition) { - setTimeout(() => - target[`scrollTo${addOffset ? 'Offset' : ''}`]( - newPosition.x, - newPosition.y, - ), - ) - } - - const calcOffset = (messageData, offset) => ({ - x: messageData.width + offset.x, - y: messageData.height + offset.y, - }) - - const offset = addOffset - ? getElementPosition(messageData.iframe) - : { x: 0, y: 0 } - - info( - iframeId, - `Reposition requested (offset x:%c${offset.x}%c y:%c${offset.y})`, - HIGHLIGHT, - FOREGROUND, - HIGHLIGHT, - ) - - const newPosition = calcOffset(messageData, offset) - - // Check for V4 as well - const target = window.parentIframe || window.parentIFrame - - if (target) scrollParent(target, newPosition) - else reposition(newPosition) - } - - function scrollTo(iframeId) { - const { x, y } = page.position - const iframe = settings[iframeId]?.iframe - - if (on('onScroll', { iframe, top: y, left: x, x, y }) === false) { - unsetPagePosition() - return - } - - setPagePosition(iframeId) - } - - function findTarget(location) { - function jumpToTarget() { - const jumpPosition = getElementPosition(target) - - info(iframeId, `Moving to in page link: %c#${hash}`, HIGHLIGHT) - - page.position = { - x: jumpPosition.x, - y: jumpPosition.y, - } - - scrollTo(iframeId) - window.location.hash = hash - } - - function jumpToParent() { - // Check for V4 as well - const target = window.parentIframe || window.parentIFrame - - if (target) { - target.moveToAnchor(hash) - return - } - - log(iframeId, `In page link #${hash} not found`) - } - - const hash = location.split('#')[1] || '' - const hashData = decodeURIComponent(hash) - - let target = - document.getElementById(hashData) || - document.getElementsByName(hashData)[0] - - if (target) { - jumpToTarget() - return - } - - if (window.top === window.self) { - log(iframeId, `In page link #${hash} not found`) - return - } - - jumpToParent() - } - - function onMouse(event) { - let mousePos = {} - - if (messageData.width === 0 && messageData.height === 0) { - const coords = getMsgBody(9).split(SEPARATOR) - mousePos = { - x: coords[1], - y: coords[0], - } - } else { - mousePos = { - x: messageData.width, - y: messageData.height, - } - } - - on(event, { - iframe: messageData.iframe, - screenX: Number(mousePos.x), - screenY: Number(mousePos.y), - type: messageData.type, - }) - } - - const on = (funcName, val) => checkEvent(iframeId, funcName, val) - - function checkSameDomain(id) { - try { - settings[id].sameOrigin = - !!settings[id]?.iframe?.contentWindow?.iframeChildListener - } catch (error) { - settings[id].sameOrigin = false - } - - log(id, `sameOrigin: %c${settings[id].sameOrigin}`, HIGHLIGHT) - } - - function checkVersion(version) { - if (version === VERSION) return - if (version === undefined) { - advise( - iframeId, - `Legacy version detected in iframe - -Detected legacy version of child page script. It is recommended to update the page in the iframe to use @iframe-resizer/child. - -See https://iframe-resizer.com/setup/#child-page-setup for more details. -`, - ) - return - } - log( - iframeId, - `Version mismatch (Child: %c${version}%c !== Parent: %c${VERSION})`, - HIGHLIGHT, - FOREGROUND, - HIGHLIGHT, - ) - } - - function setTitle(title, iframeId) { - if (!settings[iframeId]?.syncTitle) return - settings[iframeId].iframe.title = title - info(iframeId, `Set iframe title attribute: %c${title}`, HIGHLIGHT) - } - - function eventMsg() { - const { height, iframe, msg, type, width } = messageData - if (settings[iframeId]?.firstRun) firstRun() - - switch (type) { - case CLOSE: - closeIframe(iframe) - break - - case MESSAGE: - forwardMsgFromIframe(getMsgBody(6)) - break - - case MOUSE_ENTER: - onMouse('onMouseEnter') - break - - case MOUSE_LEAVE: - onMouse('onMouseLeave') - break - - case BEFORE_UNLOAD: - info(iframeId, 'Ready state reset') - settings[iframeId].initialised = false - break - - case AUTO_RESIZE: - settings[iframeId].autoResize = JSON.parse(getMsgBody(9)) - break - - case SCROLL_BY: - scrollBy() - break - - case SCROLL_TO: - scrollRequestFromChild(false) - break - - case SCROLL_TO_OFFSET: - scrollRequestFromChild(true) - break - - case PAGE_INFO: - startPageInfoMonitor() - break - - case PARENT_INFO: - startParentInfoMonitor() - break - - case PAGE_INFO_STOP: - stopPageInfoMonitor() - break - - case PARENT_INFO_STOP: - stopParentInfoMonitor() - break - - case IN_PAGE_LINK: - findTarget(getMsgBody(9)) - break - - case TITLE: - setTitle(msg, iframeId) - break - - case RESET: - resetIframe(messageData) - break - - case INIT: - resizeIframe() - checkSameDomain(iframeId) - checkVersion(msg) - settings[iframeId].initialised = true - on('onReady', iframe) - break - - default: - if (width === 0 && height === 0) { - warn( - iframeId, - `Unsupported message received (${type}), this is likely due to the iframe containing a later ` + - `version of iframe-resizer than the parent page`, - ) - return - } - - if (width === 0 || height === 0) { - log(iframeId, 'Ignoring message with 0 height or width') - return - } - - // Recheck document.hidden here, as only Firefox - // correctly supports this in the iframe - if (document.hidden) { - log(iframeId, 'Page hidden - ignored resize request') - return - } - - resizeIframe() - } - } - - function checkSettings(iframeId) { - if (!settings[iframeId]) { - throw new Error( - `${messageData.type} No settings for ${iframeId}. Message was: ${msg}`, - ) - } - } - - const iframeReady = - (source) => - ({ initChild, postMessageTarget }) => { - if (source === postMessageTarget) initChild() - } - - const iFrameReadyMsgReceived = (source) => - Object.values(settings).forEach(iframeReady(source)) - - function firstRun() { - if (!settings[iframeId]) return - log(iframeId, `First run for ${iframeId}`) - checkMode(iframeId, messageData.mode) - settings[iframeId].firstRun = false - } - - function screenMessage(msg) { - checkSettings(iframeId) - - if (!isMessageFromMetaParent()) { - log(iframeId, `Received: %c${msg}`, HIGHLIGHT) - - if (checkIframeExists() && isMessageFromIframe()) { - eventMsg() - } - } - } - - let msg = event.data - - if (msg === CHILD_READY_MESSAGE) { - iFrameReadyMsgReceived(event.source) - return - } - - if (!isMessageForUs(msg)) { - if (typeof msg !== STRING) return - consoleEvent(PARENT, 'ignoredMessage') - debug(PARENT, msg) - return - } - - const messageData = processMessage(msg) - const { id, type } = messageData - const iframeId = id - - if (!iframeId) { - warn( - '', - 'iframeResizer received messageData without id, message was: ', - msg, - ) - return - } - - consoleEvent(iframeId, type) - errorBoundary(iframeId, screenMessage)(msg) -} - -function checkEvent(iframeId, funcName, val) { - let func = null - let retVal = null - - if (settings[iframeId]) { - func = settings[iframeId][funcName] - - if (typeof func === FUNCTION) - if (funcName === 'onBeforeClose' || funcName === 'onScroll') { - try { - retVal = func(val) - } catch (error) { - // eslint-disable-next-line no-console - console.error(error) - warn(iframeId, `Error in ${funcName} callback`) - } - } else isolateUserCode(func, val) - else - throw new TypeError( - `${funcName} on iFrame[${iframeId}] is not a function`, - ) - } - - return retVal -} - -function removeIframeListeners(iframe) { - const { id } = iframe - log(id, 'Disconnected from iframe') - delete settings[id] - delete iframe.iframeResizer -} - -function closeIframe(iframe) { - const { id } = iframe - - if (checkEvent(id, 'onBeforeClose', id) === false) { - log(id, 'Close iframe cancelled by onBeforeClose') - return - } - - log(id, `Removing iFrame: %c${id}`, HIGHLIGHT) - - try { - // Catch race condition error with React - if (iframe.parentNode) { - iframe.remove() - } - } catch (error) { - warn(id, error) - } - - checkEvent(id, 'onAfterClose', id) - removeIframeListeners(iframe) -} - -function getPagePosition(iframeId) { - if (page.position !== null) return - - page.position = { - x: window.scrollX, - y: window.scrollY, - } - - log( - iframeId, - `Get page position: %c${page.position.x}%c, %c${page.position.y}`, - HIGHLIGHT, - FOREGROUND, - HIGHLIGHT, - ) -} - -function unsetPagePosition() { - page.position = null -} - -function setPagePosition(iframeId) { - if (page.position === null) return - - window.scrollTo(page.position.x, page.position.y) - info( - iframeId, - `Set page position: %c${page.position.x}%c, %c${page.position.y}`, - HIGHLIGHT, - FOREGROUND, - HIGHLIGHT, - ) - unsetPagePosition() -} - -function resetIframe(messageData) { - log( - messageData.id, - `Size reset requested by ${messageData.type === INIT ? 'parent page' : 'child page'}`, - ) - - getPagePosition(messageData.id) - setSize(messageData) - trigger(RESET, RESET, messageData.id) -} - -function setSize(messageData) { - function setDimension(dimension) { - const size = `${messageData[dimension]}px` - messageData.iframe.style[dimension] = size - info(id, `Set ${dimension}: %c${size}`, HIGHLIGHT) - } - - const { id } = messageData - const { sizeHeight, sizeWidth } = settings[id] - - if (sizeHeight) setDimension(HEIGHT) - if (sizeWidth) setDimension(WIDTH) -} - -const filterMsg = (msg) => - msg - .split(SEPARATOR) - .filter((_, index) => index !== 19) - .join(SEPARATOR) - -function trigger(calleeMsg, msg, id) { - function logSent(route) { - const displayMsg = calleeMsg in INIT_EVENTS ? filterMsg(msg) : msg - info(id, route, HIGHLIGHT, FOREGROUND, HIGHLIGHT) - info(id, `Message data: %c${displayMsg}`, HIGHLIGHT) - } - - function postMessageToIframe() { - const { iframe, postMessageTarget, sameOrigin, targetOrigin } = settings[id] - - if (sameOrigin) { - try { - iframe.contentWindow.iframeChildListener(MESSAGE_ID + msg) - logSent(`Sending message to iframe %c${id}%c via same origin%c`) - return - } catch (error) { - if (calleeMsg in INIT_EVENTS) { - settings[id].sameOrigin = false - log(id, 'New iframe does not support same origin') - } else { - warn(id, 'Same origin messaging failed, falling back to postMessage') - } - } - } - - logSent( - `Sending message to iframe: %c${id}%c targetOrigin: %c${targetOrigin}`, - ) - - postMessageTarget.postMessage(MESSAGE_ID + msg, targetOrigin) - } - - function checkAndSend() { - if (!settings[id]?.postMessageTarget) { - warn(id, `Iframe(${id}) not found`) - return - } - - postMessageToIframe() - } - - consoleEvent(id, calleeMsg) - - if (settings[id]) checkAndSend() -} - -function createOutgoingMsg(id) { - const { - autoResize, - bodyBackground, - bodyMargin, - bodyPadding, - heightCalculationMethod, - inPageLinks, - license, - log, - logExpand, - mouseEvents, - offsetHeight, - offsetWidth, - mode, - sizeHeight, - // sizeSelector, - sizeWidth, - tolerance, - widthCalculationMethod, - } = settings[id] - - return [ - id, - '8', // Backwards compatibility (PaddingV1) - sizeWidth, - log, - '32', // Backwards compatibility (Interval) - true, // Backwards compatibility (EnablePublicMethods) - autoResize, - bodyMargin, - heightCalculationMethod, - bodyBackground, - bodyPadding, - tolerance, - inPageLinks, - CHILD, // Backwards compatibility (resizeFrom) - widthCalculationMethod, - mouseEvents, - offsetHeight, - offsetWidth, - sizeHeight, - license, - page.version, - mode, - '', // sizeSelector, - logExpand, - ].join(SEPARATOR) -} - -let count = 0 -let vAdvised = false -let vInfoDisable = false - -function checkMode(iframeId, childMode = -3) { - if (vAdvised) return - const mode = Math.max(settings[iframeId].mode, childMode) - if (mode > settings[iframeId].mode) settings[iframeId].mode = mode - if (mode < 0) { - consoleClear(iframeId) - if (!settings[iframeId].vAdvised) - advise(iframeId || 'Parent', `${getModeData(mode + 2)}${getModeData(2)}`) - settings[iframeId].vAdvised = true - throw getModeData(mode + 2).replace(/<\/?[a-z][^>]*>|<\/>/gi, '') - } - if (!(mode > 0 && vInfoDisable)) { - vInfo(`v${VERSION} (${getModeLabel(mode)})`, mode) - } - if (mode < 1) advise('Parent', getModeData(3)) - vAdvised = true -} - -export default (options) => (iframe) => { - function newId() { - let id = options?.id || defaults.id + count++ - - if (document.getElementById(id) !== null) { - id += count++ - } - - return id - } - - function ensureHasId(iframeId) { - if (iframeId && typeof iframeId !== STRING) { - throw new TypeError('Invalid id for iFrame. Expected String') - } - - if (iframeId === '' || !iframeId) { - iframeId = newId() - iframe.id = iframeId - consoleEvent(iframeId, 'assignId') - log(iframeId, `Added missing iframe ID: ${iframeId} (${iframe.src})`) - } - - return iframeId - } - - function setScrolling() { - log( - iframeId, - `Iframe scrolling ${ - settings[iframeId]?.scrolling ? 'enabled' : 'disabled' - } for ${iframeId}`, - ) - - iframe.style.overflow = - settings[iframeId]?.scrolling === false ? HIDDEN : AUTO - - switch (settings[iframeId]?.scrolling) { - case 'omit': - break - - case true: - iframe.scrolling = 'yes' - break - - case false: - iframe.scrolling = 'no' - break - - default: - iframe.scrolling = settings[iframeId] - ? settings[iframeId].scrolling - : 'no' - } - } - - function setupBodyMarginValues() { - const { bodyMargin } = settings[iframeId] - - if (typeof bodyMargin === NUMBER || bodyMargin === '0') { - settings[iframeId].bodyMargin = `${bodyMargin}px` - } - } - - function checkReset() { - if ( - !(settings[iframeId]?.heightCalculationMethod in RESET_REQUIRED_METHODS) - ) - return - - resetIframe({ iframe, height: MIN_SIZE, width: MIN_SIZE, type: INIT }) - } - - function setupIframeObject() { - if (settings[iframeId]) { - const { iframe } = settings[iframeId] - const resizer = { - close: closeIframe.bind(null, iframe), - - disconnect: removeIframeListeners.bind(null, iframe), - - removeListeners() { - advise( - iframeId, - `Deprecated Method Name - -The removeListeners() method has been renamed to disconnect(). ${REMOVED_NEXT_VERSION} -`, - ) - this.disconnect() - }, - - resize() { - advise( - iframeId, - `Deprecated Method - -Use of the resize() method from the parent page is deprecated and will be removed in a future version of iframe-resizer. As their are no longer any edge cases that require triggering a resize from the parent page, it is recommended to remove this method from your code.`, - ) - trigger.bind(null, 'Window resize', RESIZE, iframeId) - }, - - moveToAnchor(anchor) { - typeAssert(anchor, STRING, 'moveToAnchor(anchor) anchor') - trigger('Move to anchor', `moveToAnchor:${anchor}`, iframeId) - }, - - sendMessage(message) { - message = JSON.stringify(message) - trigger(MESSAGE, `${MESSAGE}:${message}`, iframeId) - }, - } - - iframe.iframeResizer = resizer - iframe.iFrameResizer = resizer - } - } - - function addLoadListener(iframe, initChild) { - // allow other concurrent events to go first - const onload = () => setTimeout(initChild, AFTER_EVENT_STACK) - addEventListener(iframe, LOAD, onload) - } - - const noContent = (iframe) => { - const { src, srcdoc } = iframe - return !srcdoc && (src == null || src === '' || src === 'about:blank') - } - - const isLazy = (iframe) => iframe.loading === LAZY - const isInit = (eventType) => eventType === INIT - - function sendInit(id, initChild) { - const { iframe, waitForLoad } = settings[id] - - if (waitForLoad === true) return - if (noContent(iframe)) { - setTimeout(() => { - consoleEvent(id, 'noContent') - info(id, 'No content detected in the iframe, delaying initialisation') - }) - return - } - - setTimeout(initChild) - } - - // We have to call trigger twice, as we can not be sure if all - // iframes have completed loading when this code runs. The - // event listener also catches the page changing in the iFrame. - function init(id, message) { - const createInitChild = (eventType) => () => { - if (!settings[id]) return // iframe removed before load event - - const { firstRun, iframe } = settings[id] - - trigger(eventType, message, id) - if (!(isInit(eventType) && isLazy(iframe))) warnOnNoResponse(id, settings) - - if (!firstRun) checkReset() - } - - const { iframe } = settings[id] - - settings[id].initChild = createInitChild(INIT_FROM_IFRAME) - addLoadListener(iframe, createInitChild(ONLOAD)) - sendInit(id, createInitChild(INIT)) - } - - function checkOptions(options) { - if (!options) return {} - - if ( - 'sizeWidth' in options || - 'sizeHeight' in options || - AUTO_RESIZE in options - ) { - advise( - iframeId, - `Deprecated Option - -The sizeWidth, sizeHeight and autoResize options have been replaced with new direction option which expects values of ${VERTICAL}, ${HORIZONTAL}, ${BOTH} or ${NONE}. -`, - ) - } - - return options - } - - function setDirection() { - const { direction } = settings[iframeId] - - switch (direction) { - case VERTICAL: - break - - case HORIZONTAL: - settings[iframeId].sizeHeight = false - // eslint-disable-next-line no-fallthrough - case BOTH: - settings[iframeId].sizeWidth = true - break - - case NONE: - settings[iframeId].sizeWidth = false - settings[iframeId].sizeHeight = false - settings[iframeId].autoResize = false - break - - default: - throw new TypeError( - iframeId, - `Direction value of "${direction}" is not valid`, - ) - } - - log(iframeId, `direction: %c${direction}`, HIGHLIGHT) - } - - function setOffsetSize(offset) { - if (!offset) return // No offset set or offset is zero - - if (settings[iframeId].direction === VERTICAL) { - settings[iframeId].offsetHeight = offset - log(iframeId, `Offset height: %c${offset}`, HIGHLIGHT) +import { LABEL } from '../common/consts' +import { isObject } from '../common/utils' +import ensureHasId from './checks/id' +import checkManualLogging from './checks/manual-logging' +import { errorBoundary, event as consoleEvent, warn } from './console' +import setupEventListenersOnce from './listeners' +import setupIframe from './setup' +import setupLogging from './setup/logging' + +export default function connectResizer(options) { + if (!isObject(options)) throw new TypeError('Options is not an object') + + setupEventListenersOnce() + checkManualLogging(options) + + return (iframe) => { + const id = ensureHasId(iframe, options) + + if (LABEL in iframe) { + consoleEvent('alreadySetup') + warn(id, `Ignored iframe (${id}), already setup.`) } else { - settings[iframeId].offsetWidth = offset - log(iframeId, `Offset width: %c${offset}`, HIGHLIGHT) - } - } - - const getTargetOrigin = (remoteHost) => - remoteHost === '' || - remoteHost.match(/^(about:blank|javascript:|file:\/\/)/) !== null - ? '*' - : remoteHost - - function getPostMessageTarget() { - if (settings[iframeId].postMessageTarget === null) - settings[iframeId].postMessageTarget = iframe.contentWindow - } - - function preModeCheck() { - if (vAdvised) return - const { mode } = settings[iframeId] - if (mode !== -1) checkMode(iframeId, mode) - } - - function checkTitle(iframeId) { - const title = settings[iframeId]?.iframe?.title - return title === '' || title === undefined - } - - function updateOptionName(oldName, newName) { - if (hasOwn(settings[iframeId], oldName)) { - advise( - iframeId, - `Deprecated option\n\nThe ${oldName} option has been renamed to ${newName}. ${REMOVED_NEXT_VERSION}`, - ) - settings[iframeId][newName] = settings[iframeId][oldName] - delete settings[iframeId][oldName] - } - } - - function checkWarningTimeout() { - if (!settings[iframeId].warningTimeout) { - info(iframeId, 'warningTimeout:%c disabled', HIGHLIGHT) - } - } - - const hasMouseEvents = (options) => - hasOwn(options, 'onMouseEnter') || hasOwn(options, 'onMouseLeave') - - function setTargetOrigin() { - settings[iframeId].targetOrigin = - settings[iframeId].checkOrigin === true - ? getTargetOrigin(settings[iframeId].remoteHost) - : '*' - } - - function processOptions(options) { - settings[iframeId] = { - ...settings[iframeId], - iframe, - remoteHost: iframe?.src.split('/').slice(0, 3).join('/'), - ...defaults, - ...checkOptions(options), - mouseEvents: hasMouseEvents(options), - mode: setMode(options), - syncTitle: checkTitle(iframeId), - } - - updateOptionName(OFFSET, OFFSET_SIZE) - updateOptionName('onClose', 'onBeforeClose') - updateOptionName('onClosed', 'onAfterClose') - - consoleEvent(iframeId, 'setup') - setDirection() - setOffsetSize(options?.offsetSize || options?.offset) // ignore zero offset - checkWarningTimeout() - getPostMessageTarget() - setTargetOrigin() - } - - function setupIframe(options) { - if (beenHere()) { - warn(iframeId, `Ignored iframe (${iframeId}), already setup.`) - return - } - - processOptions(options) - checkUniqueId(iframeId) - log(iframeId, `src: %c${iframe.srcdoc || iframe.src}`, HIGHLIGHT) - preModeCheck() - setupEventListenersOnce() - setScrolling() - setupBodyMarginValues() - init(iframeId, createOutgoingMsg(iframeId)) - setupIframeObject() - log(iframeId, 'Setup complete') - endAutoGroup(iframeId) - } - - function enableVInfo(options) { - if (options?.log === -1) { - options.log = false - vInfoDisable = true + setupLogging(id, options) + errorBoundary(id, setupIframe)(iframe, options) } - } - - function checkLocationSearch(options) { - const { search } = window.location - if (search.includes('ifrlog')) { - options.log = COLLAPSE - options.logExpand = search.includes('ifrlog=expanded') - } + return iframe?.iframeResizer } - - function startLogging(iframeId, options) { - const isLogEnabled = hasOwn(options, 'log') - const isLogString = typeof options.log === STRING - const enabled = isLogEnabled - ? isLogString - ? true - : options.log - : defaults.log - - if (!hasOwn(options, 'logExpand')) { - options.logExpand = - isLogEnabled && isLogString - ? options.log === EXPAND - : defaults.logExpand - } - - enableVInfo(options) - setupConsole({ - enabled, - expand: options.logExpand, - iframeId, - }) - - if (isLogString && !(options.log in LOG_OPTIONS)) - error( - iframeId, - 'Invalid value for options.log: Accepted values are "expanded" and "collapsed"', - ) - - options.log = enabled - } - - const beenHere = () => LABEL in iframe - - const iframeId = ensureHasId(iframe.id) - - if (typeof options !== OBJECT) { - throw new TypeError('Options is not an object') - } - - checkLocationSearch(options) - startLogging(iframeId, options) - errorBoundary(iframeId, setupIframe)(options) - - return iframe?.iframeResizer } - -const sendTriggerMsg = (eventName, event) => - Object.values(settings) - .filter(({ autoResize, firstRun }) => autoResize && !firstRun) - .forEach(({ iframe }) => trigger(eventName, event, iframe.id)) - -function tabVisible() { - if (document.hidden === true) return - sendTriggerMsg('tabVisible', RESIZE) -} - -const setupEventListenersOnce = once(() => { - addEventListener(window, MESSAGE, iframeListener) - addEventListener(document, 'visibilitychange', tabVisible) - window.iframeParentListener = (data) => - setTimeout(() => iframeListener({ data, sameOrigin: true })) -}) diff --git a/packages/core/listeners.js b/packages/core/listeners.js new file mode 100644 index 000000000..89ea77191 --- /dev/null +++ b/packages/core/listeners.js @@ -0,0 +1,57 @@ +import { CHILD_READY_MESSAGE, MESSAGE, PARENT, STRING } from '../common/consts' +import { addEventListener } from '../common/listeners' +import { once } from '../common/utils' +import { debug, errorBoundary, event as consoleEvent } from './console' +import tabVisible from './events/visible' +import decodeMessage from './received/decode' +import { + checkIframeExists, + isMessageForUs, + isMessageFromIframe, + isMessageFromMetaParent, +} from './received/preflight' +import routeMessage from './router' +import iframeReady from './send/ready' +import settings from './values/settings' + +function iframeListener(event) { + const msg = event.data + + if (msg === CHILD_READY_MESSAGE) { + iframeReady(event.source) + return + } + + if (!isMessageForUs(msg)) { + if (typeof msg !== STRING) return + consoleEvent(PARENT, 'ignoredMessage') + debug(PARENT, msg) + return + } + + const messageData = decodeMessage(msg) + const { id, type } = messageData + + consoleEvent(id, type) + + switch (true) { + case !settings[id]: + throw new Error(`${type} No settings for ${id}. Message was: ${msg}`) + + case !checkIframeExists(messageData): + case isMessageFromMetaParent(messageData): + case !isMessageFromIframe(messageData, event): + return + + default: + settings[id].lastMessage = event.data + errorBoundary(id, routeMessage)(messageData) + } +} + +export default once(() => { + addEventListener(window, MESSAGE, iframeListener) + addEventListener(document, 'visibilitychange', tabVisible) + window.iframeParentListener = (data) => + setTimeout(() => iframeListener({ data, sameOrigin: true })) +}) diff --git a/packages/core/methods/attach.js b/packages/core/methods/attach.js new file mode 100644 index 000000000..65d66a1ee --- /dev/null +++ b/packages/core/methods/attach.js @@ -0,0 +1,54 @@ +import { + MESSAGE, + REMOVED_NEXT_VERSION, + RESIZE, + STRING, +} from '../../common/consts' +import { typeAssert } from '../../common/utils' +import { advise } from '../console' +import trigger from '../send/trigger' +import settings from '../values/settings' +import closeIframe from './close' +import disconnect from './disconnect' + +const DEPRECATED_REMOVE_LISTENERS = `Deprecated Method Name + +The removeListeners() method has been renamed to disconnect(). ${REMOVED_NEXT_VERSION}` + +const DEPRECATED_RESIZE = `Deprecated Method + +Use of the resize() method from the parent page is deprecated and will be removed in a future version of iframe-resizer. As their are no longer any edge cases that require triggering a resize from the parent page, it is recommended to remove this method from your code.` + +export default function attachMethods(id) { + if (settings[id]) { + const { iframe } = settings[id] + const resizer = { + close: closeIframe.bind(null, iframe), + + disconnect: disconnect.bind(null, iframe), + + moveToAnchor(anchor) { + typeAssert(anchor, STRING, 'moveToAnchor(anchor) anchor') + trigger('Move to anchor', `moveToAnchor:${anchor}`, id) + }, + + removeListeners() { + advise(id, DEPRECATED_REMOVE_LISTENERS) + this.disconnect() + }, + + resize() { + advise(id, DEPRECATED_RESIZE) + trigger.bind(null, 'Window resize', RESIZE, id) + }, + + sendMessage(message) { + message = JSON.stringify(message) + trigger(MESSAGE, `${MESSAGE}:${message}`, id) + }, + } + + iframe.iframeResizer = resizer + iframe.iFrameResizer = resizer + } +} diff --git a/packages/core/methods/close.js b/packages/core/methods/close.js new file mode 100644 index 000000000..788c05b5d --- /dev/null +++ b/packages/core/methods/close.js @@ -0,0 +1,28 @@ +import { HIGHLIGHT } from 'auto-console-group' + +import { log, warn } from '../console' +import on from '../events/wrapper' +import disconnect from './disconnect' + +export default function closeIframe(iframe) { + const { id } = iframe + + if (on(id, 'onBeforeClose', id) === false) { + log(id, 'Close iframe cancelled by onBeforeClose') + return + } + + log(id, `Removing iFrame: %c${id}`, HIGHLIGHT) + + try { + // Catch race condition error with React + if (iframe.parentNode) { + iframe.remove() + } + } catch (error) { + warn(id, error) + } + + on(id, 'onAfterClose', id) + disconnect(iframe) +} diff --git a/packages/core/methods/disconnect.js b/packages/core/methods/disconnect.js new file mode 100644 index 000000000..8537ed263 --- /dev/null +++ b/packages/core/methods/disconnect.js @@ -0,0 +1,9 @@ +import { log } from '../console' +import settings from '../values/settings' + +export default function disconnect(iframe) { + const { id } = iframe + log(id, 'Disconnected from iframe') + delete settings[id] + delete iframe.iframeResizer +} diff --git a/packages/core/methods/reset.js b/packages/core/methods/reset.js new file mode 100644 index 000000000..112c70d2d --- /dev/null +++ b/packages/core/methods/reset.js @@ -0,0 +1,18 @@ +import { INIT, RESET } from '../../common/consts' +import { log } from '../console' +import setSize from '../events/size' +import { getPagePosition } from '../page/position' +import trigger from '../send/trigger' + +export default function resetIframe(messageData) { + const { id, type } = messageData + + log( + id, + `Size reset requested by ${type === INIT ? 'parent page' : 'child page'}`, + ) + + getPagePosition(id) + setSize(messageData) + trigger(RESET, RESET, id) +} diff --git a/packages/core/monitor/common.js b/packages/core/monitor/common.js new file mode 100644 index 000000000..f281e37ba --- /dev/null +++ b/packages/core/monitor/common.js @@ -0,0 +1,93 @@ +import { LOAD, RESIZE, SCROLL } from '../../common/consts' +import { addEventListener, removeEventListener } from '../../common/listeners' +import { event, log } from '../console' +import trigger from '../send/trigger' +import settings from '../values/settings' + +export const sendInfoToIframe = (type, infoFunction) => (requestType, id) => { + const gate = {} + const { iframe } = settings[id] + + function throttle(func, frameId) { + if (!gate[frameId]) { + func() + gate[frameId] = requestAnimationFrame(() => { + gate[frameId] = null + }) + } + } + + function gatedTrigger() { + trigger(`${requestType} (${type})`, `${type}:${infoFunction(iframe)}`, id) + } + + throttle(gatedTrigger, id) +} + +export const stopInfoMonitor = (stopFunction) => (id) => { + if (stopFunction in settings[id]) { + settings[id][stopFunction]() + delete settings[id][stopFunction] + } +} + +export const startInfoMonitor = (sendInfoToIframe, type) => (id) => { + let pending = false + + const sendInfo = (requestType) => () => { + if (settings[id]) { + if (!pending || pending === requestType) { + sendInfoToIframe(requestType, id) + + pending = requestType + requestAnimationFrame(() => { + pending = false + }) + } + } else { + stop() + } + } + + const sendScroll = sendInfo(SCROLL) + const sendResize = sendInfo('resize window') + + function setListener(requestType, listener) { + log(id, `${requestType}listeners for send${type}`) + listener(window, SCROLL, sendScroll) + listener(window, RESIZE, sendResize) + } + + function stop() { + event(id, `stop${type}`) + setListener('Remove ', removeEventListener) + pageObserver.disconnect() + iframeObserver.disconnect() + removeEventListener(settings[id].iframe, LOAD, stop) + } + + function start() { + setListener('Add ', addEventListener) + + pageObserver.observe(document.body, { + attributes: true, + childList: true, + subtree: true, + }) + + iframeObserver.observe(settings[id].iframe, { + attributes: true, + childList: false, + subtree: false, + }) + } + + const pageObserver = new ResizeObserver(sendInfo('pageObserver')) + const iframeObserver = new ResizeObserver(sendInfo('iframeObserver')) + + if (!settings[id]) return + + settings[id][`stop${type}`] = stop + addEventListener(settings[id].iframe, LOAD, stop) + start() +} diff --git a/packages/core/monitor/page-info.js b/packages/core/monitor/page-info.js new file mode 100644 index 000000000..99aa9f5d1 --- /dev/null +++ b/packages/core/monitor/page-info.js @@ -0,0 +1,33 @@ +import { PAGE_INFO } from '../../common/consts' +import { sendInfoToIframe, startInfoMonitor, stopInfoMonitor } from './common' + +export function getPageInfo(iframe) { + const bodyPosition = document.body.getBoundingClientRect() + const iFramePosition = iframe.getBoundingClientRect() + const { scrollY, scrollX, innerHeight, innerWidth } = window + const { clientHeight, clientWidth } = document.documentElement + + return JSON.stringify({ + iframeHeight: iFramePosition.height, + iframeWidth: iFramePosition.width, + clientHeight: Math.max(clientHeight, innerHeight || 0), + clientWidth: Math.max(clientWidth, innerWidth || 0), + offsetTop: parseInt(iFramePosition.top - bodyPosition.top, 10), + offsetLeft: parseInt(iFramePosition.left - bodyPosition.left, 10), + scrollTop: scrollY, + scrollLeft: scrollX, + documentHeight: clientHeight, + documentWidth: clientWidth, + windowHeight: innerHeight, + windowWidth: innerWidth, + }) +} + +const sendPageInfoToIframe = sendInfoToIframe(PAGE_INFO, getPageInfo) + +export const startPageInfoMonitor = startInfoMonitor( + sendPageInfoToIframe, + 'PageInfo', +) + +export const stopPageInfoMonitor = stopInfoMonitor('stopPageInfo') diff --git a/packages/core/monitor/props.js b/packages/core/monitor/props.js new file mode 100644 index 000000000..73806f953 --- /dev/null +++ b/packages/core/monitor/props.js @@ -0,0 +1,34 @@ +import { PARENT_INFO } from '../../common/consts' +import { sendInfoToIframe, startInfoMonitor, stopInfoMonitor } from './common' + +export function getParentProps(iframe) { + const { scrollWidth, scrollHeight } = document.documentElement + const { width, height, offsetLeft, offsetTop, pageLeft, pageTop, scale } = + window.visualViewport + + return JSON.stringify({ + iframe: iframe.getBoundingClientRect(), + document: { + scrollWidth, + scrollHeight, + }, + viewport: { + width, + height, + offsetLeft, + offsetTop, + pageLeft, + pageTop, + scale, + }, + }) +} + +const sendParentInfoToIframe = sendInfoToIframe(PARENT_INFO, getParentProps) + +export const startParentInfoMonitor = startInfoMonitor( + sendParentInfoToIframe, + 'ParentInfo', +) + +export const stopParentInfoMonitor = stopInfoMonitor('stopParentInfo') diff --git a/packages/core/page/in-page-link.js b/packages/core/page/in-page-link.js new file mode 100644 index 000000000..c87734fa9 --- /dev/null +++ b/packages/core/page/in-page-link.js @@ -0,0 +1,48 @@ +import { HIGHLIGHT } from 'auto-console-group' + +import { info, log } from '../console' +import page from '../values/page' +import { getElementPosition, scrollToLink } from './scroll' + +function jumpToTarget(id, hash, target) { + const { x, y } = getElementPosition(target) + + info(id, `Moving to in page link: %c#${hash}`, HIGHLIGHT) + + page.position = { x, y } + + scrollToLink(id) + window.location.hash = hash +} + +function jumpToParent(id, hash) { + // Check for V4 as well + const target = window.parentIframe || window.parentIFrame + + if (!target) { + log(id, `In page link #${hash} not found`) + return + } + + target.moveToAnchor(hash) +} + +export default function inPageLink(id, location) { + const hash = location.split('#')[1] || '' + const hashData = decodeURIComponent(hash) + + const target = + document.getElementById(hashData) || document.getElementsByName(hashData)[0] + + if (target) { + jumpToTarget(id, hash, target) + return + } + + if (window.top === window.self) { + log(id, `In page link #${hash} not found`) + return + } + + jumpToParent(id, hash) +} diff --git a/packages/core/page/position.js b/packages/core/page/position.js new file mode 100644 index 000000000..30a909881 --- /dev/null +++ b/packages/core/page/position.js @@ -0,0 +1,48 @@ +import { FOREGROUND, HIGHLIGHT } from 'auto-console-group' + +import { info, log } from '../console' +import page from '../values/page' + +export function unsetPagePosition() { + page.position = null +} + +export const getStoredPagePosition = () => page.position + +export function setStoredPagePosition(position) { + page.position = position +} + +export function getPagePosition(id) { + if (page.position === null) + page.position = { + x: window.scrollX, + y: window.scrollY, + } + + log( + id, + `Get page position: %c${page.position.x}%c, %c${page.position.y}`, + HIGHLIGHT, + FOREGROUND, + HIGHLIGHT, + ) + + return page.position +} + +export function setPagePosition(id) { + if (page.position === null) return + + window.scrollTo(page.position.x, page.position.y) + + info( + id, + `Set page position: %c${page.position.x}%c, %c${page.position.y}`, + HIGHLIGHT, + FOREGROUND, + HIGHLIGHT, + ) + + unsetPagePosition() +} diff --git a/packages/core/page/scroll.js b/packages/core/page/scroll.js new file mode 100644 index 000000000..da6e71663 --- /dev/null +++ b/packages/core/page/scroll.js @@ -0,0 +1,87 @@ +import { FOREGROUND, HIGHLIGHT } from 'auto-console-group' + +import { info } from '../console' +import on from '../events/wrapper' +import settings from '../values/settings' +import { + getPagePosition, + getStoredPagePosition, + setPagePosition, + setStoredPagePosition, + unsetPagePosition, +} from './position' + +export function getElementPosition(target) { + const iframePosition = target.getBoundingClientRect() + const pagePosition = getPagePosition(target.id) + + return { + x: Number(iframePosition.left) + Number(pagePosition.x), + y: Number(iframePosition.top) + Number(pagePosition.y), + } +} + +export function scrollToLink(id) { + const { x, y } = getStoredPagePosition() + const iframe = settings[id]?.iframe + + if (on(id, 'onScroll', { iframe, top: y, left: x, x, y }) === false) { + unsetPagePosition() + return + } + + setPagePosition(id) +} + +export function scrollBy(messageData) { + const { id, height, width } = messageData + + // Check for V4 as well + const target = window.parentIframe || window.parentIFrame || window + + info( + id, + `scrollBy: x: %c${width}%c y: %c${height}`, + HIGHLIGHT, + FOREGROUND, + HIGHLIGHT, + ) + + target.scrollBy(width, height) +} + +const scrollRequestFromChild = (addOffset) => (messageData) => { + /* istanbul ignore next */ // Not testable in Karma + function reposition(newPosition) { + setStoredPagePosition(newPosition) + scrollToLink(id) + } + + function scrollParent(target, newPosition) { + target[`scrollTo${addOffset ? 'Offset' : ''}`](newPosition.x, newPosition.y) + } + + const calcOffset = (offset) => ({ + x: width + offset.x, + y: height + offset.y, + }) + + const { id, iframe, height, width } = messageData + const offset = addOffset ? getElementPosition(iframe) : { x: 0, y: 0 } + const newPosition = calcOffset(offset) + const target = window.parentIframe || window.parentIFrame // Check for V4 as well + + info( + id, + `Reposition requested (offset x:%c${offset.x}%c y:%c${offset.y})`, + HIGHLIGHT, + FOREGROUND, + HIGHLIGHT, + ) + + if (target) scrollParent(target, newPosition) + else reposition(newPosition) +} + +export const scrollTo = scrollRequestFromChild(false) +export const scrollToOffset = scrollRequestFromChild(true) diff --git a/packages/core/page/title.js b/packages/core/page/title.js new file mode 100644 index 000000000..6d1fe064b --- /dev/null +++ b/packages/core/page/title.js @@ -0,0 +1,15 @@ +import { HIGHLIGHT } from 'auto-console-group' + +import { info } from '../console' +import settings from '../values/settings' + +export function checkTitle(id) { + const title = settings[id]?.iframe?.title + return title === '' || title === undefined +} + +export function setTitle(id, title) { + if (!settings[id]?.syncTitle) return + settings[id].iframe.title = title + info(id, `Set iframe title attribute: %c${title}`, HIGHLIGHT) +} diff --git a/packages/core/received/decode.js b/packages/core/received/decode.js new file mode 100644 index 000000000..71f36034f --- /dev/null +++ b/packages/core/received/decode.js @@ -0,0 +1,49 @@ +import { MESSAGE_ID_LENGTH } from '../../common/consts' +import settings from '../values/settings' + +export function getPaddingEnds(compStyle) { + if (compStyle.boxSizing !== 'border-box') return 0 + + const top = compStyle.paddingTop ? parseInt(compStyle.paddingTop, 10) : 0 + const bot = compStyle.paddingBottom + ? parseInt(compStyle.paddingBottom, 10) + : 0 + + return top + bot +} + +export function getBorderEnds(compStyle) { + if (compStyle.boxSizing !== 'border-box') return 0 + + const top = compStyle.borderTopWidth + ? parseInt(compStyle.borderTopWidth, 10) + : 0 + + const bot = compStyle.borderBottomWidth + ? parseInt(compStyle.borderBottomWidth, 10) + : 0 + + return top + bot +} + +export default function decodeMessage(msg) { + const data = msg.slice(MESSAGE_ID_LENGTH).split(':') + const height = data[1] ? Number(data[1]) : 0 + const iframe = settings[data[0]]?.iframe + const compStyle = getComputedStyle(iframe) + + const messageData = { + iframe, + id: data[0], + height: height + getPaddingEnds(compStyle) + getBorderEnds(compStyle), + width: Number(data[2]), + type: data[3], + msg: data[4], + message: data[4], + } + + // eslint-disable-next-line prefer-destructuring + if (data[5]) messageData.mode = data[5] + + return messageData +} diff --git a/packages/core/received/message.js b/packages/core/received/message.js new file mode 100644 index 000000000..01c01eb4e --- /dev/null +++ b/packages/core/received/message.js @@ -0,0 +1,9 @@ +import { MESSAGE_HEADER_LENGTH, SEPARATOR } from '../../common/consts' +import settings from '../values/settings' + +export default function getMessageBody(id, offset) { + const { lastMessage } = settings[id] + return lastMessage.slice( + lastMessage.indexOf(SEPARATOR) + MESSAGE_HEADER_LENGTH + offset, + ) +} diff --git a/packages/core/received/preflight.js b/packages/core/received/preflight.js new file mode 100644 index 000000000..7bfcd31d3 --- /dev/null +++ b/packages/core/received/preflight.js @@ -0,0 +1,80 @@ +import { HIGHLIGHT } from 'auto-console-group' + +import { MESSAGE_ID, MESSAGE_ID_LENGTH } from '../../common/consts' +import { log, warn } from '../console' +import settings from '../values/settings' + +const ABOVE_TYPES = { true: 1, false: 1, undefined: 1 } + +export function checkIframeExists(messageData) { + const { id, msg, iframe } = messageData + const detectedIframe = iframe && iframe !== null + + if (!detectedIframe) { + log(id, `Received: %c${msg}`, HIGHLIGHT) + warn(id, `The target iframe was not found.`) + } + + return detectedIframe +} + +export function isMessageFromIframe(messageData, event) { + function checkAllowedOrigin() { + function checkList() { + log( + id, + `Checking connection is from allowed list of origins: %c${checkOrigin}`, + HIGHLIGHT, + ) + + for (const element of checkOrigin) { + if (element === origin) { + return true + } + } + + return false + } + + function checkSingle() { + const remoteHost = settings[id]?.remoteHost + log(id, `Checking connection is from: %c${remoteHost}`, HIGHLIGHT) + return origin === remoteHost + } + + return checkOrigin.constructor === Array ? checkList() : checkSingle() + } + + const { id } = messageData + const { data, origin, sameOrigin } = event + + if (sameOrigin) return true + + let checkOrigin = settings[id]?.checkOrigin + + if (checkOrigin && `${origin}` !== 'null' && !checkAllowedOrigin()) { + throw new Error( + `Unexpected message received from: ${origin} for ${id}. Message was: ${data}. This error can be disabled by setting the checkOrigin: false option or by providing of array of trusted domains.`, + ) + } + + return true +} + +export const isMessageForUs = (message) => + MESSAGE_ID === `${message}`.slice(0, MESSAGE_ID_LENGTH) && + message.slice(MESSAGE_ID_LENGTH).split(':')[0] in settings + +export function isMessageFromMetaParent(messageData) { + const { id, type } = messageData + + // Test if this message is from a parent above us. This is an ugly test, + // however, updating the message format would break backwards compatibility. + const isMetaParent = type in ABOVE_TYPES + + if (isMetaParent) { + log(id, 'Ignoring init message from meta parent page') + } + + return isMetaParent +} diff --git a/packages/core/router.js b/packages/core/router.js new file mode 100644 index 000000000..cb07795b6 --- /dev/null +++ b/packages/core/router.js @@ -0,0 +1,145 @@ +import { HIGHLIGHT } from 'auto-console-group' + +import { + AUTO_RESIZE, + BEFORE_UNLOAD, + CLOSE, + IN_PAGE_LINK, + INIT, + MESSAGE, + MOUSE_ENTER, + MOUSE_LEAVE, + PAGE_INFO, + PAGE_INFO_STOP, + PARENT_INFO, + PARENT_INFO_STOP, + RESET, + SCROLL_BY, + SCROLL_TO, + SCROLL_TO_OFFSET, + TITLE, +} from '../common/consts' +import checkSameDomain from './checks/origin' +import checkVersion from './checks/version' +import { info, log, warn } from './console' +import onMessage from './events/message' +import onMouse from './events/mouse' +import resizeIframe from './events/resize' +import on from './events/wrapper' +import closeIframe from './methods/close' +import resetIframe from './methods/reset' +import { startPageInfoMonitor, stopPageInfoMonitor } from './monitor/page-info' +import { startParentInfoMonitor, stopParentInfoMonitor } from './monitor/props' +import inPageLink from './page/in-page-link' +import { scrollBy, scrollTo, scrollToOffset } from './page/scroll' +import { setTitle } from './page/title' +import getMessageBody from './received/message' +import firstRun from './setup/first-run' +import settings from './values/settings' + +export default function routeMessage(messageData) { + const { height, id, iframe, mode, message, type, width } = messageData + const { lastMessage } = settings[id] + + if (settings[id]?.firstRun) firstRun(id, mode) + log(id, `Received: %c${lastMessage}`, HIGHLIGHT) + + switch (type) { + case AUTO_RESIZE: + settings[id].autoResize = JSON.parse(getMessageBody(id, 9)) + break + + case BEFORE_UNLOAD: + info(id, 'Ready state reset') + settings[id].initialised = false + break + + case CLOSE: + closeIframe(iframe) + break + + case IN_PAGE_LINK: + inPageLink(id, getMessageBody(id, 9)) + break + + case INIT: + resizeIframe(messageData) + checkSameDomain(id) + checkVersion(id, message) + settings[id].initialised = true + on(id, 'onReady', iframe) + break + + case MESSAGE: + onMessage(messageData, getMessageBody(id, 6)) + break + + case MOUSE_ENTER: + onMouse('onMouseEnter', messageData) + break + + case MOUSE_LEAVE: + onMouse('onMouseLeave', messageData) + break + + case PAGE_INFO: + startPageInfoMonitor(id) + break + + case PARENT_INFO: + startParentInfoMonitor(id) + break + + case PAGE_INFO_STOP: + stopPageInfoMonitor(id) + break + + case PARENT_INFO_STOP: + stopParentInfoMonitor(id) + break + + case RESET: + resetIframe(messageData) + break + + case SCROLL_BY: + scrollBy(messageData) + break + + case SCROLL_TO: + scrollTo(messageData) + break + + case SCROLL_TO_OFFSET: + scrollToOffset(messageData) + break + + case TITLE: + setTitle(message, id) + break + + default: + if (width === 0 && height === 0) { + warn( + id, + `Unsupported message received (${type}), this is likely due to the iframe containing a later ` + + `version of iframe-resizer than the parent page`, + ) + return + } + + if (width === 0 || height === 0) { + log(id, 'Ignoring message with 0 height or width') + return + } + + // Recheck document.hidden here, as only Firefox + // correctly supports this in the iframe + if (document.hidden) { + log(id, 'Page hidden - ignored resize request') + return + } + + resizeIframe(messageData) + } +} diff --git a/packages/core/send/offset.js b/packages/core/send/offset.js new file mode 100644 index 000000000..fb950ba11 --- /dev/null +++ b/packages/core/send/offset.js @@ -0,0 +1,19 @@ +import { HIGHLIGHT } from 'auto-console-group' + +import { VERTICAL } from '../../common/consts' +import { log } from '../console' +import settings from '../values/settings' + +export default function setOffsetSize(id, { offset, offsetSize }) { + const newOffset = offsetSize || offset + + if (!newOffset) return // No offset set or offset is zero + + if (settings[id].direction === VERTICAL) { + settings[id].offsetHeight = newOffset + log(id, `Offset height: %c${newOffset}`, HIGHLIGHT) + } else { + settings[id].offsetWidth = newOffset + log(id, `Offset width: %c${newOffset}`, HIGHLIGHT) + } +} diff --git a/packages/core/send/outgoing.js b/packages/core/send/outgoing.js new file mode 100644 index 000000000..aedc147ad --- /dev/null +++ b/packages/core/send/outgoing.js @@ -0,0 +1,59 @@ +import { CHILD, SEPARATOR } from '../../common/consts' +import page from '../values/page' +import settings from '../values/settings' + +// Backwards compatibility consts +const V1_PADDING = '8' +const INTERVAL = '32' +const RESIZE_FROM = CHILD +const PUBLIC_METHODS = true + +export default function createOutgoingMessage(id) { + const { + autoResize, + bodyBackground, + bodyMargin, + bodyPadding, + heightCalculationMethod, + inPageLinks, + license, + log, + logExpand, + mouseEvents, + offsetHeight, + offsetWidth, + mode, + sizeHeight, + // sizeSelector, + sizeWidth, + tolerance, + widthCalculationMethod, + } = settings[id] + + return [ + id, + V1_PADDING, + sizeWidth, + log, + INTERVAL, + PUBLIC_METHODS, + autoResize, + bodyMargin, + heightCalculationMethod, + bodyBackground, + bodyPadding, + tolerance, + inPageLinks, + RESIZE_FROM, + widthCalculationMethod, + mouseEvents, + offsetHeight, + offsetWidth, + sizeHeight, + license, + page.version, + mode, + '', // sizeSelector, + logExpand, + ].join(SEPARATOR) +} diff --git a/packages/core/send/ready.js b/packages/core/send/ready.js new file mode 100644 index 000000000..95a89b684 --- /dev/null +++ b/packages/core/send/ready.js @@ -0,0 +1,10 @@ +import settings from '../values/settings' + +export const sendIframeReady = + (source) => + ({ initChild, postMessageTarget }) => { + if (source === postMessageTarget) initChild() + } + +export default (source) => + Object.values(settings).forEach(sendIframeReady(source)) diff --git a/packages/core/timeout.js b/packages/core/send/timeout.js similarity index 96% rename from packages/core/timeout.js rename to packages/core/send/timeout.js index acd9343b7..a18ee5ac4 100644 --- a/packages/core/timeout.js +++ b/packages/core/send/timeout.js @@ -1,5 +1,5 @@ -import { OBJECT } from '../common/consts' -import { advise, event } from './console' +import { OBJECT } from '../../common/consts' +import { advise, event } from '../console' const getOrigin = (url) => { try { diff --git a/packages/core/timeout.test.js b/packages/core/send/timeout.test.js similarity index 98% rename from packages/core/timeout.test.js rename to packages/core/send/timeout.test.js index 82fc3cb7a..0ce6efbbc 100644 --- a/packages/core/timeout.test.js +++ b/packages/core/send/timeout.test.js @@ -1,8 +1,8 @@ -import { advise, event } from './console' +import { advise, event } from '../console' import warnOnNoResponse from './timeout' // Mock console integration used by showWarning -jest.mock('./console', () => ({ +jest.mock('../console', () => ({ advise: jest.fn(), event: jest.fn(), })) diff --git a/packages/core/send/trigger.js b/packages/core/send/trigger.js new file mode 100644 index 000000000..0a5ae8a82 --- /dev/null +++ b/packages/core/send/trigger.js @@ -0,0 +1,55 @@ +import { FOREGROUND, HIGHLIGHT } from 'auto-console-group' + +import { INIT_EVENTS, MESSAGE_ID, SEPARATOR } from '../../common/consts' +import { event as consoleEvent, info, log, warn } from '../console' +import settings from '../values/settings' + +const filterMsg = (msg) => + msg + .split(SEPARATOR) + .filter((_, index) => index !== 19) + .join(SEPARATOR) + +function dispatch(calleeMsg, msg, id) { + function logSent(route) { + const displayMsg = calleeMsg in INIT_EVENTS ? filterMsg(msg) : msg + info(id, route, HIGHLIGHT, FOREGROUND, HIGHLIGHT) + info(id, `Message data: %c${displayMsg}`, HIGHLIGHT) + } + + const { iframe, postMessageTarget, sameOrigin, targetOrigin } = settings[id] + + if (sameOrigin) { + try { + iframe.contentWindow.iframeChildListener(MESSAGE_ID + msg) + logSent(`Sending message to iframe %c${id}%c via same origin%c`) + return + } catch (error) { + if (calleeMsg in INIT_EVENTS) { + settings[id].sameOrigin = false + log(id, 'New iframe does not support same origin') + } else { + warn(id, 'Same origin messaging failed, falling back to postMessage') + } + } + } + + logSent( + `Sending message to iframe: %c${id}%c targetOrigin: %c${targetOrigin}`, + ) + + postMessageTarget.postMessage(MESSAGE_ID + msg, targetOrigin) +} + +function trigger(calleeMsg, msg, id) { + consoleEvent(id, calleeMsg) + + if (!settings[id]?.postMessageTarget) { + warn(id, `Iframe not found`) + return + } + + dispatch(calleeMsg, msg, id) +} + +export default trigger diff --git a/packages/core/setup/body-margin.js b/packages/core/setup/body-margin.js new file mode 100644 index 000000000..704207bd8 --- /dev/null +++ b/packages/core/setup/body-margin.js @@ -0,0 +1,12 @@ +import { NUMBER } from '../../common/consts' +import settings from '../values/settings' + +const ZERO = '0' + +export default function setupBodyMargin(id) { + const { bodyMargin } = settings[id] + + if (typeof bodyMargin === NUMBER || bodyMargin === ZERO) { + settings[id].bodyMargin = `${bodyMargin}px` + } +} diff --git a/packages/core/setup/direction.js b/packages/core/setup/direction.js new file mode 100644 index 000000000..ef56ad9c4 --- /dev/null +++ b/packages/core/setup/direction.js @@ -0,0 +1,32 @@ +import { HIGHLIGHT } from 'auto-console-group' + +import { BOTH, HORIZONTAL, NONE, VERTICAL } from '../../common/consts' +import { log } from '../console' +import settings from '../values/settings' + +export default function setDirection(id) { + const { direction } = settings[id] + + switch (direction) { + case VERTICAL: + break + + case HORIZONTAL: + settings[id].sizeHeight = false + // eslint-disable-next-line no-fallthrough + case BOTH: + settings[id].sizeWidth = true + break + + case NONE: + settings[id].sizeWidth = false + settings[id].sizeHeight = false + settings[id].autoResize = false + break + + default: + throw new TypeError(id, `Direction value of "${direction}" is not valid`) + } + + log(id, `direction: %c${direction}`, HIGHLIGHT) +} diff --git a/packages/core/setup/first-run.js b/packages/core/setup/first-run.js new file mode 100644 index 000000000..9224ff9d9 --- /dev/null +++ b/packages/core/setup/first-run.js @@ -0,0 +1,11 @@ +import checkMode from '../checks/mode' +import { log } from '../console' +import settings from '../values/settings' + +export default function firstRun(id, mode) { + if (!settings[id]) return + + log(id, `First run for ${id}`) + checkMode(id, mode) + settings[id].firstRun = false +} diff --git a/packages/core/setup/index.js b/packages/core/setup/index.js new file mode 100644 index 000000000..7804446bd --- /dev/null +++ b/packages/core/setup/index.js @@ -0,0 +1,29 @@ +import { HIGHLIGHT } from 'auto-console-group' + +import { preModeCheck } from '../checks/mode' +import checkUniqueId from '../checks/unique' +import { endAutoGroup, event as consoleEvent, log } from '../console' +import attachMethods from '../methods/attach' +import createOutgoingMessage from '../send/outgoing' +import setupBodyMargin from './body-margin' +import init from './init' +import processOptions from './process-options' +import setScrolling from './scrolling' + +function setup(id, iframe, options) { + processOptions(iframe, options) + log(id, `src: %c${iframe.srcdoc || iframe.src}`, HIGHLIGHT) + preModeCheck(id) + setScrolling(iframe) + setupBodyMargin(id) + init(id, createOutgoingMessage(id)) + attachMethods(id) + log(id, 'Setup complete') +} + +export default function (iframe, options) { + const { id } = iframe + consoleEvent(id, 'setup') + if (checkUniqueId(id)) setup(id, iframe, options) + endAutoGroup(id) +} diff --git a/packages/core/setup/init.js b/packages/core/setup/init.js new file mode 100644 index 000000000..c691e3043 --- /dev/null +++ b/packages/core/setup/init.js @@ -0,0 +1,80 @@ +import { + INIT, + INIT_FROM_IFRAME, + LAZY, + LOAD, + MIN_SIZE, + ONLOAD, + RESET_REQUIRED_METHODS, +} from '../../common/consts' +import { addEventListener } from '../../common/listeners' +import { event as consoleEvent, info } from '../console' +import resetIframe from '../methods/reset' +import warnOnNoResponse from '../send/timeout' +import trigger from '../send/trigger' +import settings from '../values/settings' + +const AFTER_EVENT_STACK = 1 +const isLazy = (iframe) => iframe.loading === LAZY +const isInit = (eventType) => eventType === INIT + +function checkReset(id) { + if (!(settings[id]?.heightCalculationMethod in RESET_REQUIRED_METHODS)) return + + const iframe = settings[id] + + resetIframe({ + iframe, + height: MIN_SIZE, + width: MIN_SIZE, + type: INIT, + }) +} + +function addLoadListener(iframe, initChild) { + // allow other concurrent events to go first + const onload = () => setTimeout(initChild, AFTER_EVENT_STACK) + addEventListener(iframe, LOAD, onload) +} + +const noContent = (iframe) => { + const { src, srcdoc } = iframe + return !srcdoc && (src == null || src === '' || src === 'about:blank') +} + +function sendInit(id, initChild) { + const { iframe, waitForLoad } = settings[id] + + if (waitForLoad === true) return + if (noContent(iframe)) { + setTimeout(() => { + consoleEvent(id, 'noContent') + info(id, 'No content detected in the iframe, delaying initialisation') + }) + return + } + + setTimeout(initChild) +} + +// We have to call trigger twice, as we can not be sure if all +// iframes have completed loading when this code runs. The +// event listener also catches the page changing in the iFrame. +export default function init(id, message) { + const createInitChild = (eventType) => () => { + if (!settings[id]) return // iframe removed before load event + + const { firstRun, iframe } = settings[id] + + trigger(eventType, message, id) + if (!(isInit(eventType) && isLazy(iframe))) warnOnNoResponse(id, settings) + + if (!firstRun) checkReset(id) + } + + const { iframe } = settings[id] + + settings[id].initChild = createInitChild(INIT_FROM_IFRAME) + addLoadListener(iframe, createInitChild(ONLOAD)) + sendInit(id, createInitChild(INIT)) +} diff --git a/packages/core/setup/logging.js b/packages/core/setup/logging.js new file mode 100644 index 000000000..ea4dd3771 --- /dev/null +++ b/packages/core/setup/logging.js @@ -0,0 +1,35 @@ +import { COLLAPSE, EXPAND, LOG_OPTIONS } from '../../common/consts' +import { hasOwn, isString } from '../../common/utils' +import { enableVInfo } from '../checks/mode' +import { error, setupConsole } from '../console' +import defaults from '../values/defaults' + +export default function startLogging(id, options) { + const isLogEnabled = hasOwn(options, 'log') + const isLogString = isString(options.log) + const enabled = isLogEnabled + ? isLogString + ? true + : options.log + : defaults.log + + if (!hasOwn(options, 'logExpand')) { + options.logExpand = + isLogEnabled && isLogString ? options.log === EXPAND : defaults.logExpand + } + + enableVInfo(options) + setupConsole({ + enabled, + expand: options.logExpand, + iframeId: id, + }) + + if (isLogString && !(options.log in LOG_OPTIONS)) + error( + id, + `Invalid value for options.log: Accepted values are "${EXPAND}" and "${COLLAPSE}"`, + ) + + options.log = enabled +} diff --git a/packages/core/setup/process-options.js b/packages/core/setup/process-options.js new file mode 100644 index 000000000..d204e3761 --- /dev/null +++ b/packages/core/setup/process-options.js @@ -0,0 +1,35 @@ +import setMode from '../../common/mode' +import { hasOwn } from '../../common/utils' +import checkOptions from '../checks/options' +import checkWarningTimeout from '../checks/warning-timeout' +import { checkTitle } from '../page/title' +import setOffsetSize from '../send/offset' +import defaults from '../values/defaults' +import settings from '../values/settings' +import setDirection from './direction' +import { getPostMessageTarget, setTargetOrigin } from './target-origin' +import updateOptionNames from './update-option-names' + +const hasMouseEvents = (options) => + hasOwn(options, 'onMouseEnter') || hasOwn(options, 'onMouseLeave') + +export default function processOptions(iframe, options) { + const { id } = iframe + settings[id] = { + ...settings[id], + iframe, + remoteHost: iframe?.src.split('/').slice(0, 3).join('/'), + ...defaults, + ...checkOptions(id, options), + mouseEvents: hasMouseEvents(options), + mode: setMode(options), + syncTitle: checkTitle(id), + } + + updateOptionNames(id) + setDirection(id) + setOffsetSize(id, options) + checkWarningTimeout(id) + getPostMessageTarget(iframe) + setTargetOrigin(id) +} diff --git a/packages/core/setup/scrolling.js b/packages/core/setup/scrolling.js new file mode 100644 index 000000000..c6aa033ba --- /dev/null +++ b/packages/core/setup/scrolling.js @@ -0,0 +1,36 @@ +import { AUTO, HIDDEN } from '../../common/consts' +import { log } from '../console' +import settings from '../values/settings' + +const YES = 'yes' +const NO = 'no' +const OMIT = 'omit' + +export default function setScrolling(iframe) { + const { id } = iframe + + log( + id, + `Iframe scrolling ${ + settings[id]?.scrolling ? 'enabled' : 'disabled' + } for ${id}`, + ) + + iframe.style.overflow = settings[id]?.scrolling === false ? HIDDEN : AUTO + + switch (settings[id]?.scrolling) { + case OMIT: + break + + case true: + iframe.scrolling = YES + break + + case false: + iframe.scrolling = NO + break + + default: + iframe.scrolling = settings[id]?.scrolling || NO + } +} diff --git a/packages/core/setup/target-origin.js b/packages/core/setup/target-origin.js new file mode 100644 index 000000000..148996e66 --- /dev/null +++ b/packages/core/setup/target-origin.js @@ -0,0 +1,20 @@ +import settings from '../values/settings' + +export const getTargetOrigin = (remoteHost) => + remoteHost === '' || + remoteHost.match(/^(about:blank|javascript:|file:\/\/)/) !== null + ? '*' + : remoteHost + +export function setTargetOrigin(id) { + settings[id].targetOrigin = + settings[id].checkOrigin === true + ? getTargetOrigin(settings[id].remoteHost) + : '*' +} + +export function getPostMessageTarget(iframe) { + const { id } = iframe + if (settings[id].postMessageTarget === null) + settings[id].postMessageTarget = iframe.contentWindow +} diff --git a/packages/core/setup/update-option-names.js b/packages/core/setup/update-option-names.js new file mode 100644 index 000000000..5d1ecd2c6 --- /dev/null +++ b/packages/core/setup/update-option-names.js @@ -0,0 +1,21 @@ +import { OFFSET, OFFSET_SIZE, REMOVED_NEXT_VERSION } from '../../common/consts' +import { hasOwn } from '../../common/utils' +import { advise } from '../console' +import settings from '../values/settings' + +function updateOptionName(id, oldName, newName) { + if (hasOwn(settings[id], oldName)) { + advise( + id, + `Deprecated option\n\nThe ${oldName} option has been renamed to ${newName}. ${REMOVED_NEXT_VERSION}`, + ) + settings[id][newName] = settings[id][oldName] + delete settings[id][oldName] + } +} + +export default function updateOptionNames(id) { + updateOptionName(id, OFFSET, OFFSET_SIZE) + updateOptionName(id, 'onClose', 'onBeforeClose') + updateOptionName(id, 'onClosed', 'onAfterClose') +} diff --git a/packages/core/values/defaults.js b/packages/core/values/defaults.js index 5460fe679..fbfbb0d3d 100644 --- a/packages/core/values/defaults.js +++ b/packages/core/values/defaults.js @@ -36,6 +36,7 @@ export default Object.freeze({ waitForLoad: false, warningTimeout: 5000, widthCalculationMethod: AUTO, + onBeforeClose: () => true, onAfterClose() {}, onInit: false,