import { AtomLoader } from "@web-atoms/core/dist/core/AtomLoader"; import { AtomUri } from "@web-atoms/core/dist/core/AtomUri"; import sleep from "@web-atoms/core/dist/core/sleep"; import XNode from "@web-atoms/core/dist/core/XNode"; import { NavigationService } from "@web-atoms/core/dist/services/NavigationService"; import { AtomWindowViewModel } from "@web-atoms/core/dist/view-model/AtomWindowViewModel"; import { AtomControl } from "@web-atoms/core/dist/web/controls/AtomControl"; import PopupService, { IDialogOptions, PopupWindow } from "@web-atoms/core/dist/web/services/PopupService"; import PageNavigator from "../PageNavigator"; import { StringHelper } from "@web-atoms/core/dist/core/StringHelper"; import { BindableProperty } from "@web-atoms/core/dist/core/BindableProperty"; import { AtomDisposableList } from "@web-atoms/core/dist/core/AtomDisposableList"; import Bind from "@web-atoms/core/dist/core/Bind"; import { CancelToken, IDisposable } from "@web-atoms/core/dist/core/types"; import { AncestorEnumerator, ChildEnumerator } from "@web-atoms/core/dist/web/core/AtomUI"; import { displayRouteSymbol, routeSymbol } from "@web-atoms/core/dist/core/Command"; import Route from "@web-atoms/core/dist/core/Route"; import "./MobileApp.global.css"; export function PullToRefresh() { return
; } export class Drawer extends AtomControl { // tslint:disable-next-line: no-empty protected init(): any { } protected preCreate(): void { this.element.dataset.drawerPage = "drawer-page"; this.bindEvent(this.element, "click", (e: Event) => { const target = e.target as HTMLInputElement; if (e.defaultPrevented) { return; } if (/input/i.test(target.tagName) && !/button|submit/i.test(target.type)) { return; } this.closeDrawer(); }); this.runAfterInit(() => this.app.runAsync(() => this.init?.())); } protected closeDrawer() { const ce = new CustomEvent("closeDrawer", { bubbles: true }); this.element.dispatchEvent(ce); } } delete (Drawer.prototype as any).init; let historyBackCalled = false; export class BasePage extends AtomControl { public close: (result) => void; public cancel: (error?) => void; @BindableProperty public closeWarning: string; @BindableProperty public title?: string; @BindableProperty public titleRenderer: () => XNode; @BindableProperty public iconRenderer: () => XNode; @BindableProperty public actionRenderer: () => XNode; @BindableProperty public footerRenderer: () => XNode; @BindableProperty public actionBarRenderer: () => XNode; @BindableProperty public headerRenderer: () => XNode; @BindableProperty public pullToRefreshRenderer: () => XNode; public iconClass: any; public keep = false; /** * If set to true, you must set `autofocus` attribute * to enable focus when page is visible. */ public disableAutoFocus = false; public get route() { return this.routeUrl; } public set route(url: string) { if(!url) { return; } if (history.state?.url === url) { return; } url = Route.encodeUrl(url); if (this.routeUrl === url) { return; } this.routeUrl = url; // we will unregister previous disposable... this.routeDisposable?.dispose(); let last = void 0; // this is to prevent unloading of the page // previous page exists without the route and // new page has set the current route and its // still loading setTimeout(() => last = url, 100); const state = { url }; history.pushState(state, "", url); const eventRegistration = (e: PopStateEvent) => { if (historyBackCalled) { return; } if (e.state?.url === last) { this.cancel(); } }; window.addEventListener("popstate", eventRegistration); const d = { dispose() { window.removeEventListener("popstate", eventRegistration); if (history.state?.url === state.url) { historyBackCalled = true; history.back(); setTimeout(() => historyBackCalled = false, 500); } } }; this.routeDisposable = this.registerDisposable(d); } private routeUrl: string; private routeDisposable: IDisposable; private viewModelTitle: string; private initialized: boolean; private contentElement: HTMLElement; private pullToRefreshElement: HTMLElement; private pullToRefreshDisposable: IDisposable; private scrollTop: number; public get hideToolbar() { return /true/i.test(this.element?.getAttribute("data-hide-toolbar")); } public set hideToolbar(v: boolean) { if (v) { this.element.setAttribute("data-hide-toolbar", "true"); } else { this.element.removeAttribute("data-hide-toolbar"); } } public get hideDrawer() { return /true/i.test(this.element?.getAttribute("data-hide-drawer")); } public set hideDrawer(v: boolean) { if (v) { this.element.setAttribute("data-hide-drawer", "true"); } else { this.element.removeAttribute("data-hide-drawer"); } } protected readonly cancelToken: CancelToken; public async requestCancel() { if (this.closeWarning) { if (!await PopupService.confirm({ message: this.closeWarning })) { return; } } this.cancel(); } public onPropertyChanged(name) { super.onPropertyChanged(name); switch (name) { case "footerRenderer": this.recreate(name, "footer"); break; case "iconRenderer": this.recreate(name, "icon"); break; case "titleRenderer": this.recreate(name, "title"); break; case "actionRenderer": this.recreate(name, "action"); break; case "headerRenderer": this.recreate(name, "header"); break; case "pullToRefreshRenderer": this.pullToRefreshElement = this.recreate(name, "pull-to-refresh"); this.pullToRefreshElement?.remove(); this.enablePullToRefreshEvents(); break; case "actionBarRenderer": this.recreate(name, "action-bar"); break; } } // tslint:disable-next-line: no-empty protected init(): any { } /** * This is because if someone changes renderer, entire content will * vanish, so we need to update allow update of only content element * @returns */ protected rendererChanged() { for (const content of ChildEnumerator.where(this.element, ({ dataset: { pageElement } }) => !pageElement || pageElement === "content")) { this.dispose(content); content.remove(); } const r = this.renderer; if (!r) { return; } delete this.render; this.render(r); } protected recreate(renderer, name): HTMLElement { const node = this[renderer]?.() ?? undefined; for (const e of ChildEnumerator.enumerate(this.element)) { if (e.dataset.pageElement === name) { this.dispose(e); e.remove(); break; } } if (node) { const na = node.attributes ??= {}; na["data-page-element"] = name; super.render(
{node}
); return this.element.querySelector(`[data-page-element="${name}"]`); } return null; } protected preCreate(): void { this.element.dataset.basePage = "base-page"; this.iconClass = ""; this.viewModelTitle = null; const c = new CancelToken(); // @ts-expect-error this.cancelToken = c; this.registerDisposable({ dispose() { c.cancel(); } }); this.runAfterInit(() => { if (!this.element) { return; } const anyAutofocus = this.element.querySelector(`*[autofocus]`) as HTMLElement; if (!anyAutofocus) { if (this.disableAutoFocus) { return; } const windowContent = this.element.querySelector(`[data-page-element="content"]`); if (windowContent) { const firstInput = windowContent.querySelector("input,textarea") as HTMLInputElement; if (firstInput) { firstInput.focus(); return; } } return; } anyAutofocus.focus(); }); setTimeout((p) => { p.dataset.pageState = "ready"; }, 1, this.element); this.titleRenderer = () => this.viewModelTitle || this.title)} />; this.iconRenderer = () => this.iconClass)} eventClick={(e1) => this.dispatchIconClickEvent(e1)} />; this.actionBarRenderer = () =>
; this.runAfterInit(() => { // we will not keep in the dom // this is to prevent any heavy animation classes slowing down performance this.pullToRefreshElement?.remove?.(); this.initialized = true; this.enablePullToRefreshEvents(); try { document.body.dispatchEvent(new CustomEvent("pageNavigated", { detail: { title: this.title, route: this.route || Object.getPrototypeOf(this)?.constructor?.name }, bubbles: true, cancelable: true } )); } catch {} }); } protected render(node: XNode, e?: any, creator?: any): void { if (e || node?.attributes?.["data-page-element"]) { super.render(node, e, creator); return; } this.render = super.render; const na = (node.attributes ??= {}); let extracted = {}; if (!na["data-page-element"]) { na["data-page-element"] = "content"; extracted = this.extractControlProperties(node); } super.render(
this.viewModel.title)} {...extracted}> {node}
, e, creator); this.contentElement = this.element.querySelector("[data-page-element='content']"); if (document.body.hasAttribute("ios-keyboard")) { return; } setTimeout(() => { // this.contentElement.scrollTo(0, 0); this.contentElement.scrollTop = 0; this.contentElement.scrollLeft = 0; }, 100); } protected enablePullToRefreshEvents() { const e = this.pullToRefreshElement; if (!e) { this.pullToRefreshDisposable?.dispose(); this.pullToRefreshDisposable = void 0; return e; } if (this.pullToRefreshDisposable) { return; } let elementAdded = false; const touchStart = (te: Event) => { if (!this.pullToRefreshElement) { this.pullToRefreshDisposable?.dispose(); this.pullToRefreshDisposable = null; return; } if (this.contentElement.scrollTop > 0) { return; } const isMouseEvent = te.type === "mousedown"; const moveEventName = isMouseEvent ? "mousemove" : "touchmove"; const endEventName = isMouseEvent ? "mouseup" : "touchend"; const startY = isMouseEvent ? (te as MouseEvent).screenY : (te as TouchEvent).touches[0].screenY; this.pullToRefreshElement.dataset.mode = "down"; if (isMouseEvent) { te.stopPropagation(); te.preventDefault(); } const d = new AtomDisposableList(); d.add(() => { this.contentElement.style.removeProperty("touch-action"); }); d.add(this.bindEvent(this.contentElement, moveEventName, (me: Event) => { const screenY = isMouseEvent ? (me as MouseEvent).screenY : (me as TouchEvent).touches[0].screenY; const diffX = Math.min(75, screenY - startY); if (diffX > 5) { if (!elementAdded) { this.contentElement.style.touchAction = "none"; elementAdded = true; this.element.appendChild(this.pullToRefreshElement); } } else { return; } this.contentElement.style.transform = `translateY(${diffX}px)`; if (diffX > 50) { this.pullToRefreshElement.dataset.mode = "up"; } else { this.pullToRefreshElement.dataset.mode = "down"; } }, null, { passive: true })); d.add(this.bindEvent(this.contentElement, endEventName, (ue: MouseEvent) => { ue.stopPropagation(); if (isMouseEvent) { ue.stopImmediatePropagation(); ue.preventDefault(); } d.dispose(); const done = () => { delete this.pullToRefreshElement.dataset.mode; this.contentElement.style.transform = ``; this.pullToRefreshElement.style.transform = ""; this.pullToRefreshElement.remove(); elementAdded = false; }; const diffX = ue.screenY - startY; if (diffX <= 50) { done(); return; } const ce = new CustomEvent("reloadPage", { detail: this, bubbles: true, cancelable: true }); this.contentElement.dispatchEvent(ce); if (ce.defaultPrevented) { done(); return; } this.pullToRefreshElement.dataset.mode = "loading"; const promise = (ce as any).promise as PromiseLike; if (!promise) { done(); return; } promise.then(done, done); }, null, { passive: !isMouseEvent })); }; const ed = new AtomDisposableList(); ed.add(this.bindEvent(this.contentElement, "mousedown", touchStart)); ed.add(this.bindEvent(this.contentElement, "touchstart", touchStart, null, { passive: true })); this.pullToRefreshDisposable = ed; } protected dispatchIconClickEvent(e: Event) { const ce = new CustomEvent("iconClick", { bubbles: true }); e.target.dispatchEvent(ce); } protected hide() { const { element } = this; if (!element) { return; } element.setAttribute("data-page-state", "hidden"); if (this.keep) { setTimeout(() => element.setAttribute("data-page-removed", "true"), 400); return; } element._logicalParent = element.parentElement; this.scrollTop = this.contentElement?.scrollTop; setTimeout(() => { element?.remove(); }, 400); } protected show() { if (this.keep) { this.element.removeAttribute("data-page-removed"); } else { this.element._logicalParent.appendChild(this.element); } setTimeout(() => { if (this.scrollTop) { this.contentElement.scrollTop = this.scrollTop; } this.element.setAttribute("data-page-state", "ready"); }, 1); } } delete (BasePage.prototype as any).init; export class ContentPage extends BasePage { public parameters: T; public close: (result: TResult) => any; } export type InputOf = T extends ContentPage ? T : any; export type OutputOf = T extends ContentPage ? T : any; export class TabbedPage extends BasePage { } export default class MobileApp extends AtomControl { public static current: MobileApp; public static drawer = XNode.prepare("drawer", true, true); public static async pushPage( pageUri: string | any, parameters: {[key: string]: any} = {}, clearHistory: boolean = false) { const mobileApp = this.current; const page = await AtomLoader.loadClass(pageUri, parameters ?? {}, mobileApp.app); const result = await mobileApp.loadPage(page, clearHistory); return result as T; } public drawer: typeof Drawer; public hideDrawer: () => void; public pages: BasePage[]; public selectedPage: BasePage; /** * Set this to class or url to load the page when user * hits back button when there is nothing in history stack */ public defaultPage: any; private container: HTMLDivElement; public popTo(w: any) { this.app.runAsync(async () => { while (this.selectedPage !== w) { this.selectedPage.cancel(); await sleep(500); } }); } public async back() { if (this.pages.length === 0) { const drawer = this.drawer; if (drawer && !this.hideDrawer) { const drawerPage = new drawer(this.app); // (drawerPage as any).init?.()?.catch((error) => { // if (!CancelToken.isCancelled(error)) { // // tslint:disable-next-line: no-console // console.error(error); // } // }); const modalClose = (ce: Event) => { let start = ce.target as HTMLElement; const de = drawerPage.element; while (start) { if (start === de) { return; } start = start.parentElement; } ce.preventDefault(); ce.stopImmediatePropagation?.(); ce.target.dispatchEvent(new CustomEvent("closeDrawer", { bubbles: true })); }; // const da = drawerNode.attributes ??= {}; const dispatchCloseDrawer = (de: Event) => { if (de.defaultPrevented) { return; } const target = de.target as HTMLElement; if(AncestorEnumerator.find(target, (x) => x === drawerPage.element)) { return; } de.target.dispatchEvent(new CustomEvent("closeDrawer", { bubbles: true })); }; this.element.appendChild(drawerPage.element); setTimeout(() => { this.element.dataset.drawer = "visible"; drawerPage.bindEvent(this.element, "click", dispatchCloseDrawer); drawerPage.bindEvent(document.body, "click", modalClose, null, true); }, 10); this.hideDrawer = () => { this.element.dataset.drawer = ""; setTimeout(() => { const de = drawerPage.element; drawerPage.dispose(); de.remove(); }, 400); this.hideDrawer = undefined; }; return false; } return true; } this.selectedPage.cancel("cancelled"); return false; } protected async init() { } protected preCreate(): void { MobileApp.current = this; // tslint:disable-next-line: ban-types window.addEventListener("backButton", (ce: CustomEvent) => { const { detail } = ce; ce.preventDefault(); this.app.runAsync(async () => { if (await this.back()) { detail(); } }); }); // disable top level scroll // document.body.style.overflow = "hidden"; this.drawer = null; this.element.dataset.pageApp = "page-app"; const container = this.container = document.createElement("div"); container.dataset.container = "true"; this.element.appendChild(container); this.pages = []; this.selectedPage = null; this.bindEvent(this.element, "iconClick", (e) => this.back()); this.bindEvent(this.element, "closeDrawer", (e) => { this.hideDrawer?.(); }); const navigationService = this.app.resolve(NavigationService); navigationService.registerNavigationHook( (uri, { target, clearHistory }) => { if (/^(app|root)$/.test(target)) { return this.loadPageForReturn(uri, clearHistory); } } ); this.runAfterInit(() => this.app.runAsync(() => this.init())); } protected async loadPageForReturn(url: AtomUri, clearHistory: boolean): Promise { this.defaultPage ??= url; const page = await AtomLoader.loadControl(url, this.app); if (url.query && url.query.title) { page.title ||= url.query.title.toString(); } const p = await this.loadPage(page, clearHistory); try { return await (p as any); } catch (ex) { // this will prevent warning in chrome for unhandled exception if ((ex.message ? ex.message : ex) === "cancelled") { // tslint:disable-next-line: no-console console.warn(ex); return; } throw ex; } } protected async loadPage(page: BasePage, clearHistory: boolean) { page.title ||= StringHelper.fromPascalToTitleCase(Object.getPrototypeOf(page).constructor.name); const { parameters } = page as any; const route = parameters?.[displayRouteSymbol]; if (route) { page.route = route; } const selectedPage = this.selectedPage; if (selectedPage) { (selectedPage as any).hide(); this.pages.add(selectedPage); } const hasPages = !clearHistory && this.pages.length; if (clearHistory) { for (const iterator of this.pages) { const e = iterator.element; iterator.dispose(); e.remove(); } this.pages.length = 0; this.selectedPage = null; } if (!hasPages) { page.element.dataset.pageState = "ready"; page.iconClass = "fas fa-bars"; } else { page.iconClass = "fas fa-arrow-left"; } this.container.appendChild(page.element); this.selectedPage = page; const vm = page.viewModel as AtomWindowViewModel; const element = page.element; return new Promise((resolve, reject) => { const closeFactory = (callback, result?) => (r) => { if (clearHistory) { this.app.runAsync(() => this.loadPageForReturn(this.defaultPage, true)); return; } // element.dataset.pageState = ""; element.removeAttribute("data-page-state"); const last = this.pages.pop(); (last as any).show(); setTimeout(() => { page.dispose(); element.remove(); callback(r ?? result); this.selectedPage = last; }, 300); }; const cancel = closeFactory(reject, "cancelled") as any; const close = closeFactory(resolve); if (vm) { vm.cancel = cancel; vm.close = close; } page.cancel = cancel; page.close = close; }); } } export const isMobileView = /Android|iPhone/i.test(navigator.userAgent) || (window.visualViewport.width < 500) || (window as any).forceMobileLayout; const isPopupPage = Symbol("isPopupPage"); class PopupWindowEx extends PopupWindow { static [isPopupPage] = true; public static dialogOptions: IDialogOptions; } const root = (isMobileView ? ContentPage : PopupWindowEx) as typeof AtomControl; export class PopupWindowPage extends (root as any as typeof ContentPage) { public parameters: TIn; public static dialogOptions: IDialogOptions; public close: (r: TOut) => void; public cancel: (error?: any) => void; public title: string; public headerRenderer: () => XNode; public footerRenderer: () => XNode; public titleRenderer: () => XNode; } PageNavigator.pushPageForResult = (page, parameters, clearHistory) => { if (!isMobileView && page[isPopupPage] ) { const popupPage = page as any as typeof PopupWindowEx; const options: IDialogOptions = { ... popupPage.dialogOptions ?? {}, parameters: { parameters } }; // we make window modal if not set... options.modal ??= true; if (options.modal) { return (page as any as typeof PopupWindow).showModal(options); } return (page as any as typeof PopupWindow).showWindow(options); } return MobileApp.pushPage(page, parameters, clearHistory) };