import { HtmlUtils } from "./HtmlUtils"; import { Subject } from "rxjs"; import { iframeListener } from "../Api/IframeListener"; import { touchScreenManager } from "../Touch/TouchScreenManager"; import { waScaleManager } from "../Phaser/Services/WaScaleManager"; enum iframeStates { closed = 1, loading, // loading an iframe can be slow, so we show some placeholder until it is ready opened, } const cowebsiteDomId = "cowebsite"; // the id of the whole container. const cowebsiteContainerDomId = "cowebsite-container"; // the id of the whole container. const cowebsiteMainDomId = "cowebsite-slot-0"; // the id of the parent div of the iframe. const cowebsiteBufferDomId = "cowebsite-buffer"; // the id of the container who contains cowebsite iframes. const cowebsiteAsideDomId = "cowebsite-aside"; // the id of the parent div of the iframe. const cowebsiteSubIconsDomId = "cowebsite-sub-icons"; export const cowebsiteCloseButtonId = "cowebsite-close"; const cowebsiteFullScreenButtonId = "cowebsite-fullscreen"; const cowebsiteOpenFullScreenImageId = "cowebsite-fullscreen-open"; const cowebsiteCloseFullScreenImageId = "cowebsite-fullscreen-close"; const animationTime = 500; //time used by the css transitions, in ms. interface TouchMoveCoordinates { x: number; y: number; } export type CoWebsite = { iframe: HTMLIFrameElement, icon: HTMLDivElement, position: number } type CoWebsiteSlot = { container: HTMLElement, position: number } class CoWebsiteManager { private openedMain: iframeStates = iframeStates.closed; private _onResize: Subject = new Subject(); public onResize = this._onResize.asObservable(); /** * Quickly going in and out of an iframe trigger can create conflicts between the iframe states. * So we use this promise to queue up every cowebsite state transition */ private currentOperationPromise: Promise = Promise.resolve(); private cowebsiteDom: HTMLDivElement; private cowebsiteContainerDom: HTMLDivElement; private resizing: boolean = false; private cowebsiteMainDom: HTMLDivElement; private cowebsiteBufferDom: HTMLDivElement; private cowebsiteAsideDom: HTMLDivElement; private cowebsiteSubIconsDom: HTMLDivElement; private previousTouchMoveCoordinates: TouchMoveCoordinates | null = null; //only use on touchscreens to track touch movement private coWebsites: CoWebsite[] = []; private slots: CoWebsiteSlot[]; private resizeObserver = new ResizeObserver(entries => { this.resizeAllIframes(); }); get width(): number { return this.cowebsiteDom.clientWidth; } set width(width: number) { this.cowebsiteDom.style.width = width + "px"; } set widthPercent(width: number) { this.cowebsiteDom.style.width = width + "%"; } get height(): number { return this.cowebsiteDom.clientHeight; } set height(height: number) { this.cowebsiteDom.style.height = height + "px"; } get verticalMode(): boolean { return window.innerWidth < window.innerHeight; } get isFullScreen(): boolean { return this.verticalMode ? this.height === window.innerHeight : this.width === window.innerWidth; } constructor() { this.cowebsiteDom = HtmlUtils.getElementByIdOrFail(cowebsiteDomId); this.cowebsiteContainerDom = HtmlUtils.getElementByIdOrFail(cowebsiteContainerDomId); this.cowebsiteMainDom = HtmlUtils.getElementByIdOrFail(cowebsiteMainDomId); this.cowebsiteBufferDom = HtmlUtils.getElementByIdOrFail(cowebsiteBufferDomId); this.cowebsiteAsideDom = HtmlUtils.getElementByIdOrFail(cowebsiteAsideDomId); this.cowebsiteSubIconsDom = HtmlUtils.getElementByIdOrFail(cowebsiteSubIconsDomId); this.initResizeListeners(touchScreenManager.supportTouchScreen); this.resizeObserver.observe(this.cowebsiteDom); this.resizeObserver.observe(this.cowebsiteContainerDom); this.slots = [ { container: this.cowebsiteMainDom, position: 0 }, { container: HtmlUtils.getElementByIdOrFail('cowebsite-slot-1'), position: 1 }, { container: HtmlUtils.getElementByIdOrFail('cowebsite-slot-2'), position: 2 }, { container: HtmlUtils.getElementByIdOrFail('cowebsite-slot-3'), position: 3 }, { container: HtmlUtils.getElementByIdOrFail('cowebsite-slot-4'), position: 4 }, ]; this.slots.forEach((slot) => { this.resizeObserver.observe(slot.container); }); this.initActionsListeners(); const buttonCloseCoWebsites = HtmlUtils.getElementByIdOrFail(cowebsiteCloseButtonId); buttonCloseCoWebsites.addEventListener("click", () => { if (this.isSmallScreen() && this.coWebsites.length > 1) { const coWebsite = this.getCoWebsiteByPosition(0); if (coWebsite) { this.removeCoWebsiteFromStack(coWebsite); return; } } buttonCloseCoWebsites.blur(); this.closeCoWebsites(); }); const buttonFullScreenFrame = HtmlUtils.getElementByIdOrFail(cowebsiteFullScreenButtonId); buttonFullScreenFrame.addEventListener("click", () => { buttonFullScreenFrame.blur(); this.fullscreen(); }); } public getDevicePixelRatio(): number { //on chrome engines, movementX and movementY return global screens coordinates while other browser return pixels //so on chrome-based browser we need to adjust using 'devicePixelRatio' return window.navigator.userAgent.includes("Firefox") ? 1 : window.devicePixelRatio; } private isSmallScreen(): boolean { return window.matchMedia("(max-aspect-ratio: 1/1)").matches; } private initResizeListeners(touchMode: boolean) { const movecallback = (event: MouseEvent | TouchEvent) => { let x, y; if (event.type === "mousemove") { x = (event as MouseEvent).movementX / this.getDevicePixelRatio(); y = (event as MouseEvent).movementY / this.getDevicePixelRatio(); } else { const touchEvent = (event as TouchEvent).touches[0]; const last = { x: touchEvent.pageX, y: touchEvent.pageY }; const previous = this.previousTouchMoveCoordinates as TouchMoveCoordinates; this.previousTouchMoveCoordinates = last; x = last.x - previous.x; y = last.y - previous.y; } this.verticalMode ? (this.height += y) : (this.width -= x); this.fire(); }; this.cowebsiteAsideDom.addEventListener(touchMode ? "touchstart" : "mousedown", (event) => { this.cowebsiteMainDom.style.display = "none"; this.resizing = true; if (touchMode) { const touchEvent = (event as TouchEvent).touches[0]; this.previousTouchMoveCoordinates = { x: touchEvent.pageX, y: touchEvent.pageY }; } document.addEventListener(touchMode ? "touchmove" : "mousemove", movecallback); }); document.addEventListener(touchMode ? "touchend" : "mouseup", (event) => { if (!this.resizing) return; if (touchMode) { this.previousTouchMoveCoordinates = null; } document.removeEventListener(touchMode ? "touchmove" : "mousemove", movecallback); this.cowebsiteMainDom.style.display = "block"; this.resizing = false; this.cowebsiteMainDom.style.display = "flex"; }); } private closeMain(): void { this.cowebsiteDom.classList.remove("loaded"); //edit the css class to trigger the transition this.cowebsiteDom.classList.add("hidden"); this.openedMain = iframeStates.closed; this.resetStyleMain(); this.cowebsiteDom.style.display = "none"; } private loadMain(): void { this.cowebsiteDom.style.display = "flex"; this.cowebsiteDom.classList.remove("hidden"); //edit the css class to trigger the transition this.cowebsiteDom.classList.add("loading"); this.openedMain = iframeStates.loading; } private openMain(): void { this.cowebsiteDom.classList.remove("loading", "hidden"); //edit the css class to trigger the transition this.openedMain = iframeStates.opened; this.resetStyleMain(); } public resetStyleMain() { this.cowebsiteDom.style.width = ""; this.cowebsiteDom.style.height = ""; } private initActionsListeners() { this.slots.forEach((slot: CoWebsiteSlot) => { const expandButton = slot.container.querySelector('.expand'); const highlightButton = slot.container.querySelector('.hightlight'); const closeButton = slot.container.querySelector('.close'); if (expandButton) { expandButton.addEventListener('click', (event) => { event.preventDefault(); const coWebsite = this.getCoWebsiteByPosition(slot.position); if (!coWebsite) { return; } this.moveRightPreviousCoWebsite(coWebsite, 0); }); } if (highlightButton) { highlightButton.addEventListener('click', (event) => { event.preventDefault(); const coWebsite = this.getCoWebsiteByPosition(slot.position); if (!coWebsite) { return; } this.moveRightPreviousCoWebsite(coWebsite, 1); }); } if (closeButton) { closeButton.addEventListener('click', (event) => { event.preventDefault(); const coWebsite = this.getCoWebsiteByPosition(slot.position); if (!coWebsite) { return; } this.removeCoWebsiteFromStack(coWebsite); }); } }); } public getCoWebsites(): CoWebsite[] { return this.coWebsites; } public getCoWebsiteById(coWebsiteId: string): CoWebsite|undefined { return this.coWebsites.find((coWebsite: CoWebsite) => coWebsite.iframe.id === coWebsiteId); } private getSlotByPosition(position: number): CoWebsiteSlot|undefined { return this.slots.find((slot: CoWebsiteSlot) => slot.position === position); } private getCoWebsiteByPosition(position: number): CoWebsite|undefined { return this.coWebsites.find((coWebsite: CoWebsite) => coWebsite.position === position); } private setIframeOffset(coWebsite: CoWebsite, slot: CoWebsiteSlot) { const bounding = slot.container.getBoundingClientRect(); if (coWebsite.iframe.classList.contains('thumbnail')) { coWebsite.iframe.style.width = ((bounding.right - bounding.left) * 2) + 'px'; coWebsite.iframe.style.height = ((bounding.bottom - bounding.top) * 2) + 'px'; coWebsite.iframe.style.top = (bounding.top - (Math.floor(bounding.height * 0.5))) + 'px'; coWebsite.iframe.style.left = (bounding.left - (Math.floor(bounding.width * 0.5))) + 'px'; } else { coWebsite.iframe.style.top = bounding.top + 'px'; coWebsite.iframe.style.left = bounding.left + 'px'; coWebsite.iframe.style.width = (bounding.right - bounding.left) + 'px'; coWebsite.iframe.style.height = (bounding.bottom - bounding.top) + 'px'; } } private resizeAllIframes() { this.coWebsites.forEach((coWebsite: CoWebsite) => { const slot = this.getSlotByPosition(coWebsite.position); if (slot) { this.setIframeOffset(coWebsite, slot); } }); } private moveCoWebsite(coWebsite: CoWebsite, newPosition: number) { const oldSlot = this.getSlotByPosition(coWebsite.position); const newSlot = this.getSlotByPosition(newPosition); if (!newSlot) { return; } coWebsite.iframe.scrolling = newPosition === 0 || newPosition === 1 ? "yes" : "no"; if (newPosition === 0) { coWebsite.iframe.classList.add('main'); coWebsite.icon.style.display = "none"; } else { coWebsite.iframe.classList.remove('main'); coWebsite.icon.style.display = "flex"; } if (newPosition === 1) { coWebsite.iframe.classList.add('sub-main'); } else { coWebsite.iframe.classList.remove('sub-main'); } if (newPosition >= 2) { coWebsite.iframe.classList.add('thumbnail'); } else { coWebsite.iframe.classList.remove('thumbnail'); } coWebsite.position = newPosition; if (oldSlot && !this.getCoWebsiteByPosition(oldSlot.position)) { oldSlot.container.style.display = 'none'; } newSlot.container.style.display = 'block'; coWebsite.iframe.classList.remove('pixel'); this.resizeAllIframes(); } private moveLeftPreviousCoWebsite(coWebsite: CoWebsite, newPosition: number) { const nextCoWebsite = this.getCoWebsiteByPosition(coWebsite.position + 1); this.moveCoWebsite(coWebsite, newPosition); if (nextCoWebsite) { this.moveLeftPreviousCoWebsite(nextCoWebsite, nextCoWebsite.position - 1); } } private moveRightPreviousCoWebsite(coWebsite: CoWebsite, newPosition: number) { if (newPosition >= 5) { return; } const currentCoWebsite = this.getCoWebsiteByPosition(newPosition); this.moveCoWebsite(coWebsite, newPosition); if (newPosition === 4 || !currentCoWebsite || currentCoWebsite.iframe.id === coWebsite.iframe.id ) { return; } if (!currentCoWebsite) { return; } this.moveRightPreviousCoWebsite(currentCoWebsite, currentCoWebsite.position + 1); } private removeCoWebsiteFromStack(coWebsite: CoWebsite) { this.coWebsites = this.coWebsites.filter( (coWebsiteToRemove: CoWebsite) => coWebsiteToRemove.iframe.id !== coWebsite.iframe.id ); if (this.coWebsites.length < 1) { this.closeMain(); } if (coWebsite.position > 0) { const slot = this.getSlotByPosition(coWebsite.position); if (slot) { slot.container.style.display = 'none'; } } const previousCoWebsite = this.coWebsites.find((coWebsiteToCheck: CoWebsite) => coWebsite.position + 1 === coWebsiteToCheck.position ); if (previousCoWebsite) { this.moveLeftPreviousCoWebsite(previousCoWebsite, coWebsite.position); } coWebsite.icon.remove(); coWebsite.iframe.remove(); } public searchJitsi(): CoWebsite|undefined { return this.coWebsites.find((coWebsite : CoWebsite) => coWebsite.iframe.id.toLowerCase().includes('jitsi') ); } private generateCoWebsiteIcon(iframe: HTMLIFrameElement): HTMLDivElement { const icon = document.createElement("div"); icon.id = "cowebsite-icon-" + iframe.id; icon.style.display = "none"; const iconImage = document.createElement("img"); iconImage.src = `https://www.google.com/s2/favicons?sz=64&domain_url=${iframe.src}`; const url = new URL(iframe.src); iconImage.alt = url.hostname; icon.appendChild(iconImage); return icon; } public loadCoWebsite( url: string, base: string, allowApi?: boolean, allowPolicy?: string, widthPercent?: number, position?: number ): Promise { return this.addCoWebsite((iframeBuffer) => { const iframe = document.createElement("iframe"); iframe.src = new URL(url, base).toString() if (allowPolicy) { iframe.allow = allowPolicy; } if (allowApi) { iframeListener.registerIframe(iframe); } iframeBuffer.appendChild(iframe); return iframe; }, widthPercent, position); } public async addCoWebsite( callback: (iframeBuffer: HTMLDivElement) => PromiseLike|HTMLIFrameElement, widthPercent?: number, position?: number ): Promise { return new Promise((resolve, reject) => { if (this.coWebsites.length < 1) { this.loadMain(); } else if (this.coWebsites.length === 5) { throw new Error('Too many we') } Promise.resolve(callback(this.cowebsiteBufferDom)).then(iframe =>{ iframe?.classList.add("pixel"); if (!iframe.id) { do { iframe.id = "cowebsite-iframe-" + (Math.random() + 1).toString(36).substring(7); } while (this.getCoWebsiteById(iframe.id)); } const onloadPromise = new Promise((resolve) => { iframe.onload = () => resolve(); }); const icon = this.generateCoWebsiteIcon(iframe); const coWebsite = { iframe, icon, position: position ?? this.coWebsites.length, }; // Iframe management on mobile icon.addEventListener("click", () => { if (this.isSmallScreen()) { this.moveRightPreviousCoWebsite(coWebsite, 0); } }); this.coWebsites.push(coWebsite); this.cowebsiteSubIconsDom.appendChild(icon); const onTimeoutPromise = new Promise((resolve) => { setTimeout(() => resolve(), 2000); }); this.currentOperationPromise = this.currentOperationPromise .then(() => Promise.race([onloadPromise, onTimeoutPromise])) .then(() => { if (coWebsite.position === 0) { this.openMain(); if (widthPercent) { this.widthPercent = widthPercent; } setTimeout(() => { this.fire(); position !== undefined ? this.moveRightPreviousCoWebsite(coWebsite, coWebsite.position) : this.moveCoWebsite(coWebsite, coWebsite.position); }, animationTime); } else { position !== undefined ? this.moveRightPreviousCoWebsite(coWebsite, coWebsite.position) : this.moveCoWebsite(coWebsite, coWebsite.position); } return resolve(coWebsite); }) .catch((err) => { console.error("Error loadCoWebsite => ", err); this.removeCoWebsiteFromStack(coWebsite); return reject(); }); }); }); } public closeCoWebsite(coWebsite: CoWebsite): Promise { this.currentOperationPromise = this.currentOperationPromise.then( () => new Promise((resolve) => { if (this.coWebsites.length === 1) { if (this.openedMain === iframeStates.closed) resolve(); //this method may be called twice, in case of iframe error for example this.closeMain(); this.fire(); } if (coWebsite) { iframeListener.unregisterIframe(coWebsite.iframe); } this.removeCoWebsiteFromStack(coWebsite); resolve(); }) ); return this.currentOperationPromise; } public closeJitsi() { const jitsi = this.searchJitsi(); if (jitsi) { this.closeCoWebsite(jitsi); } } public closeCoWebsites(): Promise { this.currentOperationPromise = this.currentOperationPromise .then(() => { this.coWebsites.forEach((coWebsite: CoWebsite) => { this.closeCoWebsite(coWebsite); }); }); return this.currentOperationPromise; } public getGameSize(): { width: number; height: number } { if (this.openedMain !== iframeStates.opened) { return { width: window.innerWidth, height: window.innerHeight, }; } if (!this.verticalMode) { return { width: window.innerWidth - this.width, height: window.innerHeight, }; } else { return { width: window.innerWidth, height: window.innerHeight - this.height, }; } } private fire(): void { this._onResize.next(); waScaleManager.applyNewSize(); } private fullscreen(): void { if (this.isFullScreen) { this.resetStyleMain(); this.fire(); //we don't trigger a resize of the phaser game since it won't be visible anyway. HtmlUtils.getElementByIdOrFail(cowebsiteOpenFullScreenImageId).style.display = "inline"; HtmlUtils.getElementByIdOrFail(cowebsiteCloseFullScreenImageId).style.display = "none"; } else { this.verticalMode ? (this.height = window.innerHeight) : (this.width = window.innerWidth); //we don't trigger a resize of the phaser game since it won't be visible anyway. HtmlUtils.getElementByIdOrFail(cowebsiteOpenFullScreenImageId).style.display = "none"; HtmlUtils.getElementByIdOrFail(cowebsiteCloseFullScreenImageId).style.display = "inline"; } } } export const coWebsiteManager = new CoWebsiteManager();