├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── scripts ├── build.js ├── rollup.config.js └── util.js ├── src ├── AppSwitcher │ ├── cssText.ts │ └── index.ts ├── Applet │ ├── Base.ts │ ├── EventTarget.ts │ ├── LifeCycle.ts │ ├── Prefetch.ts │ ├── State.ts │ ├── View.ts │ ├── captureShot │ │ └── index.ts │ ├── index.ts │ ├── inject │ │ ├── cssVar.ts │ │ ├── index.ts │ │ ├── openWindow.ts │ │ ├── smartSetTimeout.ts │ │ └── tapHighlight.ts │ └── manifestProcess.ts ├── AppletControls │ ├── Base.ts │ ├── EventTarget.ts │ ├── State.ts │ ├── View.ts │ ├── cssText.ts │ └── index.ts ├── Application │ ├── Base.ts │ ├── State.ts │ ├── index.ts │ └── provider.ts ├── Define │ ├── DefineApplet.ts │ ├── DefineApplication.ts │ ├── env.ts │ ├── index.ts │ ├── init.ts │ ├── prepare.ts │ ├── preset.ts │ └── slot.ts ├── Event │ └── index.ts ├── Modality │ ├── Base.ts │ ├── EventTarget.ts │ ├── State.ts │ ├── View.ts │ ├── cssText.ts │ └── index.ts ├── Sandbox │ └── index.ts ├── Scroll │ ├── index.ts │ ├── polyfill.ts │ └── types.module.ts ├── Segue │ ├── Animation.ts │ ├── Base.ts │ ├── History.ts │ ├── State.ts │ ├── Switch.ts │ ├── index.ts │ └── preset │ │ ├── fade.ts │ │ ├── flip.ts │ │ ├── grow.ts │ │ ├── popup.ts │ │ ├── slide-native.ts │ │ ├── slide.ts │ │ └── zoom.ts ├── Slide │ ├── Base.ts │ ├── EventTarget.ts │ ├── State.ts │ ├── View.ts │ ├── cssText.ts │ └── index.ts ├── global.d.ts ├── index.ts ├── launcher.ts ├── lib │ ├── cssText │ │ ├── coveredCSSText.ts │ │ ├── fullscreenBaseCSSText.ts │ │ └── globalCSSText.ts │ ├── typeError │ │ ├── errorCode.ts │ │ ├── index.ts │ │ └── type.d.ts │ ├── util │ │ ├── FrameElapsedDuration.ts │ │ ├── FrameQueue.ts │ │ ├── getCSSUnits.ts │ │ ├── getIOSVersion.ts │ │ ├── index.ts │ │ ├── requestAnimationFrame.ts │ │ ├── requestIdleCallback.ts │ │ ├── setInterval.ts │ │ ├── setTimeout.ts │ │ ├── sleep.ts │ │ ├── testHasScrolling.ts │ │ ├── testHasSlotBug.ts │ │ ├── testHasSmoothScrolling.ts │ │ ├── testHasSmoothSnapScrolling.ts │ │ └── testHasSnapReset.ts │ ├── wc │ │ ├── needsPolyfill.ts │ │ └── shim │ │ │ └── native-shim.ts │ └── webAnimations │ │ ├── clear.ts │ │ ├── ease.ts │ │ ├── finish.ts │ │ ├── load.ts │ │ ├── polyfill.ts │ │ └── reset.ts ├── pullToRefresh │ ├── cssText.ts │ └── index.ts └── types.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | charset = utf-8 12 | indent_style = space 13 | indent_size = 4 14 | 15 | # Matches the exact files either *.json or *.yml 16 | [*.{json,yml}] 17 | indent_style = space 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules 3 | # don't lint build output (make sure it's set to your correct build folder name) 4 | app 5 | scripts 6 | .eslintrc.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: [ 5 | '@typescript-eslint', 6 | ], 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | *~ 4 | *.swp 5 | *.log 6 | 7 | .DS_Store 8 | .temp/ 9 | 10 | build/ 11 | app 12 | node_modules/ 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | ./src 2 | ./scripts -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "backout", 4 | "filedir", 5 | "funs", 6 | "iframe", 7 | "ioing", 8 | "jsnext", 9 | "onwarn", 10 | "oversaturated", 11 | "prelink", 12 | "shadowroot", 13 | "subc", 14 | "subviews", 15 | "Viewports", 16 | "webcomponents", 17 | "webcomponentsjs" 18 | ] 19 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 lien 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduce 2 | 3 | A pure front-end container 4 | 5 | Bring interactive experiences comparable to Native Apps. 6 | 7 | https://lath.dev 8 | 9 | # Install: 10 | 11 | ```bash 12 | $ npm i lath --save 13 | ``` 14 | 15 | # Example 16 | https://github.com/ioing/lath-vue-example 17 | 18 | # Use 19 | 20 | ```html 21 | 22 | 23 | 24 | 25 |

26 | I am FrameworksApplets 27 |

28 |
29 | 30 |

31 | I am home 32 |

33 |
34 | 35 |

36 | I am pageA 37 |

38 |
39 | 40 | 41 | ``` 42 | 43 | ```ts 44 | import { createApplication } from 'lath' 45 | /** 46 | * "home": moduleName 47 | * "root": target element id 48 | */ 49 | createApplication({ 50 | applets: { 51 | frameworks: {...}, // frameworks module config 52 | home: {...} // normal module config 53 | pageA: {...} // normal module config 54 | pageC: { 55 | config: { 56 | source: { 57 | src: '/c.html' 58 | } 59 | }, 60 | ... 61 | } // normal module config 62 | } 63 | }).then(( application ) => { 64 | console.log(application) 65 | }) 66 | ``` 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lath", 3 | "version": "0.12.40", 4 | "description": "Seamless connection of pages.", 5 | "keywords": [ 6 | "lath", 7 | "portal", 8 | "web-portal", 9 | "pages-link", 10 | "linkup", 11 | "seamless", 12 | "pushWindow", 13 | "slide page", 14 | "page animate", 15 | "iframe", 16 | "SPA", 17 | "PWA", 18 | "Application", 19 | "webapp", 20 | "Web Application", 21 | "Web Components", 22 | "Single Page Web Application", 23 | "The Progressive JavaScript Framework" 24 | ], 25 | "main": "app/launcher.js", 26 | "module": "app/launcher.js", 27 | "jsnext:main": "app/launcher.js", 28 | "types": "app/typings/launcher.d.ts", 29 | "files": [ 30 | "app/" 31 | ], 32 | "scripts": { 33 | "clean": "rimraf ./app", 34 | "lint": "eslint --cache --ext .ts ./src", 35 | "build": "node scripts/build.js", 36 | "debug": "node --inspect-brk app/index.js" 37 | }, 38 | "engines": { 39 | "install-node": ">=14.18.3", 40 | "npm": ">=7.0.0" 41 | }, 42 | "dependencies": { 43 | "@webcomponents/custom-elements": "^1.5.0", 44 | "@webcomponents/webcomponentsjs": "^2.6.0", 45 | "html2canvas": "^1.4.1", 46 | "scroll-polyfill": "^1.0.1", 47 | "web-animations-js": "^2.3.2" 48 | }, 49 | "devDependencies": { 50 | "@types/node": "^10.12.12", 51 | "@types/web-animations-js": "^2.2.13", 52 | "@typescript-eslint/eslint-plugin": "^5.11.0", 53 | "@typescript-eslint/parser": "^5.11.0", 54 | "eslint": "^8.11.0", 55 | "rimraf": "^3.0.2", 56 | "rollup": "^2.57.0", 57 | "rollup-plugin-typescript2": "^0.31.2", 58 | "ts-loader": "^9.2.6", 59 | "tslib": "^2.3.1", 60 | "uglify-js": "^3.17.4", 61 | "typescript": "^4.6.2" 62 | }, 63 | "author": "lien", 64 | "license": "MIT" 65 | } -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const sh = require('./util').sh 2 | const fs = require('fs') 3 | const path = require('path') 4 | const filePath = path.resolve('./app') 5 | 6 | function uglifyJs(filePath) { 7 | fs.readdir(filePath, function (err, files) { 8 | if (err) { 9 | console.warn(err) 10 | } else { 11 | files.forEach(function (filename) { 12 | const filedir = path.join(filePath, filename) 13 | fs.stat(filedir, function (error, stats) { 14 | const isFile = stats.isFile() 15 | const isDir = stats.isDirectory() 16 | if (isFile) { 17 | sh(`npx uglify-js ${filedir} \ 18 | -c hoist_funs,hoist_vars \ 19 | -m \ 20 | -o ${filedir}`) 21 | } 22 | if (isDir && filename.indexOf('typings') !== 0) { 23 | uglifyJs(filedir) 24 | } 25 | }) 26 | }) 27 | } 28 | }) 29 | } 30 | 31 | async function build() { 32 | await sh('npm run clean && npx rollup -c scripts/rollup.config.js') 33 | uglifyJs(filePath) 34 | } 35 | 36 | build() 37 | -------------------------------------------------------------------------------- /scripts/rollup.config.js: -------------------------------------------------------------------------------- 1 | const typescript = require('rollup-plugin-typescript2') 2 | const pkg = require('../package.json') 3 | const banner = `/*! 4 | * ${pkg.name} v${pkg.version} 5 | * (c) ${new Date().getFullYear()} @ioing 6 | * Released under the ${pkg.license} License. 7 | */` 8 | 9 | module.exports = { 10 | input: ['./src/launcher.ts'], 11 | output: { 12 | banner, 13 | dir: './app', 14 | format: 'es' 15 | }, 16 | plugins: [ 17 | typescript({ 18 | tsconfigOverride: { 19 | compilerOptions: { 20 | rootDir: "./src", 21 | outDir: "./app/", 22 | declaration: true, 23 | declarationDir: './app/typings/', 24 | module: 'esnext', 25 | target: 'es6', 26 | sourceMap: true 27 | } 28 | }, 29 | useTsconfigDeclarationDir: true 30 | }) 31 | ], 32 | external: ['html2canvas', '@webcomponents/webcomponentsjs', 'web-animations-js/web-animations.min.js'], 33 | onwarn: function (warning) { 34 | if (warning.code === 'THIS_IS_UNDEFINED') { 35 | return 36 | } 37 | console.error(warning.message) 38 | } 39 | } -------------------------------------------------------------------------------- /scripts/util.js: -------------------------------------------------------------------------------- 1 | const childProcess = require('child_process') 2 | function sh (shell) { 3 | return new Promise((resolve, reject) => { 4 | childProcess.exec(shell, (err, stdout, stderr) => { 5 | if (err) return reject(err) 6 | process.stdout.write(stdout) 7 | process.stderr.write(stderr) 8 | resolve() 9 | }) 10 | }) 11 | } 12 | 13 | exports.sh = sh 14 | -------------------------------------------------------------------------------- /src/AppSwitcher/cssText.ts: -------------------------------------------------------------------------------- 1 | export const switcherCSSText = ` 2 | box-sizing: border-box; 3 | position: fixed; 4 | top: 0px; 5 | right: 0px; 6 | bottom: 0px; 7 | left: 0px; 8 | z-index: 102; 9 | width: 100%; 10 | height: 100%; 11 | background: rgb(70 70 70 / 50%); 12 | opacity: 0; 13 | transform: translate3d(0, 0, 0); 14 | backface-visibility: hidden; 15 | transition: opacity .4s ease; 16 | backdrop-filter: blur(20px) saturate(180%); 17 | ` 18 | export const snapWrapperCSSText = ` 19 | display: grid; 20 | box-sizing: border-box; 21 | position: fixed; 22 | top: 0px; 23 | right: 0px; 24 | bottom: 0px; 25 | left: 0px; 26 | z-index: 2; 27 | width: 100%; 28 | height: 100%; 29 | padding: 20px 10px; 30 | padding-top: calc(20px + constant(safe-area-inset-top)); 31 | padding-top: calc(20px + env(safe-area-inset-top)); 32 | scroll-padding-top: calc(20px + constant(safe-area-inset-top)); 33 | scroll-padding-top: calc(20px + env(safe-area-inset-top)); 34 | overflow-y: hidden; 35 | scroll-behavior: smooth; 36 | transform: translate3d(0, 0, 0); 37 | backface-visibility: hidden; 38 | ` 39 | 40 | export const snapWrapper2CSSText = ` 41 | ${snapWrapperCSSText} 42 | grid-template-columns: 50% 50%; 43 | grid-template-rows: repeat(30, 40%); 44 | row-gap: 15px; 45 | ` 46 | 47 | export const snapWrapper3CSSText = ` 48 | ${snapWrapperCSSText} 49 | grid-template-columns: repeat(3, 33.33%); 50 | grid-template-rows: repeat(30, 50%); 51 | row-gap: 15px; 52 | ` 53 | 54 | export const itemImgWrapperCSSText = ` 55 | display: block; 56 | position: relative; 57 | width: 100%; 58 | height: 100%; 59 | border-radius: 16px; 60 | overflow: hidden; 61 | box-shadow: 0 3px 10px rgb(0 0 0 / 30%); 62 | ` 63 | 64 | export const itemViewCSSText = ` 65 | display: flex; 66 | flex-direction: column; 67 | box-sizing: border-box; 68 | scroll-snap-align: start; 69 | cursor: pointer; 70 | margin: 0 10px; 71 | transition: transform .4s ease; 72 | ` 73 | 74 | export const itemImgCSSText = ` 75 | display: block; 76 | width: 100%; 77 | height: 100%; 78 | border-radius: 16px; 79 | transform-origin: top left; 80 | transform: translate3d(0, 0, 0); 81 | backface-visibility: hidden; 82 | overflow: hidden; 83 | ` 84 | 85 | export const itemImgCoverCSSText = ` 86 | position: fixed; 87 | top: 0; 88 | left: 0; 89 | ${itemImgCSSText} 90 | ` 91 | 92 | export const itemInfoCSSText = ` 93 | display: flex; 94 | margin: 0 auto; 95 | width: 100%; 96 | justify-content: center; 97 | align-items: center; 98 | ` 99 | 100 | export const itemTitleCSSText = ` 101 | margin-top: 4px; 102 | font-size: 16px; 103 | color: #fff; 104 | overflow: hidden; 105 | white-space: nowrap; 106 | text-overflow: ellipsis; 107 | font-family: "SF Pro Text","Myriad Set Pro","SF Pro Icons","Apple Legacy Chevron","Helvetica","Arial",sans-serif; 108 | ` 109 | 110 | export const itemCloseBtnCSSText = ` 111 | display: grid; 112 | place-items: center; 113 | position: absolute; 114 | right: 4px; 115 | top: 4px; 116 | width: 22px; 117 | height: 22px; 118 | border-radius: 11px; 119 | background-color: #666; 120 | opacity: .7 121 | ` 122 | 123 | const itemCloseBtnXShapeCSSText = ` 124 | position: absolute; 125 | height: 2px; 126 | width: 60%; 127 | background: #fff; 128 | border-radius: 1px; 129 | ` 130 | 131 | export const itemCloseBtnX1ShapeCSSText = ` 132 | ${itemCloseBtnXShapeCSSText} 133 | transform: rotate(-45deg); 134 | ` 135 | export const itemCloseBtnX2ShapeCSSText = ` 136 | ${itemCloseBtnXShapeCSSText} 137 | transform: rotate(45deg); 138 | ` -------------------------------------------------------------------------------- /src/Applet/Base.ts: -------------------------------------------------------------------------------- 1 | import { EventProvider } from '../Event' 2 | import { Sandbox } from '../Sandbox' 3 | import manifestProcess from './manifestProcess' 4 | import { DefineApplet, Slide, Modality, AppletControls, AppletEvents, AppletAllConfig, AppletResources, AppletManifest, AppletAttachBehavior, Application, Applet } from '../types' 5 | 6 | class AppletBase extends EventProvider { 7 | public id: string 8 | public param = '' 9 | public application: Application 10 | public viewport?: HTMLElement 11 | public mountBehavior?: AppletAttachBehavior 12 | public controls?: AppletControls 13 | public contentView?: DefineApplet 14 | public contentSlot?: HTMLSlotElement 15 | public parentApplet?: Applet 16 | public sandbox: Sandbox | undefined 17 | public slide: Slide | null = null 18 | public modality: Modality | null = null 19 | public view: HTMLElement | HTMLPortalElement | HTMLIFrameElement | null = null 20 | public img: HTMLElement | null = null 21 | public snapshot: HTMLCanvasElement | null = null 22 | public snapshotTime = -1 23 | public visitTime = -1 24 | public model: AppletManifest 25 | public events: AppletEvents = { 26 | transformStart: () => undefined, 27 | transformEnd: () => undefined, 28 | boot: () => undefined, 29 | load: () => undefined, 30 | loadError: () => undefined, 31 | preload: () => undefined, 32 | destroy: () => undefined 33 | } 34 | public darkTask: Array<() => void> = [] 35 | public createTime = Date.now() 36 | public transient = false 37 | public config: AppletAllConfig = { 38 | title: '', 39 | level: 0, 40 | source: {}, 41 | prerender: [], 42 | apply: undefined, 43 | background: 'auto', 44 | free: false 45 | } 46 | public components: ((w: Window) => void)[] = [] 47 | public resources: AppletResources = { 48 | script: [], 49 | image: [], 50 | worker: [], 51 | video: [], 52 | audio: [], 53 | font: [], 54 | style: [] 55 | } 56 | get self() { 57 | return this.application.applets[this.id] || this 58 | } 59 | constructor(id: string, model: AppletManifest, application: Application) { 60 | super() 61 | this.id = id 62 | this.model = model 63 | this.application = application 64 | 65 | const { config, resources, events, components } = this.setDefaultConfig(model, id) 66 | Object.assign(this.config, config) 67 | Object.assign(this.resources, resources) 68 | Object.assign(this.events, events) 69 | if (components) { 70 | this.components = components 71 | } 72 | // preloadAnimation 73 | this.application.segue.preloadAnimation(this.self) 74 | } 75 | public setDefaultConfig = manifestProcess 76 | } 77 | 78 | export { 79 | AppletBase 80 | } 81 | -------------------------------------------------------------------------------- /src/Applet/EventTarget.ts: -------------------------------------------------------------------------------- 1 | import { AppletPrefetch } from './Prefetch' 2 | 3 | class AppletEventTarget extends AppletPrefetch { 4 | public borderHolder: HTMLElement[] = [] 5 | public attachEvent(): void { 6 | this.registerBorderTouch() 7 | this.bindOverscrollHistoryNavigation() 8 | this.registerTapStatusBarToScrollToTop() 9 | this.registerPullToRefresh() 10 | } 11 | private scrollToTop(): void { 12 | const scroller = this.getMainScroller() 13 | if (!scroller) return 14 | import('../Scroll').then(({ SmoothScroller }) => { 15 | const smoothScroller = new SmoothScroller(scroller) 16 | smoothScroller.scrollTo(0, 0) 17 | }).catch((e) => { 18 | console.warn(e) 19 | }) 20 | } 21 | 22 | private registerPullToRefresh(): void { 23 | if (!this.config.pullToRefresh) return 24 | if (!this.application.pullRefreshHolder.holder) { 25 | this.application.on('pullToRefreshAvailable', () => { 26 | this.registerPullToRefresh() 27 | }) 28 | return 29 | } 30 | const holder = this.application.pullRefreshHolder.holder 31 | const spinner = this.application.pullRefreshHolder.spinner 32 | const holdLayer = this.application.pullRefreshHolder.holdLayer 33 | const scroller = this.getMainScroller(this.config.pullToRefreshTargetScrollId) 34 | if (!scroller) return 35 | import('../pullToRefresh').then((PullRefresh) => { 36 | PullRefresh.registerPullDownEvent(this.self, scroller as HTMLElement, holder, spinner, holdLayer) 37 | }).catch((e) => { 38 | console.warn(e) 39 | }) 40 | } 41 | 42 | private registerTapStatusBarToScrollToTop(): void { 43 | if (!this.config.tapStatusBarToScrollToTop) return 44 | const topHolder = this.borderHolder[0] 45 | topHolder.addEventListener('touchend', () => { 46 | this.scrollToTop() 47 | }) 48 | } 49 | 50 | private getMainScroller(id: string | undefined = this.config.mainScrollId): HTMLElement | null | undefined { 51 | let scroller: HTMLElement | null | undefined 52 | if (this.viewType === 'shadow') { 53 | scroller = id ? (this.view?.shadowRoot?.getElementById(id) || document.getElementById(id)) : this.contentView 54 | } else if (this.sameOrigin) { 55 | const sandboxWindow = this.sandbox?.window 56 | scroller = id ? sandboxWindow?.document.getElementById(id) : sandboxWindow?.document.body 57 | } 58 | return scroller 59 | } 60 | 61 | private bindOverscrollHistoryNavigation(): void { 62 | const updateTriggerTime = (types: string[], event: TouchEvent) => { 63 | if (event.type !== 'touchmove') return 64 | if (types.includes('left') || types.includes('right') || types.includes('wipe')) { 65 | const timestamp = Date.now() 66 | const touchType = types.join(' ') 67 | this.application.overscrollHistoryNavigation.moment = timestamp 68 | this.application.overscrollHistoryNavigation.type = touchType 69 | 70 | // Continue to send the message up to the parent window. 71 | parent.postMessage({ 72 | type: 'overscrollHistoryNavigationStatusChange', 73 | data: [timestamp, touchType] 74 | }, '*') 75 | } 76 | } 77 | this.on('touchBorder', updateTriggerTime) 78 | } 79 | 80 | private addBorderPanMoveHolder(viewport: HTMLElement): HTMLDivElement[] { 81 | const threshold = this.config.borderTouchSize ?? 15 82 | const topHolder = document.createElement('div') 83 | const rightHolder = document.createElement('div') 84 | const bottomHolder = document.createElement('div') 85 | const leftHolder = document.createElement('div') 86 | const mainHolder = document.createElement('div') 87 | const baseStyle = 'position: absolute; z-index: 3;' 88 | topHolder.style.cssText = `${baseStyle} top: 0; right: 0; left: 0; height: env(safe-area-inset-top); mini-height: ${threshold}px;` 89 | rightHolder.style.cssText = `${baseStyle} top: 0; right: 0; bottom: 0; z-index: 3; width: ${threshold}px;` 90 | bottomHolder.style.cssText = `${baseStyle} right: 0; bottom: 0; left: 0; z-index: 3; height: ${threshold}px;` 91 | leftHolder.style.cssText = `${baseStyle} top: 0; bottom: 0; left: 0; z-index: 3; width: ${threshold}px;` 92 | mainHolder.style.cssText = `display: none; ${baseStyle} top: 0; right: 0; bottom: 0; left: 0; z-index: 4;` 93 | viewport.appendChild(topHolder) 94 | viewport.appendChild(rightHolder) 95 | viewport.appendChild(bottomHolder) 96 | viewport.appendChild(leftHolder) 97 | viewport.appendChild(mainHolder) 98 | 99 | return this.borderHolder = [topHolder, rightHolder, bottomHolder, leftHolder, mainHolder] 100 | } 101 | 102 | private getTouchBorderType = (event: TouchEvent): string[] => { 103 | const viewport = this.viewport as HTMLElement 104 | const contentWidth = viewport.offsetWidth 105 | const contentHeight = viewport.offsetHeight 106 | const touches = event.touches[0] 107 | const x = touches.clientX 108 | const y = touches.clientY 109 | const types: string[] = [] 110 | const threshold = this.config.borderTouchSize ?? 100 111 | if (x <= threshold) { 112 | types.push('left') 113 | } else if (x >= contentWidth - threshold) { 114 | types.push('right') 115 | } 116 | if (y <= threshold) { 117 | types.push('top') 118 | } else if (y >= contentHeight - threshold) { 119 | types.push('bottom') 120 | } 121 | return types 122 | } 123 | 124 | private registerBorderHolderTouch(): void { 125 | const [topHolder, rightHolder, bottomHolder, leftHolder, mainHolder] = this.addBorderPanMoveHolder(this.viewport as HTMLElement) 126 | const touchTypes: string[] = [] 127 | const contentBinder = mainHolder.addEventListener 128 | const contentUnBinder = mainHolder.removeEventListener 129 | const trigger = (event: TouchEvent) => { 130 | this.trigger('touchBorder', touchTypes, event) 131 | } 132 | const bindPlaceholder = (event: TouchEvent): void => { 133 | touchTypes.length = 0 134 | if (event.target === topHolder) { 135 | touchTypes.push('top') 136 | } else if (event.target === rightHolder) { 137 | touchTypes.push('right') 138 | } else if (event.target === bottomHolder) { 139 | touchTypes.push('bottom') 140 | } else if (event.target === leftHolder) { 141 | touchTypes.push('left') 142 | } 143 | mainHolder.style.display = 'block' 144 | contentBinder('touchmove', trigger, true) 145 | contentBinder('touchend', trigger, true) 146 | contentBinder('touchcancel', trigger, true) 147 | } 148 | const unbindPlaceholder = (): void => { 149 | mainHolder.style.display = 'none' 150 | contentUnBinder('touchmove', trigger, true) 151 | contentUnBinder('touchend', trigger, true) 152 | contentUnBinder('touchcancel', trigger, true) 153 | } 154 | const binder = (holder: HTMLElement): void => { 155 | const holderBinder = holder.addEventListener 156 | holderBinder('touchstart', bindPlaceholder, true) 157 | holderBinder('touchmove', bindPlaceholder, true) 158 | holderBinder('touchcancel', unbindPlaceholder, true) 159 | holderBinder('touchend', unbindPlaceholder, true) 160 | } 161 | binder(topHolder) 162 | binder(rightHolder) 163 | binder(bottomHolder) 164 | binder(leftHolder) 165 | } 166 | 167 | private registerBorderTouch(): void { 168 | if (this.sandbox && !this.sameOrigin) return this.registerBorderHolderTouch() 169 | const target = this.sandbox?.window?.document || this.viewport as HTMLElement 170 | const touchTypes: string[] = [] 171 | const trigger = ((event: TouchEvent) => { 172 | this.trigger('touchBorder', touchTypes, event) 173 | if (event.type === 'touchend' || event.type === 'touchcancel') { 174 | target.removeEventListener('touchmove', trigger, true) 175 | target.removeEventListener('touchend', trigger, true) 176 | target.removeEventListener('touchcancel', trigger, true) 177 | } 178 | }) as EventListener 179 | const listener = ((event: TouchEvent): void => { 180 | touchTypes.length = 0 181 | touchTypes.push(...this.getTouchBorderType(event)) 182 | if (this.transforming) { 183 | touchTypes.push('wipe') 184 | } 185 | if (touchTypes.length > 0) { 186 | target.addEventListener('touchmove', trigger, true) 187 | target.addEventListener('touchend', trigger, true) 188 | target.addEventListener('touchcancel', trigger, true) 189 | } 190 | }) as EventListener 191 | target.addEventListener('touchstart', listener, true) 192 | } 193 | } 194 | 195 | export { 196 | AppletEventTarget 197 | } 198 | -------------------------------------------------------------------------------- /src/Applet/LifeCycle.ts: -------------------------------------------------------------------------------- 1 | import { AppletState } from './State' 2 | import { Applet, AppletAttachBehavior } from '../types' 3 | import { setTimeout } from '../lib/util' 4 | class AppletLifeCycle extends AppletState { 5 | private mutationObserver!: MutationObserver 6 | 7 | public attach(element: HTMLElement, parentApplet?: Applet, mountBehavior?: AppletAttachBehavior): void { 8 | if (!element) return 9 | this.viewport = element 10 | this.parentApplet = parentApplet 11 | this.mountBehavior = mountBehavior 12 | } 13 | 14 | public removeContainer() { 15 | if (this.isPresetAppletsView) return 16 | this.contentView?.parentElement?.removeChild(this.contentView) 17 | this.viewport?.parentNode?.removeChild(this.viewport as HTMLElement) 18 | this.viewport = undefined 19 | this.delPresetView() 20 | } 21 | 22 | public clearContainer() { 23 | if (this.isPresetAppletsView) return 24 | this.contentView?.parentElement?.removeChild(this.contentView) 25 | if (this.viewport) { 26 | this.viewport.innerHTML = '' 27 | } 28 | this.delPresetView() 29 | } 30 | 31 | public delPresetView() { 32 | this.contentView = undefined 33 | this.application.delPresetAppletsView(this.id, true) 34 | } 35 | 36 | public setParam(param: string): Promise { 37 | if (this.param !== param) { 38 | this.param = param 39 | return this.destroy(true) 40 | } 41 | return Promise.resolve(true) 42 | } 43 | 44 | public show(): void { 45 | const viewType = this.viewType 46 | if (viewType === 'portal') { 47 | (this.view as HTMLPortalElement)?.activate() 48 | return 49 | } 50 | for (const task of this.darkTask) { 51 | task() 52 | } 53 | this.transforming = false 54 | this.darkTask = [] 55 | this.visibility = true 56 | this.visitTime = Date.now() 57 | this.trigger('show') 58 | if (viewType === 'iframe') { 59 | this.triggerWindow('applet-show') 60 | this.injectWindowVisibilityState('visible') 61 | } 62 | this.subApplet?.show() 63 | } 64 | 65 | public hide(): void { 66 | this.transforming = false 67 | this.visibility = false 68 | this.trigger('hide') 69 | if (this.viewType === 'iframe') { 70 | this.triggerWindow('applet-hidden') 71 | this.injectWindowVisibilityState('hidden') 72 | } 73 | this.subApplet?.hide() 74 | } 75 | 76 | public willShow(): void { 77 | this.transforming = true 78 | this.trigger('willShow') 79 | if (this.viewType === 'iframe') { 80 | this.triggerWindow('applet-will-show') 81 | this.injectWindowVisibilityState('willVisible') 82 | } 83 | this.subApplet?.willShow() 84 | } 85 | 86 | public willHide(): void { 87 | this.transforming = true 88 | this.trigger('willHide') 89 | if (this.viewType === 'iframe') { 90 | this.triggerWindow('applet-will-hide') 91 | this.injectWindowVisibilityState('willHidden') 92 | } 93 | this.subApplet?.willHide() 94 | } 95 | 96 | public willSegueShow(): void { 97 | this.trigger('willSegueShow') 98 | } 99 | 100 | public willSegueHide(): void { 101 | this.trigger('willSegueHide') 102 | } 103 | 104 | public destroy(reserve = false, referenceChecking = true): Promise { 105 | if (referenceChecking) { 106 | [...this.allSubAppletIds, ...(this.parentApplet?.allSubAppletIds ?? [])].forEach((subAppletId) => this.application.applets[subAppletId]?.destroy(false, false)) 107 | } 108 | return new Promise((resolve) => { 109 | if (!this.view) return resolve(true) 110 | if (this.isPresetAppletsView) return resolve(false) 111 | if (this.rel === 'frameworks' || this.rel === 'system') return resolve(false) 112 | if (this.application.segue?.id === this.id && reserve === false) return resolve(false) 113 | if (this.viewType === 'iframe') this.unload() 114 | if (reserve === false) this.removeContainer() 115 | else this.clearContainer() 116 | this.resume() 117 | this.events?.destroy(this.self) 118 | this.application.removeEventGroup(this.id) 119 | this.trigger('destroy') 120 | resolve(true) 121 | }) 122 | } 123 | 124 | public observer(change: (record: MutationRecord[]) => void): MutationObserver | undefined { 125 | const target = this.sandbox ? this.sandbox.document?.documentElement : this.view 126 | if (!target) return 127 | const observer = new MutationObserver((record: MutationRecord[]) => { 128 | change(record) 129 | }) 130 | observer.observe(target, { 131 | subtree: true, 132 | attributes: true, 133 | childList: true, 134 | characterData: true, 135 | attributeOldValue: true, 136 | characterDataOldValue: true 137 | }) 138 | return observer 139 | } 140 | 141 | public collectAllElements(root: Element): Element[] { 142 | const allElements: Array = [] 143 | const findAllElements = function (nodes: NodeListOf) { 144 | for (let i = 0; i < nodes.length; i++) { 145 | const el = nodes[i] 146 | allElements.push(el) 147 | if (el.shadowRoot) { 148 | findAllElements(el.shadowRoot.querySelectorAll('*')) 149 | } 150 | } 151 | } 152 | if (root.shadowRoot) { 153 | findAllElements(root.shadowRoot.querySelectorAll('*')) 154 | } 155 | findAllElements(root.querySelectorAll('*')) 156 | 157 | return allElements 158 | } 159 | 160 | public autoMediaController(allElements: Array): Promise { 161 | return new Promise((resolve, reject) => { 162 | try { 163 | if (this.viewType !== 'iframe') return resolve() 164 | if (this.sandbox === undefined) return resolve() 165 | const videoAndAudioList = allElements.filter(el => el.tagName === 'video' || el.tagName === 'audio') 166 | for (const index in videoAndAudioList) { 167 | const videoAndAudio = videoAndAudioList[index] as HTMLVideoElement | HTMLAudioElement 168 | if (!videoAndAudio?.paused) { 169 | videoAndAudio.pause() 170 | this.darkTask.push(() => { 171 | videoAndAudio.play() 172 | }) 173 | } 174 | } 175 | } catch (error) { 176 | reject() 177 | } 178 | }) 179 | } 180 | 181 | public destructiveTags(allElements: Array): boolean { 182 | const blockTags = ['object', 'embed', 'applet', 'iframe'] 183 | const matchTags = allElements.filter(el => blockTags.includes(el.tagName)) 184 | return matchTags.length ? true : false 185 | } 186 | 187 | public guarding(): Promise { 188 | return new Promise((resolve) => { 189 | if (this.rel !== 'applet') return resolve(true) 190 | if (this.sandbox === undefined) return resolve(true) 191 | if (this.config.background === true) return resolve(true) 192 | if (this.config.background === false) return resolve(false) 193 | if (this.sameOrigin === false) return resolve(false) 194 | const view = this.view as HTMLIFrameElement 195 | try { 196 | const contentDocumentElement = view.contentDocument?.documentElement 197 | if (!contentDocumentElement) return resolve(true) 198 | const allElements = this.collectAllElements(contentDocumentElement) 199 | if (this.config.mediaGuard) this.autoMediaController(allElements).catch(resolve) 200 | if (this.destructiveTags(allElements)) return resolve(false) 201 | if (this.config.observerGuard) { 202 | const counter = { times: 0 } 203 | const observer = this.observer(() => { 204 | counter.times++ 205 | if (counter.times > 10000) { 206 | resolve(false) 207 | this.mutationObserver.disconnect() 208 | } 209 | }) 210 | if (!observer) return 211 | this.mutationObserver = observer 212 | setTimeout(() => { 213 | if (counter.times > 10) resolve(false) 214 | }, 3000) 215 | } 216 | } catch (error) { 217 | resolve(true) 218 | } 219 | }) 220 | } 221 | 222 | public cancelGuard(): void { 223 | this.mutationObserver?.disconnect() 224 | } 225 | 226 | public unload(sandbox = this.sandbox?.sandbox): Promise { 227 | return new Promise((resolve) => { 228 | this.cancelGuard() 229 | if (!sandbox) return resolve() 230 | if (sandbox === this.sandbox?.sandbox) this.saveMirroring() 231 | /* 232 | * IFrame Memory Leak when removing IFrames 233 | */ 234 | try { 235 | if (sandbox.contentWindow) { 236 | sandbox.contentWindow.onunload = null 237 | } 238 | sandbox.style.display = 'none' 239 | sandbox.src = 'about:blank' 240 | const contentWindow = sandbox.contentWindow?.window 241 | const contentDocument = sandbox.contentDocument 242 | contentWindow?.location.reload() 243 | contentDocument?.open() 244 | contentDocument?.write('') 245 | contentDocument?.close() 246 | } catch (error) { 247 | // 248 | } 249 | sandbox.parentNode?.removeChild(sandbox) 250 | resolve() 251 | }) 252 | } 253 | } 254 | 255 | export { 256 | AppletLifeCycle 257 | } 258 | -------------------------------------------------------------------------------- /src/Applet/Prefetch.ts: -------------------------------------------------------------------------------- 1 | import { AppletLifeCycle } from './LifeCycle' 2 | import { Sandbox } from '../Sandbox' 3 | import { requestIdleCallback } from '../lib/util' 4 | 5 | class AppletPrefetch extends AppletLifeCycle { 6 | public dependenciesLoad(uri?: string, type: 'src' | 'source' = 'src'): Promise { 7 | return new Promise((resolve, reject) => { 8 | const head = document.head 9 | const sandbox = new Sandbox(uri, '', type) 10 | sandbox.setOnLoad((e) => { 11 | // wait async script 12 | setTimeout(() => { 13 | sandbox.exit() 14 | }, 2000) 15 | resolve(e) 16 | }) 17 | sandbox.setOnError((e) => { 18 | sandbox.exit() 19 | reject(e) 20 | }) 21 | sandbox.enter(head) 22 | }) 23 | } 24 | 25 | public async preload(): Promise { 26 | return this.dependenciesLoad(this.uri || await this.source, this.uri ? 'src' : 'source').then(() => { 27 | this.status.preload = true 28 | this.events?.preload(this.self) 29 | this.trigger('preload') 30 | }) 31 | } 32 | 33 | public prefetch(): Promise { 34 | return new Promise((resolve, reject) => { 35 | Promise.all([ 36 | this.prefetchStatic(this.resources.script, 'script'), 37 | this.prefetchStatic(this.resources.image, 'image'), 38 | this.prefetchStatic(this.resources.worker, 'worker'), 39 | this.prefetchStatic(this.resources.video, 'video'), 40 | this.prefetchStatic(this.resources.audio, 'audio'), 41 | this.prefetchStatic(this.resources.font, 'font'), 42 | this.prefetchStatic(this.resources.style, 'style'), 43 | this.prefetchStatic(this.resources.html, 'document') 44 | ]).then(() => { 45 | this.status.prefetch = true 46 | this.trigger('prefetch') 47 | resolve(true) 48 | }).catch(reject) 49 | }) 50 | } 51 | 52 | public prefetchStatic(list: string[] = [], as = 'script'): Promise<(string | undefined | Event)[]> { 53 | return new Promise((resolve, reject) => { 54 | Promise.all([].concat(list as []).map(url => this.prelink(url, 'preload', as))).then(resolve).catch(reject) 55 | }) 56 | } 57 | 58 | public prelink(url: string, rel: 'prefetch' | 'prerender' | 'preload' = 'preload', as = 'worker | video | audio | font | script | style | image | document'): Promise { 59 | if (!url) return Promise.resolve(undefined) 60 | return new Promise((resolve, reject) => { 61 | const load = () => { 62 | const link = document.createElement('link') 63 | link.rel = rel 64 | link.href = url 65 | link.as = as 66 | link.onload = resolve 67 | link.onerror = reject 68 | if (rel === 'preload' && as === 'document') { 69 | this.dependenciesLoad(url) 70 | } 71 | if (!link.relList?.supports(rel)) { 72 | return resolve(undefined) 73 | } 74 | document.getElementsByTagName('head')[0].appendChild(link) 75 | resolve(undefined) 76 | } 77 | requestIdleCallback(load, { timeout: 15000 }) 78 | }) 79 | } 80 | 81 | public async prerender(): Promise { 82 | if (this.status.preload || this.status.prerender) { 83 | return Promise.resolve() 84 | } 85 | await this.preload() 86 | await this.prefetch() 87 | 88 | return Promise.resolve() 89 | } 90 | } 91 | 92 | export { 93 | AppletPrefetch 94 | } 95 | -------------------------------------------------------------------------------- /src/Applet/State.ts: -------------------------------------------------------------------------------- 1 | import { AppletBase } from './Base' 2 | import getIOSVersion from '../lib/util/getIOSVersion' 3 | import { Applet, AppletStatus, SegueActionOrigin, SwipeModelType } from '../types' 4 | 5 | class AppletState extends AppletBase { 6 | public visibility = false 7 | public transforming = false 8 | public segueActionOrigin?: SegueActionOrigin 9 | public viewLevel = 0 10 | public status: AppletStatus = { 11 | preload: false, 12 | prefetch: false, 13 | prerender: false, 14 | refreshing: false, 15 | requestRefresh: false 16 | } 17 | get swipeModel(): SwipeModelType { 18 | // Use native performance optimizations only for iOS. 19 | return this.application.config.swipeModel === 'default' ? ((("ontouchend" in document) && (getIOSVersion()?.[0] ?? 0) >= 9) ? 'default' : false) : true 20 | } 21 | get sameOrigin(): boolean { 22 | if (!this.uri) { 23 | if (this.config.sandbox === undefined) return true 24 | if (this.config.sandbox.includes('allow-same-origin')) return true 25 | return false 26 | } 27 | const link = new URL( 28 | this.uri, 29 | window.location.toString() 30 | ) 31 | const isSameOrigin = link.host === window.location.host 32 | return isSameOrigin 33 | } 34 | get level(): number { 35 | return this.config.level ?? 0 36 | } 37 | public setLevel(index: number) { 38 | this.viewLevel = index 39 | } 40 | get rel(): 'system' | 'frameworks' | 'applet' { 41 | if (this.id === 'system') return 'system' 42 | if (this.id === 'frameworks') return 'frameworks' 43 | return 'applet' 44 | } 45 | get uri(): string { 46 | return this.config?.source?.src || '' 47 | } 48 | get source(): string | Promise { 49 | const html = this.config?.source?.html || '' 50 | return typeof html === 'string' ? html : html() 51 | } 52 | get hasSource(): boolean { 53 | return (this.uri || this.config?.source?.html) ? true : false 54 | } 55 | get noSource(): boolean { 56 | return !this.hasSource && !this.config.render 57 | } 58 | get viewType(): 'portal' | 'iframe' | 'shadow' { 59 | if (this.rel !== 'applet') return 'shadow' 60 | if (this.hasSource) { 61 | if (this.config?.portal && this.sameOrigin) { 62 | return 'portal' 63 | } 64 | return 'iframe' 65 | } 66 | return 'shadow' 67 | } 68 | get color(): string { 69 | if (this.rel !== 'applet') return 'transparent' 70 | const application = this.application 71 | const isDarkModel = application.properties.darkTheme 72 | const color = `${this.config.color || (isDarkModel ? '#000' : '#fff')}` 73 | const inherit = this.id !== 'frameworks' && color === 'inherit' 74 | return inherit ? (application.applets.frameworks.config.color || color) : color 75 | } 76 | get expired(): boolean { 77 | if (!this.view) return false 78 | if (this.application.prevActivityApplet?.config.modality) { 79 | return false 80 | } 81 | if (this.status.requestRefresh || Date.now() - this.createTime >= (this.config.timeout ?? 60000000)) { 82 | return true 83 | } 84 | return false 85 | } 86 | get isModality(): boolean { 87 | return !!this.config.modality 88 | } 89 | get isFullscreen(): boolean { 90 | return !this.isModality 91 | } 92 | get isPresetAppletsView(): boolean { 93 | return this.application.checkIsPresetAppletsView(this.id) 94 | } 95 | get useControls(): boolean { 96 | return this.config.disableSwipeModel !== true && this.swipeModel && this.rel === 'applet' && this.isFullscreen 97 | } 98 | get noShadowDom(): boolean { 99 | if (this.viewType === 'shadow') { 100 | if (this.config.noShadowDom) { 101 | return true 102 | } 103 | return false 104 | } 105 | return false 106 | } 107 | get mirroringString(): string { 108 | try { 109 | return localStorage.getItem('__MODULE_IMG__' + this.id) || '' 110 | } catch (error) { 111 | return '' 112 | } 113 | } 114 | get contentWindow(): Window { 115 | return this.sandbox?.window?.window ?? window 116 | } 117 | get contentDocument(): HTMLElement | Document | undefined { 118 | if (!this.sameOrigin) return 119 | return this.viewType === 'shadow' ? this.viewport : this.sandbox?.document 120 | } 121 | get mirroringHTML(): string { 122 | if (this.viewType === 'shadow') { 123 | return this.view?.outerHTML ?? '' 124 | } 125 | if (!this.sameOrigin) return '' 126 | const sandbox = this.sandbox?.sandbox as HTMLIFrameElement 127 | const blockTags = ['script', 'template'] 128 | try { 129 | const contentDocument = sandbox.contentDocument 130 | if (!contentDocument) return '' 131 | const allElements = contentDocument.querySelectorAll('*') 132 | const allElementsList: Array = [] 133 | allElements.forEach((element) => { 134 | const tagName = element.tagName 135 | if (blockTags.includes(tagName)) return 136 | if (tagName.indexOf('-') !== -1) { 137 | (element as HTMLElement).style.visibility = 'hidden' 138 | } 139 | allElementsList.push(element.outerHTML) 140 | }) 141 | return allElementsList.join('\n') 142 | } catch (error) { 143 | return '' 144 | } 145 | } 146 | get subApplet(): Applet | undefined { 147 | if (this.slide) { 148 | return this.application.applets[this.slide.activeId] 149 | } 150 | return 151 | } 152 | get allSubAppletIds(): string[] { 153 | const applets = [] 154 | if (this.slide) { 155 | for (const slideItem of this.slide.slideViewApplets) { 156 | applets.push(slideItem.id) 157 | } 158 | } 159 | return applets 160 | } 161 | protected resume() { 162 | this.status = { 163 | preload: false, 164 | prefetch: false, 165 | prerender: false, 166 | refreshing: false, 167 | requestRefresh: false 168 | } 169 | this.darkTask = [] 170 | this.view = null 171 | this.img = null 172 | if (this.slide) { 173 | this.slide = null 174 | } 175 | if (this.modality) { 176 | this.modality = null 177 | } 178 | } 179 | public saveMirroring(): Promise { 180 | if (!this.config.useMirroring || !this.sameOrigin) { 181 | return Promise.resolve(false) 182 | } 183 | 184 | const key = '__MODULE_IMG__' + this.id 185 | const documentHTML = this.mirroringHTML ?? '' 186 | 187 | try { 188 | localStorage.setItem(key, documentHTML) 189 | return Promise.resolve(true) 190 | } catch { 191 | localStorage.clear() 192 | localStorage.setItem(key, documentHTML) 193 | return Promise.resolve(false) 194 | } 195 | } 196 | public setActionOrigin(origin?: SegueActionOrigin): void { 197 | if (!origin) return 198 | this.segueActionOrigin = origin 199 | } 200 | public getActionOrigin(): SegueActionOrigin | undefined { 201 | return this.segueActionOrigin 202 | } 203 | public injectWindowVisibilityState(visibility: Window['appletVisibilityState']): void { 204 | if (this.sameOrigin) { 205 | const contentWindow = this.contentWindow 206 | if (contentWindow) { 207 | contentWindow.appletVisibilityState = visibility 208 | } 209 | } 210 | } 211 | public triggerWindow(type: string): void { 212 | this.contentWindow?.postMessage({ 213 | type, 214 | appletId: this.id, 215 | historyDirection: this.application.segue.historyDirection 216 | }, '*') 217 | } 218 | } 219 | 220 | export { 221 | AppletState 222 | } 223 | -------------------------------------------------------------------------------- /src/Applet/captureShot/index.ts: -------------------------------------------------------------------------------- 1 | import { Applet } from '../../types' 2 | import html2canvas from 'html2canvas' 3 | 4 | export const capture = async (applet: Applet) => { 5 | const appletView: HTMLElement = applet.sandbox && applet.sameOrigin 6 | ? applet.contentWindow.document.documentElement 7 | : applet.view as HTMLElement 8 | const viewport = applet.viewport 9 | if (!viewport) { 10 | throw ('Capture Shot: The applet has not been initialized!') 11 | } 12 | const viewportWidth = viewport.offsetWidth 13 | const viewportHeight = viewport.offsetHeight 14 | 15 | /** 16 | * If it is a shadow view type, take a screenshot by cloning. 17 | * The aim is to reduce complexity and prevent interference. 18 | * HTML2canvas will cause the slot to be lost. 19 | */ 20 | const { application } = applet 21 | const ignoreAttrName = 'data-html2canvas-ignore' 22 | let cloneAppletView: HTMLElement | undefined 23 | let shotWrapper: HTMLElement | undefined 24 | if (applet.view === appletView) { 25 | cloneAppletView = appletView.cloneNode(true) as HTMLElement 26 | application.appletsSpace.setAttribute(ignoreAttrName, 'true') 27 | shotWrapper = document.createElement('div') 28 | shotWrapper.style.opacity = '0' 29 | shotWrapper.style.contain = 'strict' 30 | shotWrapper.appendChild(cloneAppletView) 31 | document.body.appendChild(shotWrapper) 32 | } 33 | const color = /\(/.exec(applet.color) ? (applet.view ? getComputedStyle(applet.view).backgroundColor : 'transparent') : applet.color 34 | const canvas = await html2canvas(cloneAppletView || appletView, { 35 | backgroundColor: color, 36 | useCORS: true, 37 | width: viewportWidth, 38 | height: viewportHeight, 39 | windowWidth: viewportWidth, 40 | windowHeight: viewportHeight, 41 | x: appletView.scrollLeft, 42 | y: appletView.scrollTop, 43 | removeContainer: true, 44 | imageTimeout: 2000 45 | }) 46 | canvas.style.cssText = ` 47 | width: 100% !important; 48 | height: auto !important; 49 | transition: all .4s ease; 50 | ` 51 | /** 52 | * Clear clone node. 53 | */ 54 | if (shotWrapper) { 55 | document.body.removeChild(shotWrapper) 56 | application.appletsSpace.removeAttribute(ignoreAttrName) 57 | } 58 | 59 | return canvas 60 | } -------------------------------------------------------------------------------- /src/Applet/index.ts: -------------------------------------------------------------------------------- 1 | import { AppletView } from './View' 2 | import { AppletManifest, Application } from '../types' 3 | 4 | class Applet extends AppletView { 5 | constructor(id: string, model: AppletManifest, application: Application) { 6 | super(id, model, application) 7 | this.events.boot(this.self) 8 | } 9 | } 10 | 11 | export { 12 | Applet 13 | } 14 | -------------------------------------------------------------------------------- /src/Applet/inject/cssVar.ts: -------------------------------------------------------------------------------- 1 | 2 | import { testHasScrolling } from '../../lib/util' 3 | import { Applet, ApplicationSafeAreaValue, GlobalCSSVariables } from '../../types' 4 | 5 | export default (appletWindow: Window, applet: Applet): void => { 6 | const { id, application } = applet 7 | const globalCSSVariablesConfig = application.config.globalCSSVariables 8 | const globalCSSVariables = typeof globalCSSVariablesConfig === 'function' ? globalCSSVariablesConfig() : globalCSSVariablesConfig 9 | const docStyle = appletWindow.document.documentElement.style 10 | const setGlobalCSSVariables = (variables: GlobalCSSVariables): void => { 11 | // clear snapshot 12 | applet.snapshot = null 13 | for (const key in variables) { 14 | docStyle.setProperty(key, variables[key]) 15 | } 16 | } 17 | const setCSSSafeAreaValue = (data: ApplicationSafeAreaValue): void => { 18 | setGlobalCSSVariables({ 19 | '--application-safe-area-top': data[0] ?? data, 20 | '--application-safe-area-right': data[1] ?? data, 21 | '--application-safe-area-bottom': data[2] ?? data[0] ?? data, 22 | '--application-safe-area-left': data[3] ?? data[1] ?? data 23 | }) 24 | } 25 | const safeAreaConfig = application.config.safeArea 26 | const safeArea = typeof safeAreaConfig === 'function' ? safeAreaConfig() : safeAreaConfig 27 | if (safeArea) { 28 | setCSSSafeAreaValue(safeArea) 29 | } 30 | if (globalCSSVariables) { 31 | setGlobalCSSVariables(globalCSSVariables) 32 | } 33 | const safeAreaChange = (data: ApplicationSafeAreaValue): void => { 34 | setCSSSafeAreaValue(data) 35 | } 36 | const globalCSSVariablesChange = (data: GlobalCSSVariables): void => { 37 | setGlobalCSSVariables(data) 38 | } 39 | /** 40 | * Obsolete 41 | * ------------- start ------------- 42 | */ 43 | // ios < 12.55 bug 44 | if (testHasScrolling() === false) { 45 | docStyle.cssText += 'width: 100vw; height: 100vh; overflow: auto; -webkit-overflow-scrolling: touch;' 46 | } 47 | /** 48 | * Obsolete 49 | * ------------- end ------------- 50 | */ 51 | application.on('safeAreaChange', safeAreaChange, id) 52 | application.on('globalCSSVariablesChange', globalCSSVariablesChange, id) 53 | } 54 | -------------------------------------------------------------------------------- /src/Applet/inject/index.ts: -------------------------------------------------------------------------------- 1 | import openWindow from './openWindow' 2 | import { smartSetTimeout } from './smartSetTimeout' 3 | import tapHighlight from './tapHighlight' 4 | import cssVar from './cssVar' 5 | import { Applet } from '../../types' 6 | 7 | export const injectContext = (appletWindow: Window, applet: Applet): void => { 8 | const { config } = applet 9 | const apply = Array.from(new Set(config.apply)) 10 | for (const item of apply) { 11 | switch (item) { 12 | case 'smart-setTimeout': 13 | smartSetTimeout(appletWindow) 14 | break 15 | default: 16 | break 17 | } 18 | } 19 | if (typeof config.inject === 'function') { 20 | try { 21 | config.inject(appletWindow, applet) 22 | } catch (error) { 23 | console.error('config > inject:', error) 24 | } 25 | } 26 | } 27 | 28 | export const injectDocument = (appletWindow: Window, applet: Applet): void => { 29 | const { config, application } = applet 30 | const apply = Array.from(new Set(config.apply)) 31 | const param = config.applyOptions || {} 32 | for (const item of apply) { 33 | switch (item) { 34 | case 'proxy-link': 35 | openWindow(appletWindow, application) 36 | break 37 | case 'tap-highlight': 38 | tapHighlight(appletWindow, (param[item]?.selector ?? 'use-tap-highlight') as string) 39 | break 40 | default: 41 | break 42 | } 43 | } 44 | if (typeof config.injectToDocument === 'function') { 45 | try { 46 | config.injectToDocument(appletWindow, applet) 47 | } catch (error) { 48 | console.error('config > inject:', error) 49 | } 50 | } 51 | if (applet.components) { 52 | for (const mountComponent of applet.components) { 53 | mountComponent(appletWindow) 54 | } 55 | } 56 | cssVar(appletWindow, applet) 57 | } 58 | -------------------------------------------------------------------------------- /src/Applet/inject/openWindow.ts: -------------------------------------------------------------------------------- 1 | import { Application } from '../../types' 2 | 3 | interface ObsoleteEvent extends TouchEvent { 4 | path: Array 5 | } 6 | 7 | export default (appletWindow: Window, application: Application): void => { 8 | const realOpen = appletWindow.open 9 | const blockClick = (event: MouseEvent | TouchEvent): boolean => { 10 | if (event instanceof CustomEvent && event.detail instanceof Event) { 11 | event = event.detail as MouseEvent 12 | } 13 | const getTouches = (target: HTMLElement) => { 14 | return { 15 | x: (event as TouchEvent).changedTouches?.[0]?.pageX || (event as MouseEvent).x, 16 | y: (event as TouchEvent).changedTouches?.[0]?.pageY || (event as MouseEvent).y, 17 | target 18 | } 19 | } 20 | const getProps = (target: HTMLElement) => { 21 | return { 22 | title: target.getAttribute('title') || '', 23 | preset: target.getAttribute('preset-effect') || 'slide', 24 | cloneAs: target.getAttribute('clone-as') || undefined 25 | } 26 | } 27 | const path = (event as ObsoleteEvent).path || event.composedPath?.() || [] 28 | path.splice(-3) 29 | 30 | for (const el of path) { 31 | const toApplet = el.getAttribute?.('to-applet') 32 | if (toApplet) { 33 | const { cloneAs, title, preset } = getProps(el) 34 | const [id, param] = toApplet.split('?') 35 | if (cloneAs) { 36 | application.get(id).then((applet) => { 37 | const appletManifest = applet.model 38 | Object.assign(appletManifest.config, { 39 | title, 40 | animation: preset, 41 | level: application.activityLevel 42 | }) 43 | application.add(cloneAs, appletManifest) 44 | application.to(cloneAs, '?' + param, undefined, getTouches(el)) 45 | }) 46 | break 47 | } 48 | application.to(id, '?' + param, undefined, getTouches(el)) 49 | break 50 | } else if (el.tagName === 'A') { 51 | const anchor = el as HTMLAnchorElement 52 | const href = anchor.href || String(anchor) 53 | if (href) { 54 | event.stopPropagation() 55 | event.preventDefault() 56 | if (anchor.target === '_parent' || anchor.target === '_top') { 57 | realOpen(href) 58 | return false 59 | } 60 | const { cloneAs, title, preset } = getProps(el) 61 | application.pushWindow(href, title, preset, cloneAs, getTouches(anchor)).catch(() => { 62 | realOpen(href) 63 | }) 64 | } 65 | } 66 | } 67 | return false 68 | } 69 | appletWindow.document.addEventListener('click', blockClick, true) 70 | appletWindow.open = (url?: string | URL | undefined, target?: string | undefined, features?: string | undefined): Window | null => { 71 | if (typeof url === 'string' && (!target || target.indexOf('_') === 0) && !features) { 72 | application.pushWindow(url, '').catch(() => { 73 | realOpen(url) 74 | }) 75 | } else { 76 | return realOpen(url, target, features) 77 | } 78 | return null 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Applet/inject/smartSetTimeout.ts: -------------------------------------------------------------------------------- 1 | interface TimerLog { 2 | [key: number]: boolean 3 | } 4 | 5 | interface TimerState { 6 | timerLog: TimerLog 7 | timerId: number 8 | } 9 | 10 | interface IntervalLog { 11 | [key: number]: IntervalState 12 | } 13 | 14 | interface IntervalState { 15 | timerId: number 16 | intervalId: number 17 | } 18 | 19 | const getTimerHandler = (handler: TimerHandler, appletWindow: Window, timerState: TimerState, stretch?: boolean) => { 20 | return () => { 21 | const run = () => { 22 | if (timerState.timerLog[timerState.timerId] === undefined) return 23 | if (typeof handler === 'function') { 24 | handler() 25 | } else if (typeof handler === 'string') { 26 | const evalHandle = new appletWindow.Function(`return ${handler}`) 27 | evalHandle() 28 | } 29 | delete timerState.timerLog[timerState.timerId] 30 | } 31 | // first render 32 | if (appletWindow.document.readyState !== 'complete') { 33 | run() 34 | return 35 | } 36 | // global scope 37 | if (appletWindow === window) { 38 | if (window.applicationActiveState !== 'frozen') { 39 | run() 40 | } else if (stretch) { 41 | window.addEventListener('message', (event: MessageEvent) => { 42 | if (event.data?.type === 'application-active') run() 43 | }) 44 | } 45 | return 46 | } 47 | // applet scope 48 | if (appletWindow.appletVisibilityState === 'visible') { 49 | run() 50 | } else if (stretch) { 51 | appletWindow.addEventListener('message', (event: MessageEvent) => { 52 | if (event.data?.type === 'applet-show') run() 53 | }) 54 | } 55 | } 56 | } 57 | export const smartSetTimeout = (appletWindow: Window): void => { 58 | const realSetTimeout = appletWindow.setTimeout 59 | const realClearTimeout = appletWindow.clearTimeout 60 | const realSetInterval = appletWindow.setInterval 61 | const realClearInterval = appletWindow.clearInterval 62 | 63 | const timerLog: TimerLog = {} 64 | appletWindow.setBackgroundTimeout = realSetTimeout 65 | appletWindow.setTimeout = (handler: TimerHandler, timeout?: number | undefined, ...args: unknown[]) => { 66 | const timerState = { 67 | timerLog, 68 | timerId: -1 69 | } 70 | const fn = getTimerHandler(handler, appletWindow, timerState, true) 71 | const intervalId = realSetTimeout(fn, timeout, ...args) 72 | timerLog[intervalId] = true 73 | timerState.timerId = intervalId 74 | return intervalId 75 | } 76 | appletWindow.clearTimeout = (...args) => { 77 | const timeoutID = args[0] 78 | if (timeoutID) { 79 | delete timerLog[timeoutID] 80 | } 81 | return realClearTimeout(...args) 82 | } 83 | appletWindow.setTimeout.toString = () => 'setTimeout() { [native code] }' 84 | appletWindow.clearTimeout.toString = () => 'clearTimeout() { [native code] }' 85 | 86 | // smartSetInterval 87 | const intervalLog: IntervalLog = {} 88 | appletWindow.setBackgroundInterval = realSetInterval 89 | appletWindow.setInterval = (handler: TimerHandler, timeout?: number | undefined, ...args: unknown[]) => { 90 | const timerState: IntervalState = { 91 | timerId: -1, 92 | intervalId: -1 93 | } 94 | const nextHandler = () => { 95 | if (timerState.timerId !== -1 && !intervalLog[timerState.intervalId]) return 96 | if (typeof handler === 'function') { 97 | handler() 98 | } else if (typeof handler === 'string') { 99 | const evalHandle = new appletWindow.Function(`return ${handler}`) 100 | evalHandle() 101 | } 102 | timerState.timerId = appletWindow.setTimeout(nextHandler, timeout, ...args) 103 | } 104 | const intervalId = timerState.intervalId = timerState.timerId = appletWindow.setTimeout(nextHandler, timeout, ...args) 105 | intervalLog[intervalId] = timerState 106 | 107 | return intervalId 108 | } 109 | appletWindow.clearInterval = (...args) => { 110 | const timeoutID = args[0] 111 | if (!timeoutID) return 112 | realClearInterval(timeoutID) 113 | const timerState = intervalLog[timeoutID] 114 | if (timerState) { 115 | appletWindow.clearTimeout(timerState.timerId) 116 | delete intervalLog[timerState.intervalId] 117 | } 118 | } 119 | appletWindow.setInterval.toString = () => 'setInterval() { [native code] }' 120 | appletWindow.clearInterval.toString = () => 'clearInterval() { [native code] }' 121 | } 122 | -------------------------------------------------------------------------------- /src/Applet/inject/tapHighlight.ts: -------------------------------------------------------------------------------- 1 | import { setTimeout } from '../../lib/util' 2 | 3 | interface TouchActive { 4 | element: null | HTMLElement 5 | oldFilterStyle: string 6 | oldTransitionStyle: string 7 | waitingToAddTimeId: number 8 | } 9 | 10 | interface ObsoleteTouchEvent extends TouchEvent { 11 | path: Array 12 | } 13 | 14 | export default (appletWindow: Window, capture?: string | string[]): void => { 15 | const touchActive: TouchActive = { 16 | element: null, 17 | oldFilterStyle: '', 18 | oldTransitionStyle: '', 19 | waitingToAddTimeId: -1 20 | } 21 | const addHighlight = (event: TouchEvent): void => { 22 | const captureList = capture ? typeof capture === 'string' ? capture.split(' ') : capture : null 23 | const path = (event as ObsoleteTouchEvent).path || event.composedPath?.() || [] 24 | path.splice(-3) 25 | const anchor: HTMLElement | null = (() => { 26 | for (const el of path) { 27 | if (el.tagName === 'A') return el 28 | if (!el.children?.length) continue 29 | if (captureList) { 30 | for (const attr of captureList) { 31 | if (attr.indexOf('#') === 0 && '#' + el.id === attr) return el 32 | if (attr.indexOf('.') === 0 && el.classList.length) { 33 | if (Object.values(el.classList).includes(attr.slice(1))) return el 34 | } 35 | if (el.getAttribute?.(attr)) return el 36 | } 37 | return null 38 | } 39 | } 40 | const target = (path[0]?.children?.length ? path[0] : path[1]) || event.target 41 | if (['DIV', 'A', 'IMG'].includes(target.tagName)) return target 42 | return null 43 | })() 44 | if (!anchor) return 45 | if (touchActive.element === anchor) return 46 | if (touchActive.element) { 47 | return cancelHighlight() 48 | } 49 | touchActive.element = anchor 50 | touchActive.oldFilterStyle = anchor.style.filter 51 | touchActive.oldTransitionStyle = anchor.style.transition 52 | // Prevent multiple types of click effects from overlaying 53 | // Non-standard[webkitTapHighlightColor]: This feature is non-standard and is not on a standards track. 54 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 55 | // @ts-ignore 56 | anchor.style.webkitTapHighlightColor = 'rgba(0, 0, 0, 0)' 57 | anchor.style.transition = touchActive.oldTransitionStyle ? touchActive.oldTransitionStyle + ', ' : '' + 'all .1s ease' 58 | touchActive.waitingToAddTimeId = setTimeout(() => { 59 | if (touchActive.element === anchor) { 60 | const elWidth = anchor.offsetWidth 61 | const elHeight = anchor.offsetHeight 62 | if (elWidth * elHeight > 90000) return 63 | anchor.style.filter = touchActive.oldFilterStyle + ' brightness(.8)' 64 | anchor.setAttribute('tap-highlight', 'true') 65 | } 66 | }, 30) 67 | } 68 | const cancelHighlight = (): void => { 69 | if (!touchActive.element) return 70 | clearTimeout(touchActive.waitingToAddTimeId) 71 | if (touchActive.element?.style.filter) { 72 | touchActive.element.style.filter = touchActive.oldFilterStyle 73 | } 74 | touchActive.element.removeAttribute('tap-highlight') 75 | setTimeout(() => { 76 | if (touchActive.element?.style.transition) { 77 | touchActive.element.style.transition = touchActive.oldTransitionStyle 78 | } 79 | touchActive.element = null 80 | touchActive.oldFilterStyle = '' 81 | touchActive.oldTransitionStyle = '' 82 | }, 100) 83 | } 84 | const delayCancelHighlight = (): void => { 85 | // Make entry control(appletWindow.setTimeout) 86 | appletWindow.setTimeout(() => { 87 | cancelHighlight() 88 | }, 300) 89 | } 90 | appletWindow.document.addEventListener('touchstart', addHighlight) 91 | appletWindow.document.addEventListener('touchmove', cancelHighlight) 92 | appletWindow.document.addEventListener('touchcancel', cancelHighlight) 93 | appletWindow.document.addEventListener('touchend', delayCancelHighlight) 94 | } 95 | -------------------------------------------------------------------------------- /src/Applet/manifestProcess.ts: -------------------------------------------------------------------------------- 1 | import typeError from '../lib/typeError' 2 | import getIOSversion from '../lib/util/getIOSVersion' 3 | import { AppletManifest, FrameworksAppletConfig, AppletApplyOptions } from '../types' 4 | 5 | export default (manifest: AppletManifest, id: string): AppletManifest => { 6 | const config = manifest.config 7 | if (id === 'frameworks' || id === 'system') { 8 | if (config.free) { 9 | typeError(1101, 'warn') 10 | } 11 | if (config.source?.html || config.source?.src) { 12 | typeError(1102, 'warn') 13 | } 14 | } 15 | if (config.portal) { 16 | if (!config.free) { 17 | typeError(1103, 'warn') 18 | } 19 | } 20 | if ((config.level ?? 0) > 10000) { 21 | typeError(1104, 'warn') 22 | } 23 | if (id !== 'frameworks' && !(config.source?.html || config.source?.src)) { 24 | if (config.apply) { 25 | typeError(1202, 'warn') 26 | } 27 | } else { 28 | const defaultApply: AppletApplyOptions = ['smart-setTimeout', 'proxy-link', 'tap-highlight'] 29 | const { apply = defaultApply, unApply } = config 30 | config.apply = apply 31 | if (unApply?.length) { 32 | config.apply = apply?.filter((item) => !unApply.includes(item)) 33 | } 34 | } 35 | if (config.modality?.indexOf('sheet') === 0) { 36 | if (config.animation) { 37 | typeError(1105, 'warn') 38 | } 39 | config.animation = 'popup' 40 | } else if (config.animation === 'popup') { 41 | config.modality = 'sheet' 42 | typeError(1106, 'warn') 43 | } 44 | if (config.modality) { 45 | if (config.modality?.indexOf('paper') === 0) { 46 | if (config.animation) { 47 | typeError(1105, 'warn') 48 | } 49 | const { maskOpacity, swipeClosable } = config.paperOptions || { maskOpacity: 0.5 } 50 | config.animation = 'grow' 51 | config.sheetOptions = { 52 | stillBackdrop: true, 53 | noHandlebar: true, 54 | maskOpacity, 55 | swipeClosable, 56 | borderRadius: '0px', 57 | top: '0px' 58 | } 59 | } else if (config.modality?.indexOf('overlay') === 0) { 60 | if (config.animation) { 61 | typeError(1105, 'warn') 62 | } 63 | if (!config.color) { 64 | config.color = 'transparent' 65 | } 66 | const { maskOpacity, swipeClosable } = config.overlayOptions || { maskOpacity: 0.5 } 67 | config.animation = 'popup' 68 | config.sheetOptions = { 69 | stillBackdrop: true, 70 | noHandlebar: true, 71 | maskOpacity, 72 | swipeClosable, 73 | borderRadius: '0px', 74 | top: '0px', 75 | useFade: true 76 | } 77 | } 78 | if (!config.sheetOptions) { 79 | config.sheetOptions = {} 80 | } 81 | if (!getIOSversion()) { 82 | config.sheetOptions.stillBackdrop = true 83 | } 84 | } 85 | // In scenes such as Tab, you should also close the left slide to exit when no animation is set, otherwise overlay layers will appear. 86 | if (!config.animation) { 87 | config.disableSwipeModel = true 88 | } 89 | if (!(config as FrameworksAppletConfig).swipeTransitionType) { 90 | (config as FrameworksAppletConfig).swipeTransitionType = (config as FrameworksAppletConfig).swipeTransitionType ?? (getIOSversion() ? 'slide' : 'zoom') 91 | } 92 | return manifest 93 | } 94 | 95 | -------------------------------------------------------------------------------- /src/AppletControls/Base.ts: -------------------------------------------------------------------------------- 1 | import { Applet, Application, SmoothScroller } from '../types' 2 | 3 | export class AppletControlsBase { 4 | public applet: Applet 5 | public application: Application 6 | public scroll!: SmoothScroller 7 | public controlsView!: HTMLElement 8 | public controlsOverlay!: HTMLElement 9 | public contentContainer!: HTMLElement 10 | public backdropView!: HTMLElement 11 | public backdropReducedScale = 0.03 12 | public appletViewport: HTMLElement 13 | constructor(applet: Applet) { 14 | this.applet = applet 15 | this.application = applet.application 16 | this.appletViewport = this.applet.viewport as HTMLElement 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/AppletControls/EventTarget.ts: -------------------------------------------------------------------------------- 1 | import { AppletControlsState } from './State' 2 | export class AppletControlsEventTarget extends AppletControlsState { 3 | private available = true 4 | private toggleLock = false 5 | private requestGoBack(degree: number): void { 6 | const applet = this.applet 7 | if (applet.transforming) return 8 | if (this.toggleLock === false && degree <= this.advanceDegree && this.activity) { 9 | this.toggleLock = true 10 | setTimeout(() => { 11 | this.toggleLock = false 12 | }, 300) 13 | this.application.segue.back('controls').then(() => { 14 | this.fromViewports = undefined 15 | }) 16 | } 17 | } 18 | private sliding(): void { 19 | const applet = this.applet 20 | const viewports = this.viewports 21 | const prevViewport = viewports[1] 22 | const swipeTransitionType = this.swipeTransitionType 23 | const degree = this.degree 24 | this.requestGoBack(degree) 25 | this.backdropView.animate([ 26 | { opacity: degree } 27 | ], { 28 | duration: 0, 29 | fill: 'forwards' 30 | }).play() 31 | let controlsViewBackgroundColor = 'transparent' 32 | if (degree > 1) { 33 | controlsViewBackgroundColor = `rgba(0, 0, 0, ${0.5 + 3 * (degree - 1)})` 34 | } 35 | this.controlsView.animate([ 36 | { backgroundColor: controlsViewBackgroundColor } 37 | ], { 38 | duration: 0, 39 | fill: 'forwards' 40 | }).play() 41 | if (prevViewport && applet.visibility && !applet.transforming && !this.toggleLock) { 42 | let prevViewportTransform = `scale(${1 - degree * 0.03})` 43 | if (swipeTransitionType === 'slide') { 44 | prevViewportTransform = `translate3d(${-degree * 30}%, 0, 0)` 45 | } 46 | prevViewport.animate([ 47 | { transform: prevViewportTransform } 48 | ], { 49 | duration: 0, 50 | fill: 'forwards' 51 | }).play() 52 | } 53 | this.application.trigger('transition', degree, this.application.segue.appletGroup) 54 | } 55 | private slidingListener = this.sliding.bind(this) 56 | private bindSlidingEvent(): void { 57 | this.controlsView.addEventListener('scroll', this.slidingListener) 58 | } 59 | private removeSlidingEvent(): void { 60 | this.controlsView.removeEventListener('scroll', this.slidingListener) 61 | } 62 | protected bindBaseEvent(): void { 63 | this.backdropView.addEventListener('touchstart', async () => { 64 | await this.hide() 65 | this.requestGoBack(this.degree) 66 | }) 67 | this.controlsOverlay.addEventListener('touchstart', (event) => { 68 | event.stopPropagation() 69 | event.preventDefault() 70 | }, false) 71 | // update viewports 72 | this.applet.on('willShow', () => { 73 | if (this.application.segue.stackUp) { 74 | this.clearFromViewports() 75 | } 76 | }) 77 | // when history back 78 | this.applet.on('show', () => { 79 | if (!this.visibility) { 80 | this.appearImmediately() 81 | } 82 | if (this.application.segue.stackUp) { 83 | this.prepare() 84 | } 85 | }) 86 | // isOverscrollHistoryNavigation 87 | this.applet.on('hide', () => { 88 | if (this.visibility && this.application.segue.fromOverscrollHistoryNavigation) { 89 | this.disappearImmediately() 90 | } 91 | }) 92 | } 93 | public prepare(reset = false) { 94 | const viewports = this.viewports 95 | const swipeTransitionType = this.swipeTransitionType 96 | const prevViewport = viewports[1] 97 | if (prevViewport) { 98 | let transform = '' 99 | if (swipeTransitionType === 'slide') { 100 | transform = reset ? 'translate3d(0, 0, 0)' : 'translate3d(-30%, 0, 0)' 101 | } else { 102 | transform = reset ? 'scale(1)' : `scale(${1 - this.backdropReducedScale})` 103 | } 104 | 105 | prevViewport.animate([ 106 | { transform } 107 | ], { 108 | duration: 0, 109 | fill: 'forwards' 110 | }).play() 111 | } 112 | } 113 | public async switch(show: boolean): Promise { 114 | this.controlsOverlay.style.display = 'block' 115 | this.toggleLock = true 116 | return this.scroll.snapTo(show ? this.appletViewport.offsetWidth : 0, 0).then(() => { 117 | this.toggleLock = false 118 | this.controlsOverlay.style.display = 'none' 119 | }) 120 | } 121 | public async disappearImmediately() { 122 | this.controlsView.style.scrollBehavior = 'auto' 123 | this.controlsView.scrollLeft = 1 + this.advanceDegree 124 | } 125 | public async appearImmediately() { 126 | this.controlsView.style.scrollBehavior = 'auto' 127 | this.controlsView.scrollLeft = this.appletViewport.offsetWidth 128 | } 129 | public async show(): Promise { 130 | return this.switch(true).then(() => { 131 | this.bindSlidingEvent() 132 | }) 133 | } 134 | public async hide(): Promise { 135 | return this.switch(false).then(() => { 136 | this.removeSlidingEvent() 137 | }) 138 | } 139 | public disable() { 140 | this.available = false 141 | } 142 | public enable() { 143 | this.available = true 144 | } 145 | public activate(): void { 146 | if (this.available === false) return 147 | this.backdropView.style.display = 'block' 148 | this.controlsView.style.overflow = 'auto' 149 | this.controlsView.scrollLeft = this.appletViewport.offsetWidth 150 | this.bindSlidingEvent() 151 | } 152 | public freeze(): void { 153 | this.removeSlidingEvent() 154 | this.backdropView.style.display = 'none' 155 | this.controlsView.style.overflow = 'hidden' 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/AppletControls/State.ts: -------------------------------------------------------------------------------- 1 | import { AppletControlsBase } from './Base' 2 | 3 | export class AppletControlsState extends AppletControlsBase { 4 | public fromViewports?: Array 5 | public advanceDegree = 0 6 | public swipeTransitionType = this.application.config.swipeTransitionType 7 | get visibility(): boolean { 8 | return Math.round(this.controlsView.scrollLeft / this.appletViewport.offsetWidth) === 0 ? false : true 9 | } 10 | get viewports(): HTMLElement[] { 11 | if (this.fromViewports) { 12 | return this.fromViewports 13 | } 14 | return this.fromViewports = this.application.segue.viewports 15 | } 16 | get degree(): number { 17 | return this.controlsView.scrollLeft / this.appletViewport.offsetWidth 18 | } 19 | get activity(): boolean { 20 | return this.application.activityApplet === this.applet 21 | } 22 | public clearFromViewports(): void { 23 | this.fromViewports = undefined 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/AppletControls/View.ts: -------------------------------------------------------------------------------- 1 | import { AppletControlsEventTarget } from './EventTarget' 2 | import { SmoothScroller } from '../Scroll' 3 | import { testHasScrolling } from '../lib/util' 4 | import { snapItemCSSText, sheetViewCSSText, backdropViewCSSText, controlsViewCSSText, controlsOverlayCSSText } from './cssText' 5 | 6 | export class AppletControlsView extends AppletControlsEventTarget { 7 | public attach(): void { 8 | this.controlsView = document.createElement('applet-controls') 9 | this.contentContainer = document.createElement('applet-container') 10 | this.backdropView = document.createElement('applet-backdrop') 11 | this.controlsOverlay = document.createElement('applet-controls-overlay') 12 | this.scroll = new SmoothScroller(this.controlsView) 13 | } 14 | public buildControlsView(): void { 15 | const sheetViewStyle = document.createElement('style') 16 | sheetViewStyle.innerHTML = sheetViewCSSText 17 | this.contentContainer.style.cssText = snapItemCSSText 18 | this.backdropView.style.cssText = backdropViewCSSText 19 | this.controlsView.style.cssText = controlsViewCSSText 20 | /** 21 | * Obsolete 22 | * ------------- start ------------- 23 | */ 24 | // ios < 12.55 bug 25 | if (testHasScrolling() === false) { 26 | this.controlsView.style.cssText += '-webkit-overflow-scrolling: touch;' 27 | } 28 | /** 29 | * Obsolete 30 | * ------------- end ------------- 31 | */ 32 | this.controlsOverlay.style.cssText = controlsOverlayCSSText 33 | this.controlsView.appendChild(this.backdropView) 34 | this.controlsView.appendChild(this.contentContainer) 35 | this.appletViewport.appendChild(sheetViewStyle) 36 | this.appletViewport.appendChild(this.controlsView) 37 | this.appletViewport.appendChild(this.controlsOverlay) 38 | } 39 | public create(): HTMLElement { 40 | this.attach() 41 | this.buildControlsView() 42 | this.bindBaseEvent() 43 | const applet = this.applet 44 | if (applet.rel !== 'applet' || applet.swipeModel === false || applet.mountBehavior?.noSwipeModel) { 45 | this.freeze() 46 | } 47 | return this.contentContainer 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/AppletControls/cssText.ts: -------------------------------------------------------------------------------- 1 | import { coveredCSSText } from '../lib/cssText/coveredCSSText' 2 | import { fullscreenBaseCSSText } from '../lib/cssText/fullscreenBaseCSSText' 3 | 4 | // important: relative 5 | export const snapItemCSSText = ` 6 | position: relative; 7 | display: flex; 8 | ${coveredCSSText} 9 | scroll-snap-align: start; 10 | scroll-snap-stop: always; 11 | ` 12 | 13 | export const sheetViewCSSText = ` 14 | applet-controls::-webkit-scrollbar { 15 | display: none; 16 | } 17 | applet-controls::scrollbar { 18 | display: none; 19 | } 20 | ` 21 | 22 | export const backdropViewCSSText = ` 23 | ${snapItemCSSText} 24 | background: rgba(0, 0, 0, .3); 25 | opacity: 0; 26 | ` 27 | // important: relative 28 | export const controlsViewCSSText = ` 29 | position: relative; 30 | display: flex; 31 | ${coveredCSSText} 32 | flex-direction: row; 33 | overflow: auto; 34 | scroll-behavior: auto; 35 | scroll-snap-type: both mandatory; 36 | ` 37 | 38 | export const controlsOverlayCSSText = ` 39 | position: fixed; 40 | ${fullscreenBaseCSSText} 41 | z-index: 9; 42 | display: none; 43 | ` -------------------------------------------------------------------------------- /src/AppletControls/index.ts: -------------------------------------------------------------------------------- 1 | import { AppletControlsView } from './View' 2 | export class AppletControls extends AppletControlsView { 3 | } 4 | -------------------------------------------------------------------------------- /src/Application/Base.ts: -------------------------------------------------------------------------------- 1 | import { Applet } from '../Applet/index' 2 | import { EventProvider } from '../Event' 3 | import { AppletManifest, Segue, FrameworksAppletConfig, PresetConfig, PresetApplets } from '../types' 4 | 5 | type ApplicationOptions = Pick, 'applets'> 6 | 7 | class ApplicationBase extends EventProvider { 8 | public segue!: Segue 9 | public to!: Segue['to'] 10 | public applets: { [key: string]: Applet } = {} 11 | public residentApplets: Array = [] 12 | public presetAppletsView: PresetApplets = {} 13 | public config!: FrameworksAppletConfig 14 | public readonly historyNodeLocation: number = history.length 15 | protected routerRegExp = /([^#/]+)(.+)?/ 16 | protected options!: ApplicationOptions 17 | protected resolveURL(url: string): URL { 18 | const link = new URL( 19 | url, 20 | window.location.toString() 21 | ) 22 | const linkObject = link 23 | if (link.href === undefined) { 24 | linkObject.href = String(link) 25 | } 26 | return linkObject 27 | } 28 | protected verifyAppletSrcLegitimacy(url: string): boolean { 29 | const capture = this.config.capture 30 | if (capture) return this.captureAppletSrc(url, capture) 31 | const allowHosts = this.config.allowHosts || [] 32 | if (allowHosts.length === 0) { 33 | return true 34 | } 35 | allowHosts.push(location.host) 36 | const link = new URL( 37 | decodeURIComponent(url), 38 | window.location.toString() 39 | ) 40 | const linkHost = link.host 41 | for (const host of allowHosts) { 42 | if (linkHost === host) return true 43 | } 44 | return false 45 | } 46 | protected captureAppletSrc(url: string, capture = this.config.capture): boolean { 47 | const resolve = this.resolveURL(url) 48 | const path = resolve.origin + resolve.pathname 49 | if (typeof capture === 'string') { 50 | if (capture === path) return true 51 | } else if (typeof capture === 'function') { 52 | if (capture(resolve, url)) return true 53 | } 54 | return false 55 | } 56 | protected promiseApplet(promise: () => Promise): Promise { 57 | return Promise.resolve(promise()) 58 | } 59 | public checkIsPresetAppletsView(id: string) { 60 | return this.residentApplets.includes(id) 61 | } 62 | public delPresetAppletsView(id: string, remove = false) { 63 | if (remove && !this.checkIsPresetAppletsView(id)) { 64 | const preset = this.presetAppletsView[id] 65 | if (preset && preset.parentElement) { 66 | preset.parentElement.removeChild(preset) 67 | } 68 | } 69 | delete this.presetAppletsView[id] 70 | } 71 | public setPrestAppletsView(presetApplets: PresetApplets) { 72 | this.residentApplets = Object.keys(presetApplets) 73 | this.presetAppletsView = presetApplets 74 | } 75 | public setting(options: ApplicationOptions): void { 76 | if (!options.applets.frameworks.config.prerender) { 77 | options.applets.frameworks.config.prerender = Object.keys(options.applets) 78 | } 79 | this.options = options 80 | } 81 | } 82 | 83 | export { 84 | ApplicationBase 85 | } 86 | -------------------------------------------------------------------------------- /src/Application/State.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationBase } from './Base' 2 | import { Applet } from '../types' 3 | 4 | interface ApplicationRouteInfo { 5 | id: string 6 | param: string 7 | search: string 8 | } 9 | 10 | class ApplicationState extends ApplicationBase { 11 | get route(): ApplicationRouteInfo { 12 | const router = this.routerRegExp.exec(location.hash) || [] 13 | const id = router[1] 14 | const param = router[2] 15 | const search = location.search 16 | 17 | return { 18 | id: id ? decodeURIComponent(id) : '', 19 | param: param ? decodeURIComponent(param) : '', 20 | search 21 | } 22 | } 23 | get exists(): boolean { 24 | try { 25 | return parseInt(sessionStorage.getItem(location.pathname + '__EXISTS') || '-1', 10) === history.length 26 | } catch (e) { 27 | return false 28 | } 29 | } 30 | get activityApplet(): Applet | undefined { 31 | const id = this.segue.id 32 | const applet = this.applets[id] 33 | return applet 34 | } 35 | get prevActivityApplet(): Applet | undefined { 36 | const id = this.segue.prevId 37 | const applet = this.applets[id] 38 | return applet 39 | } 40 | get activityLevel() { 41 | return (this.activityApplet?.level ?? 0) + 1 42 | } 43 | get isFullscreen(): boolean { 44 | return document.body.offsetHeight === screen.height 45 | } 46 | public overscrollHistoryNavigation = { 47 | moment: 0, 48 | type: '' 49 | } 50 | public properties = { 51 | darkTheme: window.matchMedia?.('(prefers-color-scheme: dark)')?.matches 52 | } 53 | public setExists(): Promise { 54 | const key = location.pathname + '__EXISTS' 55 | const len = String(history.length) 56 | return new Promise((resolve, reject) => { 57 | try { 58 | sessionStorage.setItem(key, len) 59 | resolve() 60 | } catch (e) { 61 | try { 62 | sessionStorage.clear() 63 | sessionStorage.setItem(key, len) 64 | } catch (error) { 65 | reject() 66 | } 67 | } 68 | }) 69 | } 70 | public activeState: 'active' | 'frozen' = 'active' 71 | public activate(active: boolean) { 72 | const state = active ? 'active' : 'frozen' 73 | const messageType = 'application-' + state 74 | if (window.applicationActiveState === state) return 75 | this.activeState = state 76 | this.trigger(state) 77 | window.applicationActiveState = state 78 | window.postMessage({ 79 | type: messageType 80 | }, '*') 81 | 82 | // Tunneling child app 83 | for (const id in this.applets) { 84 | const applet = this.applets[id] 85 | if (applet.sandbox && !applet.sameOrigin) { 86 | applet.triggerWindow(messageType) 87 | } 88 | } 89 | } 90 | } 91 | 92 | export { 93 | ApplicationState 94 | } 95 | -------------------------------------------------------------------------------- /src/Application/provider.ts: -------------------------------------------------------------------------------- 1 | import { Application } from './index' 2 | import { PushWindowOptions } from '../types' 3 | 4 | export default (app: Application): void => { 5 | window.addEventListener('message', (event: MessageEvent) => { 6 | if (event.source === window) return 7 | const { action, data } = event.data 8 | switch (action) { 9 | case 'to': 10 | app.segue.to(data.applet, data.query, data.history) 11 | break 12 | case 'back': 13 | app.segue.back() 14 | break 15 | case 'forward': 16 | app.segue.forward() 17 | break 18 | case 'pushWindow': 19 | app.pushWindow(...(data as PushWindowOptions)) 20 | break 21 | } 22 | }) 23 | return 24 | } 25 | -------------------------------------------------------------------------------- /src/Define/DefineApplet.ts: -------------------------------------------------------------------------------- 1 | import Preset from './preset' 2 | import { getEnv } from './env' 3 | import { buildAppletSlot } from './slot' 4 | import testHasScrolling from '../lib/util/testHasScrolling' 5 | import typeError from '../lib/typeError' 6 | 7 | export class DefineApplet extends HTMLElement { 8 | constructor() { 9 | super() 10 | } 11 | private installed = false 12 | public appletSlot!: HTMLSlotElement 13 | public getViewSlot() { 14 | return this.appletSlot 15 | } 16 | static get observedAttributes() { 17 | return ['applet-id'] 18 | } 19 | private get appletId() { 20 | return this.getAttribute('applet-id') 21 | } 22 | private defineApplet() { 23 | const id = this.appletId 24 | const { USE_SHADOW_DOM } = getEnv() 25 | if (!id) return 26 | /** 27 | * Obsolete 28 | * ------------- start ------------- 29 | */ 30 | // old; ios < 9 31 | if (!USE_SHADOW_DOM) { 32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 33 | Preset.appletsSlot[id] = this.appletSlot = this as any 34 | Preset.appletsDefinition[id] = this 35 | return 36 | } 37 | /** 38 | * Obsolete 39 | * ------------- end ------------- 40 | */ 41 | 42 | this.slot = 'applet-' + id 43 | this.appletSlot = buildAppletSlot(id) 44 | Preset.appletsDefinition[id] = this 45 | } 46 | attributeChangedCallback(name: string, oldValue: string, newValue: string): void { 47 | if (name === 'applet-id' && newValue) { 48 | // ${id}-obsolete 49 | if (oldValue && newValue.split('-')[0] !== oldValue.split('-')[0]) { 50 | typeError(1002, 'warn', `oldValue: ${oldValue}, newValue: ${newValue}`) 51 | typeError(1003, 'warn') 52 | } 53 | // some exception callbacks 54 | this.defineApplet?.() 55 | } 56 | } 57 | connectedCallback(): void { 58 | if (this.installed) return 59 | this.installed = true 60 | this.defineApplet() 61 | if (testHasScrolling() === false) { 62 | /** 63 | * Obsolete 64 | * ------------- start ------------- 65 | */ 66 | // ios < 12.55 bug 67 | setTimeout(() => { 68 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 69 | // @ts-ignore 70 | this.style.webkitOverflowScrolling = 'touch' 71 | }, 0) 72 | /** 73 | * Obsolete 74 | * ------------- end ------------- 75 | */ 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Define/DefineApplication.ts: -------------------------------------------------------------------------------- 1 | import Preset from './preset' 2 | import { getEnv } from './env' 3 | import { buildAppletSlot } from './slot' 4 | import typeError from '../lib/typeError' 5 | export class DefineApplication extends HTMLElement { 6 | private contentShadowRoot!: ShadowRoot 7 | private installed = false 8 | constructor() { 9 | super() 10 | } 11 | static get observedAttributes() { 12 | return ['default-applet'] 13 | } 14 | private init() { 15 | const { USE_SHADOW_DOM } = getEnv() 16 | if (this.contentShadowRoot) return 17 | /** 18 | * Obsolete 19 | * ------------- start ------------- 20 | */ 21 | if (USE_SHADOW_DOM) { 22 | this.contentShadowRoot = this.attachShadow?.({ mode: 'closed' }) 23 | } else { 24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 | this.contentShadowRoot = this as any 26 | } 27 | /** 28 | * Obsolete 29 | * ------------- end ------------- 30 | */ 31 | this.contentShadowRoot.appendChild(buildAppletSlot('system')) 32 | this.contentShadowRoot.appendChild(buildAppletSlot('frameworks')) 33 | Preset.root = this.contentShadowRoot 34 | Preset.appletsSpace = this 35 | } 36 | private get defaultApplet() { 37 | return this.getAttribute('default-applet') 38 | } 39 | private defineApplet() { 40 | if (!this.defaultApplet) return 41 | this.init() 42 | this.setupDefaultApplet(this.defaultApplet ?? 'home') 43 | } 44 | setupDefaultApplet(name: string) { 45 | this.contentShadowRoot.appendChild(buildAppletSlot(name)) 46 | Preset.defaultApplet = name 47 | Preset.awaitCallback?.() 48 | } 49 | attributeChangedCallback(name: string, oldValue: string, newValue: string): void { 50 | if (name === 'default-applet' && newValue) { 51 | if (oldValue && oldValue !== newValue) { 52 | typeError(1004, 'warn') 53 | } 54 | // some exception callbacks 55 | this.defineApplet?.() 56 | } 57 | } 58 | connectedCallback(): void { 59 | if (this.installed) return 60 | this.installed = true 61 | this.defineApplet() 62 | } 63 | } -------------------------------------------------------------------------------- /src/Define/env.ts: -------------------------------------------------------------------------------- 1 | import needsPolyfill from '../lib/wc/needsPolyfill' 2 | 3 | /** 4 | * Obsolete 5 | * ------------- start ------------- 6 | */ 7 | let _USE_SHADOW_DOM = true 8 | if (needsPolyfill && /iPhone OS [0-9]_\d(_\d)? like mac os x/ig.exec(navigator.userAgent)) { 9 | _USE_SHADOW_DOM = false 10 | } 11 | 12 | if (window.__LATH_NO_SHADOW_DOM__) { 13 | _USE_SHADOW_DOM = false 14 | } 15 | 16 | /** 17 | * Obsolete 18 | * ------------- end ------------- 19 | */ 20 | 21 | let _USE_PERCENTAGE = false 22 | interface EnvOptions { 23 | USE_SHADOW_DOM?: boolean, 24 | USE_PERCENTAGE?: boolean 25 | } 26 | 27 | export function setEnv(options: EnvOptions) { 28 | _USE_SHADOW_DOM = options.USE_SHADOW_DOM ?? _USE_SHADOW_DOM 29 | _USE_PERCENTAGE = options.USE_PERCENTAGE ?? _USE_PERCENTAGE 30 | } 31 | export function getEnv() { 32 | return { 33 | USE_SHADOW_DOM: _USE_SHADOW_DOM, 34 | USE_PERCENTAGE: _USE_PERCENTAGE 35 | } 36 | } -------------------------------------------------------------------------------- /src/Define/index.ts: -------------------------------------------------------------------------------- 1 | import Preset from './preset' 2 | import { AppletAllTypeSettings, PresetConfig, Application } from '../types' 3 | import typeError from '../lib/typeError' 4 | import autoScrollPolyfill from '../Scroll/polyfill' 5 | import loadWebAnimations from '../lib/webAnimations/load' 6 | import { initApplication } from './init' 7 | 8 | import('..').catch((e) => { 9 | console.warn(e) 10 | }) 11 | autoScrollPolyfill() 12 | loadWebAnimations() 13 | 14 | export * from './env' 15 | export const destroyApplication = () => { 16 | if (!Preset.appletsSpace) return 17 | const appletsSpace = Preset.appletsSpace 18 | const parentElement = appletsSpace.parentElement 19 | const wrapper = document.createElement('div') 20 | parentElement?.insertBefore(wrapper, appletsSpace) 21 | const childLength = appletsSpace.children.length 22 | for (let i = 0; i <= childLength; i++) { 23 | wrapper.appendChild(appletsSpace.children[i]) 24 | } 25 | } 26 | 27 | export const createApplication = async (options: Partial = { tunneling: false }): Promise => { 28 | initApplication() 29 | /** 30 | * Obsolete 31 | */ 32 | await autoScrollPolyfill() 33 | await loadWebAnimations() 34 | if (Preset.__EXISTING__) return Promise.reject('repeat') 35 | if (options.tunneling && !!window.__LATH_APPLICATION_AVAILABILITY__) return Promise.reject('tunneling') 36 | Preset.__EXISTING__ = true 37 | let Application 38 | try { 39 | ({ Application } = await import('..')) 40 | } catch (error) { 41 | console.warn(error) 42 | return Promise.reject(error) 43 | } 44 | const { tunneling = false, zIndex = undefined, applets = {} as Required['applets'] } = options 45 | if (!Preset.root) { 46 | setTimeout(() => { 47 | if (!Preset.root) { 48 | typeError(1005, 'return') 49 | } 50 | }, 5000) 51 | await Preset.awaitDefine() 52 | } 53 | const { root, defaultApplet, appletsSpace } = Preset 54 | if (!root) { 55 | return Promise.reject(typeError(1005, 'return')) 56 | } 57 | const application = new Application({ root, tunneling, zIndex, appletsSpace }) 58 | const index = applets?.frameworks?.config?.index 59 | if (!Preset.appletsDefinition[defaultApplet]) { 60 | return Promise.reject(typeError(1006, 'return')) 61 | } 62 | if (!applets.frameworks) { 63 | applets.frameworks = { 64 | config: { 65 | level: 0, 66 | index: defaultApplet, 67 | singleFlow: false, 68 | singleLock: false, 69 | animation: 'slide', 70 | transientTimeout: 1800000, 71 | disableTransient: true 72 | } 73 | } 74 | if (!applets[defaultApplet]) { 75 | applets[defaultApplet] = { 76 | config: { 77 | level: 1, 78 | title: document.title, 79 | free: false, 80 | animation: 'slide', 81 | background: true 82 | } 83 | } as AppletAllTypeSettings 84 | } 85 | } 86 | if (index && !applets[index]) { 87 | applets[index] = { 88 | config: { 89 | level: 1, 90 | title: document.title, 91 | free: false, 92 | animation: 'slide', 93 | background: true 94 | } 95 | } as AppletAllTypeSettings 96 | } 97 | for (const name in Preset.appletsDefinition) { 98 | if (!applets[name]) { 99 | applets[name] = { 100 | config: { 101 | level: 1, 102 | title: document.title, 103 | free: false, 104 | animation: 'slide', 105 | background: true 106 | } 107 | } as AppletAllTypeSettings 108 | } 109 | } 110 | application.setting({ 111 | applets 112 | }) 113 | 114 | await application.start() 115 | application.setPrestAppletsView(Preset.appletsDefinition) 116 | return Promise.resolve(application) 117 | } 118 | -------------------------------------------------------------------------------- /src/Define/init.ts: -------------------------------------------------------------------------------- 1 | import './prepare' 2 | // 1. import custom-elements iOS < 10.2 3 | import '@webcomponents/custom-elements' 4 | 5 | // 2. define 6 | import { DefineApplet } from './DefineApplet' 7 | import { DefineApplication } from './DefineApplication' 8 | import typeError from '../lib/typeError' 9 | import needsPolyfill from '../lib/wc/needsPolyfill' 10 | 11 | if (needsPolyfill) { 12 | import('@webcomponents/webcomponentsjs').catch((e) => { 13 | console.warn(e) 14 | }) 15 | } 16 | 17 | export const initApplication = () => { 18 | const defineElements = () => { 19 | if (customElements.get('define-applet') || customElements.get('define-application')) { 20 | typeError(1003) 21 | return 22 | } 23 | customElements.define('define-applet', DefineApplet) 24 | customElements.define('define-application', DefineApplication) 25 | } 26 | if (needsPolyfill) { 27 | import('@webcomponents/webcomponentsjs').then(defineElements).catch((e) => { 28 | console.warn(e) 29 | }) 30 | } else { 31 | defineElements() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Define/prepare.ts: -------------------------------------------------------------------------------- 1 | import needsPolyfill from '../lib/wc/needsPolyfill' 2 | 3 | // The core logic is separated from the above-the-fold dependency. 4 | import('..').catch((e) => { 5 | console.warn(e) 6 | }) 7 | 8 | /** 9 | * Android 4.0 & iOS 10.2 and below require Polyfill. 10 | * Currently, less than 0.1% of device types are used, so this section is loaded on demand. 11 | */ 12 | if (needsPolyfill) { 13 | import('@webcomponents/webcomponentsjs').catch((e) => { 14 | console.warn(e) 15 | }) 16 | } 17 | 18 | if (typeof Element.prototype.scrollTo !== 'function') { 19 | import('scroll-polyfill/auto').catch((e) => { 20 | console.warn(e) 21 | }) 22 | } -------------------------------------------------------------------------------- /src/Define/preset.ts: -------------------------------------------------------------------------------- 1 | import { DefineApplet } from '../types' 2 | 3 | interface PresetConst { 4 | root?: ShadowRoot 5 | appletsDefinition: { 6 | [key: string]: DefineApplet 7 | } 8 | appletsSlot: { 9 | [key: string]: HTMLSlotElement 10 | } 11 | appletsSpace?: HTMLElement 12 | defaultApplet: string 13 | awaitCallback?: () => void 14 | awaitDefine: () => Promise 15 | __EXISTING__: boolean 16 | } 17 | 18 | const Preset: PresetConst = { 19 | appletsDefinition: {}, 20 | appletsSlot: {}, 21 | defaultApplet: 'home', 22 | awaitDefine: () => new Promise((resolve) => Preset.awaitCallback = resolve), 23 | __EXISTING__: false 24 | } 25 | 26 | export default Preset -------------------------------------------------------------------------------- /src/Define/slot.ts: -------------------------------------------------------------------------------- 1 | import Preset from './preset' 2 | 3 | export const buildAppletSlot = (name: string) => { 4 | if (Preset.appletsSlot[name]) { 5 | return Preset.appletsSlot[name] 6 | } 7 | const appletSlot = document.createElement('slot') 8 | appletSlot.name = 'applet-' + name 9 | Preset.appletsSlot[name] = appletSlot 10 | return appletSlot 11 | } 12 | -------------------------------------------------------------------------------- /src/Event/index.ts: -------------------------------------------------------------------------------- 1 | import { TriggerEventTypes, TriggerEventCallbackArgs, TriggerEventCallback } from '../types' 2 | 3 | type EventMap = { 4 | [key in TriggerEventTypes]?: TriggerEventCallback[] 5 | } 6 | 7 | const EventGroupSymbol = Symbol('__EventGroupName__') 8 | class EventProvider { 9 | private _events: EventMap = {} 10 | public on>(type: T, fn: F, groupName?: string): this { 11 | Object.defineProperty(fn, EventGroupSymbol, { value: groupName, writable: true }) 12 | if (!this._events[type]) this._events[type] = [] 13 | this._events[type]?.push(fn) 14 | return this 15 | } 16 | 17 | public one>(type: T, fn: F, groupName?: string): this { 18 | const once: TriggerEventCallback = ((...args: TriggerEventCallbackArgs) => { 19 | fn(...args) 20 | this.off(type, once) 21 | }) 22 | Object.defineProperty(once, EventGroupSymbol, { value: groupName, writable: true }) 23 | 24 | if (!this._events[type]) this._events[type] = [] 25 | this._events[type]?.push(once) 26 | 27 | return this 28 | } 29 | 30 | public off>(type: T, fn: F): this { 31 | if (!this._events[type]) return this 32 | const index = this._events[type]?.indexOf(fn) ?? -1 33 | if (index > -1) this._events[type]?.splice(index, 1) 34 | return this 35 | } 36 | 37 | public removeEventGroup(groupName: string): this { 38 | const filter = (t: T) => { 39 | return (this._events[t] as (TriggerEventCallback & { [EventGroupSymbol]: string })[] | undefined)?.filter((fn) => { 40 | return fn[EventGroupSymbol] !== groupName 41 | }) 42 | } 43 | 44 | for (const type in this._events) { 45 | this._events[type as TriggerEventTypes] = filter(type as TriggerEventTypes) 46 | } 47 | 48 | return this 49 | } 50 | 51 | public trigger(type: T, ...args: TriggerEventCallbackArgs): this { 52 | if (!this._events[type]) return this 53 | 54 | this._events[type]?.forEach((fn) => { 55 | try { 56 | fn(...args) 57 | } catch (e) { 58 | this.off(type, fn) 59 | this.trigger('error', e) 60 | } 61 | }) 62 | return this 63 | } 64 | } 65 | 66 | export { 67 | EventProvider 68 | } 69 | -------------------------------------------------------------------------------- /src/Modality/Base.ts: -------------------------------------------------------------------------------- 1 | import { Applet, Application, SheetOptions, SmoothScroller } from '../types' 2 | class ModalityBase { 3 | public applet: Applet 4 | public application: Application 5 | protected scroller!: SmoothScroller 6 | protected switching = false 7 | protected scrolling = false 8 | protected maxDegreeCache?: number 9 | public backdropReducedScale = 0.1 10 | public backdropRotateX = -10 11 | public backdropPerspective = 3000 12 | public backdropBorderRadius = 20 13 | public advanceDegree = 800 / window.innerHeight * 0.03 14 | public options?: SheetOptions 15 | public modalityContainer: HTMLElement 16 | public contentContainer!: HTMLElement 17 | public miniCard?: HTMLElement 18 | public defaultToLarge?: boolean 19 | public appletViewport: HTMLElement 20 | public fromViewports?: Array 21 | public modalityOverlay!: HTMLElement 22 | public modalityPlaceholder!: HTMLElement 23 | constructor(applet: Applet) { 24 | this.applet = applet 25 | this.application = this.applet.application 26 | this.modalityContainer = document.createElement('modality-container') 27 | this.appletViewport = this.applet.viewport as HTMLElement 28 | this.options = this.applet.config.sheetOptions 29 | this.defaultToLarge = this.options?.defaultCardSize === 'large' 30 | } 31 | } 32 | 33 | export { 34 | ModalityBase 35 | } 36 | -------------------------------------------------------------------------------- /src/Modality/EventTarget.ts: -------------------------------------------------------------------------------- 1 | import { ModalityState } from './State' 2 | import { setTimeout, testHasSmoothSnapScrolling, testHasSnapReset, sleep } from '../lib/util' 3 | 4 | /** 5 | * Obsolete 6 | * ------------- start ------------- 7 | */ 8 | // old; ios < 9 9 | const NoSmoothSnapScrolling = testHasSmoothSnapScrolling() === false 10 | // old; ios < 15 11 | const HasSnapReset = testHasSnapReset() 12 | /** 13 | * Obsolete 14 | * ------------- end ------------- 15 | */ 16 | class ModalityEventTarget extends ModalityState { 17 | private scrollingTimeoutId = -1 18 | private freezePosition = -1 19 | // old; ios < 15 20 | private fixSnapReset = HasSnapReset ? false : true 21 | private fallHeight = this.application.isFullscreen ? 40 : 0 22 | private processPromise!: Promise 23 | private async switchSheet(hideThresholdScale = 1.5): Promise { 24 | if (!this.activity) return Promise.resolve() 25 | if (this.degree <= this.maxDegree / hideThresholdScale) { 26 | return this.hide() 27 | } else { 28 | return this.rise() 29 | } 30 | } 31 | private switchOverlay(open = false): void { 32 | this.modalityOverlay.style.display = open ? 'block' : 'none' 33 | } 34 | private switchSmooth(open = true): void { 35 | this.modalityContainer.style.scrollBehavior = open ? 'smooth' : 'auto' 36 | } 37 | private switchSnap(open = true): void { 38 | this.modalityContainer.style.scrollSnapType = open ? 'y mandatory' : 'none' 39 | } 40 | private switchBackdropColor(open = true): void { 41 | this.application.segue.applicationViewport.style.backgroundColor = open ? this.options?.backdropColor ?? '#000' : '' 42 | } 43 | public freezeSnap() { 44 | this.modalityPlaceholder.style.display = 'none' 45 | this.modalityContainer.scrollTop = 0 46 | } 47 | public async activateSnap(): Promise { 48 | this.switchSmooth(false) 49 | /** 50 | * Obsolete 51 | * ------------- start ------------- 52 | */ 53 | if (!HasSnapReset) this.switchSnap(false) 54 | /** 55 | * Obsolete 56 | * ------------- end ------------- 57 | */ 58 | this.modalityPlaceholder.style.display = 'flex' 59 | this.modalityContainer.scrollTop = this.modalityContainer.offsetHeight + (this.miniCard?.offsetHeight ?? 0) 60 | await sleep(0) 61 | this.switchSmooth(true) 62 | /** 63 | * Obsolete 64 | * ------------- start ------------- 65 | */ 66 | // old; ios < 15 67 | if (!HasSnapReset) this.switchSnap(true) 68 | /** 69 | * Obsolete 70 | * ------------- end ------------- 71 | */ 72 | } 73 | public async rise(): Promise { 74 | if (this.switching) return this.processPromise 75 | this.switching = true 76 | this.switchOverlay(true) 77 | const offsetTop = this.modalityContainer.offsetHeight + (this.defaultToLarge ? this.contentContainer.offsetHeight : 0) + (this.miniCard ? (this.degree >= 1 + (this.maxDegree - 1) / 2 ? (this.miniCard?.offsetHeight ?? 0) : 0) : 0) 78 | return this.processPromise = this.scroller.snapTo(0, offsetTop).then(() => { 79 | this.switching = false 80 | this.switchOverlay(false) 81 | }) 82 | } 83 | public async fall(): Promise { 84 | if (this.switching) return this.processPromise 85 | this.switching = true 86 | this.switchOverlay(true) 87 | return this.processPromise = this.scroller.snapTo(0, 0).then(async () => { 88 | this.switching = false 89 | this.switchOverlay(false) 90 | }) 91 | } 92 | public async hide(): Promise { 93 | if (this.switching) return this.processPromise 94 | this.switching = true 95 | if (!this.activity) return Promise.resolve() 96 | this.removeSlidingEvent() 97 | this.segueTransition(false) 98 | return this.fall().then(async () => { 99 | this.switching = false 100 | if (this.activity) { 101 | await this.application.segue.back() 102 | } 103 | }) 104 | } 105 | private segueTransition(show = true) { 106 | const duration = 500 107 | const delay = 100 // wait switchBackdropColor 108 | const prevViewport = this.prevViewport 109 | const relativeDegree = show ? (this.miniCard && !this.defaultToLarge ? 0 : 1) : 0 110 | const options = this.options 111 | const darkness = options?.maskOpacity ?? 0.3 112 | const useFade = options?.useFade 113 | // stillBackdrop 114 | const stillBackdrop = this.options?.stillBackdrop 115 | if (!stillBackdrop) { 116 | prevViewport.animate([ 117 | { 118 | borderRadius: `${relativeDegree * this.backdropBorderRadius}px`, 119 | transform: ` 120 | translate3d(0, ${relativeDegree * this.fallHeight}px, -100px) 121 | perspective(${this.backdropPerspective}px) 122 | rotateX(${relativeDegree * this.backdropRotateX}deg) 123 | scale(${1 - Math.max(relativeDegree * this.backdropReducedScale, 0)}) 124 | ` 125 | } 126 | ], { 127 | duration, 128 | delay, 129 | fill: 'forwards' 130 | }).play() 131 | } 132 | this.modalityContainer.animate([ 133 | { 134 | backgroundColor: `rgba(0, 0, 0, ${show ? darkness : 0})` 135 | } 136 | ], { 137 | duration, 138 | delay, 139 | fill: 'forwards' 140 | }).play() 141 | // reset opacity while "hide" 142 | if (useFade && !show) { 143 | // this.contentContainer.style.opacity = '1' 144 | this.contentContainer.animate([ 145 | { 146 | opacity: '1' 147 | } 148 | ], { 149 | duration: 0, 150 | fill: 'forwards' 151 | }).play() 152 | } 153 | } 154 | private sliding() { 155 | // this.activity: Prevents asynchronous operations from resetting closed views 156 | if (!this.activity) return 157 | const degree = this.degree 158 | const maxDegree = this.maxDegree 159 | const prevViewport = this.prevViewport 160 | const options = this.options 161 | const darkness = options?.maskOpacity ?? 0.3 162 | const useFade = options?.useFade 163 | if (degree > maxDegree) { 164 | this.contentContainer.animate([ 165 | { 166 | backgroundColor: `rgba(0, 0, 0, ${darkness + (1 - darkness) * (degree - maxDegree) / 0.2})` 167 | } 168 | ], { 169 | duration: 0, 170 | fill: 'forwards' 171 | }).play() 172 | return 173 | } 174 | // if miniCard. 175 | const relativeDegree = this.miniCard ? (degree - 1) / (maxDegree - 1) : degree 176 | // stillBackdrop 177 | const stillBackdrop = this.options?.stillBackdrop ?? (this.miniCard && degree <= 1) 178 | // this.activity: Prevents asynchronous operations from resetting closed views 179 | if (this.activity && prevViewport && !stillBackdrop) { 180 | prevViewport.animate([ 181 | { 182 | borderRadius: `${Math.min(relativeDegree, 1) * this.backdropBorderRadius}px`, 183 | transform: ` 184 | translate3d(0, ${relativeDegree * this.fallHeight}px, -100px) 185 | perspective(${this.backdropPerspective}px) 186 | rotateX(${relativeDegree * this.backdropRotateX}deg) 187 | scale(${1 - Math.max(relativeDegree * this.backdropReducedScale, 0)}) 188 | ` 189 | } 190 | ], { 191 | duration: 0, 192 | fill: 'forwards' 193 | }).play() 194 | } 195 | this.modalityContainer.animate([ 196 | { 197 | backgroundColor: `rgba(0, 0, 0, ${darkness * Math.min(degree, 1)})` 198 | } 199 | ], { 200 | duration: 0, 201 | fill: 'forwards' 202 | }).play() 203 | if (useFade) { 204 | this.contentContainer.animate([ 205 | { 206 | opacity: `${relativeDegree - ((1 - relativeDegree) * 2)}` 207 | } 208 | ], { 209 | duration: 0, 210 | fill: 'forwards' 211 | }).play() 212 | } 213 | 214 | this.scrolling = true 215 | clearTimeout(this.scrollingTimeoutId) 216 | this.scrollingTimeoutId = setTimeout(() => { 217 | this.scrolling = false 218 | /** 219 | * Obsolete 220 | * ------------- start ------------- 221 | */ 222 | // old; ios < 9 223 | if (NoSmoothSnapScrolling) { 224 | if (this.degree === degree) { 225 | if (degree <= 0.7) { 226 | this.hide() 227 | } else { 228 | this.rise() 229 | } 230 | } 231 | } 232 | /** 233 | * Obsolete 234 | * ------------- end ------------- 235 | */ 236 | }, 100) 237 | // when triggered by a blocked holder 238 | if (this.switching) return 239 | if (degree <= this.advanceDegree) { 240 | this.hide() 241 | } 242 | } 243 | private slidingListener = this.sliding.bind(this) 244 | private bindSlidingEvent(): void { 245 | this.modalityContainer.addEventListener('scroll', this.slidingListener) 246 | } 247 | private removeSlidingEvent(): void { 248 | this.modalityContainer.removeEventListener('scroll', this.slidingListener) 249 | } 250 | protected bindDragContentEvent(): void { 251 | const dragContent = this.applet.contentDocument 252 | if (!dragContent) return 253 | const startPoint: { 254 | x: number 255 | y: number 256 | swipe: boolean | undefined 257 | } = { 258 | x: 0, 259 | y: 0, 260 | swipe: undefined 261 | } 262 | const modalityContainer = this.modalityContainer 263 | const speedRate = modalityContainer.offsetHeight / modalityContainer.offsetWidth 264 | dragContent.addEventListener('touchstart', (event: Event) => { 265 | const { changedTouches } = event as TouchEvent 266 | const touch = changedTouches[0] 267 | startPoint.x = touch.pageX 268 | startPoint.y = touch.pageY 269 | startPoint.swipe = undefined 270 | }, true) 271 | dragContent.addEventListener('touchend', async () => { 272 | if (!startPoint.swipe) return 273 | startPoint.swipe = false 274 | if (!await this.checkScrollStop()) return 275 | this.switchSmooth(true) 276 | this.switchSnap(true) 277 | this.switchSheet(this.miniCard ? 2 : 1.25).then(() => { 278 | this.switchOverlay(false) 279 | this.switching = false 280 | }) 281 | }, true) 282 | dragContent.addEventListener('touchmove', (event: Event) => { 283 | const { changedTouches } = event as TouchEvent 284 | const touch = changedTouches[0] 285 | const deltaX = touch.pageX - startPoint.x 286 | const deltaY = touch.pageY - startPoint.y 287 | if (this.switching) return 288 | if (startPoint.swipe === false) return 289 | if (Math.abs(deltaX) - Math.abs(deltaY) > 20 && Math.abs(deltaY) <= 10) { 290 | startPoint.swipe = true 291 | this.switchOverlay(true) 292 | this.switchSmooth(false) 293 | this.switchSnap(false) 294 | } 295 | if (startPoint.swipe) { 296 | modalityContainer.scrollTop -= Math.ceil(deltaX) * speedRate 297 | startPoint.x = touch.pageX 298 | startPoint.y = touch.pageY 299 | } 300 | }, true) 301 | } 302 | protected bindBaseEvent() { 303 | this.applet.on('willShow', () => { 304 | this.switchBackdropColor(true) 305 | if (this.application.segue.stackUp) { 306 | this.fromViewports = undefined 307 | } 308 | }) 309 | this.applet.on('willSegueShow', () => { 310 | this.segueTransition(true) 311 | }) 312 | this.applet.on('show', () => { 313 | this.bindSlidingEvent() 314 | }) 315 | this.applet.on('willHide', () => { 316 | this.removeSlidingEvent() 317 | }) 318 | this.applet.on('willSegueHide', () => { 319 | this.segueTransition(false) 320 | }) 321 | this.applet.on('hide', () => { 322 | this.switchBackdropColor(false) 323 | }) 324 | } 325 | public freeze(): void { 326 | this.removeSlidingEvent() 327 | if (this.fixSnapReset === false) { 328 | // (offsetTop) ios < 15, Get the scroll position of the snap as 0. 329 | this.freezePosition = this.modalityContainer.scrollTop || this.contentContainer.offsetTop 330 | this.modalityPlaceholder.style.display = 'none' 331 | } 332 | } 333 | public activate(): void { 334 | if (this.fixSnapReset === false && this.freezePosition !== -1) { 335 | this.modalityPlaceholder.style.display = 'block' 336 | const freezePosition = this.freezePosition 337 | this.modalityContainer.scrollTop = freezePosition 338 | this.scroller.snapTo(0, freezePosition, 0) 339 | this.freezePosition = -1 340 | // The snap will be automatically repaired on the second execution. 341 | this.fixSnapReset = true 342 | } 343 | this.bindSlidingEvent() 344 | } 345 | } 346 | 347 | export { 348 | ModalityEventTarget 349 | } 350 | -------------------------------------------------------------------------------- /src/Modality/State.ts: -------------------------------------------------------------------------------- 1 | import { ModalityBase } from './Base' 2 | 3 | class ModalityState extends ModalityBase { 4 | /** 5 | * Gets the view of the two switched windows during a transition. 6 | * The value should be cached because it needs to be returned from the state. 7 | * If the previous view was a modal box, you need to replace it with the previous non-modal box view. 8 | */ 9 | get viewports() { 10 | if (this.fromViewports && this.activity) { 11 | return this.fromViewports 12 | } 13 | this.fromViewports = this.application.segue.viewports 14 | this.updateOverlapViewport() 15 | return this.fromViewports 16 | } 17 | get maxDegree() { 18 | if (this.maxDegreeCache) return this.maxDegreeCache 19 | const miniCardHeight = this.miniCard?.offsetHeight ?? 0 20 | return this.maxDegreeCache = 1 + miniCardHeight / this.contentContainer.offsetHeight 21 | } 22 | get degree() { 23 | return this.modalityContainer.scrollTop / Math.min(this.contentContainer.offsetHeight + (this.miniCard?.offsetHeight ?? 0), this.modalityContainer.offsetHeight) 24 | } 25 | get visibility(): boolean { 26 | return this.degree <= 0.1 ? false : true 27 | } 28 | get hasMiniCard(): boolean { 29 | return !!this.miniCard 30 | } 31 | get prevViewport(): HTMLElement { 32 | return this.viewports[1] 33 | } 34 | get activity(): boolean { 35 | return this.applet.transforming || this.application.activityApplet === this.applet 36 | } 37 | private setBackdropViewport(viewport: HTMLElement): void { 38 | if (!this.fromViewports) { 39 | this.fromViewports = this.viewports 40 | } 41 | this.fromViewports[1] = viewport 42 | } 43 | private updateOverlapViewport(): void { 44 | const prevActivityApplet = this.application.prevActivityApplet 45 | if (prevActivityApplet?.modality) { 46 | this.setBackdropViewport(prevActivityApplet.modality.viewports[1]) 47 | } 48 | } 49 | protected checkScrollStop(): Promise { 50 | return new Promise((resolve) => { 51 | const waitScrollEnd = (callback: () => void) => { 52 | setTimeout(() => this.scrolling ? waitScrollEnd(callback) : callback(), 100) 53 | } 54 | waitScrollEnd(() => resolve(true)) 55 | }) 56 | } 57 | } 58 | 59 | export { 60 | ModalityState 61 | } 62 | -------------------------------------------------------------------------------- /src/Modality/View.ts: -------------------------------------------------------------------------------- 1 | import { ModalityEventTarget } from './EventTarget' 2 | import { SmoothScroller } from "../Scroll" 3 | import { setTimeout, testHasScrolling, getCSSUnits } from '../lib/util' 4 | import { getIOSversion } from '../lib/util/getIOSVersion' 5 | import { fullscreenBaseCSSText } from '../lib/cssText/fullscreenBaseCSSText' 6 | import { baseCSSText } from './cssText' 7 | import { Applet } from '../types' 8 | 9 | /** 10 | * Obsolete 11 | * ------------- start ------------- 12 | */ 13 | // old; ios < 9 14 | const NoScrolling = testHasScrolling() === false 15 | /** 16 | * Obsolete 17 | * ------------- end ------------- 18 | */ 19 | class ModalityView extends ModalityEventTarget { 20 | constructor(applet: Applet) { 21 | super(applet) 22 | this.scroller = new SmoothScroller(this.modalityContainer as HTMLElement) 23 | this.buildModalityOverlay() 24 | applet.viewport?.appendChild(this.modalityContainer) 25 | } 26 | private buildModalityOverlay() { 27 | const modalityOverlay = document.createElement('modality-overlay') 28 | modalityOverlay.style.cssText = ` 29 | position: fixed; 30 | ${fullscreenBaseCSSText} 31 | z-index: 9; 32 | display: none; 33 | ` 34 | modalityOverlay.addEventListener('touchstart', (event) => { 35 | // Prevent interruptions due to Alert, etc., from not closing the applet properly. 36 | if (!this.options?.stillBackdrop && this.degree <= this.advanceDegree && this.activity) { 37 | this.hide() 38 | } 39 | event.stopPropagation() 40 | event.preventDefault() 41 | }, true) 42 | this.modalityOverlay = modalityOverlay 43 | } 44 | 45 | public create(): HTMLElement { 46 | const viewport = this.appletViewport 47 | const modalityContainer = this.modalityContainer 48 | const containerFirstChild = modalityContainer.firstChild 49 | const modalityPlaceholder = document.createElement('modality-placeholder') 50 | const modalityHandle = document.createElement('modality-handle') 51 | const blockedHolder = document.createElement('blocked-holder') 52 | const contentContainer = document.createElement('applet-container') 53 | const modalityStyle = document.createElement('style') 54 | const options = this.options 55 | const hasMiniCard = !!options?.miniCardHeight && !NoScrolling 56 | const miniCardHeight = getCSSUnits(options?.miniCardHeight) || '0px' 57 | const blockedHolderWidth = getCSSUnits(options?.blockedHolderWidth) || '40px' 58 | const top = getCSSUnits(options?.top) || '60px' 59 | const maskClosable = options?.maskClosable 60 | const noHandlebar = options?.noHandlebar 61 | const borderRadius = getCSSUnits(options?.borderRadius) || '16px' 62 | modalityStyle.innerHTML = baseCSSText 63 | contentContainer.style.cssText = ` 64 | position: relative; 65 | border: 0px; 66 | outline: 0px; 67 | min-width: 100%; 68 | min-height: calc(100% - ${top}); 69 | max-height: calc(100% - ${top}); 70 | border-top-left-radius: ${borderRadius}; 71 | border-top-right-radius: ${borderRadius}; 72 | scroll-snap-align: start; 73 | scroll-snap-stop: always; 74 | overflow: hidden; 75 | transform: translate3d(0, 0, 500px); 76 | ` 77 | viewport.appendChild(modalityStyle) 78 | viewport.appendChild(blockedHolder) 79 | viewport.appendChild(this.modalityOverlay) 80 | modalityPlaceholder.appendChild(modalityHandle) 81 | // set blockedHolder width 82 | blockedHolder.style.width = blockedHolderWidth 83 | if (containerFirstChild) { 84 | modalityContainer.insertBefore(modalityPlaceholder, containerFirstChild) 85 | } else { 86 | modalityContainer.appendChild(modalityPlaceholder) 87 | } 88 | /** 89 | * Obsolete 90 | * ------------- start ------------- 91 | */ 92 | // ios < 12.55 bug 93 | if (NoScrolling) { 94 | modalityContainer.style.cssText += '-webkit-overflow-scrolling: touch;' 95 | } 96 | /** 97 | * Obsolete 98 | * ------------- end ------------- 99 | */ 100 | if (hasMiniCard) { 101 | const miniCard = document.createElement('modality-mini-card') 102 | miniCard.style.cssText = ` 103 | width: 100%; 104 | min-height: calc(100% - ${miniCardHeight}); 105 | scroll-snap-align: start; 106 | scroll-snap-stop: always; 107 | ` 108 | modalityContainer.appendChild(miniCard) 109 | modalityHandle.style.top = `calc(200% - ${miniCardHeight})` 110 | this.miniCard = miniCard 111 | } 112 | if (maskClosable !== false) { 113 | modalityPlaceholder.addEventListener('click', () => { 114 | this.hide() 115 | }) 116 | this.miniCard?.addEventListener('click', () => { 117 | this.hide() 118 | }) 119 | } 120 | if (noHandlebar) { 121 | modalityHandle.style.display = 'none' 122 | } 123 | 124 | if (this.options?.swipeClosable ?? getIOSversion()) { 125 | setTimeout(() => { 126 | this.bindDragContentEvent() 127 | }, 10) 128 | } 129 | this.bindBaseEvent() 130 | modalityContainer.appendChild(contentContainer) 131 | this.modalityPlaceholder = modalityPlaceholder 132 | return this.contentContainer = contentContainer 133 | } 134 | } 135 | 136 | export { 137 | ModalityView 138 | } 139 | -------------------------------------------------------------------------------- /src/Modality/cssText.ts: -------------------------------------------------------------------------------- 1 | import { coveredCSSText } from '../lib/cssText/coveredCSSText' 2 | 3 | export const baseCSSText = ` 4 | applet-viewport { 5 | background: transparent !important; 6 | } 7 | modality-container { 8 | display: flex; 9 | height: 100%; 10 | overflow-y: scroll !important; 11 | scroll-behavior: smooth; 12 | scroll-snap-type: y mandatory; 13 | flex-direction: column; 14 | } 15 | modality-container::-webkit-scrollbar { 16 | display: none; 17 | } 18 | modality-container::scrollbar { 19 | display: none; 20 | } 21 | modality-placeholder { 22 | position: relative; 23 | display: block; 24 | ${coveredCSSText} 25 | scroll-snap-align: start; 26 | scroll-snap-stop: always; 27 | box-sizing: content-box; 28 | } 29 | modality-handle { 30 | display: block; 31 | position: absolute; 32 | top: 100%; 33 | height: 20px; 34 | width: 100%; 35 | z-index: 2; 36 | } 37 | modality-handle::before { 38 | content: ' '; 39 | display: block; 40 | width: 36px; 41 | height: 5px; 42 | margin: 5px auto; 43 | border-radius: 5px; 44 | background: #777; 45 | opacity: .5; 46 | } 47 | modality-handle:hover::before { 48 | opacity: 1; 49 | } 50 | blocked-holder { 51 | position: absolute; 52 | top: 200px; 53 | left: 0; 54 | bottom: 200px; 55 | width: 40px; 56 | z-index: 5; 57 | overflow-y: hidden; 58 | overflow-x: scroll; 59 | overscroll-behavior-y: none; 60 | display: flex; 61 | flex-flow: row-reverse; 62 | } 63 | blocked-holder::-webkit-scrollbar { 64 | display: none; 65 | } 66 | blocked-holder::scrollbar { 67 | display: none; 68 | } 69 | blocked-holder::before { 70 | content: ' '; 71 | display: flex; 72 | min-width: 100vw; 73 | height: 100%; 74 | } 75 | ` -------------------------------------------------------------------------------- /src/Modality/index.ts: -------------------------------------------------------------------------------- 1 | import { ModalityView } from "./View" 2 | class Modality extends ModalityView { 3 | } 4 | 5 | export { 6 | Modality 7 | } 8 | -------------------------------------------------------------------------------- /src/Sandbox/index.ts: -------------------------------------------------------------------------------- 1 | class Sandbox { 2 | public sandbox: HTMLIFrameElement 3 | public setting?: string 4 | private readonly blankURL = 'about:blank' 5 | constructor(uri?: string, setting?: string, type: 'src' | 'source' = 'src') { 6 | const sandbox = this.sandbox = document.createElement('iframe') 7 | sandbox[type === 'source' && uri ? 'srcdoc' : 'src'] = uri || this.blankURL 8 | sandbox.style.display = 'none' 9 | this.setting = setting 10 | return this 11 | } 12 | get window() { 13 | return this.sandbox.contentWindow as Window 14 | } 15 | get document() { 16 | return this.sandbox.contentDocument as Document 17 | } 18 | get origin() { 19 | return this.src === this.blankURL ? null : this.src 20 | } 21 | set src(src: string) { 22 | this.sandbox.src = src 23 | } 24 | public setOnUnload(onunload: null | ((this: WindowEventHandlers, ev: Event) => unknown)) { 25 | try { 26 | this.window.onunload = onunload 27 | return Promise.resolve() 28 | } catch (error) { 29 | return Promise.reject(error) 30 | } 31 | } 32 | public setOnLoad(onload: (this: GlobalEventHandlers, ev: Event) => unknown) { 33 | try { 34 | this.sandbox.onload = onload 35 | return Promise.resolve() 36 | } catch (error) { 37 | return Promise.reject(error) 38 | } 39 | } 40 | public setOnError(onerror: OnErrorEventHandler) { 41 | try { 42 | this.sandbox.onerror = onerror 43 | return Promise.resolve() 44 | } catch (error) { 45 | return Promise.reject(error) 46 | } 47 | } 48 | public set(allow = this.setting): void { 49 | if (allow === undefined) return 50 | this.sandbox.setAttribute('sandbox', allow) 51 | } 52 | public reset(allow?: string): this { 53 | this.exit() 54 | this.set(allow) 55 | return this 56 | } 57 | public open(): this { 58 | this.document?.open() 59 | return this 60 | } 61 | public write(context = ''): this { 62 | context = '' + context 63 | this.document?.write(context) 64 | return this 65 | } 66 | public close(): this { 67 | this.document?.close() 68 | return this 69 | } 70 | public append(context: string | undefined): void { 71 | this.open() 72 | this.write(context) 73 | this.close() 74 | } 75 | public enter(container: HTMLElement): void { 76 | this.set() 77 | container.appendChild(this.sandbox) 78 | } 79 | public exit(): void { 80 | const parentNode = this.sandbox.parentNode as HTMLElement 81 | parentNode && parentNode.removeChild(this.sandbox) 82 | } 83 | } 84 | 85 | export { 86 | Sandbox 87 | } 88 | -------------------------------------------------------------------------------- /src/Scroll/index.ts: -------------------------------------------------------------------------------- 1 | import { requestAnimationFrame as rAF, testHasSmoothScrolling, testHasSmoothSnapScrolling, setTimeout } from '../lib/util' 2 | 3 | interface StepOptions { 4 | startTime: number 5 | startX: number 6 | startY: number 7 | x: number 8 | y: number 9 | duration: number 10 | end: () => void 11 | } 12 | 13 | const now = window.performance && window.performance.now 14 | ? window.performance.now.bind(window.performance) : Date.now 15 | const easeInOutCubic = (x: number): number => { 16 | return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; 17 | } 18 | const hasSmoothScrolling = testHasSmoothScrolling() 19 | const hasSmoothSnapScrolling = testHasSmoothSnapScrolling() 20 | /** 21 | * Obsolete 22 | * ------------- start ------------- 23 | */ 24 | // safari pc 25 | const breakSmoothSnap = !hasSmoothScrolling && /Mac OS X [0-9][0-9]_/g.exec(navigator.userAgent) 26 | /** 27 | * Obsolete 28 | * ------------- end ------------- 29 | */ 30 | 31 | class SmoothScroller { 32 | public element: HTMLElement 33 | private touchHold = false 34 | private scrollType: 'scroll' | 'snap' = 'scroll' 35 | private scrollHold: (() => void) | null = null 36 | private defaultScrollBehavior!: string 37 | private defaultScrollSnapType!: string 38 | private scrolling = false 39 | private preStep?: Array 40 | private reservePlanId = -1 41 | private scrollDuration = 400 42 | constructor(element: HTMLElement, stoppable = false) { 43 | this.element = element 44 | if (!hasSmoothScrolling && stoppable) { 45 | element.addEventListener('touchstart', this.scrollStop, true) 46 | element.addEventListener('touchend', this.scrollContinue, true) 47 | element.addEventListener('touchcancel', this.scrollContinue, true) 48 | } 49 | } 50 | public async snapTo(x: number, y: number, duration?: number): Promise { 51 | this.touchHold = false 52 | this.scrollType = 'snap' 53 | return this.smoothScroll(x, y, duration) 54 | } 55 | public async scrollTo(x: number, y: number, duration?: number): Promise { 56 | this.touchHold = false 57 | this.scrollType = 'scroll' 58 | return this.smoothScroll(x, y, duration) 59 | } 60 | private scrollStop(): void { 61 | this.touchHold = true 62 | } 63 | private scrollContinue(): void { 64 | this.touchHold = false 65 | this.scrollHold?.() 66 | } 67 | private catchDefaultStyle(): void { 68 | if (this.scrolling === true) return 69 | const style = this.element.style 70 | this.defaultScrollBehavior = style.scrollBehavior || 'smooth' 71 | } 72 | private isBreak(): boolean { 73 | if ("ontouchend" in document) return false 74 | if (this.preStep !== undefined && (this.preStep[0] !== this.element.scrollLeft || this.preStep[1] !== this.element.scrollTop)) { 75 | this.scrollStop() 76 | return true 77 | } 78 | return false 79 | } 80 | private step(context: StepOptions): void { 81 | if (this.touchHold) { 82 | if (this.scrollType === 'scroll') { 83 | return context.end() 84 | } else { 85 | this.scrollHold = () => { 86 | this.step(context) 87 | } 88 | } 89 | } 90 | const time = now() 91 | // avoid elapsed times higher than one 92 | const elapsed = Math.min((time - context.startTime) / context.duration, 1) 93 | // apply easing to elapsed time 94 | const value = easeInOutCubic(elapsed) 95 | 96 | let currentX = context.startX + (context.x - context.startX) * value 97 | let currentY = context.startY + (context.y - context.startY) * value 98 | 99 | const directionX = context.x - context.startX > 0 ? 1 : -1 100 | const directionY = context.y - context.startY > 0 ? 1 : -1 101 | 102 | if (directionX === 1 && currentX > context.x) { 103 | currentX = context.x 104 | } else if (directionX === -1 && currentX < context.x) { 105 | currentX = context.x 106 | } 107 | 108 | if (directionY === 1 && currentY > context.y) { 109 | currentY = context.y 110 | } else if (directionY === -1 && currentY < context.y) { 111 | currentY = context.y 112 | } 113 | 114 | if (this.isBreak()) return 115 | 116 | /** 117 | * Obsolete 118 | * ------------- start ------------- 119 | */ 120 | // step.1 for ios < 10.2 121 | this.element.scrollLeft = currentX 122 | this.element.scrollTop = currentY 123 | /** 124 | * Obsolete 125 | * ------------- end ------------- 126 | */ 127 | // step.2 Note the order! 128 | this.element.scrollTo(currentX, currentY) 129 | 130 | this.preStep = [this.element.scrollLeft, this.element.scrollTop] 131 | 132 | // scroll more if we have not reached our destination 133 | if (currentX !== context.x || currentY !== context.y) { 134 | rAF(() => { 135 | this.step(context) 136 | }) 137 | } else { 138 | context.end() 139 | this.scrollHold = null 140 | } 141 | } 142 | private openSmooth(): void { 143 | this.element.style.scrollBehavior = 'smooth' 144 | /** 145 | * Obsolete 146 | * ------------- start ------------- 147 | */ 148 | if (breakSmoothSnap) { 149 | this.element.style.scrollSnapType = this.defaultScrollSnapType 150 | } 151 | /** 152 | * Obsolete 153 | * ------------- end ------------- 154 | */ 155 | } 156 | private closeSmooth(): void { 157 | this.element.style.scrollBehavior = 'auto' 158 | /** 159 | * Obsolete 160 | * ------------- start ------------- 161 | */ 162 | if (breakSmoothSnap) { 163 | this.defaultScrollSnapType = this.element.style.scrollSnapType 164 | this.element.style.scrollSnapType = 'none' 165 | } 166 | /** 167 | * Obsolete 168 | * ------------- end ------------- 169 | */ 170 | } 171 | private beforeScrolling(): void { 172 | this.catchDefaultStyle() 173 | this.preStep = undefined 174 | this.scrolling = true 175 | } 176 | private endScrolling(): void { 177 | this.element.style.scrollBehavior = this.defaultScrollBehavior || 'smooth' 178 | this.scrolling = false 179 | } 180 | private checkScrollEnd = (x: number, y: number): boolean => { 181 | return this.element.scrollLeft === x && this.element.scrollTop === y 182 | } 183 | private smoothScroll(x: number, y: number, duration?: number): Promise { 184 | if ((this.scrollType === 'scroll' && !hasSmoothScrolling) || (this.scrollType === 'snap' && !hasSmoothSnapScrolling)) { 185 | return this.smoothScrollByStep(x, y) 186 | } 187 | let scrollCycleTimeout = -1 188 | return new Promise((resolve) => { 189 | const scrollHandel = () => { 190 | clearTimeout(scrollCycleTimeout) 191 | scrollCycleTimeout = setTimeout(async () => { 192 | this.element.removeEventListener('scroll', scrollHandel, false) 193 | resolve() 194 | }, 100) as unknown as number 195 | } 196 | this.element.addEventListener('scroll', scrollHandel, false) 197 | this.beforeScrolling() 198 | this.openSmooth() 199 | if (this.checkScrollEnd(x, y)) { 200 | this.endScrolling() 201 | resolve() 202 | } 203 | this.element.scrollTo(x, y) 204 | 205 | clearTimeout(this.reservePlanId) 206 | // doesn't trigger scrolling 207 | this.reservePlanId = setTimeout(async () => { 208 | if (scrollCycleTimeout === -1) { 209 | await this.smoothScrollByStep(x, y, duration) 210 | resolve() 211 | } 212 | }, 600) as unknown as number 213 | }) 214 | } 215 | private smoothScrollByStep(x: number, y: number, duration = this.scrollDuration): Promise { 216 | this.beforeScrolling() 217 | this.closeSmooth() 218 | return new Promise(resolve => { 219 | this.step({ 220 | startTime: now(), 221 | startX: this.element.scrollLeft, 222 | startY: this.element.scrollTop, 223 | x: x, 224 | y: y, 225 | duration, 226 | end: () => { 227 | this.endScrolling() 228 | resolve() 229 | } 230 | }) 231 | }) 232 | } 233 | } 234 | 235 | export { 236 | SmoothScroller 237 | } -------------------------------------------------------------------------------- /src/Scroll/polyfill.ts: -------------------------------------------------------------------------------- 1 | export default (): Promise => { 2 | if (typeof Element.prototype.scrollTo !== 'function') { 3 | return import('scroll-polyfill/auto').catch((e) => { 4 | console.warn(e) 5 | }) 6 | } 7 | return Promise.resolve() 8 | } 9 | -------------------------------------------------------------------------------- /src/Scroll/types.module.ts: -------------------------------------------------------------------------------- 1 | declare module 'scroll-polyfill/auto' -------------------------------------------------------------------------------- /src/Segue/Animation.ts: -------------------------------------------------------------------------------- 1 | import { SegueState } from './State' 2 | import typeError from '../lib/typeError' 3 | import { SegueAnimateState, AnimationConfig, AnimationPrestType, Applet } from '../types' 4 | 5 | type SegueAnimateFn = (e: SegueAnimateState) => undefined | Promise 6 | 7 | class SegueAnimation extends SegueState { 8 | public checkAnimationNamed(): boolean { 9 | return !!this.getAnimationNames() 10 | } 11 | 12 | public checkNativeAnimation(): boolean { 13 | const animationNames = this.getAnimationNames() 14 | if (typeof animationNames === 'string') { 15 | return ['slide', 'slide-left'].includes(animationNames) 16 | } 17 | return false 18 | } 19 | 20 | public getAnimationNames(): AnimationPrestType | boolean | undefined | AnimationConfig { 21 | if (this.options.index && this.applet.isFullscreen && this.isEntryApplet) return false 22 | if (this.fromOverscrollHistoryNavigation || this.backFromType === 'controls') return false 23 | const usePrevAppletAnimation = this.countercurrent 24 | const animationNames = this[usePrevAppletAnimation ? 'prevApplet' : 'applet']?.config.animation ?? 'slide' 25 | if (animationNames === true || animationNames === 'inherit') { 26 | return this.options.defaultAnimation 27 | } 28 | return animationNames 29 | } 30 | 31 | public async getAnimationGroup(): Promise<[SegueAnimateFn, SegueAnimateFn] | SegueAnimateFn | undefined> { 32 | let animationFunction: [SegueAnimateFn, SegueAnimateFn] | SegueAnimateFn | undefined 33 | const animationNames = this.getAnimationNames() 34 | if (typeof animationNames === 'string') { 35 | animationFunction = await this.getAnimationByName(animationNames) 36 | } else if (Array.isArray(animationFunction) && typeof (animationNames as [SegueAnimateFn, SegueAnimateFn])[0] !== 'function') { 37 | return 38 | } 39 | 40 | return animationFunction 41 | } 42 | 43 | public async getAnimationOneSide(backset: number): Promise { 44 | const animationGroup = await this.getAnimationGroup() 45 | if (backset >= 0) { 46 | switch (typeof animationGroup) { 47 | case 'function': 48 | return animationGroup 49 | case 'object': 50 | return animationGroup[animationGroup.length === 2 ? backset : 0] 51 | default: 52 | return 53 | } 54 | } 55 | return 56 | } 57 | 58 | public preloadAnimation(applet: Applet) { 59 | const animationNames = applet?.config?.animation ?? 'slide' 60 | this.getAnimationByName(animationNames as AnimationPrestType).catch(() => { 61 | // Load trunk error 62 | // console.warn(`Load animation[${animationNames}] trunk error!`) 63 | }) 64 | } 65 | 66 | public async getAnimationByName(type: AnimationPrestType): Promise<[SegueAnimateFn, SegueAnimateFn] | SegueAnimateFn | undefined> { 67 | switch (type) { 68 | case 'popup': 69 | return new Promise((resolve, reject) => { 70 | import('./preset/popup').then((animate) => { 71 | const popup = animate.default 72 | resolve([popup, popup]) 73 | }).catch(reject) 74 | }) 75 | case 'grow': 76 | return new Promise((resolve, reject) => { 77 | import('./preset/grow').then((animate) => { 78 | const grow = animate.default 79 | resolve(grow) 80 | }).catch(reject) 81 | }) 82 | case 'flip': 83 | return new Promise((resolve, reject) => { 84 | import('./preset/flip').then((animate) => { 85 | const flip = animate.default 86 | resolve([flip(4), flip(4)]) 87 | }).catch(reject) 88 | }) 89 | case 'flip-left': 90 | return new Promise((resolve, reject) => { 91 | import('./preset/flip').then((animate) => { 92 | const flip = animate.default 93 | resolve([flip(3), flip(3)]) 94 | }).catch(reject) 95 | }) 96 | case 'flip-down': 97 | return new Promise((resolve, reject) => { 98 | import('./preset/flip').then((animate) => { 99 | const flip = animate.default 100 | resolve([flip(2), flip(2)]) 101 | }).catch(reject) 102 | }) 103 | case 'flip-right': 104 | return new Promise((resolve, reject) => { 105 | import('./preset/flip').then((animate) => { 106 | const flip = animate.default 107 | resolve([flip(1), flip(1)]) 108 | }).catch(reject) 109 | }) 110 | case 'flip-up': 111 | return new Promise((resolve, reject) => { 112 | import('./preset/flip').then((animate) => { 113 | const flip = animate.default 114 | resolve([flip(0), flip(0)]) 115 | }).catch(reject) 116 | }) 117 | case 'fade': 118 | return new Promise((resolve, reject) => { 119 | import('./preset/fade').then((animate) => { 120 | const fade = animate.default 121 | resolve([fade(1), fade(0)]) 122 | }).catch(reject) 123 | }) 124 | case 'zoom': 125 | return new Promise((resolve, reject) => { 126 | import('./preset/zoom').then((animate) => { 127 | const zoom = animate.default 128 | resolve([zoom(1), zoom(0)]) 129 | }).catch(reject) 130 | }) 131 | case 'slide-right': 132 | return new Promise((resolve, reject) => { 133 | import('./preset/slide').then((animate) => { 134 | const slide = animate.default 135 | resolve([slide(3), slide(1)]) 136 | }).catch(reject) 137 | }) 138 | case 'slide-up': 139 | return new Promise((resolve, reject) => { 140 | import('./preset/slide').then((animate) => { 141 | const slide = animate.default 142 | resolve([slide(2), slide(0)]) 143 | }).catch(reject) 144 | }) 145 | case 'slide-down': 146 | return new Promise((resolve, reject) => { 147 | import('./preset/slide').then((animate) => { 148 | const slide = animate.default 149 | resolve([slide(0), slide(2)]) 150 | }).catch(reject) 151 | }) 152 | case 'slide': 153 | case 'slide-left': 154 | default: 155 | // use preload 156 | if (!this.applet || !this.options) { 157 | return 158 | } 159 | if (this.applet.config.modality) { 160 | typeError(1007) 161 | return 162 | } 163 | return new Promise((resolve, reject) => { 164 | if (this.options.swipeModel) { 165 | import('./preset/slide-native').then((animate) => { 166 | const slide = animate.default 167 | resolve([slide, slide]) 168 | }).catch(reject) 169 | } else { 170 | import('./preset/slide').then((animate) => { 171 | const slide = animate.default 172 | resolve([slide(1), slide(3)]) 173 | }).catch(reject) 174 | } 175 | }) 176 | } 177 | } 178 | } 179 | 180 | export { 181 | SegueAnimation 182 | } 183 | -------------------------------------------------------------------------------- /src/Segue/Base.ts: -------------------------------------------------------------------------------- 1 | import { Application } from '../Application' 2 | import { Applet } from '../Applet' 3 | import { fullscreenBaseCSSText } from '../lib/cssText/fullscreenBaseCSSText' 4 | import { SegueOptions, SegueActionOrigin, PresetConfig } from '../types' 5 | 6 | class SegueBase { 7 | public id = '' 8 | public prevId = '' 9 | public param = '' 10 | public root: HTMLElement | ShadowRoot 11 | public application: Application 12 | public zIndex: number 13 | public applet!: Applet 14 | public prevApplet?: Applet 15 | public prevPrevApplet?: Applet 16 | public appletGroup!: Array 17 | public options!: SegueOptions 18 | public touches?: SegueActionOrigin 19 | public readonly relativeViewport: HTMLElement = document.createElement('relative-windows') 20 | public readonly absoluteViewport: HTMLElement = document.createElement('absolute-windows') 21 | public readonly fixedViewport: HTMLElement = document.createElement('fixed-windows') 22 | public readonly applicationViewport: HTMLElement = document.createElement('application-windows') 23 | public target: HTMLElement | ShadowRoot = this.relativeViewport 24 | 25 | constructor(app: Application, presetConfig: PresetConfig) { 26 | this.application = app 27 | this.root = presetConfig.root || document.body 28 | this.zIndex = presetConfig.zIndex || Number.MAX_SAFE_INTEGER 29 | this.setupViewport() 30 | } 31 | 32 | 33 | public resetBaseStyleText = ` 34 | filter: none; 35 | opacity: 1; 36 | ` 37 | public resetBaseStyle(cssText = '') { 38 | this.resetBaseStyleText = this.resetBaseStyleText + cssText 39 | } 40 | 41 | public setupViewport(): void { 42 | this.relativeViewport.id = 'relative-viewport' 43 | this.absoluteViewport.id = 'absolute-viewport' 44 | this.resetViewport() 45 | this.fixedViewport.id = 'fixed-viewport' 46 | this.fixedViewport.style.cssText = ` 47 | width: 100%; 48 | height: 0; 49 | max-height: 0; 50 | z-index: 3; 51 | overflow: hidden; 52 | contain: layout size; 53 | ` 54 | this.applicationViewport.style.cssText = ` 55 | position: fixed; 56 | ${fullscreenBaseCSSText} 57 | overflow: hidden; 58 | z-index: ${this.zIndex}; 59 | contain: layout size; 60 | ` 61 | this.applicationViewport.appendChild(this.relativeViewport) 62 | this.applicationViewport.appendChild(this.absoluteViewport) 63 | this.applicationViewport.appendChild(this.fixedViewport) 64 | this.root.appendChild(this.applicationViewport) 65 | } 66 | 67 | public resetViewport(free?: boolean): void { 68 | const baseStyle = ` 69 | position: fixed; 70 | ${fullscreenBaseCSSText} 71 | overflow: hidden; 72 | contain: layout size; 73 | ${this.resetBaseStyleText} 74 | ` 75 | /** 76 | * relativeViewport Needs to be constantly present in the visible area 77 | */ 78 | this.relativeViewport.style.cssText = ` 79 | ${baseStyle} 80 | z-index: 1; 81 | transform: translate3d(0, 0, 0); 82 | ` 83 | this.absoluteViewport.style.cssText = ` 84 | ${baseStyle} 85 | z-index: 2; 86 | transform: ${!free ? 'translate(200%, 200%)' : 'translate3d(0, 0, 0)'}; 87 | ` 88 | } 89 | } 90 | 91 | export { 92 | SegueBase 93 | } 94 | -------------------------------------------------------------------------------- /src/Segue/History.ts: -------------------------------------------------------------------------------- 1 | import { Application } from '../Application' 2 | import { SegueBase } from './Base' 3 | import { PopState, PresetConfig, SegueBackType } from '../types' 4 | 5 | class SegueHistory extends SegueBase { 6 | public prevHistoryStep: 0 | 1 | -1 = 0 7 | public historyIndex = history.length 8 | public historyShadowLength = history.length 9 | public oneHistoryIndex = 0 10 | public historyDirection = 0 11 | public backFromType: SegueBackType 12 | private backoutCount = 0 13 | private sessionHistory: PopState[] = [] 14 | private silentObserver: number | undefined = undefined 15 | private defaultURLPath = location.pathname 16 | private defaultURLSearch = location.search 17 | private historyBreakCallback: (() => void) | null = null 18 | constructor(app: Application, presetConfig: PresetConfig) { 19 | super(app, presetConfig) 20 | this.bindHistoryState() 21 | } 22 | public get fromHistoryForward(): boolean { 23 | return this.prevHistoryStep === -1 && this.historyDirection === 1 24 | } 25 | public get fromHistoryBack(): boolean { 26 | return this.prevHistoryStep === -1 && this.historyDirection === -1 27 | } 28 | public get historyState(): PopState { 29 | return document.readyState === 'loading' ? this.sessionHistory[this.oneHistoryIndex] : history.state 30 | } 31 | get isOverscrollHistoryNavigation(): boolean { 32 | if (!this.fromHistoryBack && !this.fromHistoryForward) return false 33 | const oSHN = this.application.overscrollHistoryNavigation 34 | const isWipe = oSHN.type.indexOf('wipe') !== -1 35 | const delayTime = isWipe ? 300 : 200 36 | if (Date.now() - oSHN.moment > delayTime) return false 37 | return true 38 | } 39 | private bindHistoryState(): void { 40 | addEventListener('popstate', (event: PopStateEvent) => { 41 | this.popstate(event.state) 42 | }, false) 43 | addEventListener('hashchange', () => { 44 | this.observeSilent() 45 | }, false) 46 | } 47 | public observeSilent(times = 10): void { 48 | if (this.options.oneHistory) return 49 | if (document.readyState === 'loading') times = 30 50 | clearTimeout(this.silentObserver) 51 | if (!times) return 52 | this.silentObserver = setTimeout(async () => { 53 | const route = this.historyState || history.state || this.application.route 54 | const id = decodeURIComponent(route.id) 55 | if (id && decodeURIComponent(id) !== this.id) { 56 | await this.popstate(this.historyState).catch(() => { 57 | clearTimeout(this.silentObserver) 58 | }) 59 | } 60 | this.observeSilent(times--) 61 | }, 2000) as unknown as number 62 | } 63 | public async popstate(state: PopState): Promise { 64 | const { historyIndex = this.historyShadowLength } = state ?? {} 65 | if (historyIndex === this.historyIndex) { 66 | this.historyDirection = 0 67 | } else if (historyIndex > this.historyIndex) { 68 | this.historyDirection = 1 69 | } else { 70 | this.historyDirection = -1 71 | } 72 | // back state when first hash change 73 | if (state === null) { 74 | this.historyDirection = -1 75 | this.historyIndex = this.historyIndex - 1 76 | } else { 77 | this.historyIndex = historyIndex 78 | } 79 | await this.toHistory(state) 80 | this.observeSilent(20) 81 | } 82 | public async back(type?: SegueBackType): Promise { 83 | this.backFromType = type 84 | return this.backTo().catch(() => { 85 | this.backFromType = undefined 86 | }) 87 | } 88 | public backTo(): Promise { 89 | if (!this.options.oneHistory) { 90 | history.back() 91 | this.observeSilent(20) 92 | return Promise.resolve() 93 | } 94 | const state = this.sessionHistory[this.oneHistoryIndex - 1] 95 | if (!state) return Promise.reject() 96 | this.historyDirection = -1 97 | this.oneHistoryIndex = this.oneHistoryIndex - 1 98 | return this.toHistory(state) 99 | } 100 | public forward(): Promise { 101 | if (!this.options.oneHistory) { 102 | history.forward() 103 | this.observeSilent(20) 104 | return Promise.resolve() 105 | } 106 | const state = this.sessionHistory[this.oneHistoryIndex + 1] 107 | if (!state) return Promise.reject() 108 | this.historyDirection = 1 109 | this.oneHistoryIndex = this.oneHistoryIndex + 1 110 | return this.toHistory(state) 111 | } 112 | public getSearch(search = ''): string { 113 | const resolve = new URL(search, location.origin + this.defaultURLPath + this.defaultURLSearch) 114 | return resolve.pathname + resolve.search 115 | } 116 | public pushState(id = '', title = '', search = ''): void { 117 | id = encodeURIComponent(id) 118 | search = this.getSearch(search) 119 | const length = this.historyShadowLength 120 | const state: PopState = { 121 | id, 122 | title, 123 | time: Date.now(), 124 | search, 125 | historyIndex: length 126 | } 127 | this.observeSilent(20) 128 | if (this.sessionHistory.length !== 0) { 129 | this.sessionHistory.length = this.oneHistoryIndex + 1 130 | } 131 | this.sessionHistory.push(state) 132 | this.oneHistoryIndex = this.sessionHistory.length - 1 133 | if (this.options.oneHistory) { 134 | return this.replaceState(id, title, search) 135 | } 136 | history.pushState(state, title, search + '#' + id) 137 | this.historyIndex = this.historyShadowLength = length + 1 138 | this.historyDirection = 1 139 | } 140 | public replaceState(id = '', title = '', search = ''): void { 141 | id = encodeURIComponent(id) 142 | search = this.getSearch(search) 143 | const length = this.historyShadowLength 144 | const state: PopState = { 145 | id, 146 | title, 147 | time: Date.now(), 148 | search, 149 | historyIndex: length 150 | } 151 | history.replaceState(state, title, search + '#' + id) 152 | this.historyIndex = length 153 | this.historyDirection = 0 154 | } 155 | public requestRegisterHistory(id = '', title = '', search = ''): void { 156 | if (this.applet.viewType === 'portal' || this.applet.rel !== 'applet') return 157 | if (this.options.oneHistory) { 158 | if (this.prevHistoryStep === -1) { 159 | return this.replaceState(id, title, search) 160 | } 161 | } else if (this.prevHistoryStep === -1) { 162 | return this.replaceState(id, title, search) 163 | } 164 | // No state changes are made to those returned from history 165 | if (this.fromHistoryBack || this.fromHistoryForward) return 166 | this.pushState(id, title, search) 167 | } 168 | public async toHistory(state?: PopState): Promise { 169 | const options = this.options 170 | const route = state || history.state || this.application.route 171 | const id = decodeURIComponent(route.id) || options.index || 'frameworks' 172 | const search = route.search 173 | const applet = await this.application.get(id) 174 | if (!applet) return 175 | if (this.checkSingleLock()) { 176 | this.backoutCount++ 177 | if (this.options.holdBack?.(this.backoutCount) === true) { 178 | this.pushState(id, applet.config.title, search) 179 | this.application.trigger('exit', { 180 | backoutCount: this.backoutCount 181 | }) 182 | } 183 | return 184 | } else { 185 | this.backoutCount = 0 186 | } 187 | 188 | const inLevel = applet.config.level ?? 0 189 | const outLever = this.applet.config.level ?? 0 190 | if (options.singleFlow && inLevel !== 0 && inLevel >= outLever) { 191 | return this.back() 192 | } 193 | if (this.isOverscrollHistoryNavigation) { 194 | this.historyBreakCallback?.() 195 | } 196 | this.application.segue.to(id, search, -1).then(() => { 197 | this.backFromType = undefined 198 | }) 199 | this.application.trigger('back', applet) 200 | return Promise.resolve() 201 | } 202 | public checkSingleLock(): boolean { 203 | return this.options.singleLock && this.applet.config.level === 0 && this.historyDirection === -1 ? true : false 204 | } 205 | public bindHistoryBreak(callback: () => void) { 206 | this.historyBreakCallback = callback 207 | } 208 | } 209 | 210 | export { 211 | SegueHistory 212 | } 213 | -------------------------------------------------------------------------------- /src/Segue/State.ts: -------------------------------------------------------------------------------- 1 | import { SegueHistory } from './History' 2 | import { Applet } from '../types' 3 | 4 | class SegueState extends SegueHistory { 5 | public hasAnimation = false 6 | public superSwitch = false 7 | public viewportLevelLength = 2 8 | public historyIndexOfStartOverlaying: number | undefined = undefined 9 | public fromOverscrollHistoryNavigation = false 10 | 11 | get isEntryApplet() { 12 | return !this.prevApplet || this.prevApplet.rel !== 'applet' 13 | } 14 | 15 | get immovable() { 16 | return this.superSwitch || !this.hasAnimation || this.countercurrent 17 | } 18 | 19 | get fallbackState(): 1 | -1 | 0 { 20 | if (this.appletGroup.length === 1) { 21 | return -1 22 | } 23 | if (!this.applet.config.free && this.prevApplet?.config.free) { 24 | return 1 25 | } else if (this.applet.config.free && !this.prevApplet?.config.free) { 26 | return 0 27 | } 28 | return this.stackUp ? 0 : 1 29 | } 30 | 31 | get stackUp(): boolean { 32 | return !(!this.applet.config.free && this.superSwitch) && this.applet.viewLevel >= (this.prevApplet?.viewLevel ?? 0) 33 | } 34 | 35 | get countercurrent(): boolean { 36 | return this.fallbackState === 1 || this.fromHistoryBack 37 | } 38 | 39 | get viewports(): [HTMLElement, HTMLElement] { 40 | return this.superSwitch ? [ 41 | !this.applet.config.free ? this.relativeViewport : this.absoluteViewport, 42 | !this.prevApplet?.config.free ? this.relativeViewport : this.absoluteViewport 43 | ] : [ 44 | this.applet.viewport as HTMLElement, 45 | this.prevApplet?.viewport as HTMLElement 46 | ] 47 | } 48 | 49 | get isInseparableLayer() { 50 | return !this.prevApplet || (this.prevApplet.rel === 'frameworks' && !this.prevApplet.slide) 51 | } 52 | 53 | public checkSwitchViewport(prevApplet: Applet | undefined = this.prevApplet, applet: Applet = this.applet): boolean { 54 | prevApplet = prevApplet || applet 55 | return !!applet.config.free !== !!prevApplet.config.free 56 | } 57 | 58 | public getSuperViewport(applet: Applet = this.applet): HTMLElement | ShadowRoot { 59 | return applet.rel === 'system' ? this.fixedViewport : !applet.config.free ? (this.relativeViewport.shadowRoot || this.relativeViewport) : (this.absoluteViewport.shadowRoot || this.absoluteViewport) 60 | } 61 | } 62 | 63 | export { 64 | SegueState 65 | } 66 | -------------------------------------------------------------------------------- /src/Segue/index.ts: -------------------------------------------------------------------------------- 1 | import { SegueSwitch } from './Switch' 2 | import { SegueOptions } from '../types' 3 | class Segue extends SegueSwitch { 4 | public setup(options: SegueOptions): void { 5 | this.options = options 6 | } 7 | } 8 | 9 | export { 10 | Segue 11 | } 12 | -------------------------------------------------------------------------------- /src/Segue/preset/fade.ts: -------------------------------------------------------------------------------- 1 | import { SegueAnimateState } from '../../types' 2 | 3 | export default (type: number) => { 4 | return async (state: SegueAnimateState) => { 5 | let inO: number, outO: number, inV: HTMLElement, outV: HTMLElement 6 | switch (type) { 7 | case 0: 8 | inO = 1 9 | outO = 0 10 | inV = state.view[0] 11 | outV = state.view[1] 12 | break 13 | case 1: 14 | default: 15 | inO = 0 16 | outO = 1 17 | inV = outV = state.view[0] 18 | } 19 | await inV.animate({ transform: `translate3d(0, 0, 0)`, opacity: inO }, { 20 | duration: 0, 21 | easing: 'linear', 22 | fill: 'forwards' 23 | }).finished 24 | await outV.animate({ transform: `translate3d(0, 0, 0)`, opacity: outO }, { 25 | duration: 300, 26 | easing: 'linear', 27 | fill: 'forwards' 28 | }).finished 29 | return Promise.resolve(false) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Segue/preset/flip.ts: -------------------------------------------------------------------------------- 1 | import EASE from '../../lib/webAnimations/ease' 2 | import { SegueAnimateState } from '../../types' 3 | 4 | export default (type: number) => { 5 | return async (state: SegueAnimateState) => { 6 | let origin = 'center' 7 | let minScale = 0.3 8 | let rotate = 150 9 | let duration = 1200 10 | let inDelay = duration / 8 11 | let rx = 0 12 | let ry = 1 13 | const direction = state.direction * (state.reverse ? -1 : 1) 14 | const prevApplet = state.applets[state.historyDirection === 1 ? 1 : 0] 15 | switch (type) { 16 | case 0: 17 | origin = 'top' 18 | rx = 1 19 | ry = 0 20 | duration = 1400 21 | inDelay = duration / 8 22 | break 23 | case 1: 24 | origin = 'right' 25 | break 26 | case 2: 27 | origin = 'bottom' 28 | rx = 1 29 | ry = 0 30 | duration = 1400 31 | inDelay = duration / 8 32 | break 33 | case 3: 34 | rotate = -150 35 | origin = 'left' 36 | break 37 | case 4: 38 | origin = 'center' 39 | rotate = 180 40 | minScale = 0.7 41 | duration = 1200 42 | inDelay = 0 43 | break 44 | } 45 | prevApplet.controls?.prepare(true) 46 | await Promise.all([ 47 | state.view[0].animate([ 48 | { 49 | transform: `translate3d(0, 0, 0) rotate3d(${rx}, ${ry}, 0, ${rotate * direction}deg) perspective(1000px) scale(${minScale})`, 50 | backfaceVisibility: 'hidden', 51 | transformOrigin: origin 52 | }, 53 | { 54 | transformOrigin: origin, 55 | transform: `rotate3d(${rx}, ${ry}, 0, 0deg) perspective(1000px) scale(1)`, 56 | } 57 | ], { 58 | delay: inDelay, 59 | duration, 60 | easing: EASE['ease-out-expo'], 61 | fill: 'forwards' 62 | }).finished, 63 | state.view[1].animate({ 64 | backfaceVisibility: 'hidden', 65 | transform: `rotate3d(${rx}, ${ry}, 0, ${-rotate * direction}deg) perspective(1000px) scale(${minScale})`, 66 | transformOrigin: origin 67 | }, { 68 | delay: inDelay, 69 | duration, 70 | easing: EASE['ease-out-expo'], 71 | fill: 'forwards' 72 | }).finished 73 | ]) 74 | await state.view[1].animate({ 75 | transform: `rotate3d(${rx}, ${ry}, 0, ${-rotate * direction}deg) perspective(1000px) scale(${minScale})`, 76 | transformOrigin: origin 77 | }, { 78 | duration: 0, 79 | easing: EASE['ease-out-expo'], 80 | fill: 'forwards' 81 | }).finished 82 | prevApplet.controls?.prepare() 83 | return Promise.resolve(false) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Segue/preset/grow.ts: -------------------------------------------------------------------------------- 1 | import EASE from '../../lib/webAnimations/ease' 2 | import { SegueAnimateState } from '../../types' 3 | 4 | export default async (state: SegueAnimateState) => { 5 | const applet = state.applets[state.reverse ? 1 : 0] 6 | const modality = applet.modality 7 | const clipTop = applet.config.paperOptions?.clipTop || '0px' 8 | const paperTop = typeof clipTop === 'string' ? clipTop : clipTop + 'px' 9 | const { touches } = state 10 | const target = touches?.target 11 | const frameRect = target?.getBoundingClientRect() 12 | const frameX = (frameRect?.x || 0) + 'px' 13 | const frameY = `calc(${(frameRect?.y || 0) + 'px'} - ${paperTop})` 14 | const frameWidth = frameRect?.width !== undefined ? frameRect?.width + 'px' : '100%' 15 | const frameHeight = `calc(${frameRect?.height}px + ${paperTop})` || '100%' 16 | const iframeView = applet.view?.tagName === 'IFRAME' ? applet.view : null 17 | if (!modality) return Promise.resolve(false) 18 | if (!state.reverse) { 19 | if (iframeView) { 20 | iframeView.style.willChange = 'min-width, min-height, width, height' 21 | } 22 | state.view[0].style.willChange = 'transform, min-width, min-height, width, height, opacity' 23 | modality.freezeSnap() 24 | await state.view[0].animate({ 25 | transform: `translate3d(${frameX}, ${frameY}, 500px)`, 26 | transformOrigin: 'center', 27 | backfaceVisibility: 'hidden', 28 | width: frameWidth, 29 | height: frameHeight, 30 | minWidth: '0px', 31 | minHeight: '0px', 32 | boxShadow: '2px 4px 20px rgb(0, 0, 0, .2)', 33 | borderRadius: '16px', 34 | opacity: 0 35 | }, { 36 | duration: 0, 37 | fill: 'forwards' 38 | }).finished 39 | // (delete await) Reduce low-end device animation jank. 40 | state.view[0].animate({ 41 | opacity: 1 42 | }, { 43 | duration: 100, 44 | easing: EASE['ease-out-expo'], 45 | fill: 'forwards' 46 | }).play() 47 | await state.view[0].animate({ 48 | transform: `translate3d(0px, 0px, 500px)`, 49 | width: '100%', 50 | height: '100%', 51 | borderRadius: '0px' 52 | }, { 53 | duration: 600, 54 | easing: EASE['ease-out-expo'], 55 | fill: 'forwards' 56 | }).finished 57 | await modality.rise() 58 | await state.view[0].animate({ 59 | minWidth: '100%', 60 | minHeight: '100%', 61 | boxShadow: 'none', 62 | borderRadius: '0px' 63 | }, { 64 | duration: 100, 65 | easing: EASE['ease-out-expo'], 66 | fill: 'forwards' 67 | }).finished 68 | if (iframeView) { 69 | iframeView.style.willChange = 'none' 70 | } 71 | state.view[0].style.willChange = 'none' 72 | modality.activateSnap() 73 | return Promise.resolve(false) 74 | } else { 75 | if (modality?.visibility === true) { 76 | await modality?.fall() 77 | } 78 | return Promise.resolve(false) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Segue/preset/popup.ts: -------------------------------------------------------------------------------- 1 | import { SegueAnimateState } from '../../types' 2 | 3 | export default async (state: SegueAnimateState) => { 4 | const applet = state.applets[state.reverse ? 1 : 0] 5 | const modality = applet.modality 6 | if (!modality) return Promise.resolve(false) 7 | if (!state.reverse) { 8 | await state.view[0].animate({ 9 | backfaceVisibility: 'hidden', 10 | transform: `translate3d(0, 0, 500px) scale(1)`, 11 | transformOrigin: origin 12 | }, { 13 | duration: 0, 14 | fill: 'forwards' 15 | }).finished 16 | await modality.rise() 17 | return Promise.resolve(false) 18 | } else { 19 | if (modality?.visibility === true) { 20 | await modality?.fall() 21 | } 22 | return Promise.resolve(false) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Segue/preset/slide-native.ts: -------------------------------------------------------------------------------- 1 | import EASE from '../../lib/webAnimations/ease' 2 | import { SegueAnimateState } from '../../types' 3 | 4 | export default async (state: SegueAnimateState) => { 5 | const applet = state.applets[state.reverse ? 1 : 0] 6 | const controls = applet.controls 7 | const swipeTransitionType = state.swipeTransitionType 8 | if (!controls) return Promise.resolve(false) 9 | if (!state.reverse) { 10 | await state.view[0].animate({ 11 | transform: `translate3d(0, 0, 0)` 12 | }, { 13 | duration: 0, 14 | easing: EASE['ease-in-out'], 15 | fill: 'forwards' 16 | }).finished 17 | if (swipeTransitionType === 'slide') { 18 | state.view[1].animate({ 19 | transform: `translate3d(-30%, 0, 0)` 20 | }, { 21 | duration: 400, 22 | easing: EASE['ease-in-out'], 23 | fill: 'forwards' 24 | }).play() 25 | } else { 26 | state.view[1].animate({ 27 | transform: `translate3d(0, 0, 0) scale(${1 - controls.backdropReducedScale})` 28 | }, { 29 | duration: 400, 30 | easing: EASE['ease-in-out'], 31 | fill: 'forwards' 32 | }).play() 33 | } 34 | await controls?.show() 35 | return Promise.resolve(false) 36 | } else { 37 | state.applets[0].controls?.appearImmediately() 38 | if (state.applets[1].controls?.visibility === true) { 39 | if (swipeTransitionType === 'slide') { 40 | await state.view[0].animate({ 41 | transform: `translate3d(-30%, 0, 0)` 42 | }, { 43 | duration: 0, 44 | easing: EASE['ease-in-out'], 45 | fill: 'forwards' 46 | }).finished 47 | state.view[0].animate({ 48 | transform: `translate3d(0, 0, 0)` 49 | }, { 50 | duration: 400, 51 | easing: EASE['ease-in-out'], 52 | fill: 'forwards' 53 | }).play() 54 | } else { 55 | await state.view[0].animate({ 56 | transform: `translate3d(0, 0, 0) scale(${1 - controls.backdropReducedScale})` 57 | }, { 58 | duration: 0, 59 | easing: EASE['ease-in-out'], 60 | fill: 'forwards' 61 | }).finished 62 | state.view[0].animate({ 63 | transform: `translate3d(0, 0, 0) scale(1)` 64 | }, { 65 | duration: 400, 66 | easing: EASE['ease-in-out'], 67 | fill: 'forwards' 68 | }).play() 69 | } 70 | await controls?.hide() 71 | } 72 | return Promise.resolve(false) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Segue/preset/slide.ts: -------------------------------------------------------------------------------- 1 | import EASE from '../../lib/webAnimations/ease' 2 | import { SegueAnimateState } from '../../types' 3 | 4 | export default (type: number) => { 5 | return async (state: SegueAnimateState) => { 6 | let inX = 0 7 | let outX = 0 8 | let inY = 0 9 | let outY = 0 10 | const duration = 600 11 | const applet = state.applets[state.reverse ? 1 : 0] 12 | const controls = applet.controls 13 | const backdropReducedScale = controls?.backdropReducedScale ?? 0.03 14 | const swipeTransitionType = state.swipeTransitionType 15 | 16 | switch (type) { 17 | case 0: 18 | outY = state.height 19 | inY = -outY 20 | inX = outX = 0 21 | break 22 | case 1: 23 | inX = state.width 24 | outX = -inX 25 | inY = outY = 0 26 | break 27 | case 2: 28 | inY = state.height 29 | outY = -inY 30 | inX = outX = 0 31 | break 32 | case 3: 33 | outX = state.width 34 | inX = -outX 35 | inY = outY = 0 36 | break 37 | } 38 | 39 | if (state.reverse) { 40 | await state.view[0].animate([ 41 | { filter: 'brightness(0.9)', transform: swipeTransitionType === 'slide' ? `translate3d(${inX * 0.3}px, ${inY * 0.3}px, 0)` : `scale(${1 - backdropReducedScale})` }, 42 | ], { 43 | duration: 0, 44 | easing: 'linear', 45 | fill: 'forwards' 46 | }).finished 47 | await Promise.all([ 48 | state.view[1].animate([ 49 | { transform: `translate3d(${outX}px, ${outY}px, 0)` } 50 | ], { 51 | duration, 52 | easing: EASE['ease-out-expo'], 53 | fill: 'forwards' 54 | }).finished, 55 | state.view[0].animate([ 56 | { filter: 'brightness(1)', transform: swipeTransitionType === 'slide' ? 'translate3d(0, 0, 0)' : 'scale(1)' } 57 | ], { 58 | duration, 59 | easing: EASE['ease-out-expo'], 60 | fill: 'forwards' 61 | }).finished 62 | ]) 63 | } else { 64 | await state.view[0].animate([ 65 | { transform: `translate3d(${inX}px, ${inY}px, 0)` } 66 | ], { 67 | duration: 0, 68 | easing: 'linear', 69 | fill: 'forwards' 70 | }).finished 71 | await Promise.all([ 72 | state.view[0].animate([ 73 | { transform: 'translate3d(0, 0, 100px)' }, 74 | ], { 75 | duration, 76 | easing: EASE['ease-out-expo'], 77 | fill: 'forwards' 78 | }).finished, 79 | 80 | state.view[1].animate([ 81 | { filter: 'brightness(0.9)', transform: swipeTransitionType === 'slide' ? `translate3d(${outX * .3}px, ${outY * .3}px, 0)` : `scale(${1 - backdropReducedScale})` } 82 | ], { 83 | duration, 84 | easing: EASE['ease-out-expo'], 85 | fill: 'forwards' 86 | }).finished 87 | ]) 88 | } 89 | return Promise.resolve(false) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Segue/preset/zoom.ts: -------------------------------------------------------------------------------- 1 | import EASE from '../../lib/webAnimations/ease' 2 | import { SegueAnimateState } from '../../types' 3 | 4 | export default (type: number) => { 5 | return async (state: SegueAnimateState) => { 6 | const attachOrigin = typeof state.attach === 'string' ? state.attach : `${state.attach[0]}px ${state.attach[1]}px` 7 | const actionOrigin = state.applets[1].getActionOrigin() 8 | const origin = actionOrigin ? `${actionOrigin.x}px ${actionOrigin.y}px` : (typeof state.origin === 'string' ? state.origin : `${state.origin[0]}px ${state.origin[1]}px`) 9 | if (type === 0) { 10 | await Promise.all([ 11 | state.view[0].animate([ 12 | { 13 | backfaceVisibility: 'hidden', 14 | filter: 'brightness(.5)', 15 | transform: `translate3d(0, 0, 0) scale(2.5)`, 16 | transformOrigin: attachOrigin 17 | }, 18 | { 19 | backfaceVisibility: 'hidden', 20 | filter: 'brightness(1)', 21 | transform: `translate3d(0, 0, 0) scale(1)`, 22 | transformOrigin: attachOrigin 23 | } 24 | ], { 25 | duration: 767, 26 | easing: EASE['ease-out-expo'], 27 | fill: 'forwards' 28 | }).finished, 29 | state.view[1].animate({ 30 | backfaceVisibility: 'hidden', 31 | transform: `translate3d(0, 0, 0) scale(0.0001)`, 32 | transformOrigin: origin 33 | }, { 34 | duration: 767, 35 | easing: EASE['ease-out-expo'], 36 | fill: 'forwards' 37 | }).finished.then(() => { 38 | state.view[1].animate({ 39 | opacity: 0 40 | }, { 41 | duration: 10, 42 | easing: EASE['ease'], 43 | fill: 'forwards' 44 | }).play() 45 | }) 46 | ]) 47 | return false 48 | } else { 49 | await state.view[0].animate([ 50 | { 51 | opacity: 1 52 | } 53 | ], { 54 | duration: 0, 55 | easing: EASE['linear'], 56 | fill: 'forwards' 57 | }).finished 58 | await Promise.all([ 59 | state.view[0].animate([ 60 | { 61 | backfaceVisibility: 'hidden', 62 | transform: `translate3d(0, 0, 0) scale(0)`, 63 | transformOrigin: origin 64 | }, 65 | { 66 | transform: `translate3d(0, 0, 0) scale(1)`, 67 | } 68 | ], { 69 | duration: 767, 70 | easing: EASE['ease-out-expo'], 71 | fill: 'forwards' 72 | }).finished, 73 | state.view[1].animate([ 74 | { 75 | backfaceVisibility: 'hidden', 76 | transform: `translate3d(0, 0, 0) scale(1)`, 77 | transformOrigin: attachOrigin, 78 | filter: 'brightness(1)' 79 | }, 80 | { 81 | transform: `translate3d(0, 0, 0) scale(2.5)`, 82 | filter: 'brightness(0.5)' 83 | } 84 | ], { 85 | duration: 767, 86 | easing: EASE['ease-out-expo'], 87 | fill: 'forwards' 88 | }).finished 89 | ]) 90 | return false 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Slide/Base.ts: -------------------------------------------------------------------------------- 1 | import { Applet, Application, SlideViewApplets, SlideViewSnapType } from "../types" 2 | 3 | interface SlideViewOptions { 4 | slideViewSnapType: SlideViewSnapType 5 | openSlideViewLeftHolder?: boolean 6 | slideViewGridRepeat?: number 7 | } 8 | 9 | export class SlideBase { 10 | public slideView!: HTMLElement 11 | public slideViewHolder!: HTMLElement 12 | public applet!: Applet 13 | public slideViewApplets!: SlideViewApplets 14 | public slideViewOverlay!: HTMLElement 15 | public options!: SlideViewOptions 16 | public scrolling = false 17 | public switching = false 18 | public distance = 0 19 | public activeId = '' 20 | public application!: Application 21 | public snapType: SlideViewSnapType = 'x' 22 | constructor(applet: Applet, options: SlideViewOptions = { openSlideViewLeftHolder: true, slideViewSnapType: 'x', slideViewGridRepeat: 0 }, target = applet.viewport) { 23 | const slideViewApplets = applet.config.defaultSlideViewApplets 24 | if (!slideViewApplets || !target) return 25 | this.applet = applet 26 | this.application = applet.application 27 | this.slideViewApplets = slideViewApplets 28 | this.options = options 29 | this.snapType = options.slideViewSnapType 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Slide/EventTarget.ts: -------------------------------------------------------------------------------- 1 | import { SlideState } from './State' 2 | import { setTimeout, testHasSmoothSnapScrolling } from '../lib/util' 3 | import { SmoothScroller } from '../types' 4 | 5 | export class SlideEventTarget extends SlideState { 6 | public scroller!: SmoothScroller 7 | public hasSmoothSnapScrolling = testHasSmoothSnapScrolling() 8 | private scrollCancelTimeoutId = -1 9 | private scrollCycleTimeout = 300 // !! > 200 10 | public closeOverlay(): void { 11 | this.slideViewOverlay.style.display = 'none' 12 | } 13 | public openOverlay(): void { 14 | this.slideViewOverlay.style.display = 'block' 15 | } 16 | public bindHolderEvent(): void { 17 | const cover = (e: TouchEvent) => { 18 | e.stopPropagation() 19 | this.openOverlay() 20 | this.slideView.style.overflowX = 'hidden' 21 | } 22 | const reset = () => { 23 | this.closeOverlay() 24 | this.slideView.style.overflowX = 'auto' 25 | // Not supported once 26 | /** 27 | * Obsolete 28 | * ------------- start ------------- 29 | */ 30 | this.slideView.removeEventListener('touchcancel', reset, true) 31 | this.slideView.removeEventListener('touchend', reset, true) 32 | /** 33 | * Obsolete 34 | * ------------- end ------------- 35 | */ 36 | } 37 | this.slideViewHolder.addEventListener('touchstart', cover, true) 38 | this.slideViewHolder.addEventListener('touchmove', cover, true) 39 | this.slideViewHolder.addEventListener('touchcancel', reset, true) 40 | this.slideViewHolder.addEventListener('touchend', reset, true) 41 | } 42 | public dispatchScrollEvent(): void { 43 | this.application.activate(false) 44 | this.applet.trigger('sliding', this.slidingState) 45 | } 46 | public bindScrollEvent(): void { 47 | const slideView = this.slideView 48 | slideView.addEventListener('scroll', () => { 49 | if (this.switching) return 50 | if (!this.scrolling) { 51 | this.freezeAll() 52 | this.scrolling = true 53 | } 54 | this.dispatchScrollEvent() 55 | clearTimeout(this.scrollCancelTimeoutId) 56 | this.scrollCancelTimeoutId = setTimeout(async () => { 57 | // for ios < 10.2 58 | if (!this.hasSmoothSnapScrolling) { 59 | await this.scroller.scrollTo(this.slidingState.xIndex * this.slideView.offsetWidth, this.slidingState.yIndex * slideView.offsetHeight) 60 | } 61 | this.scrolling = false 62 | this.activate(this.index) 63 | this.application.activate(true) 64 | }, this.scrollCycleTimeout) as unknown as number 65 | }) 66 | } 67 | public async destroy(index: number): Promise { 68 | const id = this.getAppletIdByIndex(index) 69 | if (!id) return Promise.resolve(true) 70 | const applet = this.application.applets[id] 71 | if (!applet) return Promise.resolve(true) 72 | if (applet.config.background !== false) return Promise.resolve(false) 73 | return applet.destroy(true) 74 | } 75 | public async freeze(index: number): Promise { 76 | const id = this.getAppletIdByIndex(index) 77 | if (!id) return Promise.resolve() 78 | return this.application.get(id).then(applet => { 79 | applet.hide() 80 | this.applet.trigger('slideOut', applet) 81 | }) 82 | } 83 | public async activate(index: number): Promise { 84 | const id = this.getAppletIdByIndex(index) 85 | if (!id) return Promise.resolve() 86 | return this.application.get(id).then(async applet => { 87 | applet.show() 88 | this.applet.trigger('slideEnter', applet) 89 | if (id !== this.activeId) { 90 | this.destroy(this.getAppletIndexById(this.activeId)) 91 | } 92 | this.activeId = id 93 | if (applet.view && applet.visibility) { 94 | return Promise.resolve() 95 | } 96 | return applet.build() 97 | }) 98 | } 99 | public freezeAll() { 100 | const length = this.slideViewApplets.length 101 | for (let i = 0; i <= length; i++) { 102 | this.freeze(i) 103 | } 104 | } 105 | public async to(id: number | string, smooth = true): Promise { 106 | const index = this.getAppletIndexById(id) 107 | const slideView = this.slideView 108 | let toX = 0 109 | let toY = 0 110 | if (this.options.slideViewSnapType === 'x') { 111 | const width = slideView.offsetWidth 112 | const borderEndX = width * this.slideViewApplets.length 113 | toX = Math.min(Math.max(index * width, 0), borderEndX) 114 | } else { 115 | const height = slideView.offsetHeight 116 | const borderEndY = height * this.slideViewApplets.length 117 | toY = Math.min(Math.max(index * height, 0), borderEndY) 118 | } 119 | if (this.index !== index) { 120 | await this.freeze(this.index) 121 | } 122 | if (smooth === false) { 123 | slideView.style.scrollBehavior = 'auto' 124 | slideView.scrollLeft = toX 125 | slideView.scrollTop = toY 126 | slideView.style.scrollBehavior = 'smooth' 127 | return Promise.resolve() 128 | } 129 | this.switching = true 130 | this.openOverlay() 131 | this.application.activate(false) 132 | return this.scroller.snapTo(toX, toY).then(() => { 133 | this.closeOverlay() 134 | this.switching = false 135 | this.activate(this.index) 136 | this.application.activate(true) 137 | }) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Slide/State.ts: -------------------------------------------------------------------------------- 1 | import { SlideBase } from './Base' 2 | import { SlidingState } from '../types' 3 | export class SlideState extends SlideBase { 4 | private slidingStateCatch?: SlidingState 5 | get index() { 6 | const slideView = this.slideView 7 | const x = Math.round(slideView.scrollLeft / slideView.offsetWidth) + 1 8 | const y = Math.round(slideView.scrollTop / slideView.offsetHeight) + 1 9 | return x * y - 1 10 | } 11 | get slidingState(): SlidingState { 12 | if (this.slidingStateCatch) return this.slidingStateCatch 13 | const slideView = this.slideView 14 | return this.slidingStateCatch = { 15 | get x() { 16 | return slideView.scrollLeft 17 | }, 18 | get y() { 19 | return slideView.scrollTop 20 | }, 21 | get xIndex() { 22 | return Math.round(slideView.scrollLeft / slideView.offsetWidth) 23 | }, 24 | get yIndex() { 25 | return Math.round(slideView.scrollTop / slideView.offsetHeight) 26 | } 27 | } 28 | } 29 | public getAppletIdByIndex(index: number) { 30 | return this.slideViewApplets[index]?.id 31 | } 32 | public getAppletIndexById(id: string | number): number { 33 | if (typeof id === 'number') return id 34 | let index = 0 35 | const slideViewApplets = this.slideViewApplets 36 | for (const applet of slideViewApplets) { 37 | if (applet.id === id) { 38 | return index 39 | } 40 | index++ 41 | } 42 | return 0 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Slide/View.ts: -------------------------------------------------------------------------------- 1 | import { SlideEventTarget } from './EventTarget' 2 | import { SmoothScroller } from "../Scroll" 3 | import { requestIdleCallback, testHasScrolling } from '../lib/util' 4 | import { viewportCSSText, slideBaseCSSText, getSlideViewCSSText, slideViewHolderCSSText, slideViewOverlayCSSText } from './cssText' 5 | import { Applet, SlideViewApplets, SlideViewSnapType } from "../types" 6 | 7 | interface SlideViewOptions { 8 | slideViewSnapType: SlideViewSnapType 9 | openSlideViewLeftHolder?: boolean 10 | slideViewGridRepeat?: number 11 | } 12 | 13 | export class SlideView extends SlideEventTarget { 14 | public slideViewApplets!: SlideViewApplets 15 | public scroller!: SmoothScroller 16 | public scrolling = false 17 | public switching = false 18 | public distance = 0 19 | public activeId = '' 20 | constructor(applet: Applet, options: SlideViewOptions, target: HTMLElement) { 21 | super(applet, options, target) 22 | this.createSlideView(this.slideViewApplets, target) 23 | this.bindScrollEvent() 24 | this.bindHolderEvent() 25 | this.scroller = new SmoothScroller(this.slideView) 26 | } 27 | public resetViewportStyle(viewport: HTMLElement): void { 28 | viewport.style.cssText = viewportCSSText 29 | } 30 | public createSlideView(slideViewApplets: SlideViewApplets, target: HTMLElement): void { 31 | const slideView = document.createElement('slideView-viewport') 32 | const slideViewBaseStyle = document.createElement('style') 33 | const slideViewHolder = document.createElement('slideView-holder') 34 | const slideViewOverlay = document.createElement('slideView-overlay') 35 | const snapType = this.snapType 36 | const slideViewGridRepeat = this.options.slideViewGridRepeat ?? 2 37 | slideViewBaseStyle.innerHTML = slideBaseCSSText 38 | // important: z-index 39 | // multiple view,slide default overlay 40 | slideView.style.cssText = getSlideViewCSSText(snapType, slideViewGridRepeat, this.hasSmoothSnapScrolling) 41 | /** 42 | * Obsolete 43 | * ------------- start ------------- 44 | */ 45 | // ios < 12.55 bug 46 | if (testHasScrolling() === false) { 47 | slideView.style.cssText += '-webkit-overflow-scrolling: touch;' 48 | } 49 | /** 50 | * Obsolete 51 | * ------------- end ------------- 52 | */ 53 | slideViewHolder.style.cssText = slideViewHolderCSSText 54 | slideViewOverlay.style.cssText = slideViewOverlayCSSText 55 | slideViewOverlay.addEventListener('touchstart', (event) => { 56 | event.stopPropagation() 57 | event.preventDefault() 58 | }, true) 59 | // Redundant protection 60 | slideViewOverlay.addEventListener('touchend', () => { 61 | this.closeOverlay() 62 | }, true) 63 | slideViewApplets.forEach((applet, index) => { 64 | const { id, activate } = applet 65 | const viewport = document.createElement('applet-viewport') 66 | viewport.id = 'applet-viewport-' + id 67 | this.resetViewportStyle(viewport) 68 | if (index === 0) { 69 | this.activeId = id 70 | if (this.options.openSlideViewLeftHolder) { 71 | viewport.appendChild(slideViewHolder) 72 | } 73 | } 74 | slideView.appendChild(viewport) 75 | this.application.get(id).then((applet) => { 76 | applet.attach(viewport, this.applet, { 77 | agentSegue: async () => { 78 | return this.application.to(this.applet.id).then(() => { 79 | this.applet.slide?.to(id) 80 | }) 81 | }, 82 | noSwipeModel: true 83 | }) 84 | if (activate === 'instant' || index === 0) { 85 | applet.build() 86 | if (index === 0) { 87 | applet.show() 88 | } 89 | } else if (activate === 'lazy') { 90 | requestIdleCallback(() => { 91 | applet.build() 92 | }, { timeout: 3000 }) 93 | } 94 | }) 95 | }) 96 | target.appendChild(slideViewBaseStyle) 97 | target.appendChild(slideView) 98 | target.appendChild(slideViewOverlay) 99 | this.slideView = slideView 100 | this.slideViewHolder = slideViewHolder 101 | this.slideViewOverlay = slideViewOverlay 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Slide/cssText.ts: -------------------------------------------------------------------------------- 1 | import { coveredCSSText } from '../lib/cssText/coveredCSSText' 2 | import { fullscreenBaseCSSText } from '../lib/cssText/fullscreenBaseCSSText' 3 | import { SlideViewSnapType } from "../types" 4 | 5 | export const viewportCSSText = ` 6 | display: flex; 7 | ${coveredCSSText} 8 | scroll-snap-align: start; 9 | scroll-snap-stop: always; 10 | transform: translate3d(0, 0, 0); 11 | ` 12 | 13 | export const slideBaseCSSText = ` 14 | slideView-viewport::-webkit-scrollbar { 15 | display: none; 16 | } 17 | slideView-viewport::scrollbar { 18 | display: none; 19 | } 20 | ` 21 | 22 | export const getSlideViewCSSText = (snapType: SlideViewSnapType, slideViewGridRepeat: number, hasSmoothSnapScrolling: boolean) => { 23 | return ` 24 | ${snapType === 'y' ? 'overflow-y: auto; overflow-x: hidden;' : snapType === 'x' ? 'display: flex; overflow-x: auto; overflow-y: hidden;' : ` 25 | display: grid; 26 | overflow: auto; 27 | grid-template-columns: repeat(${slideViewGridRepeat}, 100%); 28 | grid-template-rows: repeat(${slideViewGridRepeat}, 100%); 29 | `} 30 | position: absolute; 31 | ${fullscreenBaseCSSText} 32 | z-index: 2; 33 | scroll-behavior: ${!hasSmoothSnapScrolling ? 'auto' : 'smooth'}; 34 | scroll-snap-type: ${snapType} mandatory; 35 | ` 36 | } 37 | 38 | export const slideViewHolderCSSText = ` 39 | position: absolute; 40 | top: 15%; 41 | bottom: 15%; 42 | left: 0; 43 | width: 15px; 44 | z-index: 5; 45 | ` 46 | export const slideViewOverlayCSSText = ` 47 | position: fixed; 48 | ${fullscreenBaseCSSText} 49 | z-index: 7; 50 | display: none; 51 | ` -------------------------------------------------------------------------------- /src/Slide/index.ts: -------------------------------------------------------------------------------- 1 | import { SlideView } from "./View"; 2 | 3 | export class Slide extends SlideView { 4 | } 5 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | type timeout = (handler: TimerHandler, timeout?: number | undefined, ...args: unknown[]) => number 2 | declare interface Window { 3 | Function: FunctionConstructor 4 | setBackgroundTimeout?: timeout 5 | setBackgroundInterval?: timeout 6 | appletVisibilityState: 'visible' | 'hidden' | 'willVisible' | 'willHidden' 7 | applicationActiveState?: 'active' | 'frozen' 8 | __LATH_APPLICATION_AVAILABILITY__?: boolean 9 | __LATH_APPLICATION_TUNNELING__?: boolean 10 | WebComponents: { 11 | needsPolyfill: boolean 12 | ready: boolean 13 | waitFor: (fn: () => void) => void 14 | _batchCustomElements: () => void 15 | } 16 | __isElementAnimateDefined?: boolean 17 | ShadyDOM: { 18 | force: boolean 19 | }, 20 | __LATH_NO_SHADOW_DOM__?: boolean 21 | } 22 | declare interface HTMLPortalElement extends HTMLIFrameElement { 23 | activate: () => Promise 24 | } 25 | 26 | declare interface AnimationKeyFrame extends AnimationKeyFrame { 27 | [key: string]: string | number | [string | number, string | number] | undefined 28 | } 29 | 30 | declare global { 31 | declare namespace JSX { 32 | interface IntrinsicElements { 33 | 'define-applet': { 34 | children: Element 35 | 'applet-id': string 36 | } 37 | 'define-application': { 38 | children: Element 39 | 'default-applet': string 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Application } from './Application' 2 | 3 | export { 4 | Application 5 | } 6 | -------------------------------------------------------------------------------- /src/launcher.ts: -------------------------------------------------------------------------------- 1 | import { createApplication, destroyApplication, setEnv } from './Define' 2 | 3 | export { 4 | setEnv, 5 | createApplication, 6 | destroyApplication 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/cssText/coveredCSSText.ts: -------------------------------------------------------------------------------- 1 | export const coveredCSSText = ` 2 | width: 100%; 3 | height: 100%; 4 | min-width: 100%; 5 | min-height: 100%; 6 | max-width: 100%; 7 | max-height: 100%; 8 | ` 9 | -------------------------------------------------------------------------------- /src/lib/cssText/fullscreenBaseCSSText.ts: -------------------------------------------------------------------------------- 1 | import { getEnv } from '../../Define/env' 2 | const { USE_PERCENTAGE } = getEnv() 3 | 4 | export const fullscreenBaseCSSText = ` 5 | top: 0px; 6 | right: 0px; 7 | bottom: 0px; 8 | left: 0px; 9 | width: ${USE_PERCENTAGE ? '100%' : '100vw'}; 10 | height: ${USE_PERCENTAGE ? '100%' : '100vh'}; 11 | min-width: ${USE_PERCENTAGE ? '100%' : '100vw'}; 12 | min-height: ${USE_PERCENTAGE ? '100%' : '100vh'}; 13 | max-width: ${USE_PERCENTAGE ? '100%' : '100vw'}; 14 | max-height: ${USE_PERCENTAGE ? '100%' : '100vh'}; 15 | ` -------------------------------------------------------------------------------- /src/lib/cssText/globalCSSText.ts: -------------------------------------------------------------------------------- 1 | export const globalCSSText = ` 2 | html, body { 3 | width: 100vw; 4 | height: 100vh; 5 | min-width: 100vw; 6 | min-height: 100vh; 7 | max-width: 100vw; 8 | max-height: 100vh; 9 | margin: 0px; 10 | padding: 0px; 11 | overflow: hidden; 12 | } 13 | body::-webkit-scrollbar { 14 | display: none; 15 | } 16 | body::scrollbar { 17 | display: none; 18 | } 19 | define-applet { 20 | display: block; 21 | } 22 | ` 23 | -------------------------------------------------------------------------------- /src/lib/typeError/errorCode.ts: -------------------------------------------------------------------------------- 1 | const ErrorCode: ErrorCodeType = { 2 | 1001: ' not found!Only call at the Top Level / inside loops, conditions, or nested functions.', 3 | 1002: 'Avoid redefine \'applet-id\'! "[$]"', 4 | 1003: 'Only call at the Top Level / inside loops, conditions, or nested functions.', 5 | 1004: 'Non-changeable \'default-applet\'!', 6 | 1005: ' execution exception! Maybe the browser version is not supported.', 7 | 1006: 'The defined applet(\'defaultApplet\') could not be found!', 8 | 1007: '\'FrameworksApplet\' must be included!', 9 | 1008: 'Unable to get applet with id "[$]".', 10 | 1009: 'Can\'t get the applet\'s manifest! "[$]"]', 11 | 1101: 'Applet config: Applets(id == frameworks/system) applets do not need to configured with [free].', 12 | 1102: 'Applet config: Applets(id == frameworks/system) do not need to configured with [source].', 13 | 1103: 'Applet config: [free & portal] conflict! [free] must be true when [portal] sets true.', 14 | 1104: 'Applet config: [level] needs to be less than 9999!', 15 | 1105: 'Applet config: [modality] applets do not need to configured with [animation].', 16 | 1106: 'Applet config: Using the "sheet" animation type requires setting the modality to "sheet".', 17 | 1107: 'Applet config: An unknown modality type was used.', 18 | 1201: 'Applet has entered cross-domain mode, stopping the ability to inject.', 19 | 1202: 'Shared window Applets (i.e. non-iframe) only support one-time execution of \'apply\' and \'inject\' methods. If you have the above configuration, merge it into the Frameworks Applet.' 20 | } 21 | 22 | export { 23 | ErrorCode 24 | } -------------------------------------------------------------------------------- /src/lib/typeError/index.ts: -------------------------------------------------------------------------------- 1 | async function getMessage(code: number, args: string[]) { 2 | let ErrorCode 3 | try { 4 | ({ ErrorCode } = await import('./errorCode')) 5 | } catch (error) { 6 | console.warn(error) 7 | return { 8 | ErrorCode: {} 9 | } 10 | } 11 | const message = ErrorCode[code] 12 | if (message && args) { 13 | return String.raw({ 14 | raw: message.split('[$]') 15 | }, ...args) 16 | } 17 | return message ?? 'unknown' 18 | } 19 | 20 | export default async (code: number, type: 'error' | 'info' | 'warn' | 'return' = 'error', ...args: string[]) => { 21 | const message = await getMessage(code, args) 22 | if (type === 'return') { 23 | return message 24 | } 25 | if (type === 'error') { 26 | console.error('LATH Error:', message) 27 | } else if (type === 'info') { 28 | console.info('LATH Info:', message) 29 | } else if (type === 'warn') { 30 | console.warn('LATH Warning:', message) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/typeError/type.d.ts: -------------------------------------------------------------------------------- 1 | declare type ErrorCodeType = { 2 | [key: number]: string 3 | } -------------------------------------------------------------------------------- /src/lib/util/FrameElapsedDuration.ts: -------------------------------------------------------------------------------- 1 | class FrameElapsedDuration { 2 | public previousTimeStamp!: number 3 | getElapsed(timestamp: number) { 4 | if (!this.previousTimeStamp) this.previousTimeStamp = timestamp - 16.7 5 | const previousElapsed = Math.min(timestamp - this.previousTimeStamp, 70) / 2 6 | this.previousTimeStamp = timestamp 7 | 8 | return previousElapsed 9 | } 10 | } 11 | 12 | export { 13 | FrameElapsedDuration 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/util/FrameQueue.ts: -------------------------------------------------------------------------------- 1 | import requestAnimationFrame from "./requestAnimationFrame" 2 | import { FrameElapsedDuration } from './FrameElapsedDuration' 3 | 4 | type FrameFn = (frameAverageDuration: number, framesQueuesLength: number) => Promise 5 | 6 | class FrameQueue { 7 | private queue: FrameFn[] = [] 8 | private inProgress = false 9 | private averageDuration = new FrameElapsedDuration() 10 | public pushState(frameFn: T) { 11 | this.queue.push(frameFn) 12 | } 13 | public run() { 14 | if (this.inProgress) return 15 | this.inProgress = true 16 | requestAnimationFrame(() => { 17 | requestAnimationFrame(this.step.bind(this)) 18 | }) 19 | } 20 | public async step(timestamp: number) { 21 | const frameFn = this.queue.shift() 22 | if (frameFn) { 23 | await frameFn(this.averageDuration.getElapsed(timestamp), this.queue.length) 24 | } 25 | if (!this.queue.length) { 26 | this.inProgress = false 27 | } else { 28 | requestAnimationFrame(this.step.bind(this)) 29 | } 30 | } 31 | public clear() { 32 | this.queue = [] 33 | } 34 | } 35 | 36 | export { 37 | FrameQueue 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/util/getCSSUnits.ts: -------------------------------------------------------------------------------- 1 | export function getCSSUnits(value?: string | number): string | undefined { 2 | if (!value) return 3 | if (typeof value === 'string') return value 4 | return value + 'px' 5 | } 6 | 7 | export default getCSSUnits 8 | -------------------------------------------------------------------------------- /src/lib/util/getIOSVersion.ts: -------------------------------------------------------------------------------- 1 | export function getIOSversion(): number[] | null { 2 | if (/(iPhone|iPod|iPad)/i.test(navigator.userAgent)) { 3 | // supports iOS 2.0 and later 4 | const v = (navigator.userAgent).match(/OS (\d+)_(\d+)_?(\d+)?/) 5 | if (!v) return [0] 6 | return [parseInt(v[1], 10), parseInt(v[2], 10), parseInt(v[3] || '0', 10)] 7 | } 8 | return null 9 | } 10 | 11 | export default getIOSversion 12 | -------------------------------------------------------------------------------- /src/lib/util/index.ts: -------------------------------------------------------------------------------- 1 | import setTimeout from "./setTimeout" 2 | import setInterval from "./setInterval" 3 | import requestIdleCallback from './requestIdleCallback' 4 | import requestAnimationFrame from "./requestAnimationFrame" 5 | import testHasSmoothSnapScrolling from "./testHasSmoothSnapScrolling" 6 | import testHasSmoothScrolling from "./testHasSmoothScrolling" 7 | import testHasScrolling from './testHasScrolling' 8 | import testHasSlotBug from './testHasSlotBug' 9 | import testHasSnapReset from './testHasSnapReset' 10 | import getCSSUnits from './getCSSUnits' 11 | import sleep from './sleep' 12 | 13 | export { 14 | setTimeout, 15 | setInterval, 16 | requestIdleCallback, 17 | requestAnimationFrame, 18 | testHasSmoothSnapScrolling, 19 | testHasSmoothScrolling, 20 | testHasScrolling, 21 | testHasSlotBug, 22 | testHasSnapReset, 23 | getCSSUnits, 24 | sleep 25 | } -------------------------------------------------------------------------------- /src/lib/util/requestAnimationFrame.ts: -------------------------------------------------------------------------------- 1 | import setTimeout from "./setTimeout" 2 | 3 | export default window.requestAnimationFrame || setTimeout 4 | -------------------------------------------------------------------------------- /src/lib/util/requestIdleCallback.ts: -------------------------------------------------------------------------------- 1 | import setTimeout from "./setTimeout" 2 | 3 | export default window.requestIdleCallback || ((callback: IdleRequestCallback, options?: IdleRequestOptions | undefined) => { 4 | const start = Date.now() 5 | return setTimeout(function () { 6 | callback({ 7 | didTimeout: false, 8 | timeRemaining: function () { 9 | return Math.max(0, 50 - (Date.now() - start)) 10 | } 11 | }) 12 | }, Math.min(options?.timeout ?? 1, 1)) 13 | }) 14 | -------------------------------------------------------------------------------- /src/lib/util/setInterval.ts: -------------------------------------------------------------------------------- 1 | export default window.setBackgroundInterval || window.setInterval 2 | -------------------------------------------------------------------------------- /src/lib/util/setTimeout.ts: -------------------------------------------------------------------------------- 1 | export default window.setBackgroundTimeout || window.setTimeout 2 | -------------------------------------------------------------------------------- /src/lib/util/sleep.ts: -------------------------------------------------------------------------------- 1 | import setTimeout from "./setTimeout" 2 | 3 | export default (delay = 0) => { 4 | return new Promise((resolve) => { 5 | setTimeout(resolve, delay) 6 | }) 7 | } -------------------------------------------------------------------------------- /src/lib/util/testHasScrolling.ts: -------------------------------------------------------------------------------- 1 | import testHasSmoothScrolling from "./testHasSmoothScrolling" 2 | import getIOSVersion from './getIOSVersion' 3 | 4 | /** 5 | * -webkit-overflow-scrolling 6 | * supported ios (5-12.5) 7 | */ 8 | function testHasScrolling() { 9 | const iosVersion = getIOSVersion() 10 | if (iosVersion && iosVersion[0] < 13 && !testHasSmoothScrolling()) { 11 | return false 12 | } 13 | return true 14 | } 15 | 16 | export default testHasScrolling 17 | -------------------------------------------------------------------------------- /src/lib/util/testHasSlotBug.ts: -------------------------------------------------------------------------------- 1 | import getIOSVersion from './getIOSVersion' 2 | 3 | function testHasSlotBug(): boolean { 4 | const iosVersion = getIOSVersion() 5 | if (iosVersion && iosVersion[0] <= 13) { 6 | return true 7 | } 8 | return false 9 | } 10 | 11 | export default testHasSlotBug 12 | -------------------------------------------------------------------------------- /src/lib/util/testHasSmoothScrolling.ts: -------------------------------------------------------------------------------- 1 | function testHasSmoothScrolling(): boolean { 2 | const testDom = document.createElement('div') 3 | testDom.style.scrollBehavior = 'smooth' 4 | if (testDom.style.cssText.indexOf('smooth') === -1) { 5 | return false 6 | } 7 | return CSS.supports('scroll-behavior', 'smooth') 8 | } 9 | 10 | export default testHasSmoothScrolling -------------------------------------------------------------------------------- /src/lib/util/testHasSmoothSnapScrolling.ts: -------------------------------------------------------------------------------- 1 | import testHasSmoothScrolling from "./testHasSmoothScrolling" 2 | 3 | function testHasSmoothSnapScrolling(): boolean { 4 | if ('scrollSnapAlign' in document.documentElement.style || 5 | 'webkitScrollSnapAlign' in document.documentElement.style || 6 | 'msScrollSnapAlign' in document.documentElement.style 7 | ) { 8 | if (testHasSmoothScrolling()) { 9 | return true 10 | } 11 | } 12 | return false 13 | } 14 | 15 | export default testHasSmoothSnapScrolling 16 | -------------------------------------------------------------------------------- /src/lib/util/testHasSnapReset.ts: -------------------------------------------------------------------------------- 1 | import getIOSVersion from './getIOSVersion' 2 | 3 | /** 4 | * supported ios (> 15) 5 | */ 6 | function testHasSnapReset() { 7 | const iosVersion = getIOSVersion() 8 | if (iosVersion && iosVersion[0] < 15) { 9 | return true 10 | } 11 | return false 12 | } 13 | 14 | export default testHasSnapReset 15 | -------------------------------------------------------------------------------- /src/lib/wc/needsPolyfill.ts: -------------------------------------------------------------------------------- 1 | const needsShadowDom = !('attachShadow' in Element.prototype && 'getRootNode' in Element.prototype) 2 | const needsCustomElements = !window.customElements 3 | const needsTemplate = (function () { 4 | // no real