Repository: ragnarlotus/vue-flux
Branch: main
Commit: 211ba37a6741
Files: 217
Total size: 559.1 KB
Directory structure:
gitextract_8a1o24_7/
├── .editorconfig
├── .gitattributes
├── .github/
│ └── FUNDING.yml
├── .gitignore
├── .markdownlint.cjs
├── .prettierrc.json
├── .vscode/
│ └── extensions.json
├── LICENSE
├── README.md
├── env.d.ts
├── eslint.config.ts
├── ia.txt
├── index.html
├── package.json
├── src/
│ ├── App.vue
│ ├── assets/
│ │ └── css/
│ │ ├── base.scss
│ │ └── main.css
│ ├── complements/
│ │ ├── FluxCaption/
│ │ │ ├── FluxCaption.test.ts
│ │ │ └── FluxCaption.vue
│ │ ├── FluxControls/
│ │ │ ├── FluxControls.test.ts
│ │ │ ├── FluxControls.vue
│ │ │ └── buttons/
│ │ │ ├── Next.vue
│ │ │ ├── Play.vue
│ │ │ ├── Prev.vue
│ │ │ ├── Stop.vue
│ │ │ └── index.ts
│ │ ├── FluxIndex/
│ │ │ ├── Button/
│ │ │ │ ├── Button.test.ts
│ │ │ │ └── Button.vue
│ │ │ ├── FluxIndex.vue
│ │ │ ├── List/
│ │ │ │ ├── List.test.ts
│ │ │ │ └── List.vue
│ │ │ └── Thumb/
│ │ │ ├── Thumb.vue
│ │ │ └── useThumbs.ts
│ │ ├── FluxPagination/
│ │ │ └── FluxPagination.vue
│ │ ├── FluxPreloader/
│ │ │ └── FluxPreloader.vue
│ │ ├── __test__/
│ │ │ └── PlayerHelper.ts
│ │ └── index.ts
│ ├── components/
│ │ ├── FluxButton/
│ │ │ ├── FluxButton.test.ts
│ │ │ └── FluxButton.vue
│ │ ├── FluxCube/
│ │ │ ├── FluxCube.vue
│ │ │ ├── Sides.ts
│ │ │ ├── Turns.ts
│ │ │ ├── __mocks__/
│ │ │ │ ├── FluxCube.vue
│ │ │ │ └── Side.vue
│ │ │ ├── factories/
│ │ │ │ ├── CubeFactory.test.ts
│ │ │ │ ├── CubeFactory.ts
│ │ │ │ ├── CubeSideFactory.ts
│ │ │ │ ├── SideTransformFactory.test.ts
│ │ │ │ └── SideTransformFactory.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── FluxGrid/
│ │ │ ├── FluxGrid.vue
│ │ │ ├── __mocks__/
│ │ │ │ ├── FluxGrid.vue
│ │ │ │ └── Tile.vue
│ │ │ ├── factories/
│ │ │ │ ├── GridFactory.ts
│ │ │ │ ├── GridTileFactory.ts
│ │ │ │ └── index.ts
│ │ │ └── types.ts
│ │ ├── FluxImage/
│ │ │ ├── FluxImage.vue
│ │ │ ├── __mocks__/
│ │ │ │ └── FluxImage.vue
│ │ │ └── types.ts
│ │ ├── FluxParallax/
│ │ │ ├── FluxParallax.vue
│ │ │ └── types.ts
│ │ ├── FluxTransition/
│ │ │ ├── FluxTransition.vue
│ │ │ └── types.ts
│ │ ├── FluxVortex/
│ │ │ ├── FluxVortex.vue
│ │ │ ├── __mocks__/
│ │ │ │ ├── FluxVortex.vue
│ │ │ │ └── Tile.vue
│ │ │ ├── factories/
│ │ │ │ ├── VortexCircleFactory.ts
│ │ │ │ ├── VortexFactory.ts
│ │ │ │ └── index.ts
│ │ │ └── types.ts
│ │ ├── FluxWrapper/
│ │ │ ├── FluxWrapper.vue
│ │ │ ├── __mocks__/
│ │ │ │ └── FluxWrapper.vue
│ │ │ └── types.ts
│ │ ├── VueFlux/
│ │ │ ├── VueFlux.vue
│ │ │ ├── __test__/
│ │ │ │ └── emit.ts
│ │ │ └── types.ts
│ │ ├── index.ts
│ │ ├── types.ts
│ │ └── useComponent.ts
│ ├── controllers/
│ │ ├── Display/
│ │ │ └── Display.ts
│ │ ├── Keys/
│ │ │ └── Keys.ts
│ │ ├── Mouse/
│ │ │ └── Mouse.ts
│ │ ├── Player/
│ │ │ ├── Directions.ts
│ │ │ ├── Player.ts
│ │ │ ├── Resource.ts
│ │ │ ├── Statuses.ts
│ │ │ ├── Transition.ts
│ │ │ ├── __mocks__/
│ │ │ │ ├── Player.ts
│ │ │ │ ├── Resource.ts
│ │ │ │ └── Transitions.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── Timers/
│ │ │ └── Timers.ts
│ │ ├── Touches/
│ │ │ └── Touches.ts
│ │ └── index.ts
│ ├── lib.ts
│ ├── main.ts
│ ├── module.d.ts
│ ├── playgrounds/
│ │ ├── PgFluxCaption.vue
│ │ ├── PgFluxControls.vue
│ │ ├── PgFluxCube.vue
│ │ ├── PgFluxGrid.vue
│ │ ├── PgFluxImage.vue
│ │ ├── PgFluxIndex.vue
│ │ ├── PgFluxPagination.vue
│ │ ├── PgFluxParallax.vue
│ │ ├── PgFluxParallaxOp.vue
│ │ ├── PgFluxPreloader.vue
│ │ ├── PgFluxTransition.vue
│ │ ├── PgVueFlux.vue
│ │ └── components/
│ │ └── PgButton.vue
│ ├── repositories/
│ │ ├── Resources/
│ │ │ ├── Resources.test.ts
│ │ │ ├── Resources.ts
│ │ │ ├── ResourcesMapper.test.ts
│ │ │ ├── ResourcesMapper.ts
│ │ │ └── types.ts
│ │ ├── Transitions/
│ │ │ ├── Transitions.test.ts
│ │ │ ├── Transitions.ts
│ │ │ ├── TransitionsMapper.test.ts
│ │ │ ├── TransitionsMapper.ts
│ │ │ └── types.ts
│ │ └── index.ts
│ ├── resources/
│ │ ├── Img/
│ │ │ ├── Img.test.ts
│ │ │ ├── Img.ts
│ │ │ └── __mocks__/
│ │ │ └── Img.ts
│ │ ├── ResizeTypes.ts
│ │ ├── Resource.ts
│ │ ├── Statuses.ts
│ │ ├── __test__/
│ │ │ └── ResourceFactory.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── shared/
│ │ ├── Maths/
│ │ │ ├── Maths.test.ts
│ │ │ └── Maths.ts
│ │ ├── Position/
│ │ │ ├── Position.test.ts
│ │ │ └── Position.ts
│ │ ├── ResizeCalculator/
│ │ │ ├── ResizeCalculator.test.ts
│ │ │ └── ResizeCalculator.ts
│ │ ├── ResourceLoader/
│ │ │ ├── ResourceLoader.test.ts
│ │ │ ├── ResourceLoader.ts
│ │ │ ├── __mocks__/
│ │ │ │ └── ResourceLoader.ts
│ │ │ └── __test__/
│ │ │ └── ResourceLoaderFactory.ts
│ │ ├── Size/
│ │ │ ├── Size.test.ts
│ │ │ └── Size.ts
│ │ └── index.ts
│ └── transitions/
│ ├── Blinds2D/
│ │ ├── Blinds2D.test.ts
│ │ ├── Blinds2D.vue
│ │ └── types.ts
│ ├── Blinds3D/
│ │ ├── Blinds3D.test.ts
│ │ ├── Blinds3D.vue
│ │ └── types.ts
│ ├── Blocks1/
│ │ ├── Blocks1.test.ts
│ │ ├── Blocks1.vue
│ │ └── types.ts
│ ├── Blocks2/
│ │ ├── Blocks2.test.ts
│ │ ├── Blocks2.vue
│ │ └── types.ts
│ ├── Book/
│ │ ├── Book.test.ts
│ │ ├── Book.vue
│ │ └── types.ts
│ ├── Camera/
│ │ ├── Camera.test.ts
│ │ ├── Camera.vue
│ │ └── types.ts
│ ├── Concentric/
│ │ ├── Concentric.test.ts
│ │ ├── Concentric.vue
│ │ └── types.ts
│ ├── Cube/
│ │ ├── Cube.test.ts
│ │ ├── Cube.vue
│ │ └── types.ts
│ ├── Explode/
│ │ ├── Explode.test.ts
│ │ ├── Explode.vue
│ │ └── types.ts
│ ├── Fade/
│ │ ├── Fade.test.ts
│ │ ├── Fade.vue
│ │ └── types.ts
│ ├── Fall/
│ │ ├── Fall.test.ts
│ │ ├── Fall.vue
│ │ └── types.ts
│ ├── Kenburn/
│ │ ├── Kenburn.test.ts
│ │ ├── Kenburn.vue
│ │ └── types.ts
│ ├── Round1/
│ │ ├── Round1.test.ts
│ │ ├── Round1.vue
│ │ └── types.ts
│ ├── Round2/
│ │ ├── Round2.test.ts
│ │ ├── Round2.vue
│ │ └── types.ts
│ ├── Slide/
│ │ ├── Slide.test.ts
│ │ ├── Slide.vue
│ │ └── types.ts
│ ├── Swipe/
│ │ ├── Swipe.test.ts
│ │ ├── Swipe.vue
│ │ └── types.ts
│ ├── Warp/
│ │ ├── Warp.test.ts
│ │ ├── Warp.vue
│ │ └── types.ts
│ ├── Waterfall/
│ │ ├── Waterfall.test.ts
│ │ ├── Waterfall.vue
│ │ └── types.ts
│ ├── Wave/
│ │ ├── Wave.test.ts
│ │ ├── Wave.vue
│ │ └── types.ts
│ ├── Zip/
│ │ ├── Zip.test.ts
│ │ ├── Zip.vue
│ │ └── types.ts
│ ├── __test__/
│ │ └── AnimationWrapper.ts
│ ├── index.ts
│ ├── types.ts
│ └── useTransition.ts
├── tsconfig.app.json
├── tsconfig.build.json
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.vitest.json
├── vite.config.ts
└── vitest.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 3
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
================================================
FILE: .gitattributes
================================================
* text=auto eol=lf
ia.txt text eol=lf
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: [ragnarlotus]# Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
================================================
FILE: .markdownlint.cjs
================================================
module.exports = {
default: true,
MD001: false,
MD013: false,
MD024: false,
MD033: false,
MD036: false,
MD041: false,
};
================================================
FILE: .prettierrc.json
================================================
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": true,
"singleQuote": true,
"printWidth": 100,
"useTabs": true,
"tabWidth": 3,
"vueIndentScriptAndStyle": true
}
================================================
FILE: .vscode/extensions.json
================================================
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
## Documentation and demos
**[Version 5 documentation](https://ragnarlotus.github.io/vue-flux-docs/documentation/v5/overview)**
**[Version 6 documentation](https://ragnarlotus.github.io/vue-flux-docs/documentation/v6/overview)**
**[Version 7 documentation](https://ragnarlotus.github.io/vue-flux-docs/documentation/v7/overview)**
**[Version 7 demos](https://ragnarlotus.github.io/vue-flux-docs/demos/demos)**
# Overview
This is an image slider developed with [vue](https://vuejs.org/) 3 which comes with 20 cool transitions out of the box.






## Features
| Feature | Description |
|---------|-------------|
| Responsive | The slider and the images are adapted to container to fill it always |
| Compatibility | Supported by all major browsers |
| Expandable | You can add your custom transitions very easily |
| Customization | Total customizable to suit most needs |
| Gestures | Mobile friendly by gestures |
| Functionality | You can use arrow keys to navigate. Switch to full screen |
| Parallax | It includes a parallax component very easy to set up |
## Quick start
Install and save the package.
``` bash
npm install --save vue-flux@latest
```
Add component. This one has all the complements, so you can remove the ones you don't want.
``` html
```
## Performance
Weight is about 60 KB so is pretty light having only the essential CSS. It also does not require a high end computer as animations are performed with CSS3 hardware acceleration.
## Included transitions
#### 2D transitions
* Fade: fades from one image to next.
* Kenburn: fades, zoom and moves current image to next.
* Swipe: swipes the image to display next like uncovered with a curtain.
* Slide: slides the image horizontally revealing the next.
* Waterfall: divides the image in bars and drops them down in turns.
* Zip: divides the image in bars and slides them up and down alternately like a zip.
* Blinds 2D: divides the image in vertical bars that blinds and fades out.
* Blocks 1: the image is split in blocks that shrink and fade out randomly.
* Blocks 2: the image is split in blocks that shrink and fade out in wave from a corner to the opposite.
* Concentric: a concentric effect is performed by rotating the image converted into circles.
* Warp: a concentric effect is performed by rotating the image converted into circles in alternate direction.
* Camera: from outside to inside the image is being circled in black like a camera.
#### 3D transitions
* Cube: turns the image to a side like if place in a cube.
* Book: makes the effect of turning a page to display next image.
* Fall: the image falls in front displaying next image.
* Wave: makes the image 3D and divides it in slices that turn vertically to display the next image.
* Blinds 3D: divides the image in vertical bars that blinds 180 deg to form the next image.
* Round 1: the image is split in blocks that turn 180 deg horizontally to form next image.
* Round 2: panels start to round vertically revealing the next image in upper arrow form leaving trail.
* Explode: the image starts to explode from the center to outside.
## Parallax
As simple as this.
``` html
CONTENT
```
## Troubleshooting
If you find yourself running into issues during installation or running the slider, please check our [documentation](https://ragnarlotus.github.io/vue-flux-docs/documentation/v7/overview). If still needs help open an [issue](https://github.com/ragnarlotus/vue-flux/issues/new). I will be happy to discuss how they can be solved.
## Documentation
You can view the full documentation at the project's [documentation](https://ragnarlotus.github.io/vue-flux-docs/documentation/v7/overview) with examples and detailed information.
## Changelog
Check the [changelog](https://ragnarlotus.github.io/vue-flux-docs/documentation/v7/changelog) for update info.
## Inspiration
This slider was inspired by [Flux Slider](http://joelambert.co.uk/flux/).
## Contributing
Contributions, questions and comments are all welcome and encouraged.
Do not hesitate to send me your own transitions to add them to the slider.
================================================
FILE: env.d.ts
================================================
///
================================================
FILE: eslint.config.ts
================================================
import { globalIgnores } from 'eslint/config';
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript';
import pluginVue from 'eslint-plugin-vue';
import pluginVitest from '@vitest/eslint-plugin';
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting';
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
skipFormatting,
{
rules: {
'vue/multi-word-component-names': 'off',
},
},
);
================================================
FILE: ia.txt
================================================
=== vue-flux IA bundle ===
Generated on: Fri Dec 12 06:10:34 2025
===== FILE: package.json =====
{
"name": "vue-flux",
"version": "7.1.3",
"type": "module",
"description": "Vue image and other resources slider",
"author": "ragnar lotus",
"repository": {
"type": "git",
"url": "git+https://github.com/ragnarlotus/vue-flux.git"
},
"keywords": [
"vue",
"image",
"slider",
"carousel",
"parallax"
],
"license": "MIT",
"bugs": "https://github.com/ragnarlotus/vue-flux/issues",
"homepage": "https://ragnarlotus.github.io/vue-flux-docs/",
"main": "./dist/vue-flux.umd.cjs",
"module": "./dist/vue-flux.js",
"files": [
"dist"
],
"types": "./dist/vue-flux.d.ts",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:coverage": "vitest run --coverage --watch",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"exports": {
".": {
"types": "./dist/vue-flux.d.ts",
"import": "./dist/vue-flux.js",
"require": "./dist/vue-flux.umd.cjs"
},
"./style.css": "./dist/vue-flux.css",
"./complements": {
"types": "./dist/complements/index.d.ts",
"import": "./dist/complements/index.js"
},
"./transitions": {
"types": "./dist/transitions/index.d.ts",
"import": "./dist/transitions/index.js"
}
},
"sideEffects": [
"*.css"
],
"peerDependencies": {
"vue": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.17",
"@tsconfig/node22": "^22.0.5",
"@types/jsdom": "^27.0.0",
"@types/node": "^25.0.0",
"@vitejs/plugin-vue": "^6.0.2",
"@vitest/coverage-v8": "^4.0.15",
"@vitest/eslint-plugin": "^1.5.2",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.1",
"eslint-plugin-vue": "~10.6.2",
"jiti": "^2.6.1",
"jsdom": "^27.3.0",
"npm-run-all2": "^8.0.4",
"prettier": "3.7.4",
"sass": "^1.96.0",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"vite": "^7.2.7",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-vue-devtools": "^8.0.5",
"vitest": "^4.0.15",
"vue": "^3.5.25",
"vue-cosk": "^1.0.0",
"vue-tsc": "^3.1.8"
}
}
===== FILE: vite.config.ts =====
import { fileURLToPath, URL } from 'node:url';
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import tailwindcss from '@tailwindcss/vite';
import vueDevTools from 'vite-plugin-vue-devtools';
import dts from 'vite-plugin-dts';
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
tailwindcss(),
dts({
tsconfigPath: './tsconfig.build.json',
rollupTypes: true,
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
copyPublicDir: false,
lib: {
entry: resolve(__dirname, 'src/lib.ts'),
name: 'VueFlux',
fileName: 'vue-flux',
},
rollupOptions: {
external: ['vue'],
output: {
globals: {
vue: 'Vue',
},
},
},
},
});
===== FILE: vitest.config.ts =====
import { fileURLToPath } from 'node:url';
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config';
import viteConfig from './vite.config';
export default mergeConfig(
viteConfig,
defineConfig({
test: {
globals: true,
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
);
===== FILE: tsconfig.json =====
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
],
"compilerOptions": {
"types": ["vitest/globals"]
},
"exclude": [
"src/App.vue",
"src/main.ts",
"node_modules",
"dist",
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.spec.ts",
"src/**/*.spec.tsx"
]
}
===== FILE: src/App.vue =====
===== FILE: src/assets/css/base.scss =====
label {
margin-top: 12px;
display: block;
span {
margin-right: 6px;
}
}
===== FILE: src/assets/css/main.css =====
@import 'tailwindcss';
@import './base.scss';
===== FILE: src/complements/FluxCaption/FluxCaption.test.ts =====
import { Player, Timers } from '../../controllers';
import { mount } from '@vue/test-utils';
import FluxCaption from './FluxCaption.vue';
import emit from '../../components/VueFlux/__test__/emit';
import {
vueFluxConfig,
setCurrentResource,
setCurrentTransition,
} from '../__test__/PlayerHelper';
vi.mock('../../controllers/Player/Player');
const defaultCaption = 'the caption';
describe('complements: FluxCaption', () => {
const timers = new Timers();
it('should mount properly without slot', () => {
const player = new Player(vueFluxConfig, timers, emit);
expect(() => {
mount(FluxCaption, {
props: {
player,
},
});
}).not.toThrow();
});
it('should not be visible if no caption', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player);
const wrapper = mount(FluxCaption, {
props: {
player,
},
});
expect(wrapper.html().includes('class="flux-caption"')).toBeTruthy();
});
it('should not be visible if caption has no length', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player, '');
const wrapper = mount(FluxCaption, {
props: {
player,
},
});
expect(wrapper.html().includes('class="flux-caption"')).toBeTruthy();
});
it('should not be visible if transition running', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player, defaultCaption);
setCurrentTransition(player);
const wrapper = mount(FluxCaption, {
props: {
player,
},
});
expect(wrapper.html().includes('class="flux-caption"')).toBeTruthy();
});
it('should display the caption', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player, defaultCaption);
const wrapper = mount(FluxCaption, {
props: {
player,
},
});
expect(
wrapper.html().includes('class="flux-caption visible"')
).toBeTruthy();
});
it('should mount properly with slot', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player, defaultCaption);
const wrapper = mount(FluxCaption, {
props: {
player,
},
slots: {
default: `
{{ params.caption }}
`,
},
});
expect(
wrapper.html().includes(`${defaultCaption}
`)
).toBeTruthy();
});
});
===== FILE: src/complements/FluxCaption/FluxCaption.vue =====
{{ caption }}
===== FILE: src/complements/FluxControls/FluxControls.test.ts =====
import { ref, type Ref } from 'vue';
import { Player, Timers } from '../../controllers';
import { Directions, Statuses } from '../../controllers/Player';
import * as Buttons from './buttons';
import FluxControls from './FluxControls.vue';
import { mount } from '@vue/test-utils';
import emit from '../../components/VueFlux/__test__/emit';
import { vueFluxConfig, setCurrentResource, setCurrentTransition } from '../__test__/PlayerHelper';
vi.mock('../../controllers/Player/Player');
describe('complements: FluxControls', () => {
const timers = new Timers();
const mouseOver: Ref = ref(false);
beforeEach(() => {
mouseOver.value = false;
});
it('should mount properly without slot', () => {
const player = new Player(vueFluxConfig, timers, emit);
expect(() => {
mount(FluxControls, {
props: {
mouseOver,
player,
},
});
}).not.toThrow();
});
it('should not be visible if transition running', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player);
setCurrentTransition(player);
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
expect(wrapper.html().includes('class="flux-controls"')).toBeFalsy();
});
it('should not be visible if transition running and mouse not moving', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player);
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
expect(wrapper.html().includes('class="flux-controls"')).toBeFalsy();
});
it('should be visible if no transition running and mouse moving', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player);
mouseOver.value = true;
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
expect(wrapper.html().includes('class="flux-controls"')).toBeTruthy();
});
it('should display play button', () => {
const player = new Player(vueFluxConfig, timers, emit);
player.status.value = Statuses.stopped;
setCurrentResource(player);
mouseOver.value = true;
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
expect(() => {
wrapper.getComponent(Buttons.Play);
}).not.toThrow();
});
it('should play when button pressed', async () => {
const player = new Player(vueFluxConfig, timers, emit);
player.status.value = Statuses.stopped;
setCurrentResource(player);
mouseOver.value = true;
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
await wrapper.getComponent(Buttons.Play).trigger('click');
expect(player.play).toHaveBeenCalledWith(Directions.next, expect.any(Number));
});
it('should display stop button', () => {
const player = new Player(vueFluxConfig, timers, emit);
player.status.value = Statuses.playing;
setCurrentResource(player);
mouseOver.value = true;
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
expect(() => {
wrapper.getComponent(Buttons.Stop);
}).not.toThrow();
});
it('should stop when button pressed', async () => {
const player = new Player(vueFluxConfig, timers, emit);
player.status.value = Statuses.playing;
setCurrentResource(player);
mouseOver.value = true;
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
await wrapper.getComponent(Buttons.Stop).trigger('click');
expect(player.stop).toHaveBeenCalledOnce();
});
it('should display previous resource when button pressed', async () => {
const player = new Player(vueFluxConfig, timers, emit);
player.status.value = Statuses.playing;
setCurrentResource(player);
mouseOver.value = true;
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
await wrapper.getComponent(Buttons.Prev).trigger('click');
expect(player.show).toHaveBeenCalledWith(Directions.prev);
});
it('should display next resource when button pressed', async () => {
const player = new Player(vueFluxConfig, timers, emit);
player.status.value = Statuses.playing;
setCurrentResource(player);
mouseOver.value = true;
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
await wrapper.getComponent(Buttons.Next).trigger('click');
expect(player.show).toHaveBeenCalledWith(Directions.next);
});
});
===== FILE: src/complements/FluxControls/FluxControls.vue =====
===== FILE: src/complements/FluxControls/buttons/Next.vue =====
===== FILE: src/complements/FluxControls/buttons/Play.vue =====
===== FILE: src/complements/FluxControls/buttons/Prev.vue =====
===== FILE: src/complements/FluxControls/buttons/Stop.vue =====
===== FILE: src/complements/FluxControls/buttons/index.ts =====
export { default as Prev } from './Prev.vue';
export { default as Play } from './Play.vue';
export { default as Stop } from './Stop.vue';
export { default as Next } from './Next.vue';
===== FILE: src/complements/FluxIndex/Button/Button.test.ts =====
import { type Ref, ref } from 'vue';
import { mount } from '@vue/test-utils';
import Button from './Button.vue';
describe('complements: FluxIndex Button', () => {
const mouseOver: Ref = ref(false);
beforeEach(() => {
mouseOver.value = false;
});
it('mounts properly', () => {
expect(() => {
mount(Button, {
props: {
mouseOver,
},
});
}).not.toThrow();
});
it('is visible when mouse over', () => {
mouseOver.value = true;
const wrapper = mount(Button, {
props: {
mouseOver,
},
});
expect(wrapper.html().includes('toggle bottom left')).toBeTruthy();
});
it('is NOT visible when mouse NOT over', () => {
const wrapper = mount(Button, {
props: {
mouseOver,
},
});
expect(wrapper.html().includes('toggle bottom left')).toBeFalsy();
});
});
===== FILE: src/complements/FluxIndex/Button/Button.vue =====
===== FILE: src/complements/FluxIndex/FluxIndex.vue =====
===== FILE: src/complements/FluxIndex/List/List.test.ts =====
import { type Ref, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { Player, Timers } from '../../../controllers';
import List from './List.vue';
import { Size } from '../../../shared';
import emit from '../../../components/VueFlux/__test__/emit';
import { vueFluxConfig, setCurrentResource } from '../../__test__/PlayerHelper';
import Thumb from '../Thumb/Thumb.vue';
import ResourceFactory from '../../../resources/__test__/ResourceFactory';
vi.mock('../../../resources/Img/Img');
vi.mock('../../../shared/ResourceLoader/ResourceLoader');
vi.mock('../../../controllers/Player/Player');
describe('complements: FluxIndex List', () => {
const timers = new Timers();
const displaySize: Size = new Size({ width: 640, height: 360 });
const mouseOver: Ref = ref(false);
beforeEach(() => {
mouseOver.value = false;
});
it('mounts properly', () => {
const player = new Player(vueFluxConfig, timers, emit);
expect(() => {
mount(List, {
props: {
displaySize,
player,
mouseOver,
},
});
}).not.toThrow();
});
it('is not visible by default', async () => {
mouseOver.value = true;
const player = new Player(vueFluxConfig, timers, emit);
const wrapper = mount(List, {
props: {
displaySize,
player,
mouseOver,
},
});
expect(wrapper.html().includes('nav class=""')).toBeTruthy();
});
it('shows the list when button clicked', async () => {
mouseOver.value = true;
const player = new Player(vueFluxConfig, timers, emit);
const wrapper = mount(List, {
props: {
displaySize,
player,
mouseOver,
},
});
await wrapper.vm.show();
expect(wrapper.html().includes('nav class="visible"')).toBeTruthy();
});
it('does nothing if clicked resource is the same as current resource', async () => {
mouseOver.value = true;
const player = new Player(vueFluxConfig, timers, emit);
const resources = ResourceFactory.create(10);
await player.resources.update(resources, 10, displaySize);
setCurrentResource(player);
const wrapper = mount(List, {
props: {
displaySize,
player,
mouseOver,
},
});
await wrapper.find({ ref: '$list' }).findAllComponents(Thumb)[0].trigger('click');
expect(player.show).not.toHaveBeenCalled();
});
});
===== FILE: src/complements/FluxIndex/List/List.vue =====
===== FILE: src/complements/FluxIndex/Thumb/Thumb.vue =====
===== FILE: src/complements/FluxIndex/Thumb/useThumbs.ts =====
import { computed } from 'vue';
import { Player } from '../../../controllers';
import { Size } from '../../../shared';
export default function useThumbs(displaySize: Size, player: Player) {
const size = computed(() => {
let { width, height } = displaySize.toValue();
width = width! / 4.2;
height = (width * 90) / 160;
if (width > 160) {
width = 160;
height = 90;
}
return new Size({
width,
height,
});
});
function getClass(index: number) {
const { current } = player.resource;
if (current === null) {
return '';
}
if (current.index !== index) {
return '';
}
return 'current';
}
return { size, getClass };
}
===== FILE: src/complements/FluxPagination/FluxPagination.vue =====
===== FILE: src/complements/FluxPreloader/FluxPreloader.vue =====
{{ loader.value?.progress }}%
===== FILE: src/complements/__test__/PlayerHelper.ts =====
import type { VueFluxConfig } from '../../components/VueFlux/types';
import { Player } from '../../controllers/Player';
import type { ResourceIndex } from '../../repositories/Resources/types';
import type { TransitionIndex } from '../../repositories/Transitions/types';
import { Img } from '../../resources';
import { Blinds2D } from '../../transitions';
export const vueFluxConfig = {
allowFullscreen: false,
allowToSkipTransition: true,
aspectRatio: '16:9',
autohideTime: 2500,
autoplay: false,
bindKeys: false,
delay: 5000,
enableGestures: false,
infinite: true,
lazyLoad: true,
lazyLoadAfter: 5,
} as VueFluxConfig;
export function setCurrentResource(player: Player, caption?: string) {
player.resource.current = {
index: 0,
rsc: new Img('url', caption),
options: {},
} as ResourceIndex;
}
export function setCurrentTransition(player: Player) {
player.transition.current = {
index: 0,
component: Blinds2D,
options: {},
} as TransitionIndex;
}
===== FILE: src/complements/index.ts =====
export { default as FluxCaption } from './FluxCaption/FluxCaption.vue';
export { default as FluxControls } from './FluxControls/FluxControls.vue';
export { default as FluxIndex } from './FluxIndex/FluxIndex.vue';
export { default as FluxPagination } from './FluxPagination/FluxPagination.vue';
export { default as FluxPreloader } from './FluxPreloader/FluxPreloader.vue';
===== FILE: src/components/FluxButton/FluxButton.test.ts =====
import FluxButton from './FluxButton.vue';
import { mount } from '@vue/test-utils';
describe('component: FluxButton', () => {
it('should mount properly', () => {
const nextLine = '';
const wrapper = mount(FluxButton, {
slots: {
default: nextLine,
},
});
expect(
wrapper.html().includes('
===== FILE: src/components/FluxCube/FluxCube.vue =====
===== FILE: src/components/FluxCube/Sides.ts =====
enum Sides {
front = 'front',
back = 'back',
left = 'left',
right = 'right',
top = 'top',
bottom = 'bottom',
}
export default Sides;
===== FILE: src/components/FluxCube/Turns.ts =====
enum Turns {
front = 'front',
back = 'back',
backr = 'backr',
backl = 'backl',
left = 'left',
right = 'right',
top = 'top',
bottom = 'bottom',
}
export default Turns;
===== FILE: src/components/FluxCube/__mocks__/FluxCube.vue =====
===== FILE: src/components/FluxCube/__mocks__/Side.vue =====
===== FILE: src/components/FluxCube/factories/CubeFactory.test.ts =====
import { Img } from '../../../resources';
import { Position, Size } from '../../../shared';
import { type SideProps } from '../types';
import CubeFactory from './CubeFactory';
import CubeSideFactory from './CubeSideFactory';
import SideTransformFactory from './SideTransformFactory';
describe('factory: CubeFactory', () => {
let rsc, rscs, color, colors, offset, offsets;
const depth = 160;
const size = new Size({
width: 640,
height: 360,
});
const viewSize = new Size();
const sideTransformFactory = new SideTransformFactory(depth, size, viewSize);
vi.spyOn(CubeSideFactory, 'getProps').mockImplementation(() => ({}) as SideProps);
beforeEach(() => {
vi.clearAllMocks();
});
it('generates a cube using a color', () => {
color = '#ccc';
const cubeProps = CubeFactory.getSidesProps(sideTransformFactory, color);
expect(CubeSideFactory.getProps).toHaveBeenCalledTimes(6);
expect(Object.keys(cubeProps)).toHaveLength(6);
});
it('generates a cube using a colors', () => {
colors = {
top: '#ccc',
left: '#ccc',
back: '#ccc',
};
const cubeProps = CubeFactory.getSidesProps(sideTransformFactory, undefined, colors);
expect(CubeSideFactory.getProps).toHaveBeenCalledTimes(3);
expect(Object.keys(cubeProps)).toHaveLength(3);
});
it('generates a cube using a rsc', () => {
rsc = new Img('url', 'caption');
const cubeProps = CubeFactory.getSidesProps(sideTransformFactory, undefined, undefined, rsc);
expect(CubeSideFactory.getProps).toHaveBeenCalledTimes(6);
expect(Object.keys(cubeProps)).toHaveLength(6);
});
it('generates a cube using a rscs', () => {
rscs = {
bottom: new Img('url', 'caption'),
right: new Img('url', 'caption'),
front: new Img('url', 'caption'),
};
const cubeProps = CubeFactory.getSidesProps(
sideTransformFactory,
undefined,
undefined,
undefined,
rscs,
);
expect(CubeSideFactory.getProps).toHaveBeenCalledTimes(3);
expect(Object.keys(cubeProps)).toHaveLength(3);
});
it('generates a cube using a color with offset', () => {
color = '#ccc';
offset = new Position({ top: 160, left: 80 });
const cubeProps = CubeFactory.getSidesProps(
sideTransformFactory,
color,
undefined,
undefined,
undefined,
offset,
);
expect(CubeSideFactory.getProps).toHaveBeenCalledTimes(6);
expect(Object.keys(cubeProps)).toHaveLength(6);
});
it('generates a cube using a colors with offsets', () => {
colors = {
top: '#ccc',
left: '#ccc',
back: '#ccc',
};
offsets = {
top: new Position({ top: 160, left: 80 }),
left: new Position({ top: 160, left: 80 }),
back: new Position({ top: 160, left: 80 }),
};
const cubeProps = CubeFactory.getSidesProps(
sideTransformFactory,
undefined,
colors,
undefined,
undefined,
undefined,
offsets,
);
expect(CubeSideFactory.getProps).toHaveBeenCalledTimes(3);
expect(Object.keys(cubeProps)).toHaveLength(3);
});
});
===== FILE: src/components/FluxCube/factories/CubeFactory.ts =====
import type { Side, SidesColors, SidesResources, SidesOffsets, SidesProps } from '../types';
import CubeSideFactory from './CubeSideFactory';
import SideTransformFactory from './SideTransformFactory';
import { Position } from '../../../shared';
import Sides from '../Sides';
import { Resource } from '../../../resources';
import type { CSSProperties } from 'vue';
function isSideDefined(side: Side, colors?: SidesColors, rscs?: SidesResources) {
if (colors && colors[side]) {
return true;
}
if (rscs && rscs[side]) {
return true;
}
return false;
}
function getDefinedSides(
color?: CSSProperties['color'],
colors?: SidesColors,
rsc?: Resource,
rscs?: SidesResources,
) {
const sides = Object.values(Sides);
if (color || rsc) {
return sides;
}
return Object.values(Sides).filter((side) => isSideDefined(side, colors, rscs));
}
export default class CubeFactory {
static getSidesProps(
sideTransformFactory: SideTransformFactory,
color?: CSSProperties['color'],
colors?: SidesColors,
rsc?: Resource,
rscs?: SidesResources,
offset?: Position,
offsets?: SidesOffsets,
) {
const sides = getDefinedSides(color, colors, rsc, rscs);
const props: SidesProps = {};
sides.forEach((side: Side) => {
props[side] = CubeSideFactory.getProps(
sideTransformFactory,
side,
colors && colors[side] ? colors[side] : color,
rscs && rscs[side] ? rscs[side] : rsc,
offsets && offsets[side] ? offsets[side] : offset,
);
});
return props;
}
}
===== FILE: src/components/FluxCube/factories/CubeSideFactory.ts =====
import { Position } from '../../../shared';
import { Resource } from '../../../resources';
import type { Side, SideProps } from '../types';
import SideTransformFactory from './SideTransformFactory';
import { FluxImage } from '../../';
import type { CSSProperties } from 'vue';
export default class CubeSideFactory {
static getProps(
sideTransformFactory: SideTransformFactory,
side: Side,
color?: CSSProperties['color'],
rsc?: Resource,
offset?: Position,
) {
const { depth, size, viewSize } = sideTransformFactory;
const props: SideProps = {
name: side,
component: rsc ? rsc.transition.component : FluxImage,
color: color,
rsc: rsc,
size: size.clone(),
viewSize: viewSize.clone(),
offset: offset,
style: {
position: 'absolute',
transform: sideTransformFactory.getSideCss(side),
backfaceVisibility: 'hidden',
},
};
if (['left', 'right'].includes(side)) {
props.viewSize.width.value = depth;
props.size.width.value = depth;
}
if (['top', 'bottom'].includes(side)) {
props.viewSize.height.value = depth;
props.size.height.value = depth;
}
return props;
}
}
===== FILE: src/components/FluxCube/factories/SideTransformFactory.test.ts =====
import { Size } from '../../../shared';
import Turns from '../Turns';
import SideTransformFactory from './SideTransformFactory';
describe('factory: SideTransformFactory', () => {
const depth = 160;
const size = new Size({
width: 640,
height: 360,
});
const viewSize = new Size();
const sideTransformFactory = new SideTransformFactory(depth, size, viewSize);
it('should get the proper rotate angles', () => {
const expectations = {
front: 'rotateX(0deg) rotateY(0deg)',
right: 'rotateX(0deg) rotateY(90deg)',
left: 'rotateX(0deg) rotateY(-90deg)',
top: 'rotateX(90deg) rotateY(0deg)',
bottom: 'rotateX(-90deg) rotateY(0deg)',
back: 'rotateX(0deg) rotateY(180deg)',
backl: 'rotateX(0deg) rotateY(-180deg)',
backr: 'rotateX(0deg) rotateY(180deg)',
};
Object.values(Turns).forEach((turn) => {
expect(sideTransformFactory.getRotate(turn)).toBe(expectations[turn]);
});
});
it('should get proper translate coordinates', () => {
const expectations = {
front: 'translate3d(0%, 0%, 0px)',
right: 'translate3d(50%, 0%, 560px)',
left: 'translate3d(-50%, 0%, 80px)',
top: 'translate3d(0%, -50%, 80px)',
bottom: 'translate3d(0%, 50%, 280px)',
back: 'translate3d(0%, 0%, 160px)',
backl: 'translate3d(0%, 0%, 160px)',
backr: 'translate3d(0%, 0%, 160px)',
};
Object.values(Turns).forEach((turn) => {
expect(sideTransformFactory.getTranslate(turn)).toBe(
expectations[turn]
);
});
});
it('should get each side style', () => {
const expectations = {
front: 'rotateX(0deg) rotateY(0deg) translate3d(0%, 0%, 0px)',
right: 'rotateX(0deg) rotateY(90deg) translate3d(50%, 0%, 560px)',
left: 'rotateX(0deg) rotateY(-90deg) translate3d(-50%, 0%, 80px)',
top: 'rotateX(90deg) rotateY(0deg) translate3d(0%, -50%, 80px)',
bottom: 'rotateX(-90deg) rotateY(0deg) translate3d(0%, 50%, 280px)',
back: 'rotateX(0deg) rotateY(180deg) translate3d(0%, 0%, 160px)',
backl: 'rotateX(0deg) rotateY(-180deg) translate3d(0%, 0%, 160px)',
backr: 'rotateX(0deg) rotateY(180deg) translate3d(0%, 0%, 160px)',
};
Object.values(Turns).forEach((turn) => {
expect(sideTransformFactory.getSideCss(turn)).toBe(expectations[turn]);
});
});
});
===== FILE: src/components/FluxCube/factories/SideTransformFactory.ts =====
import { type Ref, computed } from 'vue';
import { Size } from '../../../shared';
import type { Side, Turn } from '../types';
const rotate: {
x: {
[key: string]: string;
};
y: {
[key: string]: string;
};
} = {
x: {
top: '90',
bottom: '-90',
},
y: {
back: '180',
backr: '180',
backl: '-180',
left: '-90',
right: '90',
},
};
const translate: {
x: {
[key: string]: string;
};
y: {
[key: string]: string;
};
} = {
x: {
left: '-50',
right: '50',
},
y: {
top: '-50',
bottom: '50',
},
};
export default class SideTransformFactory {
depth: number;
size: Size;
viewSize: Size;
translateZ: Ref<{ [key: string]: number }> = computed(() => {
const halfDepth = this.depth / 2;
const { width, height } = this.size.toValue();
const { width: viewWidth, height: viewHeight } = this.viewSize.toValue();
return {
front: 0,
back: this.depth,
backr: this.depth,
backl: this.depth,
left: halfDepth,
right: (viewWidth ?? width!) - halfDepth,
top: halfDepth,
bottom: (viewHeight ?? height!) - halfDepth,
};
});
constructor(depth: number, size: Size, viewSize: Size) {
this.depth = depth;
this.size = size;
this.viewSize = viewSize;
}
public getRotate(turn: Side | Turn) {
const rx = rotate.x[turn] ?? '0';
const ry = rotate.y[turn] ?? '0';
return `rotateX(${rx}deg) rotateY(${ry}deg)`;
}
public getTranslate(side: Side | Turn) {
const tx = translate.x[side] ?? '0';
const ty = translate.y[side] ?? '0';
const tz = this.translateZ.value[side]!.toString();
return `translate3d(${tx}%, ${ty}%, ${tz}px)`;
}
public getSideCss(side: Side | Turn) {
return `${this.getRotate(side)} ${this.getTranslate(side)}`;
}
}
===== FILE: src/components/FluxCube/index.ts =====
export { default as FluxCube } from './FluxCube.vue';
export { default as Sides } from './Sides';
export { default as Turns } from './Turns';
===== FILE: src/components/FluxCube/types.ts =====
import type { CSSProperties, Component } from 'vue';
import { Resource } from '../../resources';
import { Position, Size } from '../../shared';
import type { ComponentProps, FluxComponent } from '../types';
import Sides from './Sides';
import Turns from './Turns';
export interface FluxCubeProps extends ComponentProps {
colors?: SidesColors;
rscs?: SidesResources;
offsets?: SidesOffsets;
depth?: number;
origin?: string;
}
export type Side = keyof typeof Sides;
export type Turn = keyof typeof Turns;
export interface SidesColors {
[Sides.front]?: string;
[Sides.back]?: string;
[Sides.left]?: string;
[Sides.right]?: string;
[Sides.top]?: string;
[Sides.bottom]?: string;
}
export interface SidesResources {
[Sides.front]?: Resource;
[Sides.back]?: Resource;
[Sides.left]?: Resource;
[Sides.right]?: Resource;
[Sides.top]?: Resource;
[Sides.bottom]?: Resource;
}
export interface SidesOffsets {
[Sides.front]?: Position;
[Sides.back]?: Position;
[Sides.left]?: Position;
[Sides.right]?: Position;
[Sides.top]?: Position;
[Sides.bottom]?: Position;
}
export interface SideProps {
name: Side;
component: Component;
rsc?: Resource;
size: Size;
viewSize: Size;
color?: CSSProperties['color'];
offset?: Position;
style: CSSProperties;
}
export interface SidesProps {
[Sides.front]?: SideProps;
[Sides.back]?: SideProps;
[Sides.left]?: SideProps;
[Sides.right]?: SideProps;
[Sides.top]?: SideProps;
[Sides.bottom]?: SideProps;
}
export interface SidesComponents {
[Sides.front]?: FluxComponent;
[Sides.back]?: FluxComponent;
[Sides.left]?: FluxComponent;
[Sides.right]?: FluxComponent;
[Sides.top]?: FluxComponent;
[Sides.bottom]?: FluxComponent;
}
===== FILE: src/components/FluxGrid/FluxGrid.vue =====
===== FILE: src/components/FluxGrid/__mocks__/FluxGrid.vue =====
===== FILE: src/components/FluxGrid/__mocks__/Tile.vue =====
===== FILE: src/components/FluxGrid/factories/GridFactory.ts =====
import { Size } from '../../../shared';
import GridTileFactory from './GridTileFactory';
import type { FluxGridProps, FluxGridTileProps } from '../types';
export default class GridFactory {
static getTilesProps(props: FluxGridProps) {
const { rows, cols, size, color, colors, rsc, rscs, depth } = props;
const numRows = Math.ceil(rows!);
const numCols = Math.ceil(cols!);
const grid = {
numRows,
numCols,
numTiles: numRows * numCols,
size,
depth: depth!,
color,
colors,
rsc,
rscs,
};
const tile = {
number: 0,
size: new Size({
width: Math.floor(size.width.value! / numCols),
height: Math.floor(size.height.value! / numRows),
}),
css: props.tileCss,
};
const tilesProps: FluxGridTileProps[] = [];
for (let tileNumber = 0; tileNumber < grid.numTiles; tileNumber++) {
tile.number = tileNumber;
tilesProps.push(GridTileFactory.getProps(grid, tile));
}
return tilesProps;
}
}
===== FILE: src/components/FluxGrid/factories/GridTileFactory.ts =====
import type { CSSProperties } from 'vue';
import { Resource } from '../../../resources';
import { Size, Position } from '../../../shared';
import type { SidesColors, SidesResources } from '../../FluxCube/types';
import type { FluxGridTileProps } from '../types';
export function getRowNumber(tileNumber: number, numCols: number) {
return Math.floor(tileNumber / numCols);
}
export function getColNumber(tileNumber: number, numCols: number) {
return tileNumber % numCols;
}
export default class GridTileFactory {
static getProps(
grid: {
numRows: number;
numCols: number;
numTiles: number;
size: Size;
depth: number;
color?: CSSProperties['color'];
colors?: SidesColors;
rsc?: Resource;
rscs?: SidesResources;
},
tile: {
number: number;
size: Size;
css?: CSSProperties;
},
) {
let { width, height } = tile.size.toValue();
const row = getRowNumber(tile.number, grid.numCols);
const col = getColNumber(tile.number, grid.numCols);
const props: FluxGridTileProps = {
color: grid.color,
colors: grid.colors,
rsc: grid.rsc,
rscs: grid.rscs,
size: grid.size,
depth: grid.depth,
offset: new Position({
top: row * height!,
left: col * width!,
}),
};
if (row + 1 === grid.numRows) {
height = grid.size.height.value! - row * height!;
}
if (col + 1 === grid.numCols) {
width = grid.size.width.value! - col * width!;
}
props.viewSize = new Size({
width,
height,
});
props.css = {
...tile.css,
position: 'absolute',
...props.offset.toPx(),
zIndex:
tile.number + 1 < grid.numTiles / 2 ? tile.number + 1 : grid.numTiles - tile.number,
};
return props;
}
}
===== FILE: src/components/FluxGrid/factories/index.ts =====
export { default as GridFactory } from './GridFactory';
export {
default as GridTileFactory,
getRowNumber,
getColNumber,
} from './GridTileFactory';
===== FILE: src/components/FluxGrid/types.ts =====
import type { CSSProperties } from 'vue';
import { Position, Size } from '../../shared';
import { Resource } from '../../resources';
import type { SidesColors, SidesResources } from '../FluxCube/types';
import type { ComponentProps } from '../types';
export interface FluxGridProps extends ComponentProps {
colors?: SidesColors;
rscs?: SidesResources;
rows?: number;
cols?: number;
depth?: number;
tileCss?: CSSProperties;
}
export interface FluxGridTileProps {
color?: CSSProperties['color'];
colors?: SidesColors;
rsc?: Resource;
rscs?: SidesResources;
size: Size;
depth: number;
offset: Position;
viewSize?: Size;
css?: CSSProperties;
}
===== FILE: src/components/FluxImage/FluxImage.vue =====
===== FILE: src/components/FluxImage/__mocks__/FluxImage.vue =====
===== FILE: src/components/FluxImage/types.ts =====
import type { ComponentProps } from '../types';
export interface FluxImageProps extends ComponentProps {}
===== FILE: src/components/FluxParallax/FluxParallax.vue =====
===== FILE: src/components/FluxParallax/types.ts =====
import type { CSSProperties, ComputedRef } from 'vue';
import { Resource } from '../../resources';
export interface FluxParallaxProps {
rsc: Resource;
holder?: Window | Element;
type?: 'visible' | 'relative' | 'fixed';
offset?: string;
}
export interface FluxParallaxStyles {
base: CSSProperties;
defined: CSSProperties;
final: ComputedRef;
}
export interface DisplayProps {
width: number;
height: number;
aspectRatio: number;
}
export interface ViewProps {
top: number;
width: number;
height: number;
aspectRatio: number;
}
===== FILE: src/components/FluxTransition/FluxTransition.vue =====
===== FILE: src/components/FluxTransition/types.ts =====
import { Resource } from '../../resources';
import { Size } from '../../shared';
import type { FluxComponent } from '../types';
export interface FluxTransitionProps {
size: Size;
transition: object;
from: Resource;
to: Resource;
displayComponent?: null | FluxComponent;
options?: object;
}
===== FILE: src/components/FluxVortex/FluxVortex.vue =====
===== FILE: src/components/FluxVortex/__mocks__/FluxVortex.vue =====
===== FILE: src/components/FluxVortex/__mocks__/Tile.vue =====
===== FILE: src/components/FluxVortex/factories/VortexCircleFactory.ts =====
import type { CSSProperties } from 'vue';
import { Position } from '../../../shared';
import type { FluxVortexCirclesProps } from '../types';
export default class VortexCircleFactory {
static getProps(
vortex: {
numCircles: number;
diagonal: number;
radius: number;
topGap: number;
leftGap: number;
},
circleNumber: number,
circleCss?: CSSProperties,
) {
const size = (vortex.numCircles - circleNumber) * vortex.radius * 2;
const gap = vortex.radius * circleNumber;
const offset = new Position({
top: vortex.topGap + gap,
left: vortex.leftGap + gap,
});
const circle: FluxVortexCirclesProps = {
offset: offset,
css: {
...circleCss,
...offset.toPx(),
position: 'absolute',
width: size + 'px',
height: size + 'px',
backgroundRepeat: 'repeat',
borderRadius: '50%',
zIndex: circleNumber,
},
};
return circle;
}
}
===== FILE: src/components/FluxVortex/factories/VortexFactory.ts =====
import { Maths } from '../../../shared';
import type { FluxVortexProps, FluxVortexCirclesProps } from '../types';
import VortexCircleFactory from './VortexCircleFactory';
export default class VortexFactory {
static getCirclesProps(props: FluxVortexProps) {
const { width, height } = props.size.toValue();
const numCircles = Math.round(props.circles!);
const diagonal = Maths.diag({ width: width!, height: height! });
const radius = Math.ceil(diagonal / 2 / numCircles);
const topGap = Math.ceil(height! / 2 - radius * numCircles);
const leftGap = Math.ceil(width! / 2 - radius * numCircles);
const vortex = {
numCircles,
diagonal,
radius,
topGap,
leftGap,
};
const circlesProps: FluxVortexCirclesProps[] = [];
for (let circleNumber = 0; circleNumber < numCircles; circleNumber++) {
circlesProps.push(VortexCircleFactory.getProps(vortex, circleNumber, props.tileCss));
}
return circlesProps;
}
}
===== FILE: src/components/FluxVortex/factories/index.ts =====
export { default as VortexFactory } from './VortexFactory';
export { default as VortexCircleFactory } from './VortexCircleFactory';
===== FILE: src/components/FluxVortex/types.ts =====
import type { CSSProperties } from 'vue';
import { Resource } from '../../resources';
import type { ComponentProps } from '../types';
import { Position } from '../../shared';
export interface FluxVortexProps extends ComponentProps {
rsc: Resource;
circles?: number;
tileCss?: CSSProperties;
}
export interface FluxVortexCirclesProps {
offset: Position;
css: CSSProperties;
}
===== FILE: src/components/FluxWrapper/FluxWrapper.vue =====
===== FILE: src/components/FluxWrapper/__mocks__/FluxWrapper.vue =====
===== FILE: src/components/FluxWrapper/types.ts =====
import type { ComponentProps } from '../types';
export interface FluxWrapperProps extends ComponentProps {}
===== FILE: src/components/VueFlux/VueFlux.vue =====
===== FILE: src/components/VueFlux/__test__/emit.ts =====
import { vi } from 'vitest';
import type { VueFluxEmits } from '../types';
export default vi.fn() as unknown as VueFluxEmits;
===== FILE: src/components/VueFlux/types.ts =====
import { Resource, type ResourceWithOptions } from '../../resources';
import type { TransitionWithOptions } from '../../transitions';
import { type Direction, PlayerResource, PlayerTransition } from '../../controllers/Player';
import { type Component } from 'vue';
export interface VueFluxOptions {
allowFullscreen?: boolean;
allowToSkipTransition?: boolean;
aspectRatio?: string;
autohideTime?: number;
autoplay?: boolean;
bindKeys?: boolean;
delay?: number;
enableGestures?: boolean;
infinite?: boolean;
lazyLoad?: boolean;
lazyLoadAfter?: number;
}
export interface VueFluxProps {
options?: VueFluxOptions;
rscs: (Resource | ResourceWithOptions)[];
transitions: (Component | TransitionWithOptions)[];
}
export interface VueFluxEmits {
(e: 'created'): void;
(e: 'mounted'): void;
(e: 'unmounted'): void;
(e: 'play', resourceIndex: number | Direction, delay?: number): void;
(e: 'stop'): void;
(e: 'show', resource: PlayerResource, transition: PlayerTransition): void;
(e: 'optionsUpdated'): void;
(e: 'transitionsUpdated'): void;
(e: 'resourcesPreloadStart'): void;
(e: 'resourcesPreloadEnd'): void;
(e: 'resourcesLazyloadStart'): void;
(e: 'resourcesLazyloadEnd'): void;
(e: 'fullscreenEnter'): void;
(e: 'fullscreenExit'): void;
(e: 'transitionStart', resource: PlayerResource, transition: PlayerTransition): void;
(e: 'transitionCancel', resource: PlayerResource, transition: PlayerTransition): void;
(e: 'transitionEnd', resource: PlayerResource, transition: PlayerTransition): void;
}
export interface VueFluxConfig {
allowFullscreen: boolean;
allowToSkipTransition: boolean;
aspectRatio: string;
autohideTime: number;
autoplay: boolean;
bindKeys: boolean;
delay: number;
enableGestures: boolean;
infinite: boolean;
lazyLoad: boolean;
lazyLoadAfter: number;
}
===== FILE: src/components/index.ts =====
export { default as FluxButton } from './FluxButton/FluxButton.vue';
export * from './FluxCube';
export { default as FluxGrid } from './FluxGrid/FluxGrid.vue';
export { default as FluxImage } from './FluxImage/FluxImage.vue';
export { default as FluxParallax } from './FluxParallax/FluxParallax.vue';
export { default as FluxTransition } from './FluxTransition/FluxTransition.vue';
export { default as FluxVortex } from './FluxVortex/FluxVortex.vue';
export { default as FluxWrapper } from './FluxWrapper/FluxWrapper.vue';
export { default as VueFlux } from './VueFlux/VueFlux.vue';
export type * from './VueFlux/types';
export type * from './FluxCube/types';
export type * from './FluxGrid/types';
export type * from './FluxParallax/types';
export type * from './FluxTransition/types';
export type * from './FluxVortex/types';
export type * from './FluxWrapper/types';
export type * from './types';
===== FILE: src/components/types.ts =====
import type { CSSProperties, Component } from 'vue';
import { Resource } from '../resources';
import { Size, Position } from '../shared';
export interface ComponentProps {
color?: CSSProperties['color'];
rsc?: Resource;
size: Size;
viewSize?: Size;
offset?: Position;
css?: CSSProperties;
}
export interface ComponentStyles {
base?: CSSProperties;
color?: CSSProperties;
rsc?: CSSProperties;
size?: CSSProperties;
}
export type FluxComponent = Component & {
setCss: (s: CSSProperties) => void;
transform: (s: CSSProperties) => void;
show: () => void;
hide: () => void;
};
===== FILE: src/components/useComponent.ts =====
import { computed, type CSSProperties, type Ref, unref } from 'vue';
import { Size } from '../shared';
import type { ComponentProps, ComponentStyles } from './types';
export default function useComponent(
$el: Ref,
props: ComponentProps,
css: ComponentStyles,
) {
if (css.base === undefined) {
css.base = {} as CSSProperties;
}
const size = computed(() => {
const { size, viewSize = new Size() } = props;
const { width = size.width.value, height = size.height.value } = viewSize.toValue();
const finalSize = new Size({ width, height });
if (!finalSize.isValid()) {
return {};
}
return finalSize.toPx();
});
const style = computed(() => ({
...unref(size),
...unref(css.color),
...unref(css.rsc),
...unref(props.css),
...unref(css.base),
}));
const setCss = (s: CSSProperties) => {
Object.assign(css.base as CSSProperties, s);
};
const transform = (s: CSSProperties) => {
if ($el.value === null) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$el.value.clientHeight;
setCss(s);
};
const show = () => {
setCss({
visibility: 'visible',
});
};
const hide = () => {
setCss({
visibility: 'hidden',
});
};
return {
style,
setCss,
transform,
show,
hide,
};
}
===== FILE: src/controllers/Display/Display.ts =====
import { nextTick, type Ref, type Component } from 'vue';
import { Size } from '../../shared';
import type { VueFluxConfig, VueFluxEmits } from '../../components';
export default class Display {
node: Ref;
config: VueFluxConfig | null;
emit: null | VueFluxEmits = null;
size: Size = new Size();
private readonly onResize = () => {
this.updateSize();
};
constructor(
node: Ref,
config: VueFluxConfig | null = null,
emit: null | VueFluxEmits = null,
) {
this.node = node;
this.config = config;
this.emit = emit;
}
static async getSize(node: Ref) {
const display = new Display(node);
await display.updateSize();
return display.size;
}
addResizeListener() {
window.addEventListener('resize', this.onResize, {
passive: true,
});
void this.updateSize();
}
removeResizeListener() {
window.removeEventListener('resize', this.onResize);
}
getAspectRatio() {
if (this.config !== null) {
const [width, height] = this.config.aspectRatio.split(':');
return [parseFloat(width ?? ''), parseFloat(height ?? '')];
}
return [16, 9];
}
async updateSize() {
this.size.reset();
await nextTick();
if (this.node.value === null) {
return;
}
const computedStyle = getComputedStyle(this.node.value as HTMLElement);
const width = parseFloat(computedStyle.width);
let height = parseFloat(computedStyle.height);
if (['0px', 'auto', null].includes(computedStyle.height)) {
const [arWidth, arHeight] = this.getAspectRatio();
if (arWidth === undefined || arHeight === undefined) {
return;
}
height = (width / arWidth) * arHeight;
}
this.size.update({
width,
height,
});
}
inFullScreen = () => !!document.fullscreenElement;
toggleFullScreen() {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
this.inFullScreen() ? this.exitFullScreen() : this.enterFullScreen();
}
async enterFullScreen() {
if (this.node?.value === null || !this.config?.allowFullscreen) {
return;
}
await (this.node.value as HTMLElement).requestFullscreen();
if (this.emit !== null) {
this.emit('fullscreenEnter');
}
}
async exitFullScreen() {
await document.exitFullscreen();
if (this.emit !== null) {
this.emit('fullscreenExit');
}
}
}
===== FILE: src/controllers/Keys/Keys.ts =====
import type { VueFluxConfig } from '../../components';
import { Directions, Player } from '../';
export default class Keys {
config: VueFluxConfig;
player: Player;
constructor(config: VueFluxConfig, player: Player) {
this.config = config;
this.player = player;
}
setup() {
this.removeKeyListener();
if (this.config.bindKeys) {
window.addEventListener('keydown', this.keydown);
}
}
removeKeyListener() {
window.removeEventListener('keydown', this.keydown);
}
keydown = (event: KeyboardEvent) => {
if (['ArrowLeft', 'Left'].includes(event.key)) {
this.player.show(Directions.prev);
return;
}
if (['ArrowRight', 'Right'].includes(event.key)) {
this.player.show(Directions.next);
return;
}
};
}
===== FILE: src/controllers/Mouse/Mouse.ts =====
import { type Ref, ref } from 'vue';
import Timers from '../Timers/Timers';
import type { VueFluxConfig } from '../../components';
export default class Mouse {
isOver: Ref = ref(false);
setup(config: VueFluxConfig, timers: Timers) {
timers.clear('mouseOver');
if (config.autohideTime === 0) {
this.isOver.value = true;
}
}
toggle(config: VueFluxConfig, timers: Timers, over: boolean) {
if (config.autohideTime === 0) {
return;
}
this.isOver.value = over;
this[over ? 'over' : 'out'](config, timers);
}
out(_config: VueFluxConfig, timers: Timers) {
timers.clear('mouseOver');
}
over(config: VueFluxConfig, timers: Timers) {
timers.set('mouseOver', config.autohideTime, () => (this.isOver.value = false));
}
}
===== FILE: src/controllers/Player/Directions.ts =====
enum Directions {
prev = 'prev',
next = 'next',
}
export default Directions;
===== FILE: src/controllers/Player/Player.ts =====
import { shallowReactive, nextTick, type Ref, ref } from 'vue';
import {
Resources,
Transitions,
type ResourceIndex,
type TransitionIndex,
} from '../../repositories';
import { PlayerResource, PlayerTransition, Directions, type Direction, Statuses } from './';
import type { FluxComponent, VueFluxConfig, VueFluxEmits } from '../../components';
import { Timers } from '../';
export default class Player {
resource: PlayerResource;
transition: PlayerTransition;
status: Ref = ref(Statuses.stopped);
config: VueFluxConfig;
timers: Timers;
emit: VueFluxEmits;
resources: Resources;
transitions: Transitions;
$displayComponent: Ref = ref(null);
constructor(
config: VueFluxConfig,
timers: Timers,
emit: VueFluxEmits,
) {
this.config = config;
this.timers = timers;
this.emit = emit;
this.resources = new Resources(emit);
this.transitions = new Transitions();
this.resource = shallowReactive(new PlayerResource());
this.transition = shallowReactive(new PlayerTransition());
}
setup($displayComponent: Ref) {
this.$displayComponent = $displayComponent;
}
play(resourceIndex: number | Direction = Directions.next, delay?: number) {
const { config, timers, resource } = this;
this.status.value = Statuses.playing;
if (this.transition.current !== null) {
return;
}
const rsc = this.resources?.find(resourceIndex, resource.current?.index);
timers.set('transition', delay || rsc?.options.delay || config.delay, () => {
this.show(resourceIndex);
});
this.emit('play', resourceIndex, delay);
}
async stop(cancelTransition: boolean = false) {
const { timers } = this;
this.status.value = Statuses.stopped;
timers.clear('transition');
if (this.transition.current !== null && cancelTransition === true) {
await this.end(cancelTransition);
}
this.emit('stop');
}
isReadyToShow() {
if (this.resource.current === null) {
throw new ReferenceError('Current resource not set');
}
if (this.resources === null) {
throw new ReferenceError('Resources list not set');
}
if (this.resources.list.length === 0) {
throw new RangeError('Resources list empty');
}
if (this.transition.last === null) {
throw new ReferenceError('Last transition not set');
}
if (this.transitions === null) {
throw new ReferenceError('Transitions list not set');
}
if (this.transitions.list.length === 0) {
throw new RangeError('Transitions list empty');
}
if (this.$displayComponent.value === null) {
throw new ReferenceError('Display component not set');
}
return true;
}
async show(
resourceIndex: number | Direction = Directions.next,
transitionIndex: number | Direction = Directions.next,
) {
if (!this.isReadyToShow()) {
return;
}
const { resource, resources, config, transitions } = this;
if (this.transition.current !== null) {
if (config.allowToSkipTransition) {
await this.end(true);
this.show(resourceIndex, transitionIndex);
}
return;
}
const resourceTo: ResourceIndex = resources!.find(resourceIndex, resource.current!.index);
if (resource.currentSameAs(resourceTo)) {
return;
}
resource.prepareTo(resourceTo);
this.timers.clear('transition');
const transition: TransitionIndex =
typeof transitionIndex === 'number'
? transitions!.getByIndex(transitionIndex)
: transitions!.getByOrder(transitionIndex, this.transition.last!.index);
if (transition.options.direction === undefined) {
if (typeof resourceIndex !== 'number') {
transition.options.direction = resourceIndex;
} else {
transition.options.direction =
this.resource.from!.index < this.resource.to!.index
? Directions.next
: Directions.prev;
}
}
this.transition.current = transition;
this.emit('show', this.resource, this.transition);
}
start() {
this.resource.current = this.resource.to;
this.emit('transitionStart', this.resource, this.transition);
}
async end(cancel: boolean = false) {
const { config, resource, resources, timers, transition } = this;
if (resource.current === null || resources === null) {
return;
}
transition.setCurrentFinished();
await nextTick();
if (cancel === true) {
this.emit('transitionCancel', this.resource, this.transition);
} else {
this.emit('transitionEnd', this.resource, this.transition);
}
if (this.shouldStopPlaying(config.infinite, resource.current, resources.list.length - 1)) {
this.stop();
return;
}
if (this.shouldPlayNext()) {
timers.set('transition', resource.current.options.delay || config.delay, () => {
this.show();
});
}
}
private shouldStopPlaying(
infinite: boolean,
currentResource: ResourceIndex,
totalResources: number,
) {
if (
infinite === false &&
currentResource.index >= totalResources &&
this.status.value === Statuses.playing
) {
return true;
}
if (currentResource.options.stop === true) {
return true;
}
return false;
}
private shouldPlayNext() {
if (this.status.value === Statuses.playing) {
return true;
}
return false;
}
}
===== FILE: src/controllers/Player/Resource.ts =====
import { Resources, type ResourceIndex } from '../../repositories';
export default class PlayerResource {
current: ResourceIndex | null = null;
from: ResourceIndex | null = null;
to: ResourceIndex | null = null;
reset() {
this.current = null;
this.from = null;
this.to = null;
}
init(repository: Resources) {
this.current = repository.getFirst();
}
currentSameAs(resourceTo: ResourceIndex) {
if (this.current!.index === resourceTo.index) {
return true;
}
return false;
}
prepareTo(resourceTo: ResourceIndex) {
this.from = this.current;
this.to = resourceTo;
}
}
===== FILE: src/controllers/Player/Statuses.ts =====
enum Statuses {
stopped = 'stopped',
playing = 'playing',
}
export default Statuses;
===== FILE: src/controllers/Player/Transition.ts =====
import { Transitions, type TransitionIndex } from '../../repositories';
export default class PlayerTransition {
current: TransitionIndex | null = null;
last: TransitionIndex | null = null;
reset() {
this.current = null;
this.last = null;
}
init(transitions: Transitions) {
this.last = transitions.getLast();
}
setCurrentFinished() {
this.last = this.current;
this.current = null;
}
}
===== FILE: src/controllers/Player/__mocks__/Player.ts =====
import { vi } from 'vitest';
import { type Ref, ref, shallowReactive } from 'vue';
import type { VueFluxConfig, VueFluxEmits } from '../../../components/VueFlux/types';
import { PlayerResource, PlayerTransition, Statuses, Timers } from '../..';
import { Resources, Transitions } from '../../../repositories';
import type { FluxComponent } from '../../../components/types';
export default class Player {
resource: PlayerResource;
transition: PlayerTransition;
status: Ref = ref(Statuses.stopped);
config: VueFluxConfig;
timers: Timers;
emit: VueFluxEmits;
resources: Resources;
transitions: Transitions;
$displayComponent: Ref = ref(null);
constructor(config: VueFluxConfig, timers: Timers, emit: VueFluxEmits) {
this.config = config;
this.timers = timers;
this.emit = emit;
this.resources = new Resources(emit);
this.transitions = new Transitions();
this.resource = shallowReactive(new PlayerResource());
this.transition = shallowReactive(new PlayerTransition());
}
setup = vi.fn();
play = vi.fn();
stop = vi.fn();
show = vi.fn();
start = vi.fn();
end = vi.fn();
}
===== FILE: src/controllers/Player/__mocks__/Resource.ts =====
import type { ResourceIndex } from '../../../repositories';
import { vi } from 'vitest';
export default class PlayerResource {
current: ResourceIndex | null = null;
from: ResourceIndex | null = null;
to: ResourceIndex | null = null;
reset = vi.fn();
init = vi.fn();
currentSameAs = vi.fn();
prepareTo = vi.fn();
}
===== FILE: src/controllers/Player/__mocks__/Transitions.ts =====
import type { TransitionIndex } from '../../../repositories';
import { vi } from 'vitest';
export default class PlayerTransition {
current: TransitionIndex | null = null;
last: TransitionIndex | null = null;
reset = vi.fn();
init = vi.fn();
setCurrentFinished = vi.fn();
}
===== FILE: src/controllers/Player/index.ts =====
export { default as Directions } from './Directions';
export { default as Statuses } from './Statuses';
export { default as PlayerResource } from './Resource';
export { default as PlayerTransition } from './Transition';
export { default as Player } from './Player';
export type * from './types';
===== FILE: src/controllers/Player/types.ts =====
import Directions from './Directions';
export type Direction = Directions.prev | Directions.next;
===== FILE: src/controllers/Timers/Timers.ts =====
export default class Timers {
timers: {
[index: string]: ReturnType;
} = {};
set(index: string, time: number, cb: () => void) {
this.clear(index);
this.timers[index] = setTimeout(cb, time);
}
clear(index?: string) {
const keys = index !== undefined ? [index] : Object.keys(this.timers);
keys.forEach((key) => {
clearTimeout(this.timers[key]);
delete this.timers[key];
});
}
}
===== FILE: src/controllers/Touches/Touches.ts =====
import type { VueFluxConfig } from '../../components';
import { Directions, Display, Mouse, Player, Timers } from '../';
export default class Touches {
startX = 0;
startY = 0;
startTime = 0;
endTime = 0;
prevTouchTime = 0;
// Max distance in pixels from start until end
tapThreshold = 5;
// Max time in ms from first to second tap
doubleTapThreshold = 200;
// Distance in percentage to trigger slide
slideTrigger = 0.3;
start(event: TouchEvent, config: VueFluxConfig) {
if (!config.enableGestures) {
return;
}
const touch = event.changedTouches[0];
if (!touch) {
return;
}
this.startTime = Date.now();
this.startX = touch.clientX;
this.startY = touch.clientY;
}
end(
event: TouchEvent,
config: VueFluxConfig,
player: Player,
display: Display,
timers: Timers,
mouse: Mouse,
) {
this.prevTouchTime = this.endTime;
this.endTime = Date.now();
const touch = event.changedTouches[0];
if (!touch) {
return;
}
const offsetX = touch.clientX - this.startX;
const offsetY = touch.clientY - this.startY;
if (this.tap(offsetX, offsetY)) {
mouse.toggle(config, timers, true);
return;
}
if (!config.enableGestures) {
return;
}
if (this.slideRight(offsetX, display)) {
player.show(Directions.prev);
} else if (this.slideLeft(offsetX, display)) {
player.show(Directions.next);
}
}
tap = (offsetX: number, offsetY: number) =>
Math.abs(offsetX) < this.tapThreshold && Math.abs(offsetY) < this.tapThreshold;
doubleTap = () => this.endTime - this.prevTouchTime < this.doubleTapThreshold;
slideLeft = (offsetX: number, display: Display) =>
display.size.isValid() &&
offsetX < 0 &&
offsetX < -(display.size!.width.value! * this.slideTrigger);
slideRight = (offsetX: number, display: Display) =>
display.size.isValid() &&
offsetX > 0 &&
offsetX > display.size.width.value! * this.slideTrigger;
slideUp = (offsetY: number, display: Display) =>
display.size.isValid() &&
offsetY < 0 &&
offsetY < -(display.size.height.value! * this.slideTrigger);
slideDown = (offsetY: number, display: Display) =>
display.size.isValid() &&
offsetY > 0 &&
offsetY > display.size.height.value! * this.slideTrigger;
}
===== FILE: src/controllers/index.ts =====
export * from './Player';
export { default as Display } from './Display/Display';
export { default as Keys } from './Keys/Keys';
export { default as Mouse } from './Mouse/Mouse';
export { default as Timers } from './Timers/Timers';
export { default as Touches } from './Touches/Touches';
===== FILE: src/lib.ts =====
export * from './components';
export * from './complements';
export * from './resources';
export * from './transitions';
export {
Player,
Directions,
Statuses,
PlayerResource,
PlayerTransition,
} from './controllers/Player';
export type * from './controllers/Player/types';
export { Size, Position } from './shared';
===== FILE: src/main.ts =====
import './assets/css/main.css';
import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');
===== FILE: src/module.d.ts =====
declare module '*';
===== FILE: src/playgrounds/PgFluxCaption.vue =====
===== FILE: src/playgrounds/PgFluxControls.vue =====
===== FILE: src/playgrounds/PgFluxCube.vue =====
===== FILE: src/playgrounds/PgFluxGrid.vue =====
===== FILE: src/playgrounds/PgFluxImage.vue =====
===== FILE: src/playgrounds/PgFluxIndex.vue =====
===== FILE: src/playgrounds/PgFluxPagination.vue =====
{{ pageProps.index }}
===== FILE: src/playgrounds/PgFluxParallax.vue =====
===== FILE: src/playgrounds/PgFluxParallaxOp.vue =====
===== FILE: src/playgrounds/PgFluxPreloader.vue =====
===== FILE: src/playgrounds/PgFluxTransition.vue =====
===== FILE: src/playgrounds/PgVueFlux.vue =====
===== FILE: src/playgrounds/components/PgButton.vue =====
===== FILE: src/repositories/Resources/Resources.test.ts =====
import { Directions } from '../../controllers';
import { Resource } from '../../resources';
import { Size } from '../../shared';
import { default as ResourcesRepository } from './Resources';
import ResourceFactory from '../../resources/__test__/ResourceFactory';
import emit from '../../components/VueFlux/__test__/emit';
vi.mock('../../resources/Img/Img');
vi.mock('../../shared/ResourceLoader/ResourceLoader');
describe('repositories: Resources', () => {
let repo: ResourcesRepository;
let resources: Resource[];
const size: Size = new Size({
width: 640,
height: 360,
});
describe('width preloading', () => {
beforeEach(async () => {
vi.clearAllMocks();
repo = new ResourcesRepository(emit);
resources = ResourceFactory.create(5);
await repo.update(resources, 5, size);
});
it('updates the repository transitions', () => {
expect(repo.list).toHaveLength(5);
});
it('emits when preload starts', () => {
expect(emit).toHaveBeenCalledWith('resourcesPreloadStart');
});
it('emits when preload ends', () => {
expect(emit).toHaveBeenCalledWith('resourcesPreloadEnd');
});
it('gets the first resource', () => {
expect(repo.getFirst().rsc).toBe(resources[0]);
});
it('gets the last resource', () => {
expect(repo.getLast().rsc).toBe(resources[4]);
});
it('get resource by index', () => {
expect(repo.getByIndex(2).rsc).toBe(resources[2]);
});
it('throws error because the requested index does not exist', () => {
const index = resources.length + 1;
expect(() => repo.getByIndex(index)).toThrow(
`Resource index ${index} not found`
);
});
it('get resource by order next', () => {
expect(
repo.getByOrder(Directions.next, resources.length - 1).rsc
).toBe(resources[0]);
});
it('get resource by order prev', () => {
expect(repo.getByOrder(Directions.prev, 0).rsc).toBe(
resources[resources.length - 1]
);
});
it('throws an error when trying to find a resource by order without passing the current index', () => {
expect(() => repo.find(Directions.next)).toThrow(
'Missing currentIndex parameter'
);
});
});
describe('with lazy loading', () => {
const numResources = 10;
const resourcesToPreload = 5;
beforeEach(async () => {
vi.clearAllMocks();
repo = new ResourcesRepository(emit);
resources = ResourceFactory.create(numResources);
await repo.update(resources, resourcesToPreload, size);
});
it('emits resourcesLazyloadStart when start lazy loading', () =>
new Promise((done) => {
expect(emit).toHaveBeenCalledWith('resourcesLazyloadStart');
done();
}));
it('emits resourcesLazyloadStart when start lazy loading', () =>
new Promise((done) => {
expect(repo.list).toHaveLength(numResources);
expect(emit).toHaveBeenCalledWith('resourcesLazyloadEnd');
done();
}));
});
});
===== FILE: src/repositories/Resources/Resources.ts =====
import { type Ref, ref, shallowReactive } from 'vue';
import { Resource, type ResourceWithOptions } from '../../resources';
import { Size, ResourceLoader } from '../../shared';
import { type Direction, Directions } from '../../controllers/Player';
import type { ResourceIndex } from './types';
import ResourcesMapper from './ResourcesMapper';
import type { VueFluxEmits } from '../../components';
export default class Resources {
list: ResourceWithOptions[] = shallowReactive([]);
loader: Ref = ref(null);
emit: VueFluxEmits;
constructor(emit: VueFluxEmits) {
this.emit = emit;
}
private getPrev(currentIndex: number) {
return this.getByIndex(currentIndex > 0 ? currentIndex - 1 : this.list.length - 1);
}
private getNext(currentIndex: number) {
return this.getByIndex(currentIndex === this.list.length - 1 ? 0 : currentIndex + 1);
}
getFirst() {
return this.getByIndex(0);
}
getLast() {
return this.getByOrder(Directions.prev, 0);
}
getByIndex(index: number) {
if (this.list[index] === undefined) {
throw new ReferenceError(`Resource index ${index} not found`);
}
return {
index,
rsc: this.list[index].resource,
options: JSON.parse(JSON.stringify(this.list[index].options)),
} as ResourceIndex;
}
getByOrder(order: Direction, currentIndex: number) {
return {
prev: () => this.getPrev(currentIndex),
next: () => this.getNext(currentIndex),
}[order]();
}
find(by: number | Direction, currentIndex?: number) {
if (typeof by === 'number') {
return this.getByIndex(by);
}
if (currentIndex === undefined) {
throw new ReferenceError('Missing currentIndex parameter');
}
return this.getByOrder(by, currentIndex);
}
update(rscs: (Resource | ResourceWithOptions)[], numToPreload: number, displaySize: Size) {
if (this.loader.value?.hasFinished() === false) {
this.loader.value?.cancel();
}
this.list.splice(0);
const resources = ResourcesMapper.withOptions(rscs);
const updatePromise = new Promise((resolve, reject) => {
this.loader.value = new ResourceLoader(
resources,
numToPreload,
displaySize,
() => this.preloadStart(),
(loaded: ResourceWithOptions[]) => this.preloadEnd(loaded, resolve),
() => this.lazyLoadStart(),
(loaded: ResourceWithOptions[]) => this.lazyLoadEnd(loaded),
reject,
);
});
return updatePromise;
}
preloadStart() {
this.emit('resourcesPreloadStart');
}
preloadEnd(loaded: ResourceWithOptions[], resolve: () => void) {
this.list.push(...loaded);
this.emit('resourcesPreloadEnd');
resolve();
}
lazyLoadStart() {
this.emit('resourcesLazyloadStart');
}
lazyLoadEnd(loaded: ResourceWithOptions[]) {
this.list.push(...loaded);
this.emit('resourcesLazyloadEnd');
}
}
===== FILE: src/repositories/Resources/ResourcesMapper.test.ts =====
import { Img, type ResourceWithOptions } from '../../resources';
import ResourcesMapper from './ResourcesMapper';
describe('repositories: ResourcesMapper', () => {
it('turns all the transitions array as transitions with options', () => {
const resources = [
new Img('url1'),
{
resource: new Img('url2'),
options: {
delay: 8000,
},
} as ResourceWithOptions,
new Img('url3'),
];
const resourcesWithOptions = ResourcesMapper.withOptions(resources);
expect(resourcesWithOptions[0]!.resource).toBe(resources[0]);
// @ts-expect-error:next-line
expect(resourcesWithOptions[1]!.resource).toBe(resources[1].resource);
expect(resourcesWithOptions[1]!.options.delay).toBe(8000);
expect(resourcesWithOptions[2]!.resource).toBe(resources[2]);
expect(resourcesWithOptions[2]!.options).toStrictEqual({});
});
});
===== FILE: src/repositories/Resources/ResourcesMapper.ts =====
import { Resource, type ResourceWithOptions } from '../../resources';
export default class ResourcesMapper {
static withOptions(rscs: (Resource | ResourceWithOptions)[]) {
return rscs.map((rsc) => {
let resource = rsc;
let options = {};
if ('resource' in rsc) {
resource = rsc.resource as Resource;
if ('options' in rsc) {
options = rsc.options as object;
}
}
return { resource, options } as ResourceWithOptions;
});
}
}
===== FILE: src/repositories/Resources/types.ts =====
import Resource from '../../resources/Resource';
export interface ResourceIndex {
index: number;
rsc: Resource;
options: {
delay?: number;
stop?: boolean;
};
}
===== FILE: src/repositories/Transitions/Transitions.test.ts =====
import { Directions } from '../../controllers';
import { default as TransitionsRepository } from './Transitions';
function transitionsFactory(numTransitions: number) {
return new Array(numTransitions).fill({});
}
describe('repositories: Transitions', () => {
let repo: TransitionsRepository;
let transitions: object[];
beforeEach(() => {
repo = new TransitionsRepository();
});
it('updates the repository transitions', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.list).toHaveLength(5);
});
it('removes the previous transitions on update', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.list).toHaveLength(5);
transitions = transitionsFactory(2);
repo.update(transitions);
expect(repo.list).toHaveLength(2);
});
it('gets the first transition', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.getFirst().component).toBe(transitions[0]);
});
it('gets the last transition', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.getLast().component).toBe(
transitions[transitions.length - 1]
);
});
it('gets the transition by an index number', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.getByIndex(2).component).toBe(transitions[2]);
});
it('gets the transition by order next', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.getByOrder(Directions.next, 2).component).toBe(
transitions[3]
);
});
it('gets fist the transition by order next', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.getByOrder(Directions.next, 4).component).toBe(
transitions[3]
);
});
it('gets the transition by order previous', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.getByOrder(Directions.prev, 2).component).toBe(
transitions[1]
);
});
it('gets the last transition by order previous', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.getByOrder(Directions.prev, 0).component).toBe(
transitions[1]
);
});
});
===== FILE: src/repositories/Transitions/Transitions.ts =====
import { type Component, shallowReactive } from 'vue';
import { Directions, type Direction } from '../../controllers/Player';
import type { TransitionIndex } from './types';
import type { TransitionWithOptions } from '../../transitions/types';
import TransitionsMapper from './TransitionsMapper';
export default class Transitions {
list: TransitionWithOptions[] = shallowReactive([]);
private getPrev(lastIndex: number) {
return this.getByIndex(lastIndex > 0 ? lastIndex - 1 : this.list.length - 1);
}
private getNext(lastIndex: number) {
return this.getByIndex(lastIndex === this.list.length - 1 ? 0 : lastIndex + 1);
}
getFirst() {
return this.getByIndex(0);
}
getLast() {
return this.getByOrder(Directions.prev, 0);
}
getByIndex(index: number) {
const item = this.list[index];
if (!item) {
throw new Error(`Transition index ${index} out of range`);
}
return {
index,
component: item.component,
options: JSON.parse(JSON.stringify(item.options)),
} as TransitionIndex;
}
getByOrder(direction: Direction, lastIndex: number) {
return {
prev: () => this.getPrev(lastIndex),
next: () => this.getNext(lastIndex),
}[direction]();
}
update(transitions: (Component | TransitionWithOptions)[]) {
this.list.splice(0);
const transitionsWithOptions = TransitionsMapper.withOptions(transitions);
this.list.push(...transitionsWithOptions);
}
}
===== FILE: src/repositories/Transitions/TransitionsMapper.test.ts =====
import TransitionsMapper from './TransitionsMapper';
import { Fade, Kenburn, Swipe, Slide, type TransitionWithOptions } from '../../transitions';
describe('repositories: TransitionsMapper', () => {
it('turns all the transitions array as transitions with options', () => {
const transitions = [
Fade,
{
component: Kenburn,
options: { totalDuration: 1600 },
} as TransitionWithOptions,
Swipe,
{
component: Slide,
options: { totalDuration: 4600 },
} as TransitionWithOptions,
];
const transitionsWithOptions = TransitionsMapper.withOptions(transitions);
expect(transitionsWithOptions[0]!.component).toBe(Fade);
expect(transitionsWithOptions[1]!.component).toBe(Kenburn);
// @ts-expect-error:next-line
expect(transitionsWithOptions[1]!.options.totalDuration).toBe(1600);
expect(transitionsWithOptions[2]!.component).toBe(Swipe);
expect(transitionsWithOptions[3]!.component).toBe(Slide);
// @ts-expect-error:next-line
expect(transitionsWithOptions[3]!.options.totalDuration).toBe(4600);
});
});
===== FILE: src/repositories/Transitions/TransitionsMapper.ts =====
import type { Component } from 'vue';
import type { TransitionComponent, TransitionWithOptions } from '../../transitions';
export default class TransitionsMapper {
static withOptions(transitions: (Component | TransitionWithOptions)[]) {
return transitions.map((transition) => {
let component = transition;
let options = {};
if ('component' in transition) {
component = transition.component as TransitionComponent;
if ('options' in transition) {
options = transition.options;
}
}
return { component, options } as TransitionWithOptions;
});
}
}
===== FILE: src/repositories/Transitions/types.ts =====
import type { Component } from 'vue';
import type { Direction } from '../../controllers/Player';
export interface TransitionIndex {
index: number;
component: Component;
options: {
direction?: Direction;
};
}
===== FILE: src/repositories/index.ts =====
export { default as Resources } from './Resources/Resources';
export { default as Transitions } from './Transitions/Transitions';
export type { ResourceIndex } from './Resources/types';
export type { TransitionIndex } from './Transitions/types';
===== FILE: src/resources/Img/Img.test.ts =====
import { FluxImage } from '../../components';
import ResizeTypes from '../ResizeTypes';
import Statuses from '../Statuses';
import type { DisplayParameter, ResizeType, TransitionParameter } from '../types';
import Img from './Img';
describe('resources: Img', () => {
let img,
src: string,
caption: string,
resizeType: ResizeType,
backgroundColor: string,
promise: Promise,
resolve: () => void,
reject: (message: string) => void;
beforeEach(() => {
src = 'src';
caption = 'caption';
resizeType = ResizeTypes.fill;
backgroundColor = '#ccc';
});
it('creates the instance properly with default params', () => {
img = new Img(src);
expect(img.src).toBe(src);
expect(img.caption).toBe('');
expect(img.resizeType).toBe(ResizeTypes.fill);
expect(img.backgroundColor).toBeNull();
});
it('creates the instance properly with custom params', () => {
img = new Img(src, caption, resizeType, backgroundColor);
expect(img.src).toBe(src);
expect(img.caption).toBe(caption);
expect(img.resizeType).toBe(resizeType);
expect(img.backgroundColor).toBe(backgroundColor);
});
it('creates the instance with the required parameters of abstract Resource', () => {
img = new Img(src);
expect(img.display).toStrictEqual({
component: FluxImage,
props: {},
} as DisplayParameter);
expect(img.transition).toStrictEqual({
component: FluxImage,
props: {},
} as TransitionParameter);
expect(img.errorMessage).toBe(`Image ${src} could not be loaded`);
});
it('returns a promise and sets it to property loader', () => {
img = new Img(src);
promise = img.load();
expect(promise).toBeTypeOf('object');
expect(img.loader).toBe(promise);
});
it('changes the status to loading', () => {
img = new Img(src);
img.load();
expect(img.status.value).toBe(Statuses.loading);
});
it('returns the loader if already request to load', () => {
img = new Img(src);
promise = img.load();
expect(img.load()).toBe(promise);
});
it.todo('calls onLoad when load success');
it('sets reals size on load', () => {
src = '/imgs/pixel.png';
img = new Img(src);
const htmlImage = new Image();
htmlImage.width = 640;
htmlImage.height = 480;
resolve = vi.fn();
img.onLoad(htmlImage, resolve);
expect(img.realSize.toValue()).toStrictEqual({ width: 640, height: 480 });
});
it('sets status loaded on load', () => {
src = '/imgs/pixel.png';
img = new Img(src);
const htmlImage = new Image();
htmlImage.width = 640;
htmlImage.height = 480;
resolve = vi.fn();
img.onLoad(htmlImage, resolve);
expect(img.status.value).toBe(Statuses.loaded);
});
it('calls promise resolve on load', () => {
src = '/imgs/pixel.png';
img = new Img(src);
const htmlImage = new Image();
htmlImage.width = 640;
htmlImage.height = 480;
resolve = vi.fn();
img.onLoad(htmlImage, resolve);
expect(resolve).toHaveBeenCalledOnce();
});
it.todo('calls onError when load fails');
it('sets the status to error on error', () => {
img = new Img(src);
reject = vi.fn();
img.onError(reject);
expect(img.status.value).toBe(Statuses.error);
});
it('performs promise reject on error', () => {
img = new Img(src);
reject = vi.fn();
img.onError(reject);
expect(reject).toHaveBeenCalledWith(img.errorMessage);
});
});
===== FILE: src/resources/Img/Img.ts =====
import { FluxImage } from '../../components';
import { Resource, Statuses, ResizeTypes } from '../';
import { Size } from '../../shared';
import type { DisplayParameter, ResizeType, TransitionParameter } from '../types';
export default class Img extends Resource {
constructor(
src: string,
caption: string = '',
resizeType: ResizeType = ResizeTypes.fill,
backgroundColor: null | string = null,
) {
const display: DisplayParameter = {
component: FluxImage,
props: {},
};
const transition: TransitionParameter = {
component: FluxImage,
props: {},
};
const errorMessage = `Image ${src} could not be loaded`;
super(src, caption, resizeType, backgroundColor, display, transition, errorMessage);
}
load() {
if (this.loader !== null) {
return this.loader;
}
this.loader = new Promise((resolve, reject) => {
this.status.value = Statuses.loading;
const img = new Image();
img.onload = () => this.onLoad(img, resolve);
img.onerror = () => this.onError(reject);
img.src = this.src;
});
return this.loader;
}
onLoad(img: HTMLImageElement, resolve: () => void) {
this.realSize = new Size({
width: img.naturalWidth || img.width,
height: img.naturalHeight || img.height,
});
this.status.value = Statuses.loaded;
resolve();
}
onError(reject: (message: string) => void) {
this.status.value = Statuses.error;
reject(this.errorMessage);
}
}
===== FILE: src/resources/Img/__mocks__/Img.ts =====
import { vi } from 'vitest';
import { ResizeTypes, Statuses, Resource } from '../../';
import { FluxImage } from '../../../components';
export default class Img extends Resource {
constructor() {
super(
'',
'',
ResizeTypes.fill,
null,
{ component: FluxImage, props: {} },
{ component: FluxImage, props: {} },
''
);
}
load = vi.fn().mockImplementation(() => {
return new Promise((resolve) => {
this.status.value = Statuses.loading;
this.onLoad(null, resolve);
});
});
onLoad = vi
.fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementation((_el: unknown, resolve: () => void) => {
this.status.value = Statuses.loaded;
resolve();
});
onError = vi
.fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementation((_reject: (message: string) => void) => {});
}
===== FILE: src/resources/ResizeTypes.ts =====
export enum ResizeTypes {
fill = 'fill',
fit = 'fit',
}
export default ResizeTypes;
===== FILE: src/resources/Resource.ts =====
import { computed, ref, type Ref } from 'vue';
import { Size, Position, ResizeCalculator } from '../shared';
import type { DisplayParameter, ResizedProps, ResizeType, TransitionParameter } from './types';
import { Statuses, ResizeTypes } from './';
export default abstract class Resource {
src: string;
loader: Promise | null = null;
errorMessage: string;
status: Ref = ref(Statuses.notLoaded);
realSize: Size = new Size();
displaySize: Size = new Size();
caption: string = '';
resizeType: ResizeType;
backgroundColor: null | string = null;
display: DisplayParameter;
transition: TransitionParameter;
constructor(
src: string,
caption: string,
resizeType: ResizeType = ResizeTypes.fill,
backgroundColor: null | string = null,
display: DisplayParameter,
transition: TransitionParameter,
errorMessage: string,
) {
this.src = src;
this.caption = caption;
this.resizeType = resizeType;
this.backgroundColor = backgroundColor;
this.display = display;
this.transition = transition;
this.errorMessage = errorMessage;
}
isLoading = () => this.status.value === Statuses.loading;
isLoaded = () => this.status.value === Statuses.loaded;
isError = () => this.status.value === Statuses.error;
abstract load(): Promise;
abstract onLoad(el: unknown, resolve: () => void): void;
abstract onError(reject: (message: string) => void): void;
calcResizeProps(displaySize: Size) {
if ([displaySize.isValid(), this.realSize.isValid()].includes(false)) {
return {};
}
const resCalc = new ResizeCalculator(this.realSize);
const { size, position } = resCalc.resizeTo(displaySize, this.resizeType);
return {
...size.toValue(),
...position.toValue(),
};
}
resizeProps = computed<{
top?: number;
left?: number;
width?: number;
height?: number;
}>(() => this.calcResizeProps(this.displaySize));
getResizeProps(size: Size, offset?: Position) {
const resizedProps: ResizedProps = {
width: 0,
height: 0,
top: 0,
left: 0,
};
if (!this.displaySize.isValid()) {
this.displaySize.update(size.toValue());
}
Object.assign(
resizedProps,
size.equals(this.displaySize) ? this.resizeProps.value : this.calcResizeProps(size),
);
if (offset !== undefined) {
resizedProps.top -= offset.top.value || 0;
resizedProps.left -= offset.left.value || 0;
}
return resizedProps;
}
}
===== FILE: src/resources/Statuses.ts =====
export enum Statuses {
notLoaded = 'notLoaded',
loading = 'loading',
loaded = 'loaded',
error = 'error',
}
export default Statuses;
===== FILE: src/resources/__test__/ResourceFactory.ts =====
import { Img } from '../';
export default class ResourceFactory {
static create(amount: number) {
return new Array(amount).fill(new Img(''));
}
}
===== FILE: src/resources/index.ts =====
export { default as Resource } from './Resource';
export { default as Img } from './Img/Img';
export { default as Statuses } from './Statuses';
export { default as ResizeTypes } from './ResizeTypes';
export type * from './types';
===== FILE: src/resources/types.ts =====
import type { Component } from 'vue';
import { Resource } from '.';
import ResizeTypes from './ResizeTypes';
export type ResizeType = keyof typeof ResizeTypes;
export interface ResizedProps {
width: number;
height: number;
top: number;
left: number;
}
export interface DisplayParameter {
component: Component;
props: object;
}
export interface TransitionParameter {
component: Component;
props: object;
}
export interface ResourceWithOptions {
resource: Resource;
options: {
delay?: number;
stop?: boolean;
};
}
===== FILE: src/shared/Maths/Maths.test.ts =====
import * as Maths from './Maths';
describe('shared: Maths', () => {
it('calculates the diagonal', () => {
const size = {
width: 640,
height: 360,
};
expect(Maths.diag(size)).toBe(735);
});
it('calculates the aspect ratio', () => {
const size = {
width: 640,
height: 320,
};
expect(Maths.aspectRatio(size)).toBe(2);
});
});
===== FILE: src/shared/Maths/Maths.ts =====
export const diag = ({ width, height }: { width: number; height: number }) =>
Math.ceil(Math.sqrt(width * width + height * height));
export const aspectRatio = ({
width,
height,
}: {
width: number;
height: number;
}) => width / height;
===== FILE: src/shared/Position/Position.test.ts =====
import Position from './Position';
describe('shared: Position', () => {
let pos: Position;
let coords: object;
it('initializes values to null without parameters', () => {
pos = new Position();
expect(pos.top.value).toBeNull();
expect(pos.left.value).toBeNull();
});
it('sets param values', () => {
pos = new Position({ top: 100 });
expect(pos.top.value).toBe(100);
pos = new Position({ left: 100 });
expect(pos.left.value).toBe(100);
pos = new Position({
top: 100,
left: 200,
});
expect(pos.top.value).toBe(100);
expect(pos.left.value).toBe(200);
});
it('reset values', () => {
pos = new Position({
top: 100,
left: 200,
});
pos.reset();
expect(pos.top.value).toBeNull();
expect(pos.left.value).toBeNull();
});
it('is invalid if top or left is null', () => {
pos = new Position({ top: 100 });
expect(pos.isValid()).toBeFalsy();
pos = new Position({ left: 100 });
expect(pos.isValid()).toBeFalsy();
});
it('is valid when top and left have values', () => {
pos = new Position({
top: 100,
left: 200,
});
expect(pos.isValid()).toBeTruthy();
});
it('updates the values', () => {
pos = new Position({
top: 100,
left: 200,
});
pos.update({
top: 50,
});
expect(pos.top.value).toBe(50);
expect(pos.left.value).toBeNull();
pos.update({
left: 100,
});
expect(pos.top.value).toBeNull();
expect(pos.left.value).toBe(100);
pos.update({
top: 200,
left: 400,
});
expect(pos.top.value).toBe(200);
expect(pos.left.value).toBe(400);
});
it('returns the values as plain object', () => {
coords = {
top: 100,
left: 200,
};
pos = new Position(coords);
expect(pos.toValue()).toStrictEqual(coords);
pos = new Position();
expect(pos.toValue()).toStrictEqual({
top: undefined,
left: undefined,
});
});
it('throws exception when trying to get the values with px suffix', () => {
pos = new Position();
expect(() => pos.toPx()).toThrow('Invalid position in pixels');
});
it('returns the values with px suffix', () => {
coords = {
top: 100,
left: 200,
};
pos = new Position(coords);
expect(pos.toPx()).toStrictEqual({
top: coords['top' as keyof object] + 'px',
left: coords['left' as keyof object] + 'px',
});
});
});
===== FILE: src/shared/Position/Position.ts =====
import { ref, type Ref } from 'vue';
export default class Position {
top: Ref = ref(null);
left: Ref = ref(null);
constructor(
{ top = null, left = null }: { top?: null | number; left?: null | number } = {
top: null,
left: null,
},
) {
this.update({ top, left });
}
reset() {
this.top.value = null;
this.left.value = null;
}
isValid() {
return ![this.top.value, this.left.value].includes(null);
}
update({ top, left }: { top?: null | number; left?: null | number }) {
this.top.value = top ?? null;
this.left.value = left ?? null;
}
toValue() {
const rawPosition: {
top?: number;
left?: number;
} = {
top: undefined,
left: undefined,
};
if (this.top.value !== null) {
rawPosition.top = this.top.value;
}
if (this.left.value !== null) {
rawPosition.left = this.left.value;
}
return rawPosition;
}
toPx() {
if (!this.isValid()) {
throw new RangeError('Invalid position in pixels');
}
return {
top: this.top.value!.toString() + 'px',
left: this.left.value!.toString() + 'px',
};
}
}
===== FILE: src/shared/ResizeCalculator/ResizeCalculator.test.ts =====
import { Size } from '../';
import { ResizeTypes } from '../../resources';
import ResizeCalculator, { Orientations } from './ResizeCalculator';
describe('shared: ResizeCalculator', () => {
let calc: ResizeCalculator;
let realSize: Size;
let newSize: Size;
beforeEach(() => {
realSize = new Size();
newSize = new Size();
});
it('if the size is invalid throws error', () => {
vi.spyOn(realSize, 'isValid').mockImplementation(() => false);
expect(() => {
calc = new ResizeCalculator(realSize);
}).toThrow('Invalid real size');
expect(realSize.isValid).toHaveBeenCalledWith();
});
it('if the size is valid when creating the calculator', () => {
vi.spyOn(realSize, 'isValid').mockImplementation(() => true);
expect(() => {
calc = new ResizeCalculator(realSize);
}).not.toThrow();
expect(realSize.isValid).toHaveBeenCalledWith();
});
it('detects the orientation', () => {
realSize.update({
width: 640,
height: 360,
});
calc = new ResizeCalculator(realSize);
expect(calc.realOrientation).toBe(Orientations.landscape);
realSize.update({
width: 360,
height: 640,
});
calc = new ResizeCalculator(realSize);
expect(calc.realOrientation).toBe(Orientations.portrait);
});
it('if the new size is valid', () => {
vi.spyOn(newSize, 'isValid').mockImplementation(() => false);
realSize.update({
width: 640,
height: 360,
});
calc = new ResizeCalculator(realSize);
expect(() => {
calc.resizeTo(newSize, ResizeTypes.fill);
}).toThrow('Invalid size to resize');
expect(newSize.isValid).toHaveBeenCalledWith();
});
it('new size L real size L and newAspectRatio >= realAspectRatio and type fill', () => {
newSize.update({
width: 280,
height: 140,
});
realSize.update({
width: 640,
height: 360,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fill
);
expect(adaptedSize.toValue()).toStrictEqual({
width: 280,
height: 157.5,
});
expect(adaptedPosition.toValue()).toStrictEqual({ top: -8.75, left: 0 });
});
it('new size L real size L and newAspectRatio < realAspectRatio and type fill', () => {
newSize.update({
width: 280,
height: 180,
});
realSize.update({
width: 280,
height: 140,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fit
);
expect(adaptedSize.toValue()).toStrictEqual({
width: 280,
height: 140,
});
expect(adaptedPosition.toValue()).toStrictEqual({ top: 20, left: 0 });
});
it('new size L real size L and newAspectRatio >= realAspectRatio and type fit', () => {
newSize.update({
width: 280,
height: 140,
});
realSize.update({
width: 280,
height: 200,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fit
);
expect(adaptedSize.toValue()).toStrictEqual({ width: 196, height: 140 });
expect(adaptedPosition.toValue()).toStrictEqual({ top: 0, left: 42 });
});
it('new size L real size L and newAspectRatio < realAspectRatio and type fit', () => {
newSize.update({
width: 280,
height: 180,
});
realSize.update({
width: 280,
height: 140,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fit
);
expect(adaptedSize.toValue()).toStrictEqual({
width: 280,
height: 140,
});
expect(adaptedPosition.toValue()).toStrictEqual({ top: 20, left: 0 });
});
it('new size L real size P and type fill', () => {
newSize.update({
width: 280,
height: 140,
});
realSize.update({
width: 140,
height: 280,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fill
);
expect(adaptedSize.toValue()).toStrictEqual({ width: 280, height: 560 });
expect(adaptedPosition.toValue()).toStrictEqual({ top: -210, left: 0 });
});
it('new size L real size P and type fit', () => {
newSize.update({
width: 280,
height: 140,
});
realSize.update({
width: 140,
height: 280,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fit
);
expect(adaptedSize.toValue()).toStrictEqual({
width: 70,
height: 140,
});
expect(adaptedPosition.toValue()).toStrictEqual({ top: 0, left: 105 });
});
it('new size P real size L and type fill', () => {
newSize.update({
width: 140,
height: 280,
});
realSize.update({
width: 280,
height: 140,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fill
);
expect(adaptedSize.toValue()).toStrictEqual({ width: 560, height: 280 });
expect(adaptedPosition.toValue()).toStrictEqual({ top: 0, left: -210 });
});
it('new size P real size L and type fit', () => {
newSize.update({
width: 140,
height: 280,
});
realSize.update({
width: 280,
height: 140,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fit
);
expect(adaptedSize.toValue()).toStrictEqual({
width: 140,
height: 70,
});
expect(adaptedPosition.toValue()).toStrictEqual({
top: 105,
left: 0,
});
});
it('new size P real size P and newAspectRatio >= realAspectRatio and type fill', () => {
newSize.update({
width: 140,
height: 280,
});
realSize.update({
width: 180,
height: 280,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fill
);
expect(adaptedSize.toValue()).toStrictEqual({
width: 180,
height: 280,
});
expect(adaptedPosition.toValue()).toStrictEqual({ top: 0, left: -20 });
});
it('new size P real size P and newAspectRatio < realAspectRatio and type fill', () => {
newSize.update({
width: 180,
height: 280,
});
realSize.update({
width: 140,
height: 280,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fill
);
expect(adaptedSize.toValue()).toStrictEqual({
width: 180,
height: 360,
});
expect(adaptedPosition.toValue()).toStrictEqual({ top: -40, left: 0 });
});
it('new size P real size P and newAspectRatio >= realAspectRatio and type fit', () => {
newSize.update({
width: 140,
height: 280,
});
realSize.update({
width: 200,
height: 280,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fit
);
expect(adaptedSize.toValue()).toStrictEqual({ width: 140, height: 196 });
expect(adaptedPosition.toValue()).toStrictEqual({ top: 42, left: 0 });
});
it('new size P real size P and newAspectRatio < realAspectRatio and type fit', () => {
newSize.update({
width: 180,
height: 280,
});
realSize.update({
width: 140,
height: 280,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fit
);
expect(adaptedSize.toValue()).toStrictEqual({ width: 140, height: 280 });
expect(adaptedPosition.toValue()).toStrictEqual({ top: 0, left: 20 });
});
});
===== FILE: src/shared/ResizeCalculator/ResizeCalculator.ts =====
import { type ResizeType, ResizeTypes } from '../../resources';
import { Size, Position } from '../';
export enum Orientations {
landscape = 'landscape',
portrait = 'portrait',
}
const getOrientation = (aspectRatio: number) =>
aspectRatio >= 1 ? Orientations.landscape : Orientations.portrait;
type Orientation = keyof typeof Orientations;
export default class ResizeCalculator {
realSize: Size;
realAspectRatio: number;
realOrientation: Orientation;
constructor(realSize: Size) {
if (realSize.isValid() === false) {
throw new RangeError('Invalid real size');
}
this.realSize = realSize;
this.realAspectRatio = this.realSize.getAspectRatio();
this.realOrientation = getOrientation(this.realAspectRatio);
}
public resizeTo(resizeSize: Size, resizeType: ResizeType) {
if (resizeSize.isValid() === false) {
throw new RangeError('Invalid size to resize');
}
const resizeAspectRatio = resizeSize.getAspectRatio();
const resizeOrientation = getOrientation(resizeAspectRatio);
const adaptedSize: Size = this.getAdaptedSize(
resizeSize,
resizeAspectRatio,
resizeOrientation,
resizeType,
);
const adaptedPosition: Position = this.getAdaptedPosition(
resizeSize,
resizeAspectRatio,
adaptedSize,
resizeType,
);
return {
size: adaptedSize,
position: adaptedPosition,
};
}
private getAdaptedSize(
resizeSize: Size,
resizeAspectRatio: number,
resizeOrientation: Orientation,
resizeType: ResizeType,
) {
if (
resizeOrientation === Orientations.landscape &&
this.realOrientation === Orientations.portrait &&
resizeType === ResizeTypes.fill
) {
return this.getAdaptedSizeByWith(resizeSize);
}
if (
resizeOrientation === Orientations.landscape &&
this.realOrientation === Orientations.landscape &&
resizeAspectRatio >= this.realAspectRatio &&
resizeType === ResizeTypes.fill
) {
return this.getAdaptedSizeByWith(resizeSize);
}
if (
resizeOrientation === Orientations.landscape &&
this.realOrientation === Orientations.landscape &&
resizeAspectRatio < this.realAspectRatio &&
resizeType === ResizeTypes.fit
) {
return this.getAdaptedSizeByWith(resizeSize);
}
if (
resizeOrientation === Orientations.portrait &&
this.realOrientation === Orientations.landscape &&
resizeType === ResizeTypes.fit
) {
return this.getAdaptedSizeByWith(resizeSize);
}
if (
resizeOrientation === Orientations.portrait &&
this.realOrientation === Orientations.portrait &&
resizeAspectRatio > this.realAspectRatio &&
resizeType === ResizeTypes.fill
) {
return this.getAdaptedSizeByWith(resizeSize);
}
if (
resizeOrientation === Orientations.portrait &&
this.realOrientation === Orientations.portrait &&
resizeAspectRatio <= this.realAspectRatio &&
resizeType === ResizeTypes.fit
) {
return this.getAdaptedSizeByWith(resizeSize);
}
return this.getAdaptedSizeByHeight(resizeSize);
}
private getAdaptedSizeByWith(resizeSize: Size) {
return new Size({
width: resizeSize.width.value,
height: resizeSize.width.value! / this.realAspectRatio,
});
}
private getAdaptedSizeByHeight(resizeSize: Size) {
return new Size({
width: this.realAspectRatio * resizeSize.height.value!,
height: resizeSize.height.value,
});
}
private getAdaptedPosition(
resizeSize: Size,
resizeAspectRatio: number,
adaptedSize: Size,
resizeType: ResizeType,
) {
if (this.realAspectRatio <= resizeAspectRatio && resizeType === ResizeTypes.fill) {
return this.getAdaptedPositionVertically(resizeSize, adaptedSize);
}
if (this.realAspectRatio > resizeAspectRatio && resizeType === ResizeTypes.fit) {
return this.getAdaptedPositionVertically(resizeSize, adaptedSize);
}
return this.getAdaptedPositionHorizontally(resizeSize, adaptedSize);
}
getAdaptedPositionVertically(resizeSize: Size, adaptedSize: Size) {
return new Position({
top: (resizeSize.height.value! - adaptedSize.height.value!) / 2,
left: 0,
});
}
getAdaptedPositionHorizontally(resizeSize: Size, adaptedSize: Size) {
return new Position({
top: 0,
left: (resizeSize.width.value! - adaptedSize.width.value!) / 2,
});
}
}
===== FILE: src/shared/ResourceLoader/ResourceLoader.test.ts =====
import { vi } from 'vitest';
import ResourceLoader from './ResourceLoader';
import ResourceLoaderFactory from './__test__/ResourceLoaderFactory';
import { Statuses } from '../../resources';
vi.mock('../../resources/Img/Img');
describe('shared: ResourceLoader', () => {
let rscLoader: ResourceLoader;
beforeEach(() => {
vi.clearAllMocks();
});
it('calls onPreloadStart when preload starts', () => {
rscLoader = ResourceLoaderFactory.create(10, 5);
expect(rscLoader.onPreloadStart).toHaveBeenCalledOnce();
});
it('preloads all resources if num resources less than num to preload', () => {
rscLoader = ResourceLoaderFactory.create(10, 15);
expect(rscLoader.toPreload).toBe(10);
});
it('start preloading when created', () => {
rscLoader = ResourceLoaderFactory.create(10, 10);
expect(rscLoader.onPreloadStart).toHaveBeenCalledOnce();
});
it('start preloading the resources', () => {
rscLoader = ResourceLoaderFactory.create(15, 10);
expect(
rscLoader.rscs.every((rsc) =>
[Statuses.loading, Statuses.loaded].includes(rsc.resource.status.value),
),
).toBeTruthy();
});
it('checks if resources preloaded are less than to preload and preloads the remaining', () => {
rscLoader = ResourceLoaderFactory.create(15, 6);
rscLoader.counter.success = 4;
rscLoader.counter.error = 2;
rscLoader.counter.total = 6;
rscLoader.preloadEnd();
expect(rscLoader.preLoading).toHaveLength(8);
});
it('calls onPreloadEnd when all preloaded', () =>
new Promise((done) => {
rscLoader = ResourceLoaderFactory.create(5, 5, undefined, () => {
expect(rscLoader.onPreloadStart).toHaveBeenCalledOnce();
expect(rscLoader.onPreloadEnd).toHaveBeenCalledWith(expect.any(Array));
done();
});
}));
it('starts lazy loading when preloading finish', () =>
new Promise((done) => {
rscLoader = ResourceLoaderFactory.create(20, 5, undefined, undefined, () => {
expect(rscLoader.onPreloadStart).toHaveBeenCalledOnce();
expect(rscLoader.onPreloadEnd).toHaveBeenCalledOnce();
expect(rscLoader.counter.total).toBe(5);
expect(rscLoader.onLazyLoadStart).toHaveBeenCalledOnce();
done();
});
}));
it('calls onLazyLoadEnd when lazy loading finish', () =>
new Promise((done) => {
rscLoader = ResourceLoaderFactory.create(20, 5, undefined, undefined, undefined, () => {
expect(rscLoader.onPreloadStart).toHaveBeenCalledOnce();
expect(rscLoader.onPreloadEnd).toHaveBeenCalledOnce();
expect(rscLoader.onLazyLoadStart).toHaveBeenCalledOnce();
expect(rscLoader.onLazyLoadEnd).toHaveBeenCalledWith(expect.any(Array));
expect(rscLoader.counter.total).toBe(20);
done();
});
}));
it('does not update display size if cancelled', () => {
rscLoader = ResourceLoaderFactory.create(10, 5);
rscLoader.cancel();
const rsc = rscLoader.rscs[0];
vi.spyOn(rsc!.resource.displaySize, 'update');
rscLoader.loadSuccess(rsc!);
expect(rsc!.resource.displaySize.update).not.toHaveBeenCalled();
});
it('calculates the progress properly', () => {
rscLoader = ResourceLoaderFactory.create(15, 6);
rscLoader.counter.success = 4;
rscLoader.counter.error = 2;
rscLoader.counter.total = 6;
rscLoader.updateProgress();
expect(rscLoader.progress.value).toBe(67);
});
});
===== FILE: src/shared/ResourceLoader/ResourceLoader.ts =====
import { type Ref, ref } from 'vue';
import { Size } from '../';
import type { ResourceWithOptions } from '../../resources';
export default class ResourceLoader {
rscs: ResourceWithOptions[] = [];
counter = {
success: 0,
error: 0,
total: 0,
};
toPreload: number;
preLoading: ResourceWithOptions[] = [];
lazyLoading: ResourceWithOptions[] = [];
progress: Ref = ref(0);
displaySize: Size;
onPreloadStart: () => void;
onPreloadEnd: (loaded: ResourceWithOptions[]) => void;
onLazyLoadStart: () => void;
onLazyLoadEnd: (loaded: ResourceWithOptions[]) => void;
isCancelled: boolean = false;
reject: (message: string, rscs: ResourceWithOptions[]) => void;
constructor(
rscs: ResourceWithOptions[],
toPreload: number,
displaySize: Size,
onPreloadStart: () => void,
onPreloadEnd: (loaded: ResourceWithOptions[]) => void,
onLazyLoadStart: () => void,
onLazyLoadEnd: (loaded: ResourceWithOptions[]) => void,
reject: (message: string, rscs: ResourceWithOptions[]) => void,
) {
this.rscs = rscs;
this.toPreload = toPreload > rscs.length ? rscs.length : toPreload;
this.displaySize = displaySize;
this.onPreloadStart = onPreloadStart;
this.onPreloadEnd = onPreloadEnd;
this.onLazyLoadStart = onLazyLoadStart;
this.onLazyLoadEnd = onLazyLoadEnd;
this.reject = reject;
this.preloadStart();
}
preloadStart() {
this.onPreloadStart();
const { counter } = this;
const toLoad = this.rscs.slice(
counter.total,
counter.total + this.toPreload - counter.success,
);
this.preLoading = this.preLoading.concat(toLoad);
toLoad.forEach((rsc) => this.load(rsc));
}
preloadEnd() {
const { counter, toPreload } = this;
if (counter.success < toPreload && counter.total < this.rscs.length) {
this.preloadStart();
return;
}
const preloadedSuccessfully = this.preLoading.filter((rsc) => rsc.resource.isLoaded());
this.onPreloadEnd(preloadedSuccessfully);
this.preLoading.length = 0;
if (counter.total < this.rscs.length) {
this.lazyLoadStart();
}
}
lazyLoadStart() {
this.onLazyLoadStart();
this.lazyLoading = this.rscs.slice(this.counter.total);
this.lazyLoading.forEach((rsc) => this.load(rsc));
}
lazyLoadEnd() {
const lazyLoadedSuccessfully = this.lazyLoading.filter((rsc) => rsc.resource.isLoaded());
this.onLazyLoadEnd(lazyLoadedSuccessfully);
this.lazyLoading.length = 0;
}
load(rsc: ResourceWithOptions) {
rsc.resource
.load()
.then(() => {
this.loadSuccess(rsc);
})
.catch((error) => {
this.loadError(error);
})
.finally(() => {
this.counter.total++;
if (this.isCancelled) {
return;
}
if (this.preLoading.length !== 0) {
this.updateProgress();
}
if (this.counter.total === this.toPreload) {
this.preloadEnd();
} else if (this.counter.total === this.rscs.length) {
this.lazyLoadEnd();
}
});
}
loadSuccess(rsc: ResourceWithOptions) {
this.counter.success++;
if (this.isCancelled) {
return;
}
rsc.resource.displaySize.update(this.displaySize.toValue());
}
loadError(error: string) {
this.counter.error++;
if (this.isCancelled) {
return;
}
console.error(error);
}
updateProgress() {
this.progress.value = Math.ceil((this.counter.success * 100) / this.toPreload);
}
hasFinished() {
return this.counter.total === this.rscs.length;
}
cancel() {
this.isCancelled = true;
this.reject('Resources loading cancelled', this.rscs);
}
}
===== FILE: src/shared/ResourceLoader/__mocks__/ResourceLoader.ts =====
import { Size } from '../../';
import type { ResourceWithOptions } from '../../../resources';
export default class ResourceLoader {
rscs: ResourceWithOptions[] = [];
counter = {
success: 0,
error: 0,
total: 0,
};
toPreload: number;
preLoading: ResourceWithOptions[] = [];
lazyLoading: ResourceWithOptions[] = [];
displaySize: Size;
onPreloadStart: () => void;
onPreloadEnd: (loaded: ResourceWithOptions[]) => void;
onLazyLoadStart: () => void;
onLazyLoadEnd: (loaded: ResourceWithOptions[]) => void;
reject: (message: string, rscs: ResourceWithOptions[]) => void;
constructor(
rscs: ResourceWithOptions[],
toPreload: number,
displaySize: Size,
onPreloadStart: () => void,
onPreloadEnd: (loaded: ResourceWithOptions[]) => void,
onLazyLoadStart: () => void,
onLazyLoadEnd: (loaded: ResourceWithOptions[]) => void,
reject: (message: string, rscs: ResourceWithOptions[]) => void,
) {
this.rscs = rscs;
this.toPreload = toPreload > rscs.length ? rscs.length : toPreload;
this.displaySize = displaySize;
this.onPreloadStart = onPreloadStart;
this.onPreloadEnd = onPreloadEnd;
this.onLazyLoadStart = onLazyLoadStart;
this.onLazyLoadEnd = onLazyLoadEnd;
this.reject = reject;
this.preloadStart();
}
preloadStart() {
this.onPreloadStart();
const { counter } = this;
const toLoad = this.rscs.slice(
counter.total,
counter.total + this.toPreload - counter.success,
);
this.preLoading = this.preLoading.concat(toLoad);
toLoad.forEach((rsc) => this.load(rsc));
}
preloadEnd() {
const { counter, toPreload } = this;
if (counter.success < toPreload && counter.total < toPreload) {
this.preloadStart();
return;
}
const preloadedSuccessfully = this.preLoading.filter((rsc) => rsc.resource.isLoaded());
this.onPreloadEnd(preloadedSuccessfully);
this.preLoading.length = 0;
if (counter.total < this.rscs.length) {
this.lazyLoadStart();
}
}
lazyLoadStart() {
this.onLazyLoadStart();
this.lazyLoading = this.rscs.slice(this.counter.total);
this.lazyLoading.forEach((rsc) => this.load(rsc));
}
lazyLoadEnd() {
const lazyLoadedSuccessfully = this.lazyLoading.filter((rsc) => rsc.resource.isLoaded());
this.onLazyLoadEnd(lazyLoadedSuccessfully);
this.lazyLoading.length = 0;
}
load(rsc: ResourceWithOptions) {
rsc.resource
.load()
.then(() => {
this.loadSuccess();
})
.finally(() => {
this.counter.total++;
if (this.counter.total === this.toPreload) {
this.preloadEnd();
} else if (this.counter.total === this.rscs.length) {
this.lazyLoadEnd();
}
});
}
loadSuccess() {
this.counter.success++;
}
hasFinished() {
return this.counter.total === this.rscs.length;
}
}
===== FILE: src/shared/ResourceLoader/__test__/ResourceLoaderFactory.ts =====
import { vi } from 'vitest';
import ResourceFactory from '../../../resources/__test__/ResourceFactory';
import ResourceLoader from '../ResourceLoader';
import Size from '../../Size/Size';
import type { ResourceWithOptions } from '../../../resources/types';
export default class ResourceLoaderFactory {
static create(
numResources: number,
numToPreload: number,
preloadStartMock?: () => void,
preloadEndMock?: () => void,
lazyLoadStartMock?: () => void,
lazyLoadEndMock?: () => void,
) {
const displaySize = new Size({
width: 640,
height: 360,
});
const onPreloadStart = vi.fn();
if (preloadStartMock) {
onPreloadStart.mockImplementation(preloadStartMock);
}
const onPreloadEnd = vi.fn();
if (preloadEndMock) {
onPreloadEnd.mockImplementation(preloadEndMock);
}
const onLazyLoadStart = vi.fn();
if (lazyLoadStartMock) {
onLazyLoadStart.mockImplementation(lazyLoadStartMock);
}
const onLazyLoadEnd = vi.fn();
if (lazyLoadEndMock) {
onLazyLoadEnd.mockImplementation(lazyLoadEndMock);
}
const reject = vi.fn();
const resources = ResourceFactory.create(numResources).map((resource) => {
return {
resource: resource,
options: {},
} as ResourceWithOptions;
});
return new ResourceLoader(
resources,
numToPreload,
displaySize,
onPreloadStart,
onPreloadEnd,
onLazyLoadStart,
onLazyLoadEnd,
reject,
);
}
}
===== FILE: src/shared/Size/Size.test.ts =====
import Size from './Size';
describe('shared: Size', () => {
let size: Size;
let params: object;
it('initializes values to null without parameters', () => {
size = new Size();
expect(size.width.value).toBeNull();
expect(size.height.value).toBeNull();
});
it('sets param values', () => {
size = new Size({ width: 100 });
expect(size.width.value).toBe(100);
size = new Size({ height: 100 });
expect(size.height.value).toBe(100);
size = new Size({
width: 100,
height: 200,
});
expect(size.width.value).toBe(100);
expect(size.height.value).toBe(200);
});
it('reset values', () => {
size = new Size({
width: 100,
height: 200,
});
size.reset();
expect(size.width.value).toBeNull();
expect(size.height.value).toBeNull();
});
it('is invalid if width or height is null', () => {
size = new Size({ width: 100 });
expect(size.isValid()).toBeFalsy();
size = new Size({ height: 100 });
expect(size.isValid()).toBeFalsy();
});
it('is valid when width and height have values', () => {
size = new Size({
width: 100,
height: 200,
});
expect(size.isValid()).toBeTruthy();
});
it('updates the values', () => {
size = new Size({
width: 100,
height: 200,
});
size.update({
width: 50,
});
expect(size.width.value).toBe(50);
expect(size.height.value).toBeNull();
size.update({
height: 100,
});
expect(size.width.value).toBeNull();
expect(size.height.value).toBe(100);
size.update({
width: 200,
height: 400,
});
expect(size.width.value).toBe(200);
expect(size.height.value).toBe(400);
});
it('throws an exception trying to calc aspect ratio when size is invalid', () => {
size = new Size({ width: 100 });
expect(() => size.getAspectRatio()).toThrow(
'Could not get aspect ratio due to invalid size'
);
});
it('gets the aspect ration when size is valid', () => {
size = new Size({
width: 100,
height: 200,
});
expect(size.getAspectRatio()).toBeTypeOf('number');
});
it('clones the size', () => {
params = {
width: 100,
height: 200,
};
size = new Size({
width: 100,
height: 200,
});
expect(size.clone().toValue()).toStrictEqual(params);
});
it('returns false when width does not match other size', () => {
size = new Size({
width: 100,
height: 200,
});
expect(
size.equals(
new Size({
width: 50,
height: 200,
})
)
).toBeFalsy();
});
it('returns false when height does not match other size', () => {
size = new Size({
width: 100,
height: 200,
});
expect(
size.equals(
new Size({
width: 100,
height: 50,
})
)
).toBeFalsy();
});
it('returns true when size equals another size', () => {
params = {
width: 100,
height: 200,
};
size = new Size(params);
expect(size.equals(new Size(params))).toBeTruthy();
});
it('returns the values as plain object', () => {
params = {
width: 100,
height: 200,
};
size = new Size(params);
expect(size.toValue()).toStrictEqual(params);
size = new Size();
expect(size.toValue()).toStrictEqual({});
});
it('throws exception when trying to get the values with px suffix', () => {
size = new Size();
expect(() => size.toPx()).toThrow('Invalid size in pixels');
});
it('returns the values with px suffix', () => {
params = {
width: 100,
height: 200,
};
size = new Size(params);
expect(size.toPx()).toStrictEqual({
width: params['width' as keyof object] + 'px',
height: params['height' as keyof object] + 'px',
});
});
});
===== FILE: src/shared/Size/Size.ts =====
import { ref, type Ref } from 'vue';
import { Maths } from '../';
export default class Size {
width: Ref = ref(null);
height: Ref = ref(null);
constructor(
{
width = null,
height = null,
}: {
width?: null | number;
height?: null | number;
} = { width: null, height: null },
) {
this.update({ width, height });
}
reset() {
this.width.value = null;
this.height.value = null;
}
isValid() {
return ![this.width.value, this.height.value].includes(null);
}
update({ width, height }: { width?: null | number; height?: null | number }) {
this.width.value = width ?? null;
this.height.value = height ?? null;
}
getAspectRatio() {
if (!this.isValid()) {
throw new RangeError('Could not get aspect ratio due to invalid size');
}
return Maths.aspectRatio(this.toValue() as { width: number; height: number });
}
clone() {
return new Size(this.toValue());
}
equals(otherSize: Size) {
if (this.width.value !== otherSize.width.value) {
return false;
}
if (this.height.value !== otherSize.height.value) {
return false;
}
return true;
}
toValue() {
const rawSize: {
width?: number;
height?: number;
} = {};
if (this.width.value !== null) {
rawSize.width = this.width.value;
}
if (this.height.value !== null) {
rawSize.height = this.height.value;
}
return rawSize;
}
toPx() {
if (!this.isValid()) {
throw new RangeError('Invalid size in pixels');
}
return {
width: this.width.value!.toString() + 'px',
height: this.height.value!.toString() + 'px',
};
}
}
===== FILE: src/shared/index.ts =====
export * as Maths from './Maths/Maths';
export { default as Position } from './Position/Position';
export { default as ResizeCalculator } from './ResizeCalculator/ResizeCalculator';
export { default as ResourceLoader } from './ResourceLoader/ResourceLoader';
export { default as Size } from './Size/Size';
===== FILE: src/transitions/Blinds2D/Blinds2D.test.ts =====
import Blinds2D from './Blinds2D.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Blinds2D', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Blinds2D, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Blinds2D, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'scaleX(0)',
transition: 'all 800ms linear 0ms',
});
expect($tiles[9].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'scaleX(0)',
transition: 'all 800ms linear 900ms',
});
expect(wrapper.vm.totalDuration).toBe(1800);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Blinds2D, {
direction: Directions.prev,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'scaleX(0)',
transition: 'all 400ms ease-out 300ms',
});
expect($tiles[5].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'scaleX(0)',
transition: 'all 400ms ease-out 0ms',
});
expect(wrapper.vm.totalDuration).toBe(760);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Blinds2D, {
direction: Directions.next,
cols: 6,
tileDuration: 700,
tileDelay: 90,
easing: 'ease-in',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'scaleX(0)',
transition: 'all 700ms ease-in 0ms',
});
expect($tiles[5].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'scaleX(0)',
transition: 'all 700ms ease-in 450ms',
});
expect(wrapper.vm.totalDuration).toBe(1240);
});
});
===== FILE: src/transitions/Blinds2D/Blinds2D.vue =====
===== FILE: src/transitions/Blinds2D/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionBlinds2DOptions extends TransitionOptions {
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionBlinds2DProps extends TransitionProps {
options?: TransitionBlinds2DOptions;
}
export interface TransitionBlinds2DConf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
===== FILE: src/transitions/Blinds3D/Blinds3D.test.ts =====
import Blinds3D from './Blinds3D.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers';
import { Turns } from '../../components/FluxCube';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Blinds3D', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Blinds3D, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('expects to set proper CSS styles before animation', () => {
const wrapper = AnimationWrapper(Blinds3D, {});
const maskStyle = wrapper.props('maskStyle');
expect(maskStyle.overflow).toBe('visible');
const gridCss = wrapper
.getComponent({
ref: '$grid',
})
.props('css');
expect(gridCss.perspective).toBeDefined();
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Blinds3D, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 800ms ease-out 0ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.backr);
expect($tiles[5].setCss).toHaveBeenCalledWith({
transition: 'all 800ms ease-out 750ms',
});
expect($tiles[5].turn).toHaveBeenCalledWith(Turns.backr);
expect(wrapper.vm.totalDuration).toBe(1700);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Blinds3D, {
direction: Directions.prev,
cols: 8,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-in 420ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.backl);
expect($tiles[7].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-in 0ms',
});
expect($tiles[7].turn).toHaveBeenCalledWith(Turns.backl);
expect(wrapper.vm.totalDuration).toBe(880);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Blinds3D, {
direction: Directions.next,
cols: 10,
tileDuration: 400,
tileDelay: 60,
easing: 'linear',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 400ms linear 0ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.backr);
expect($tiles[9].setCss).toHaveBeenCalledWith({
transition: 'all 400ms linear 540ms',
});
expect($tiles[9].turn).toHaveBeenCalledWith(Turns.backr);
expect(wrapper.vm.totalDuration).toBe(1000);
});
});
===== FILE: src/transitions/Blinds3D/Blinds3D.vue =====
===== FILE: src/transitions/Blinds3D/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionBlinds3DOptions extends TransitionOptions {
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionBlinds3DProps extends TransitionProps {
options?: TransitionBlinds3DOptions;
}
export interface TransitionBlinds3DConf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
===== FILE: src/transitions/Blocks1/Blocks1.test.ts =====
import Blocks1 from './Blocks1.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Blocks1', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Blocks1, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Blocks1, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3, 0.3)',
transition: expect.any(String),
});
expect($tiles[31].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3, 0.3)',
transition: expect.any(String),
});
expect(wrapper.vm.totalDuration).toBe(1300);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Blocks1, {
direction: Directions.prev,
rows: 3,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3, 0.3)',
transition: expect.any(String),
});
expect($tiles[17].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3, 0.3)',
transition: expect.any(String),
});
expect(wrapper.vm.totalDuration).toBe(460);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Blocks1, {
direction: Directions.next,
rows: 4,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3, 0.3)',
transition: expect.any(String),
});
expect($tiles[23].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3, 0.3)',
transition: expect.any(String),
});
expect(wrapper.vm.totalDuration).toBe(460);
});
});
===== FILE: src/transitions/Blocks1/Blocks1.vue =====
===== FILE: src/transitions/Blocks1/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionBlocks1Options extends TransitionOptions {
rows?: number;
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionBlocks1Props extends TransitionProps {
options?: TransitionBlocks1Options;
}
export interface TransitionBlocks1Conf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
===== FILE: src/transitions/Blocks2/Blocks2.test.ts =====
import Blocks2 from './Blocks2.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Blocks2', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Blocks2, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Blocks2, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3)',
transition: 'all 800ms ease 0ms',
});
expect($tiles[31].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3)',
transition: 'all 800ms ease 800ms',
});
expect(wrapper.vm.totalDuration).toBe(2080);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Blocks2, {
direction: Directions.prev,
rows: 3,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '1',
transform: 'scale(1)',
transition: 'all 400ms ease-in-out 480ms',
});
expect($tiles[17].transform).toHaveBeenCalledWith({
opacity: '1',
transform: 'scale(1)',
transition: 'all 400ms ease-in-out 60ms',
});
expect(wrapper.vm.totalDuration).toBe(940);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Blocks2, {
direction: Directions.next,
rows: 4,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3)',
transition: 'all 400ms ease-in-out 0ms',
});
expect($tiles[23].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3)',
transition: 'all 400ms ease-in-out 480ms',
});
expect(wrapper.vm.totalDuration).toBe(1000);
});
});
===== FILE: src/transitions/Blocks2/Blocks2.vue =====
===== FILE: src/transitions/Blocks2/types.ts =====
import type { CSSProperties } from 'vue';
import { Resource } from '../../resources';
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionBlocks2Options extends TransitionOptions {
rows?: number;
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionBlocks2Props extends TransitionProps {
options?: TransitionBlocks2Options;
}
export interface TransitionBlocks2Conf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
export interface BackgroundProps {
rsc: null | Resource;
css: CSSProperties;
}
===== FILE: src/transitions/Book/Book.test.ts =====
import Book from './Book.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxImage/FluxImage.vue');
vi.mock('../../components/FluxCube/FluxCube.vue');
describe('transition: Book', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Book, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Book, {});
const size = wrapper.props('size').toValue();
const $from = wrapper.getComponent({
ref: '$from',
});
const viewSize = $from.props('viewSize').toValue();
expect(viewSize).toStrictEqual({
width: Math.ceil(size.width / 2),
height: size.height,
});
expect($from.props('offset').toValue()).toStrictEqual({
top: 0,
left: 0,
});
expect($from.props('css')).toStrictEqual({
top: 0,
left: 0,
position: 'absolute',
});
const $cube = wrapper.getComponent({
ref: '$cube',
});
const cubeOffsets = $cube.props('offsets');
expect(cubeOffsets.front.toValue()).toStrictEqual({
top: 0,
left: 320,
});
expect(cubeOffsets.back.toValue()).toStrictEqual({
top: 0,
left: 0,
});
expect($cube.props('origin')).toBe('left center');
expect($cube.props('css')).toStrictEqual({
top: 0,
left: '320px',
position: 'absolute',
});
wrapper.vm.onPlay();
expect($cube.vm.transform).toHaveBeenCalledWith({
transform: 'rotateY(-180deg)',
transition: 'transform 1200ms ease-out',
});
expect(wrapper.vm.totalDuration).toBe(1200);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Book, {
direction: Directions.prev,
totalDuration: 900,
easing: 'linear',
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('offset').toValue()).toStrictEqual({
top: 0,
left: 320,
});
expect($from.props('css')).toStrictEqual({
top: 0,
left: '320px',
position: 'absolute',
});
const $cube = wrapper.getComponent({
ref: '$cube',
});
const cubeOffsets = $cube.props('offsets');
expect(cubeOffsets.front.toValue()).toStrictEqual({
top: 0,
left: 0,
});
expect(cubeOffsets.back.toValue()).toStrictEqual({
top: 0,
left: 320,
});
expect($cube.props('origin')).toBe('right center');
expect($cube.props('css')).toStrictEqual({
top: 0,
left: 0,
position: 'absolute',
});
wrapper.vm.onPlay();
expect($cube.vm.transform).toHaveBeenCalledWith({
transform: 'rotateY(180deg)',
transition: 'transform 900ms linear',
});
expect(wrapper.vm.totalDuration).toBe(900);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Book, {
direction: Directions.next,
totalDuration: 1000,
easing: 'ease-out',
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('offset').toValue()).toStrictEqual({
top: 0,
left: 0,
});
expect($from.props('css')).toStrictEqual({
top: 0,
left: 0,
position: 'absolute',
});
const $cube = wrapper.getComponent({
ref: '$cube',
});
const cubeOffsets = $cube.props('offsets');
expect(cubeOffsets.front.toValue()).toStrictEqual({
top: 0,
left: 320,
});
expect(cubeOffsets.back.toValue()).toStrictEqual({
top: 0,
left: 0,
});
expect($cube.props('origin')).toBe('left center');
expect($cube.props('css')).toStrictEqual({
top: 0,
left: '320px',
position: 'absolute',
});
wrapper.vm.onPlay();
expect($cube.vm.transform).toHaveBeenCalledWith({
transform: 'rotateY(-180deg)',
transition: 'transform 1000ms ease-out',
});
expect(wrapper.vm.totalDuration).toBe(1000);
});
});
===== FILE: src/transitions/Book/Book.vue =====
===== FILE: src/transitions/Book/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionBookOptions extends TransitionOptions {
totalDuration?: number;
}
export interface TransitionBookProps extends TransitionProps {
options?: TransitionBookOptions;
}
export interface TransitionBookConf extends TransitionConf {
totalDuration: number;
}
===== FILE: src/transitions/Camera/Camera.test.ts =====
import Camera from './Camera.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
import { Maths, Size } from '../../shared';
vi.mock('../../components/FluxWrapper/FluxWrapper.vue');
describe('transition: Camera', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Camera, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Camera, {});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
const size = wrapper.props('size').toValue() as {
width: number;
height: number;
};
const diagSize = Maths.diag(size);
expect($wrapper.props('size')).toStrictEqual(
new Size({ width: diagSize, height: diagSize })
);
expect($wrapper.props('css')).toStrictEqual({
boxSizing: 'border-box',
position: 'absolute',
display: 'flex',
justifyContent: 'center',
overflow: 'hidden',
borderRadius: '50%',
border: '0 solid #111',
top: (size.height - diagSize) / 2 + 'px',
left: (size.width - diagSize) / 2 + 'px',
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('css')).toStrictEqual({
alignSelf: 'center',
flex: 'none',
});
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
borderWidth: '367.5px',
transition: 'all 400ms cubic-bezier(0.385, 0, 0.795, 0.560) 0ms',
});
expect(wrapper.vm.totalDuration).toBe(900);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Camera, {
direction: Directions.prev,
totalDuration: 800,
easing: 'ease-out',
});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
const size = wrapper.props('size').toValue() as {
width: number;
height: number;
};
const diagSize = Maths.diag(size);
expect($wrapper.props('size')).toStrictEqual(
new Size({ width: diagSize, height: diagSize })
);
expect($wrapper.props('css')).toStrictEqual({
boxSizing: 'border-box',
position: 'absolute',
display: 'flex',
justifyContent: 'center',
overflow: 'hidden',
borderRadius: '50%',
border: '0 solid #111',
top: (size.height - diagSize) / 2 + 'px',
left: (size.width - diagSize) / 2 + 'px',
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('css')).toStrictEqual({
alignSelf: 'center',
flex: 'none',
});
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
borderWidth: '367.5px',
transition: 'all 350ms ease-out 0ms',
});
expect(wrapper.vm.totalDuration).toBe(800);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Camera, {
direction: Directions.next,
totalDuration: 1000,
easing: 'ease-in',
});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
const size = wrapper.props('size').toValue() as {
width: number;
height: number;
};
const diagSize = Maths.diag(size);
expect($wrapper.props('size')).toStrictEqual(
new Size({ width: diagSize, height: diagSize })
);
expect($wrapper.props('css')).toStrictEqual({
boxSizing: 'border-box',
position: 'absolute',
display: 'flex',
justifyContent: 'center',
overflow: 'hidden',
borderRadius: '50%',
border: '0 solid #111',
top: (size.height - diagSize) / 2 + 'px',
left: (size.width - diagSize) / 2 + 'px',
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('css')).toStrictEqual({
alignSelf: 'center',
flex: 'none',
});
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
borderWidth: '367.5px',
transition: 'all 450ms ease-in 0ms',
});
expect(wrapper.vm.totalDuration).toBe(1000);
});
});
===== FILE: src/transitions/Camera/Camera.vue =====
===== FILE: src/transitions/Camera/types.ts =====
import type { CSSProperties } from 'vue';
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionCameraOptions extends TransitionOptions {
totalDuration?: number;
backgroundColor?: CSSProperties['color'];
}
export interface TransitionCameraProps extends TransitionProps {
options?: TransitionCameraOptions;
}
export interface TransitionCameraConf extends TransitionConf {
totalDuration: number;
backgroundColor: CSSProperties['color'];
}
===== FILE: src/transitions/Concentric/Concentric.test.ts =====
import Concentric from './Concentric.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxVortex/FluxVortex.vue');
describe('transition: Concentric', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Concentric, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Concentric, {});
const $vortex = wrapper.getComponent({
ref: '$vortex',
});
wrapper.vm.onPlay();
expect($vortex.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $vortex.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(90deg)',
transition: 'all 800ms linear 0ms',
});
expect($tiles[6].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(90deg)',
transition: 'all 800ms linear 900ms',
});
expect(wrapper.vm.totalDuration).toBe(1850);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Concentric, {
direction: Directions.prev,
circles: 5,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $vortex = wrapper.getComponent({
ref: '$vortex',
});
wrapper.vm.onPlay();
expect($vortex.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $vortex.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[4].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 400ms ease-out 240ms',
});
expect(wrapper.vm.totalDuration).toBe(700);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Concentric, {
direction: Directions.next,
circles: 10,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $vortex = wrapper.getComponent({
ref: '$vortex',
});
wrapper.vm.onPlay();
expect($vortex.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $vortex.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(90deg)',
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[9].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(90deg)',
transition: 'all 400ms ease-out 540ms',
});
expect(wrapper.vm.totalDuration).toBe(1000);
});
});
===== FILE: src/transitions/Concentric/Concentric.vue =====
===== FILE: src/transitions/Concentric/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionConcentricOptions extends TransitionOptions {
circles?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionConcentricProps extends TransitionProps {
options?: TransitionConcentricOptions;
}
export interface TransitionConcentricConf extends TransitionConf {
circles: number;
tileDuration: number;
tileDelay: number;
}
===== FILE: src/transitions/Cube/Cube.test.ts =====
import Cube from './Cube.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers';
import { Turns } from '../../components/FluxCube';
vi.mock('../../components/FluxCube/FluxCube.vue');
describe('transition: Cube', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Cube, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('expects to set proper CSS styles before animation', () => {
const wrapper = AnimationWrapper(Cube, {});
const maskStyle = wrapper.props('maskStyle');
expect(maskStyle.overflow).toBe('visible');
expect(maskStyle.perspective).toBeDefined();
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Cube, {});
const $cube = wrapper.getComponent({
ref: '$cube',
});
wrapper.vm.onPlay();
expect($cube.vm.turn).toHaveBeenCalledWith(Turns.left);
expect(wrapper.vm.totalDuration).toBe(1400);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Cube, {
direction: Directions.prev,
totalDuration: 900,
easing: 'ease-in',
});
const $cube = wrapper.getComponent({
ref: '$cube',
});
wrapper.vm.onPlay();
expect($cube.vm.turn).toHaveBeenCalledWith(Turns.right);
expect(wrapper.vm.totalDuration).toBe(900);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Cube, {
direction: Directions.next,
totalDuration: 300,
easing: 'ease-in-out',
});
const $cube = wrapper.getComponent({
ref: '$cube',
});
wrapper.vm.onPlay();
expect($cube.vm.turn).toHaveBeenCalledWith(Turns.left);
expect(wrapper.vm.totalDuration).toBe(300);
});
});
===== FILE: src/transitions/Cube/Cube.vue =====
===== FILE: src/transitions/Cube/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionCubeOptions extends TransitionOptions {
totalDuration?: number;
}
export interface TransitionCubeProps extends TransitionProps {
options?: TransitionCubeOptions;
}
export interface TransitionCubeConf extends TransitionConf {
totalDuration: number;
}
===== FILE: src/transitions/Explode/Explode.test.ts =====
import Explode from './Explode.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Explode', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Explode, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Explode, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
const maskStyle = wrapper.props('maskStyle');
expect(maskStyle.overflow).toBe('visible');
const gridCss = wrapper
.getComponent({
ref: '$grid',
})
.props('css');
expect(gridCss.overflow).toBe('visible');
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
borderRadius: '100%',
opacity: '0',
transform: 'scale(2)',
transition: 'all 300ms linear 500ms',
});
expect($tiles[21].transform).toHaveBeenCalledWith({
borderRadius: '100%',
opacity: '0',
transform: 'scale(2)',
transition: 'all 300ms linear 0ms',
});
expect(wrapper.vm.totalDuration).toBe(1400);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Explode, {
direction: Directions.prev,
rows: 3,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
borderRadius: '100%',
opacity: '0',
transform: 'scale(2)',
transition: 'all 400ms ease-in-out 150ms',
});
expect($tiles[8].transform).toHaveBeenCalledWith({
borderRadius: '100%',
opacity: '0',
transform: 'scale(2)',
transition: 'all 400ms ease-in-out -30ms',
});
expect(wrapper.vm.totalDuration).toBe(540);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Explode, {
direction: Directions.next,
rows: 4,
cols: 7,
tileDuration: 200,
tileDelay: 80,
easing: 'ease-in',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
borderRadius: '100%',
opacity: '0',
transform: 'scale(2)',
transition: 'all 200ms ease-in 280ms',
});
expect($tiles[13].transform).toHaveBeenCalledWith({
borderRadius: '100%',
opacity: '0',
transform: 'scale(2)',
transition: 'all 200ms ease-in 200ms',
});
expect(wrapper.vm.totalDuration).toBe(880);
});
});
===== FILE: src/transitions/Explode/Explode.vue =====
===== FILE: src/transitions/Explode/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionExplodeOptions extends TransitionOptions {
rows?: number;
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionExplodeProps extends TransitionProps {
options?: TransitionExplodeOptions;
}
export interface TransitionExplodeConf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
===== FILE: src/transitions/Fade/Fade.test.ts =====
import Fade from './Fade.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxImage/FluxImage.vue');
describe('transition: Fade', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Fade, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Fade, {});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('css')).toStrictEqual({
zIndex: 1,
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
opacity: 0,
transition: 'opacity 1200ms ease-in',
});
expect(wrapper.vm.totalDuration).toBe(1200);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Fade, {
direction: Directions.prev,
totalDuration: 1800,
easing: 'linear',
});
const $from = wrapper.getComponent({
ref: '$from',
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
opacity: 0,
transition: 'opacity 1800ms linear',
});
expect(wrapper.vm.totalDuration).toBe(1800);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Fade, {
direction: Directions.next,
totalDuration: 600,
easing: 'ease-out',
});
const $from = wrapper.getComponent({
ref: '$from',
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
opacity: 0,
transition: 'opacity 600ms ease-out',
});
expect(wrapper.vm.totalDuration).toBe(600);
});
});
===== FILE: src/transitions/Fade/Fade.vue =====
===== FILE: src/transitions/Fade/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionFadeOptions extends TransitionOptions {
totalDuration?: number;
}
export interface TransitionFadeProps extends TransitionProps {
options?: TransitionFadeOptions;
}
export interface TransitionFadeConf extends TransitionConf {
totalDuration: number;
}
===== FILE: src/transitions/Fall/Fall.test.ts =====
import Fall from './Fall.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxImage/FluxImage.vue');
describe('transition: Fall', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Fall, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Fall, {});
const $from = wrapper.getComponent({
ref: '$from',
});
const maskStyle = wrapper.props('maskStyle');
expect(maskStyle.overflow).toBe('visible');
expect(maskStyle.perspective).toBeDefined();
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
transform: 'rotateX(-83.6deg)',
transition: 'transform 1600ms ease-in',
});
expect(wrapper.vm.totalDuration).toBe(1600);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Fall, {
direction: Directions.prev,
totalDuration: 1200,
easing: 'linear',
});
const $from = wrapper.getComponent({
ref: '$from',
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
transform: 'rotateX(-83.6deg)',
transition: 'transform 1200ms linear',
});
expect(wrapper.vm.totalDuration).toBe(1200);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Fall, {
direction: Directions.next,
totalDuration: 1000,
easing: 'ease-out',
});
const $from = wrapper.getComponent({
ref: '$from',
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
transform: 'rotateX(-83.6deg)',
transition: 'transform 1000ms ease-out',
});
expect(wrapper.vm.totalDuration).toBe(1000);
});
});
===== FILE: src/transitions/Fall/Fall.vue =====
===== FILE: src/transitions/Fall/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionFallOptions extends TransitionOptions {
totalDuration?: number;
}
export interface TransitionFallProps extends TransitionProps {
options?: TransitionFallOptions;
}
export interface TransitionFallConf extends TransitionConf {
totalDuration: number;
}
===== FILE: src/transitions/Kenburn/Kenburn.test.ts =====
import Kenburn from './Kenburn.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxImage/FluxImage.vue');
describe('transition: Kenburn', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Kenburn, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Kenburn, {});
const $from = wrapper.getComponent({
ref: '$from',
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
opacity: 0,
transform: expect.any(String),
transition: 'all 1500ms linear',
});
expect(wrapper.vm.totalDuration).toBe(1500);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Kenburn, {
direction: Directions.prev,
totalDuration: 800,
easing: 'ease-in',
});
const $from = wrapper.getComponent({
ref: '$from',
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
opacity: 0,
transform: expect.any(String),
transition: 'all 800ms ease-in',
});
expect(wrapper.vm.totalDuration).toBe(800);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Kenburn, {
direction: Directions.next,
totalDuration: 800,
easing: 'ease-in',
});
const $from = wrapper.getComponent({
ref: '$from',
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
opacity: 0,
transform: expect.any(String),
transition: 'all 800ms ease-in',
});
expect(wrapper.vm.totalDuration).toBe(800);
});
});
===== FILE: src/transitions/Kenburn/Kenburn.vue =====
===== FILE: src/transitions/Kenburn/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionKenburnOptions extends TransitionOptions {
totalDuration?: number;
}
export interface TransitionKenburnProps extends TransitionProps {
options?: TransitionKenburnOptions;
}
export interface TransitionKenburnConf extends TransitionConf {
totalDuration: number;
}
===== FILE: src/transitions/Round1/Round1.test.ts =====
import Round1 from './Round1.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
import { Turns } from '../../components/FluxCube';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Round1', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Round1, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Round1, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
const maskStyle = wrapper.props('maskStyle');
expect(maskStyle.overflow).toBe('visible');
const gridCss = wrapper
.getComponent({
ref: '$grid',
})
.props('css');
expect(gridCss.perspective).toBeDefined();
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 800ms ease-out 0ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.backr);
expect($tiles[9].setCss).toHaveBeenCalledWith({
transition: 'all 800ms ease-out 300ms',
});
expect($tiles[9].turn).toHaveBeenCalledWith(Turns.backr);
expect(wrapper.vm.totalDuration).toBe(2400);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Round1, {
direction: Directions.prev,
rows: 3,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-in-out 480ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.backl);
expect($tiles[17].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-in-out 60ms',
});
expect($tiles[17].turn).toHaveBeenCalledWith(Turns.backl);
expect(wrapper.vm.totalDuration).toBe(720);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Round1, {
direction: Directions.next,
rows: 3,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-in-out 0ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.backr);
expect($tiles[17].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-in-out 420ms',
});
expect($tiles[17].turn).toHaveBeenCalledWith(Turns.backr);
expect(wrapper.vm.totalDuration).toBe(720);
});
});
===== FILE: src/transitions/Round1/Round1.vue =====
===== FILE: src/transitions/Round1/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionRound1Options extends TransitionOptions {
rows?: number;
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionRound1Props extends TransitionProps {
options?: TransitionRound1Options;
}
export interface TransitionRound1Conf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
===== FILE: src/transitions/Round2/Round2.test.ts =====
import Round2 from './Round2.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Round2', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Round2, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Round2, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
const maskStyle = wrapper.props('maskStyle');
expect(maskStyle.overflow).toBe('visible');
const gridCss = wrapper
.getComponent({
ref: '$grid',
})
.props('css');
expect(gridCss.perspective).toBeDefined();
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateY(-540deg)',
transition: 'all 800ms linear 100ms',
});
expect($tiles[9].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateY(-540deg)',
transition: 'all 800ms linear 0ms',
});
expect(wrapper.vm.totalDuration).toBe(1900);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Round2, {
direction: Directions.prev,
rows: 3,
cols: 6,
tileDuration: 400,
tileDelay: 60,
rotateX: -310,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateY(-310deg)',
transition: 'all 400ms ease-out 360ms',
});
expect($tiles[17].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateY(-310deg)',
transition: 'all 400ms ease-out 60ms',
});
expect(wrapper.vm.totalDuration).toBe(720);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Round2, {
direction: Directions.next,
rows: 3,
cols: 6,
tileDuration: 400,
tileDelay: 60,
rotateX: -310,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateY(-310deg)',
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[17].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateY(-310deg)',
transition: 'all 400ms ease-out 300ms',
});
expect(wrapper.vm.totalDuration).toBe(720);
});
});
===== FILE: src/transitions/Round2/Round2.vue =====
===== FILE: src/transitions/Round2/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionRound2Options extends TransitionOptions {
rows?: number;
cols?: number;
tileDuration?: number;
tileDelay?: number;
rotateX?: number;
}
export interface TransitionRound2Props extends TransitionProps {
options?: TransitionRound2Options;
}
export interface TransitionRound2Conf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
rotateX: number;
}
===== FILE: src/transitions/Slide/Slide.test.ts =====
import Slide from './Slide.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
import { Size } from '../../shared';
vi.mock('../../components/FluxWrapper/FluxWrapper.vue');
describe('transition: Slide', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Slide, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Slide, {});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
expect($wrapper.props('size')).toStrictEqual(
new Size({ width: 1280, height: 360 })
);
expect($wrapper.props('css')).toStrictEqual({
display: 'flex',
flexWrap: 'nowrap',
});
const $left = wrapper.getComponent({
ref: '$left',
});
expect($left.props('size')).toStrictEqual(wrapper.props('size'));
const $right = wrapper.getComponent({
ref: '$right',
});
expect($right.props('size')).toStrictEqual(wrapper.props('size'));
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
transform: 'translateX(-50%)',
transition: 'transform 1400ms ease-in-out',
});
expect(wrapper.vm.totalDuration).toBe(1400);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Slide, {
direction: Directions.prev,
totalDuration: 800,
easing: 'ease-out',
});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
expect($wrapper.props('size')).toStrictEqual(
new Size({ width: 1280, height: 360 })
);
expect($wrapper.props('css')).toStrictEqual({
display: 'flex',
flexWrap: 'nowrap',
transform: 'translateX(-50%)',
});
const $left = wrapper.getComponent({
ref: '$left',
});
expect($left.props('size')).toStrictEqual(wrapper.props('size'));
expect($left.props('rsc')).toStrictEqual(wrapper.props('to'));
const $right = wrapper.getComponent({
ref: '$right',
});
expect($right.props('size')).toStrictEqual(wrapper.props('size'));
expect($right.props('rsc')).toStrictEqual(wrapper.props('from'));
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
transform: 'translateX(0)',
transition: 'transform 800ms ease-out',
});
expect(wrapper.vm.totalDuration).toBe(800);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Slide, {
direction: Directions.next,
totalDuration: 800,
easing: 'ease-out',
});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
expect($wrapper.props('size')).toStrictEqual(
new Size({ width: 1280, height: 360 })
);
expect($wrapper.props('css')).toStrictEqual({
display: 'flex',
flexWrap: 'nowrap',
});
const $left = wrapper.getComponent({
ref: '$left',
});
expect($left.props('size')).toStrictEqual(wrapper.props('size'));
expect($left.props('rsc')).toStrictEqual(wrapper.props('from'));
const $right = wrapper.getComponent({
ref: '$right',
});
expect($right.props('size')).toStrictEqual(wrapper.props('size'));
expect($right.props('rsc')).toStrictEqual(wrapper.props('to'));
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
transform: 'translateX(-50%)',
transition: 'transform 800ms ease-out',
});
expect(wrapper.vm.totalDuration).toBe(800);
});
});
===== FILE: src/transitions/Slide/Slide.vue =====
===== FILE: src/transitions/Slide/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionSlideOptions extends TransitionOptions {
totalDuration?: number;
}
export interface TransitionSlideProps extends TransitionProps {
options?: TransitionSlideOptions;
}
export interface TransitionSlideConf extends TransitionConf {
totalDuration: number;
}
===== FILE: src/transitions/Swipe/Swipe.test.ts =====
import Swipe from './Swipe.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxWrapper/FluxWrapper.vue');
describe('transition: Swipe', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Swipe, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Swipe, {});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
expect($wrapper.props('size')).toStrictEqual(wrapper.props('size'));
expect($wrapper.props('css')).toStrictEqual({
display: 'flex',
flexWrap: 'nowrap',
justifyContent: 'flex-start',
left: 0,
position: 'absolute',
top: 0,
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('css')).toStrictEqual({
flex: '0 0 auto',
});
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
transition: 'width 1400ms ease-in-out',
width: 0,
});
expect(wrapper.vm.totalDuration).toBe(1400);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Swipe, {
direction: Directions.prev,
totalDuration: 800,
easing: 'ease-out',
});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
expect($wrapper.props('size')).toStrictEqual(wrapper.props('size'));
expect($wrapper.props('css')).toStrictEqual({
display: 'flex',
flexWrap: 'nowrap',
justifyContent: 'flex-end',
right: 0,
position: 'absolute',
top: 0,
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('css')).toStrictEqual({
flex: '0 0 auto',
});
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
transition: 'width 800ms ease-out',
width: 0,
});
expect(wrapper.vm.totalDuration).toBe(800);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Swipe, {
direction: Directions.next,
totalDuration: 800,
easing: 'ease-out',
});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
expect($wrapper.props('size')).toStrictEqual(wrapper.props('size'));
expect($wrapper.props('css')).toStrictEqual({
display: 'flex',
flexWrap: 'nowrap',
justifyContent: 'flex-start',
left: 0,
position: 'absolute',
top: 0,
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('css')).toStrictEqual({
flex: '0 0 auto',
});
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
transition: 'width 800ms ease-out',
width: 0,
});
expect(wrapper.vm.totalDuration).toBe(800);
});
});
===== FILE: src/transitions/Swipe/Swipe.vue =====
===== FILE: src/transitions/Swipe/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionSwipeOptions extends TransitionOptions {
totalDuration?: number;
}
export interface TransitionSwipeProps extends TransitionProps {
options?: TransitionSwipeOptions;
}
export interface TransitionSwipeConf extends TransitionConf {
totalDuration: number;
}
===== FILE: src/transitions/Warp/Warp.test.ts =====
import Warp from './Warp.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxVortex/FluxVortex.vue');
describe('transition: Warp', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Warp, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Warp, {});
const $vortex = wrapper.getComponent({
ref: '$vortex',
});
wrapper.vm.onPlay();
expect($vortex.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $vortex.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 800ms linear 0ms',
});
expect($tiles[6].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 800ms linear 900ms',
});
expect(wrapper.vm.totalDuration).toBe(1850);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Warp, {
direction: Directions.prev,
circles: 10,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $vortex = wrapper.getComponent({
ref: '$vortex',
});
wrapper.vm.onPlay();
expect($vortex.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $vortex.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 400ms ease-out 540ms',
});
expect($tiles[6].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 400ms ease-out 180ms',
});
expect(wrapper.vm.totalDuration).toBe(1000);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Warp, {
direction: Directions.next,
circles: 10,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $vortex = wrapper.getComponent({
ref: '$vortex',
});
wrapper.vm.onPlay();
expect($vortex.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $vortex.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[6].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 400ms ease-out 360ms',
});
expect(wrapper.vm.totalDuration).toBe(1000);
});
});
===== FILE: src/transitions/Warp/Warp.vue =====
===== FILE: src/transitions/Warp/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionWarpOptions extends TransitionOptions {
circles?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionWarpProps extends TransitionProps {
options?: TransitionWarpOptions;
}
export interface TransitionWarpConf extends TransitionConf {
circles: number;
tileDuration: number;
tileDelay: number;
}
===== FILE: src/transitions/Waterfall/Waterfall.test.ts =====
import Waterfall from './Waterfall.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Waterfall', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Waterfall, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Waterfall, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 600ms cubic-bezier(0.55, 0.055, 0.675, 0.19) 0ms',
});
expect($tiles[9].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 600ms cubic-bezier(0.55, 0.055, 0.675, 0.19) 810ms',
});
expect(wrapper.vm.totalDuration).toBe(1500);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Waterfall, {
direction: Directions.prev,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 400ms ease-out 300ms',
});
expect($tiles[5].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 400ms ease-out 0ms',
});
expect(wrapper.vm.totalDuration).toBe(760);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Waterfall, {
direction: Directions.next,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[5].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 400ms ease-out 300ms',
});
expect(wrapper.vm.totalDuration).toBe(760);
});
});
===== FILE: src/transitions/Waterfall/Waterfall.vue =====
===== FILE: src/transitions/Waterfall/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionWaterfallOptions extends TransitionOptions {
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionWaterfallProps extends TransitionProps {
options?: TransitionWaterfallOptions;
}
export interface TransitionWaterfallConf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
===== FILE: src/transitions/Wave/Wave.test.ts =====
import Wave from './Wave.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers';
import { Turns } from '../../components/FluxCube';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Wave', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Wave, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('expects to set proper CSS styles before animation', () => {
const wrapper = AnimationWrapper(Wave, {});
const maskStyle = wrapper.props('maskStyle');
expect(maskStyle.overflow).toBe('visible');
const gridCss = wrapper
.getComponent({
ref: '$grid',
})
.props('css');
expect(gridCss.perspective).toBeDefined();
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Wave, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 900ms cubic-bezier(0.3, -0.3, 0.735, 0.285) 0ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.bottom);
expect($tiles[7].setCss).toHaveBeenCalledWith({
transition: 'all 900ms cubic-bezier(0.3, -0.3, 0.735, 0.285) 770ms',
});
expect($tiles[7].turn).toHaveBeenCalledWith(Turns.bottom);
expect(wrapper.vm.totalDuration).toBe(1780);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Wave, {
direction: Directions.prev,
cols: 6,
tileDuration: 400,
tileDelay: 60,
sideColor: '#999',
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-out 300ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.bottom);
expect($tiles[5].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[5].turn).toHaveBeenCalledWith(Turns.bottom);
expect(wrapper.vm.totalDuration).toBe(760);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Wave, {
direction: Directions.next,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.bottom);
expect($tiles[5].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-out 300ms',
});
expect($tiles[5].turn).toHaveBeenCalledWith(Turns.bottom);
expect(wrapper.vm.totalDuration).toBe(760);
});
});
===== FILE: src/transitions/Wave/Wave.vue =====
===== FILE: src/transitions/Wave/types.ts =====
import type { CSSProperties } from 'vue';
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionWaveOptions extends TransitionOptions {
cols?: number;
tileDuration?: number;
tileDelay?: number;
sideColor?: CSSProperties['color'];
}
export interface TransitionWaveProps extends TransitionProps {
options?: TransitionWaveOptions;
}
export interface TransitionWaveConf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
sideColor: CSSProperties['color'];
}
===== FILE: src/transitions/Zip/Zip.test.ts =====
import Zip from './Zip.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Zip', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Zip, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Zip, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 600ms ease-in 0ms',
});
expect($tiles[9].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(-100%)',
transition: 'all 600ms ease-in 720ms',
});
expect(wrapper.vm.totalDuration).toBe(1400);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Zip, {
direction: Directions.prev,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 400ms ease-out 300ms',
});
expect($tiles[5].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(-100%)',
transition: 'all 400ms ease-out 0ms',
});
expect(wrapper.vm.totalDuration).toBe(760);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Zip, {
direction: Directions.next,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[5].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(-100%)',
transition: 'all 400ms ease-out 300ms',
});
expect(wrapper.vm.totalDuration).toBe(760);
});
});
===== FILE: src/transitions/Zip/Zip.vue =====
===== FILE: src/transitions/Zip/types.ts =====
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionZipOptions extends TransitionOptions {
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionZipProps extends TransitionProps {
options?: TransitionZipOptions;
}
export interface TransitionZipConf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
===== FILE: src/transitions/__test__/AnimationWrapper.ts =====
import { mount } from '@vue/test-utils';
import { type Component, markRaw } from 'vue';
import { type FluxComponent, FluxImage } from '../../components';
import { Img } from '../../resources';
import { Size } from '../../shared';
const size = markRaw(
new Size({
width: 640,
height: 360,
}),
);
const from = new Img('from');
const to = new Img('to');
const maskStyle = {
overflow: 'hidden',
perspective: 'none',
zIndex: 3,
};
const displayComponent = mount(markRaw(FluxImage), {
props: {
color: '#ccc',
size: size,
},
});
export default (component: Component, options: object = {}) => {
return mount(component, {
props: {
size,
from,
to,
options,
maskStyle,
displayComponent: displayComponent.vm as FluxComponent,
},
});
};
===== FILE: src/transitions/index.ts =====
export { default as Fade } from './Fade/Fade.vue';
export { default as Kenburn } from './Kenburn/Kenburn.vue';
export { default as Swipe } from './Swipe/Swipe.vue';
export { default as Slide } from './Slide/Slide.vue';
export { default as Waterfall } from './Waterfall/Waterfall.vue';
export { default as Zip } from './Zip/Zip.vue';
export { default as Blinds2D } from './Blinds2D/Blinds2D.vue';
export { default as Blocks1 } from './Blocks1/Blocks1.vue';
export { default as Blocks2 } from './Blocks2/Blocks2.vue';
export { default as Concentric } from './Concentric/Concentric.vue';
export { default as Warp } from './Warp/Warp.vue';
export { default as Camera } from './Camera/Camera.vue';
export { default as Cube } from './Cube/Cube.vue';
export { default as Book } from './Book/Book.vue';
export { default as Fall } from './Fall/Fall.vue';
export { default as Wave } from './Wave/Wave.vue';
export { default as Blinds3D } from './Blinds3D/Blinds3D.vue';
export { default as Round1 } from './Round1/Round1.vue';
export { default as Round2 } from './Round2/Round2.vue';
export { default as Explode } from './Explode/Explode.vue';
export { default as useTransition } from './useTransition';
export type * from './types';
export type * from './Blinds2D/types';
export type * from './Blinds3D/types';
export type * from './Blocks1/types';
export type * from './Blocks2/types';
export type * from './Book/types';
export type * from './Camera/types';
export type * from './Concentric/types';
export type * from './Cube/types';
export type * from './Explode/types';
export type * from './Fade/types';
export type * from './Fall/types';
export type * from './Kenburn/types';
export type * from './Round1/types';
export type * from './Round2/types';
export type * from './Slide/types';
export type * from './Swipe/types';
export type * from './Warp/types';
export type * from './Waterfall/types';
export type * from './Wave/types';
export type * from './Zip/types';
===== FILE: src/transitions/types.ts =====
import { Resource } from '../resources';
import type { CSSProperties, Component } from 'vue';
import type { Direction } from '../controllers/Player';
import { Size } from '../shared';
import type { FluxComponent } from '../components';
export interface TransitionProps {
size: Size;
from: Resource;
to?: Resource;
options?: object;
maskStyle: CSSProperties;
displayComponent: FluxComponent;
}
export interface TransitionOptions {
direction?: Direction;
easing?: CSSProperties['animation-timing-function'];
}
export interface TransitionConf {
totalDuration?: number;
direction?: Direction;
easing: CSSProperties['animation-timing-function'];
}
export type TransitionComponent = Component & {
totalDuration: number;
onPlay: () => void;
};
export interface TransitionWithOptions {
component: Component;
options: object;
}
===== FILE: src/transitions/useTransition.ts =====
import { Directions } from '../controllers/Player';
import type { TransitionConf } from './types';
export default function useTransition(conf: TransitionConf, options?: object) {
Object.assign(conf, { direction: Directions.next }, options);
}
================================================
FILE: index.html
================================================
Vite App
================================================
FILE: package.json
================================================
{
"name": "vue-flux",
"version": "7.1.3",
"type": "module",
"description": "Vue image and other resources slider",
"author": "ragnar lotus",
"repository": {
"type": "git",
"url": "git+https://github.com/ragnarlotus/vue-flux.git"
},
"keywords": [
"vue",
"image",
"slider",
"carousel",
"parallax"
],
"license": "MIT",
"bugs": "https://github.com/ragnarlotus/vue-flux/issues",
"homepage": "https://ragnarlotus.github.io/vue-flux-docs/",
"main": "./dist/vue-flux.umd.cjs",
"module": "./dist/vue-flux.js",
"files": [
"dist"
],
"types": "./dist/vue-flux.d.ts",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:coverage": "vitest run --coverage --watch",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"exports": {
".": {
"types": "./dist/vue-flux.d.ts",
"import": "./dist/vue-flux.js",
"require": "./dist/vue-flux.umd.cjs"
},
"./style.css": "./dist/vue-flux.css",
"./complements": {
"types": "./dist/complements/index.d.ts",
"import": "./dist/complements/index.js"
},
"./transitions": {
"types": "./dist/transitions/index.d.ts",
"import": "./dist/transitions/index.js"
}
},
"sideEffects": [
"*.css"
],
"peerDependencies": {
"vue": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.17",
"@tsconfig/node22": "^22.0.5",
"@types/jsdom": "^27.0.0",
"@types/node": "^25.0.0",
"@vitejs/plugin-vue": "^6.0.2",
"@vitest/coverage-v8": "^4.0.15",
"@vitest/eslint-plugin": "^1.5.2",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.1",
"eslint-plugin-vue": "~10.6.2",
"jiti": "^2.6.1",
"jsdom": "^27.3.0",
"npm-run-all2": "^8.0.4",
"prettier": "3.7.4",
"sass": "^1.96.0",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"vite": "^7.2.7",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-vue-devtools": "^8.0.5",
"vitest": "^4.0.15",
"vue": "^3.5.25",
"vue-cosk": "^1.0.0",
"vue-tsc": "^3.1.8"
}
}
================================================
FILE: src/App.vue
================================================
================================================
FILE: src/assets/css/base.scss
================================================
label {
margin-top: 12px;
display: block;
span {
margin-right: 6px;
}
}
================================================
FILE: src/assets/css/main.css
================================================
@import 'tailwindcss';
@import './base.scss';
================================================
FILE: src/complements/FluxCaption/FluxCaption.test.ts
================================================
import { Player, Timers } from '../../controllers';
import { mount } from '@vue/test-utils';
import FluxCaption from './FluxCaption.vue';
import emit from '../../components/VueFlux/__test__/emit';
import {
vueFluxConfig,
setCurrentResource,
setCurrentTransition,
} from '../__test__/PlayerHelper';
vi.mock('../../controllers/Player/Player');
const defaultCaption = 'the caption';
describe('complements: FluxCaption', () => {
const timers = new Timers();
it('should mount properly without slot', () => {
const player = new Player(vueFluxConfig, timers, emit);
expect(() => {
mount(FluxCaption, {
props: {
player,
},
});
}).not.toThrow();
});
it('should not be visible if no caption', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player);
const wrapper = mount(FluxCaption, {
props: {
player,
},
});
expect(wrapper.html().includes('class="flux-caption"')).toBeTruthy();
});
it('should not be visible if caption has no length', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player, '');
const wrapper = mount(FluxCaption, {
props: {
player,
},
});
expect(wrapper.html().includes('class="flux-caption"')).toBeTruthy();
});
it('should not be visible if transition running', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player, defaultCaption);
setCurrentTransition(player);
const wrapper = mount(FluxCaption, {
props: {
player,
},
});
expect(wrapper.html().includes('class="flux-caption"')).toBeTruthy();
});
it('should display the caption', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player, defaultCaption);
const wrapper = mount(FluxCaption, {
props: {
player,
},
});
expect(
wrapper.html().includes('class="flux-caption visible"')
).toBeTruthy();
});
it('should mount properly with slot', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player, defaultCaption);
const wrapper = mount(FluxCaption, {
props: {
player,
},
slots: {
default: `{{ params.caption }}
`,
},
});
expect(
wrapper.html().includes(`${defaultCaption}
`)
).toBeTruthy();
});
});
================================================
FILE: src/complements/FluxCaption/FluxCaption.vue
================================================
{{ caption }}
================================================
FILE: src/complements/FluxControls/FluxControls.test.ts
================================================
import { ref, type Ref } from 'vue';
import { Player, Timers } from '../../controllers';
import { Directions, Statuses } from '../../controllers/Player';
import * as Buttons from './buttons';
import FluxControls from './FluxControls.vue';
import { mount } from '@vue/test-utils';
import emit from '../../components/VueFlux/__test__/emit';
import { vueFluxConfig, setCurrentResource, setCurrentTransition } from '../__test__/PlayerHelper';
vi.mock('../../controllers/Player/Player');
describe('complements: FluxControls', () => {
const timers = new Timers();
const mouseOver: Ref = ref(false);
beforeEach(() => {
mouseOver.value = false;
});
it('should mount properly without slot', () => {
const player = new Player(vueFluxConfig, timers, emit);
expect(() => {
mount(FluxControls, {
props: {
mouseOver,
player,
},
});
}).not.toThrow();
});
it('should not be visible if transition running', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player);
setCurrentTransition(player);
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
expect(wrapper.html().includes('class="flux-controls"')).toBeFalsy();
});
it('should not be visible if transition running and mouse not moving', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player);
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
expect(wrapper.html().includes('class="flux-controls"')).toBeFalsy();
});
it('should be visible if no transition running and mouse moving', () => {
const player = new Player(vueFluxConfig, timers, emit);
setCurrentResource(player);
mouseOver.value = true;
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
expect(wrapper.html().includes('class="flux-controls"')).toBeTruthy();
});
it('should display play button', () => {
const player = new Player(vueFluxConfig, timers, emit);
player.status.value = Statuses.stopped;
setCurrentResource(player);
mouseOver.value = true;
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
expect(() => {
wrapper.getComponent(Buttons.Play);
}).not.toThrow();
});
it('should play when button pressed', async () => {
const player = new Player(vueFluxConfig, timers, emit);
player.status.value = Statuses.stopped;
setCurrentResource(player);
mouseOver.value = true;
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
await wrapper.getComponent(Buttons.Play).trigger('click');
expect(player.play).toHaveBeenCalledWith(Directions.next, expect.any(Number));
});
it('should display stop button', () => {
const player = new Player(vueFluxConfig, timers, emit);
player.status.value = Statuses.playing;
setCurrentResource(player);
mouseOver.value = true;
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
expect(() => {
wrapper.getComponent(Buttons.Stop);
}).not.toThrow();
});
it('should stop when button pressed', async () => {
const player = new Player(vueFluxConfig, timers, emit);
player.status.value = Statuses.playing;
setCurrentResource(player);
mouseOver.value = true;
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
await wrapper.getComponent(Buttons.Stop).trigger('click');
expect(player.stop).toHaveBeenCalledOnce();
});
it('should display previous resource when button pressed', async () => {
const player = new Player(vueFluxConfig, timers, emit);
player.status.value = Statuses.playing;
setCurrentResource(player);
mouseOver.value = true;
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
await wrapper.getComponent(Buttons.Prev).trigger('click');
expect(player.show).toHaveBeenCalledWith(Directions.prev);
});
it('should display next resource when button pressed', async () => {
const player = new Player(vueFluxConfig, timers, emit);
player.status.value = Statuses.playing;
setCurrentResource(player);
mouseOver.value = true;
const wrapper = mount(FluxControls, {
props: {
mouseOver,
player,
},
});
await wrapper.getComponent(Buttons.Next).trigger('click');
expect(player.show).toHaveBeenCalledWith(Directions.next);
});
});
================================================
FILE: src/complements/FluxControls/FluxControls.vue
================================================
================================================
FILE: src/complements/FluxControls/buttons/Next.vue
================================================
================================================
FILE: src/complements/FluxControls/buttons/Play.vue
================================================
================================================
FILE: src/complements/FluxControls/buttons/Prev.vue
================================================
================================================
FILE: src/complements/FluxControls/buttons/Stop.vue
================================================
================================================
FILE: src/complements/FluxControls/buttons/index.ts
================================================
export { default as Prev } from './Prev.vue';
export { default as Play } from './Play.vue';
export { default as Stop } from './Stop.vue';
export { default as Next } from './Next.vue';
================================================
FILE: src/complements/FluxIndex/Button/Button.test.ts
================================================
import { type Ref, ref } from 'vue';
import { mount } from '@vue/test-utils';
import Button from './Button.vue';
describe('complements: FluxIndex Button', () => {
const mouseOver: Ref = ref(false);
beforeEach(() => {
mouseOver.value = false;
});
it('mounts properly', () => {
expect(() => {
mount(Button, {
props: {
mouseOver,
},
});
}).not.toThrow();
});
it('is visible when mouse over', () => {
mouseOver.value = true;
const wrapper = mount(Button, {
props: {
mouseOver,
},
});
expect(wrapper.html().includes('toggle bottom left')).toBeTruthy();
});
it('is NOT visible when mouse NOT over', () => {
const wrapper = mount(Button, {
props: {
mouseOver,
},
});
expect(wrapper.html().includes('toggle bottom left')).toBeFalsy();
});
});
================================================
FILE: src/complements/FluxIndex/Button/Button.vue
================================================
================================================
FILE: src/complements/FluxIndex/FluxIndex.vue
================================================
================================================
FILE: src/complements/FluxIndex/List/List.test.ts
================================================
import { type Ref, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { Player, Timers } from '../../../controllers';
import List from './List.vue';
import { Size } from '../../../shared';
import emit from '../../../components/VueFlux/__test__/emit';
import { vueFluxConfig, setCurrentResource } from '../../__test__/PlayerHelper';
import Thumb from '../Thumb/Thumb.vue';
import ResourceFactory from '../../../resources/__test__/ResourceFactory';
vi.mock('../../../resources/Img/Img');
vi.mock('../../../shared/ResourceLoader/ResourceLoader');
vi.mock('../../../controllers/Player/Player');
describe('complements: FluxIndex List', () => {
const timers = new Timers();
const displaySize: Size = new Size({ width: 640, height: 360 });
const mouseOver: Ref = ref(false);
beforeEach(() => {
mouseOver.value = false;
});
it('mounts properly', () => {
const player = new Player(vueFluxConfig, timers, emit);
expect(() => {
mount(List, {
props: {
displaySize,
player,
mouseOver,
},
});
}).not.toThrow();
});
it('is not visible by default', async () => {
mouseOver.value = true;
const player = new Player(vueFluxConfig, timers, emit);
const wrapper = mount(List, {
props: {
displaySize,
player,
mouseOver,
},
});
expect(wrapper.html().includes('nav class=""')).toBeTruthy();
});
it('shows the list when button clicked', async () => {
mouseOver.value = true;
const player = new Player(vueFluxConfig, timers, emit);
const wrapper = mount(List, {
props: {
displaySize,
player,
mouseOver,
},
});
await wrapper.vm.show();
expect(wrapper.html().includes('nav class="visible"')).toBeTruthy();
});
it('does nothing if clicked resource is the same as current resource', async () => {
mouseOver.value = true;
const player = new Player(vueFluxConfig, timers, emit);
const resources = ResourceFactory.create(10);
await player.resources.update(resources, 10, displaySize);
setCurrentResource(player);
const wrapper = mount(List, {
props: {
displaySize,
player,
mouseOver,
},
});
await wrapper.find({ ref: '$list' }).findAllComponents(Thumb)[0].trigger('click');
expect(player.show).not.toHaveBeenCalled();
});
});
================================================
FILE: src/complements/FluxIndex/List/List.vue
================================================
================================================
FILE: src/complements/FluxIndex/Thumb/Thumb.vue
================================================
================================================
FILE: src/complements/FluxIndex/Thumb/useThumbs.ts
================================================
import { computed } from 'vue';
import { Player } from '../../../controllers';
import { Size } from '../../../shared';
export default function useThumbs(displaySize: Size, player: Player) {
const size = computed(() => {
let { width, height } = displaySize.toValue();
width = width! / 4.2;
height = (width * 90) / 160;
if (width > 160) {
width = 160;
height = 90;
}
return new Size({
width,
height,
});
});
function getClass(index: number) {
const { current } = player.resource;
if (current === null) {
return '';
}
if (current.index !== index) {
return '';
}
return 'current';
}
return { size, getClass };
}
================================================
FILE: src/complements/FluxPagination/FluxPagination.vue
================================================
================================================
FILE: src/complements/FluxPreloader/FluxPreloader.vue
================================================
{{ loader.value?.progress }}%
================================================
FILE: src/complements/__test__/PlayerHelper.ts
================================================
import type { VueFluxConfig } from '../../components/VueFlux/types';
import { Player } from '../../controllers/Player';
import type { ResourceIndex } from '../../repositories/Resources/types';
import type { TransitionIndex } from '../../repositories/Transitions/types';
import { Img } from '../../resources';
import { Blinds2D } from '../../transitions';
export const vueFluxConfig = {
allowFullscreen: false,
allowToSkipTransition: true,
aspectRatio: '16:9',
autohideTime: 2500,
autoplay: false,
bindKeys: false,
delay: 5000,
enableGestures: false,
infinite: true,
lazyLoad: true,
lazyLoadAfter: 5,
} as VueFluxConfig;
export function setCurrentResource(player: Player, caption?: string) {
player.resource.current = {
index: 0,
rsc: new Img('url', caption),
options: {},
} as ResourceIndex;
}
export function setCurrentTransition(player: Player) {
player.transition.current = {
index: 0,
component: Blinds2D,
options: {},
} as TransitionIndex;
}
================================================
FILE: src/complements/index.ts
================================================
export { default as FluxCaption } from './FluxCaption/FluxCaption.vue';
export { default as FluxControls } from './FluxControls/FluxControls.vue';
export { default as FluxIndex } from './FluxIndex/FluxIndex.vue';
export { default as FluxPagination } from './FluxPagination/FluxPagination.vue';
export { default as FluxPreloader } from './FluxPreloader/FluxPreloader.vue';
================================================
FILE: src/components/FluxButton/FluxButton.test.ts
================================================
import FluxButton from './FluxButton.vue';
import { mount } from '@vue/test-utils';
describe('component: FluxButton', () => {
it('should mount properly', () => {
const nextLine = '';
const wrapper = mount(FluxButton, {
slots: {
default: nextLine,
},
});
expect(
wrapper.html().includes('
================================================
FILE: src/components/FluxCube/FluxCube.vue
================================================
================================================
FILE: src/components/FluxCube/Sides.ts
================================================
enum Sides {
front = 'front',
back = 'back',
left = 'left',
right = 'right',
top = 'top',
bottom = 'bottom',
}
export default Sides;
================================================
FILE: src/components/FluxCube/Turns.ts
================================================
enum Turns {
front = 'front',
back = 'back',
backr = 'backr',
backl = 'backl',
left = 'left',
right = 'right',
top = 'top',
bottom = 'bottom',
}
export default Turns;
================================================
FILE: src/components/FluxCube/__mocks__/FluxCube.vue
================================================
================================================
FILE: src/components/FluxCube/__mocks__/Side.vue
================================================
================================================
FILE: src/components/FluxCube/factories/CubeFactory.test.ts
================================================
import { Img } from '../../../resources';
import { Position, Size } from '../../../shared';
import { type SideProps } from '../types';
import CubeFactory from './CubeFactory';
import CubeSideFactory from './CubeSideFactory';
import SideTransformFactory from './SideTransformFactory';
describe('factory: CubeFactory', () => {
let rsc, rscs, color, colors, offset, offsets;
const depth = 160;
const size = new Size({
width: 640,
height: 360,
});
const viewSize = new Size();
const sideTransformFactory = new SideTransformFactory(depth, size, viewSize);
vi.spyOn(CubeSideFactory, 'getProps').mockImplementation(() => ({}) as SideProps);
beforeEach(() => {
vi.clearAllMocks();
});
it('generates a cube using a color', () => {
color = '#ccc';
const cubeProps = CubeFactory.getSidesProps(sideTransformFactory, color);
expect(CubeSideFactory.getProps).toHaveBeenCalledTimes(6);
expect(Object.keys(cubeProps)).toHaveLength(6);
});
it('generates a cube using a colors', () => {
colors = {
top: '#ccc',
left: '#ccc',
back: '#ccc',
};
const cubeProps = CubeFactory.getSidesProps(sideTransformFactory, undefined, colors);
expect(CubeSideFactory.getProps).toHaveBeenCalledTimes(3);
expect(Object.keys(cubeProps)).toHaveLength(3);
});
it('generates a cube using a rsc', () => {
rsc = new Img('url', 'caption');
const cubeProps = CubeFactory.getSidesProps(sideTransformFactory, undefined, undefined, rsc);
expect(CubeSideFactory.getProps).toHaveBeenCalledTimes(6);
expect(Object.keys(cubeProps)).toHaveLength(6);
});
it('generates a cube using a rscs', () => {
rscs = {
bottom: new Img('url', 'caption'),
right: new Img('url', 'caption'),
front: new Img('url', 'caption'),
};
const cubeProps = CubeFactory.getSidesProps(
sideTransformFactory,
undefined,
undefined,
undefined,
rscs,
);
expect(CubeSideFactory.getProps).toHaveBeenCalledTimes(3);
expect(Object.keys(cubeProps)).toHaveLength(3);
});
it('generates a cube using a color with offset', () => {
color = '#ccc';
offset = new Position({ top: 160, left: 80 });
const cubeProps = CubeFactory.getSidesProps(
sideTransformFactory,
color,
undefined,
undefined,
undefined,
offset,
);
expect(CubeSideFactory.getProps).toHaveBeenCalledTimes(6);
expect(Object.keys(cubeProps)).toHaveLength(6);
});
it('generates a cube using a colors with offsets', () => {
colors = {
top: '#ccc',
left: '#ccc',
back: '#ccc',
};
offsets = {
top: new Position({ top: 160, left: 80 }),
left: new Position({ top: 160, left: 80 }),
back: new Position({ top: 160, left: 80 }),
};
const cubeProps = CubeFactory.getSidesProps(
sideTransformFactory,
undefined,
colors,
undefined,
undefined,
undefined,
offsets,
);
expect(CubeSideFactory.getProps).toHaveBeenCalledTimes(3);
expect(Object.keys(cubeProps)).toHaveLength(3);
});
});
================================================
FILE: src/components/FluxCube/factories/CubeFactory.ts
================================================
import type { Side, SidesColors, SidesResources, SidesOffsets, SidesProps } from '../types';
import CubeSideFactory from './CubeSideFactory';
import SideTransformFactory from './SideTransformFactory';
import { Position } from '../../../shared';
import Sides from '../Sides';
import { Resource } from '../../../resources';
import type { CSSProperties } from 'vue';
function isSideDefined(side: Side, colors?: SidesColors, rscs?: SidesResources) {
if (colors && colors[side]) {
return true;
}
if (rscs && rscs[side]) {
return true;
}
return false;
}
function getDefinedSides(
color?: CSSProperties['color'],
colors?: SidesColors,
rsc?: Resource,
rscs?: SidesResources,
) {
const sides = Object.values(Sides);
if (color || rsc) {
return sides;
}
return Object.values(Sides).filter((side) => isSideDefined(side, colors, rscs));
}
export default class CubeFactory {
static getSidesProps(
sideTransformFactory: SideTransformFactory,
color?: CSSProperties['color'],
colors?: SidesColors,
rsc?: Resource,
rscs?: SidesResources,
offset?: Position,
offsets?: SidesOffsets,
) {
const sides = getDefinedSides(color, colors, rsc, rscs);
const props: SidesProps = {};
sides.forEach((side: Side) => {
props[side] = CubeSideFactory.getProps(
sideTransformFactory,
side,
colors && colors[side] ? colors[side] : color,
rscs && rscs[side] ? rscs[side] : rsc,
offsets && offsets[side] ? offsets[side] : offset,
);
});
return props;
}
}
================================================
FILE: src/components/FluxCube/factories/CubeSideFactory.ts
================================================
import { Position } from '../../../shared';
import { Resource } from '../../../resources';
import type { Side, SideProps } from '../types';
import SideTransformFactory from './SideTransformFactory';
import { FluxImage } from '../../';
import type { CSSProperties } from 'vue';
export default class CubeSideFactory {
static getProps(
sideTransformFactory: SideTransformFactory,
side: Side,
color?: CSSProperties['color'],
rsc?: Resource,
offset?: Position,
) {
const { depth, size, viewSize } = sideTransformFactory;
const props: SideProps = {
name: side,
component: rsc ? rsc.transition.component : FluxImage,
color: color,
rsc: rsc,
size: size.clone(),
viewSize: viewSize.clone(),
offset: offset,
style: {
position: 'absolute',
transform: sideTransformFactory.getSideCss(side),
backfaceVisibility: 'hidden',
},
};
if (['left', 'right'].includes(side)) {
props.viewSize.width.value = depth;
props.size.width.value = depth;
}
if (['top', 'bottom'].includes(side)) {
props.viewSize.height.value = depth;
props.size.height.value = depth;
}
return props;
}
}
================================================
FILE: src/components/FluxCube/factories/SideTransformFactory.test.ts
================================================
import { Size } from '../../../shared';
import Turns from '../Turns';
import SideTransformFactory from './SideTransformFactory';
describe('factory: SideTransformFactory', () => {
const depth = 160;
const size = new Size({
width: 640,
height: 360,
});
const viewSize = new Size();
const sideTransformFactory = new SideTransformFactory(depth, size, viewSize);
it('should get the proper rotate angles', () => {
const expectations = {
front: 'rotateX(0deg) rotateY(0deg)',
right: 'rotateX(0deg) rotateY(90deg)',
left: 'rotateX(0deg) rotateY(-90deg)',
top: 'rotateX(90deg) rotateY(0deg)',
bottom: 'rotateX(-90deg) rotateY(0deg)',
back: 'rotateX(0deg) rotateY(180deg)',
backl: 'rotateX(0deg) rotateY(-180deg)',
backr: 'rotateX(0deg) rotateY(180deg)',
};
Object.values(Turns).forEach((turn) => {
expect(sideTransformFactory.getRotate(turn)).toBe(expectations[turn]);
});
});
it('should get proper translate coordinates', () => {
const expectations = {
front: 'translate3d(0%, 0%, 0px)',
right: 'translate3d(50%, 0%, 560px)',
left: 'translate3d(-50%, 0%, 80px)',
top: 'translate3d(0%, -50%, 80px)',
bottom: 'translate3d(0%, 50%, 280px)',
back: 'translate3d(0%, 0%, 160px)',
backl: 'translate3d(0%, 0%, 160px)',
backr: 'translate3d(0%, 0%, 160px)',
};
Object.values(Turns).forEach((turn) => {
expect(sideTransformFactory.getTranslate(turn)).toBe(
expectations[turn]
);
});
});
it('should get each side style', () => {
const expectations = {
front: 'rotateX(0deg) rotateY(0deg) translate3d(0%, 0%, 0px)',
right: 'rotateX(0deg) rotateY(90deg) translate3d(50%, 0%, 560px)',
left: 'rotateX(0deg) rotateY(-90deg) translate3d(-50%, 0%, 80px)',
top: 'rotateX(90deg) rotateY(0deg) translate3d(0%, -50%, 80px)',
bottom: 'rotateX(-90deg) rotateY(0deg) translate3d(0%, 50%, 280px)',
back: 'rotateX(0deg) rotateY(180deg) translate3d(0%, 0%, 160px)',
backl: 'rotateX(0deg) rotateY(-180deg) translate3d(0%, 0%, 160px)',
backr: 'rotateX(0deg) rotateY(180deg) translate3d(0%, 0%, 160px)',
};
Object.values(Turns).forEach((turn) => {
expect(sideTransformFactory.getSideCss(turn)).toBe(expectations[turn]);
});
});
});
================================================
FILE: src/components/FluxCube/factories/SideTransformFactory.ts
================================================
import { type Ref, computed } from 'vue';
import { Size } from '../../../shared';
import type { Side, Turn } from '../types';
const rotate: {
x: {
[key: string]: string;
};
y: {
[key: string]: string;
};
} = {
x: {
top: '90',
bottom: '-90',
},
y: {
back: '180',
backr: '180',
backl: '-180',
left: '-90',
right: '90',
},
};
const translate: {
x: {
[key: string]: string;
};
y: {
[key: string]: string;
};
} = {
x: {
left: '-50',
right: '50',
},
y: {
top: '-50',
bottom: '50',
},
};
export default class SideTransformFactory {
depth: number;
size: Size;
viewSize: Size;
translateZ: Ref<{ [key: string]: number }> = computed(() => {
const halfDepth = this.depth / 2;
const { width, height } = this.size.toValue();
const { width: viewWidth, height: viewHeight } = this.viewSize.toValue();
return {
front: 0,
back: this.depth,
backr: this.depth,
backl: this.depth,
left: halfDepth,
right: (viewWidth ?? width!) - halfDepth,
top: halfDepth,
bottom: (viewHeight ?? height!) - halfDepth,
};
});
constructor(depth: number, size: Size, viewSize: Size) {
this.depth = depth;
this.size = size;
this.viewSize = viewSize;
}
public getRotate(turn: Side | Turn) {
const rx = rotate.x[turn] ?? '0';
const ry = rotate.y[turn] ?? '0';
return `rotateX(${rx}deg) rotateY(${ry}deg)`;
}
public getTranslate(side: Side | Turn) {
const tx = translate.x[side] ?? '0';
const ty = translate.y[side] ?? '0';
const tz = this.translateZ.value[side]!.toString();
return `translate3d(${tx}%, ${ty}%, ${tz}px)`;
}
public getSideCss(side: Side | Turn) {
return `${this.getRotate(side)} ${this.getTranslate(side)}`;
}
}
================================================
FILE: src/components/FluxCube/index.ts
================================================
export { default as FluxCube } from './FluxCube.vue';
export { default as Sides } from './Sides';
export { default as Turns } from './Turns';
================================================
FILE: src/components/FluxCube/types.ts
================================================
import type { CSSProperties, Component } from 'vue';
import { Resource } from '../../resources';
import { Position, Size } from '../../shared';
import type { ComponentProps, FluxComponent } from '../types';
import Sides from './Sides';
import Turns from './Turns';
export interface FluxCubeProps extends ComponentProps {
colors?: SidesColors;
rscs?: SidesResources;
offsets?: SidesOffsets;
depth?: number;
origin?: string;
}
export type Side = keyof typeof Sides;
export type Turn = keyof typeof Turns;
export interface SidesColors {
[Sides.front]?: string;
[Sides.back]?: string;
[Sides.left]?: string;
[Sides.right]?: string;
[Sides.top]?: string;
[Sides.bottom]?: string;
}
export interface SidesResources {
[Sides.front]?: Resource;
[Sides.back]?: Resource;
[Sides.left]?: Resource;
[Sides.right]?: Resource;
[Sides.top]?: Resource;
[Sides.bottom]?: Resource;
}
export interface SidesOffsets {
[Sides.front]?: Position;
[Sides.back]?: Position;
[Sides.left]?: Position;
[Sides.right]?: Position;
[Sides.top]?: Position;
[Sides.bottom]?: Position;
}
export interface SideProps {
name: Side;
component: Component;
rsc?: Resource;
size: Size;
viewSize: Size;
color?: CSSProperties['color'];
offset?: Position;
style: CSSProperties;
}
export interface SidesProps {
[Sides.front]?: SideProps;
[Sides.back]?: SideProps;
[Sides.left]?: SideProps;
[Sides.right]?: SideProps;
[Sides.top]?: SideProps;
[Sides.bottom]?: SideProps;
}
export interface SidesComponents {
[Sides.front]?: FluxComponent;
[Sides.back]?: FluxComponent;
[Sides.left]?: FluxComponent;
[Sides.right]?: FluxComponent;
[Sides.top]?: FluxComponent;
[Sides.bottom]?: FluxComponent;
}
================================================
FILE: src/components/FluxGrid/FluxGrid.vue
================================================
================================================
FILE: src/components/FluxGrid/__mocks__/FluxGrid.vue
================================================
================================================
FILE: src/components/FluxGrid/__mocks__/Tile.vue
================================================
================================================
FILE: src/components/FluxGrid/factories/GridFactory.ts
================================================
import { Size } from '../../../shared';
import GridTileFactory from './GridTileFactory';
import type { FluxGridProps, FluxGridTileProps } from '../types';
export default class GridFactory {
static getTilesProps(props: FluxGridProps) {
const { rows, cols, size, color, colors, rsc, rscs, depth } = props;
const numRows = Math.ceil(rows!);
const numCols = Math.ceil(cols!);
const grid = {
numRows,
numCols,
numTiles: numRows * numCols,
size,
depth: depth!,
color,
colors,
rsc,
rscs,
};
const tile = {
number: 0,
size: new Size({
width: Math.floor(size.width.value! / numCols),
height: Math.floor(size.height.value! / numRows),
}),
css: props.tileCss,
};
const tilesProps: FluxGridTileProps[] = [];
for (let tileNumber = 0; tileNumber < grid.numTiles; tileNumber++) {
tile.number = tileNumber;
tilesProps.push(GridTileFactory.getProps(grid, tile));
}
return tilesProps;
}
}
================================================
FILE: src/components/FluxGrid/factories/GridTileFactory.ts
================================================
import type { CSSProperties } from 'vue';
import { Resource } from '../../../resources';
import { Size, Position } from '../../../shared';
import type { SidesColors, SidesResources } from '../../FluxCube/types';
import type { FluxGridTileProps } from '../types';
export function getRowNumber(tileNumber: number, numCols: number) {
return Math.floor(tileNumber / numCols);
}
export function getColNumber(tileNumber: number, numCols: number) {
return tileNumber % numCols;
}
export default class GridTileFactory {
static getProps(
grid: {
numRows: number;
numCols: number;
numTiles: number;
size: Size;
depth: number;
color?: CSSProperties['color'];
colors?: SidesColors;
rsc?: Resource;
rscs?: SidesResources;
},
tile: {
number: number;
size: Size;
css?: CSSProperties;
},
) {
let { width, height } = tile.size.toValue();
const row = getRowNumber(tile.number, grid.numCols);
const col = getColNumber(tile.number, grid.numCols);
const props: FluxGridTileProps = {
color: grid.color,
colors: grid.colors,
rsc: grid.rsc,
rscs: grid.rscs,
size: grid.size,
depth: grid.depth,
offset: new Position({
top: row * height!,
left: col * width!,
}),
};
if (row + 1 === grid.numRows) {
height = grid.size.height.value! - row * height!;
}
if (col + 1 === grid.numCols) {
width = grid.size.width.value! - col * width!;
}
props.viewSize = new Size({
width,
height,
});
props.css = {
...tile.css,
position: 'absolute',
...props.offset.toPx(),
zIndex:
tile.number + 1 < grid.numTiles / 2 ? tile.number + 1 : grid.numTiles - tile.number,
};
return props;
}
}
================================================
FILE: src/components/FluxGrid/factories/index.ts
================================================
export { default as GridFactory } from './GridFactory';
export {
default as GridTileFactory,
getRowNumber,
getColNumber,
} from './GridTileFactory';
================================================
FILE: src/components/FluxGrid/types.ts
================================================
import type { CSSProperties } from 'vue';
import { Position, Size } from '../../shared';
import { Resource } from '../../resources';
import type { SidesColors, SidesResources } from '../FluxCube/types';
import type { ComponentProps } from '../types';
export interface FluxGridProps extends ComponentProps {
colors?: SidesColors;
rscs?: SidesResources;
rows?: number;
cols?: number;
depth?: number;
tileCss?: CSSProperties;
}
export interface FluxGridTileProps {
color?: CSSProperties['color'];
colors?: SidesColors;
rsc?: Resource;
rscs?: SidesResources;
size: Size;
depth: number;
offset: Position;
viewSize?: Size;
css?: CSSProperties;
}
================================================
FILE: src/components/FluxImage/FluxImage.vue
================================================
================================================
FILE: src/components/FluxImage/__mocks__/FluxImage.vue
================================================
================================================
FILE: src/components/FluxImage/types.ts
================================================
import type { ComponentProps } from '../types';
export interface FluxImageProps extends ComponentProps {}
================================================
FILE: src/components/FluxParallax/FluxParallax.vue
================================================
================================================
FILE: src/components/FluxParallax/types.ts
================================================
import type { CSSProperties, ComputedRef } from 'vue';
import { Resource } from '../../resources';
export interface FluxParallaxProps {
rsc: Resource;
holder?: Window | Element;
type?: 'visible' | 'relative' | 'fixed';
offset?: string;
}
export interface FluxParallaxStyles {
base: CSSProperties;
defined: CSSProperties;
final: ComputedRef;
}
export interface DisplayProps {
width: number;
height: number;
aspectRatio: number;
}
export interface ViewProps {
top: number;
width: number;
height: number;
aspectRatio: number;
}
================================================
FILE: src/components/FluxTransition/FluxTransition.vue
================================================
================================================
FILE: src/components/FluxTransition/types.ts
================================================
import { Resource } from '../../resources';
import { Size } from '../../shared';
import type { FluxComponent } from '../types';
export interface FluxTransitionProps {
size: Size;
transition: object;
from: Resource;
to: Resource;
displayComponent?: null | FluxComponent;
options?: object;
}
================================================
FILE: src/components/FluxVortex/FluxVortex.vue
================================================
================================================
FILE: src/components/FluxVortex/__mocks__/FluxVortex.vue
================================================
================================================
FILE: src/components/FluxVortex/__mocks__/Tile.vue
================================================
================================================
FILE: src/components/FluxVortex/factories/VortexCircleFactory.ts
================================================
import type { CSSProperties } from 'vue';
import { Position } from '../../../shared';
import type { FluxVortexCirclesProps } from '../types';
export default class VortexCircleFactory {
static getProps(
vortex: {
numCircles: number;
diagonal: number;
radius: number;
topGap: number;
leftGap: number;
},
circleNumber: number,
circleCss?: CSSProperties,
) {
const size = (vortex.numCircles - circleNumber) * vortex.radius * 2;
const gap = vortex.radius * circleNumber;
const offset = new Position({
top: vortex.topGap + gap,
left: vortex.leftGap + gap,
});
const circle: FluxVortexCirclesProps = {
offset: offset,
css: {
...circleCss,
...offset.toPx(),
position: 'absolute',
width: size + 'px',
height: size + 'px',
backgroundRepeat: 'repeat',
borderRadius: '50%',
zIndex: circleNumber,
},
};
return circle;
}
}
================================================
FILE: src/components/FluxVortex/factories/VortexFactory.ts
================================================
import { Maths } from '../../../shared';
import type { FluxVortexProps, FluxVortexCirclesProps } from '../types';
import VortexCircleFactory from './VortexCircleFactory';
export default class VortexFactory {
static getCirclesProps(props: FluxVortexProps) {
const { width, height } = props.size.toValue();
const numCircles = Math.round(props.circles!);
const diagonal = Maths.diag({ width: width!, height: height! });
const radius = Math.ceil(diagonal / 2 / numCircles);
const topGap = Math.ceil(height! / 2 - radius * numCircles);
const leftGap = Math.ceil(width! / 2 - radius * numCircles);
const vortex = {
numCircles,
diagonal,
radius,
topGap,
leftGap,
};
const circlesProps: FluxVortexCirclesProps[] = [];
for (let circleNumber = 0; circleNumber < numCircles; circleNumber++) {
circlesProps.push(VortexCircleFactory.getProps(vortex, circleNumber, props.tileCss));
}
return circlesProps;
}
}
================================================
FILE: src/components/FluxVortex/factories/index.ts
================================================
export { default as VortexFactory } from './VortexFactory';
export { default as VortexCircleFactory } from './VortexCircleFactory';
================================================
FILE: src/components/FluxVortex/types.ts
================================================
import type { CSSProperties } from 'vue';
import { Resource } from '../../resources';
import type { ComponentProps } from '../types';
import { Position } from '../../shared';
export interface FluxVortexProps extends ComponentProps {
rsc: Resource;
circles?: number;
tileCss?: CSSProperties;
}
export interface FluxVortexCirclesProps {
offset: Position;
css: CSSProperties;
}
================================================
FILE: src/components/FluxWrapper/FluxWrapper.vue
================================================
================================================
FILE: src/components/FluxWrapper/__mocks__/FluxWrapper.vue
================================================
================================================
FILE: src/components/FluxWrapper/types.ts
================================================
import type { ComponentProps } from '../types';
export interface FluxWrapperProps extends ComponentProps {}
================================================
FILE: src/components/VueFlux/VueFlux.vue
================================================
================================================
FILE: src/components/VueFlux/__test__/emit.ts
================================================
import { vi } from 'vitest';
import type { VueFluxEmits } from '../types';
export default vi.fn() as unknown as VueFluxEmits;
================================================
FILE: src/components/VueFlux/types.ts
================================================
import { Resource, type ResourceWithOptions } from '../../resources';
import type { TransitionWithOptions } from '../../transitions';
import { type Direction, PlayerResource, PlayerTransition } from '../../controllers/Player';
import { type Component } from 'vue';
export interface VueFluxOptions {
allowFullscreen?: boolean;
allowToSkipTransition?: boolean;
aspectRatio?: string;
autohideTime?: number;
autoplay?: boolean;
bindKeys?: boolean;
delay?: number;
enableGestures?: boolean;
infinite?: boolean;
lazyLoad?: boolean;
lazyLoadAfter?: number;
}
export interface VueFluxProps {
options?: VueFluxOptions;
rscs: (Resource | ResourceWithOptions)[];
transitions: (Component | TransitionWithOptions)[];
}
export interface VueFluxEmits {
(e: 'created'): void;
(e: 'mounted'): void;
(e: 'unmounted'): void;
(e: 'play', resourceIndex: number | Direction, delay?: number): void;
(e: 'stop'): void;
(e: 'show', resource: PlayerResource, transition: PlayerTransition): void;
(e: 'optionsUpdated'): void;
(e: 'transitionsUpdated'): void;
(e: 'resourcesPreloadStart'): void;
(e: 'resourcesPreloadEnd'): void;
(e: 'resourcesLazyloadStart'): void;
(e: 'resourcesLazyloadEnd'): void;
(e: 'fullscreenEnter'): void;
(e: 'fullscreenExit'): void;
(e: 'transitionStart', resource: PlayerResource, transition: PlayerTransition): void;
(e: 'transitionCancel', resource: PlayerResource, transition: PlayerTransition): void;
(e: 'transitionEnd', resource: PlayerResource, transition: PlayerTransition): void;
}
export interface VueFluxConfig {
allowFullscreen: boolean;
allowToSkipTransition: boolean;
aspectRatio: string;
autohideTime: number;
autoplay: boolean;
bindKeys: boolean;
delay: number;
enableGestures: boolean;
infinite: boolean;
lazyLoad: boolean;
lazyLoadAfter: number;
}
================================================
FILE: src/components/index.ts
================================================
export { default as FluxButton } from './FluxButton/FluxButton.vue';
export * from './FluxCube';
export { default as FluxGrid } from './FluxGrid/FluxGrid.vue';
export { default as FluxImage } from './FluxImage/FluxImage.vue';
export { default as FluxParallax } from './FluxParallax/FluxParallax.vue';
export { default as FluxTransition } from './FluxTransition/FluxTransition.vue';
export { default as FluxVortex } from './FluxVortex/FluxVortex.vue';
export { default as FluxWrapper } from './FluxWrapper/FluxWrapper.vue';
export { default as VueFlux } from './VueFlux/VueFlux.vue';
export type * from './VueFlux/types';
export type * from './FluxCube/types';
export type * from './FluxGrid/types';
export type * from './FluxParallax/types';
export type * from './FluxTransition/types';
export type * from './FluxVortex/types';
export type * from './FluxWrapper/types';
export type * from './types';
================================================
FILE: src/components/types.ts
================================================
import type { CSSProperties, Component } from 'vue';
import { Resource } from '../resources';
import { Size, Position } from '../shared';
export interface ComponentProps {
color?: CSSProperties['color'];
rsc?: Resource;
size: Size;
viewSize?: Size;
offset?: Position;
css?: CSSProperties;
}
export interface ComponentStyles {
base?: CSSProperties;
color?: CSSProperties;
rsc?: CSSProperties;
size?: CSSProperties;
}
export type FluxComponent = Component & {
setCss: (s: CSSProperties) => void;
transform: (s: CSSProperties) => void;
show: () => void;
hide: () => void;
};
================================================
FILE: src/components/useComponent.ts
================================================
import { computed, type CSSProperties, type Ref, unref } from 'vue';
import { Size } from '../shared';
import type { ComponentProps, ComponentStyles } from './types';
export default function useComponent(
$el: Ref,
props: ComponentProps,
css: ComponentStyles,
) {
if (css.base === undefined) {
css.base = {} as CSSProperties;
}
const size = computed(() => {
const { size, viewSize = new Size() } = props;
const { width = size.width.value, height = size.height.value } = viewSize.toValue();
const finalSize = new Size({ width, height });
if (!finalSize.isValid()) {
return {};
}
return finalSize.toPx();
});
const style = computed(() => ({
...unref(size),
...unref(css.color),
...unref(css.rsc),
...unref(props.css),
...unref(css.base),
}));
const setCss = (s: CSSProperties) => {
Object.assign(css.base as CSSProperties, s);
};
const transform = (s: CSSProperties) => {
if ($el.value === null) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$el.value.clientHeight;
setCss(s);
};
const show = () => {
setCss({
visibility: 'visible',
});
};
const hide = () => {
setCss({
visibility: 'hidden',
});
};
return {
style,
setCss,
transform,
show,
hide,
};
}
================================================
FILE: src/controllers/Display/Display.ts
================================================
import { nextTick, type Ref, type Component } from 'vue';
import { Size } from '../../shared';
import type { VueFluxConfig, VueFluxEmits } from '../../components';
export default class Display {
node: Ref;
config: VueFluxConfig | null;
emit: null | VueFluxEmits = null;
size: Size = new Size();
private readonly onResize = () => {
this.updateSize();
};
constructor(
node: Ref,
config: VueFluxConfig | null = null,
emit: null | VueFluxEmits = null,
) {
this.node = node;
this.config = config;
this.emit = emit;
}
static async getSize(node: Ref) {
const display = new Display(node);
await display.updateSize();
return display.size;
}
addResizeListener() {
window.addEventListener('resize', this.onResize, {
passive: true,
});
void this.updateSize();
}
removeResizeListener() {
window.removeEventListener('resize', this.onResize);
}
getAspectRatio() {
if (this.config !== null) {
const [width, height] = this.config.aspectRatio.split(':');
return [parseFloat(width ?? ''), parseFloat(height ?? '')];
}
return [16, 9];
}
async updateSize() {
this.size.reset();
await nextTick();
if (this.node.value === null) {
return;
}
const computedStyle = getComputedStyle(this.node.value as HTMLElement);
const width = parseFloat(computedStyle.width);
let height = parseFloat(computedStyle.height);
if (['0px', 'auto', null].includes(computedStyle.height)) {
const [arWidth, arHeight] = this.getAspectRatio();
if (arWidth === undefined || arHeight === undefined) {
return;
}
height = (width / arWidth) * arHeight;
}
this.size.update({
width,
height,
});
}
inFullScreen = () => !!document.fullscreenElement;
toggleFullScreen() {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
this.inFullScreen() ? this.exitFullScreen() : this.enterFullScreen();
}
async enterFullScreen() {
if (this.node?.value === null || !this.config?.allowFullscreen) {
return;
}
await (this.node.value as HTMLElement).requestFullscreen();
if (this.emit !== null) {
this.emit('fullscreenEnter');
}
}
async exitFullScreen() {
await document.exitFullscreen();
if (this.emit !== null) {
this.emit('fullscreenExit');
}
}
}
================================================
FILE: src/controllers/Keys/Keys.ts
================================================
import type { VueFluxConfig } from '../../components';
import { Directions, Player } from '../';
export default class Keys {
config: VueFluxConfig;
player: Player;
constructor(config: VueFluxConfig, player: Player) {
this.config = config;
this.player = player;
}
setup() {
this.removeKeyListener();
if (this.config.bindKeys) {
window.addEventListener('keydown', this.keydown);
}
}
removeKeyListener() {
window.removeEventListener('keydown', this.keydown);
}
keydown = (event: KeyboardEvent) => {
if (['ArrowLeft', 'Left'].includes(event.key)) {
this.player.show(Directions.prev);
return;
}
if (['ArrowRight', 'Right'].includes(event.key)) {
this.player.show(Directions.next);
return;
}
};
}
================================================
FILE: src/controllers/Mouse/Mouse.ts
================================================
import { type Ref, ref } from 'vue';
import Timers from '../Timers/Timers';
import type { VueFluxConfig } from '../../components';
export default class Mouse {
isOver: Ref = ref(false);
setup(config: VueFluxConfig, timers: Timers) {
timers.clear('mouseOver');
if (config.autohideTime === 0) {
this.isOver.value = true;
}
}
toggle(config: VueFluxConfig, timers: Timers, over: boolean) {
if (config.autohideTime === 0) {
return;
}
this.isOver.value = over;
this[over ? 'over' : 'out'](config, timers);
}
out(_config: VueFluxConfig, timers: Timers) {
timers.clear('mouseOver');
}
over(config: VueFluxConfig, timers: Timers) {
timers.set('mouseOver', config.autohideTime, () => (this.isOver.value = false));
}
}
================================================
FILE: src/controllers/Player/Directions.ts
================================================
enum Directions {
prev = 'prev',
next = 'next',
}
export default Directions;
================================================
FILE: src/controllers/Player/Player.ts
================================================
import { shallowReactive, nextTick, type Ref, ref } from 'vue';
import {
Resources,
Transitions,
type ResourceIndex,
type TransitionIndex,
} from '../../repositories';
import { PlayerResource, PlayerTransition, Directions, type Direction, Statuses } from './';
import type { FluxComponent, VueFluxConfig, VueFluxEmits } from '../../components';
import { Timers } from '../';
export default class Player {
resource: PlayerResource;
transition: PlayerTransition;
status: Ref = ref(Statuses.stopped);
config: VueFluxConfig;
timers: Timers;
emit: VueFluxEmits;
resources: Resources;
transitions: Transitions;
$displayComponent: Ref = ref(null);
constructor(
config: VueFluxConfig,
timers: Timers,
emit: VueFluxEmits,
) {
this.config = config;
this.timers = timers;
this.emit = emit;
this.resources = new Resources(emit);
this.transitions = new Transitions();
this.resource = shallowReactive(new PlayerResource());
this.transition = shallowReactive(new PlayerTransition());
}
setup($displayComponent: Ref) {
this.$displayComponent = $displayComponent;
}
play(resourceIndex: number | Direction = Directions.next, delay?: number) {
const { config, timers, resource } = this;
this.status.value = Statuses.playing;
if (this.transition.current !== null) {
return;
}
const rsc = this.resources?.find(resourceIndex, resource.current?.index);
timers.set('transition', delay || rsc?.options.delay || config.delay, () => {
this.show(resourceIndex);
});
this.emit('play', resourceIndex, delay);
}
async stop(cancelTransition: boolean = false) {
const { timers } = this;
this.status.value = Statuses.stopped;
timers.clear('transition');
if (this.transition.current !== null && cancelTransition === true) {
await this.end(cancelTransition);
}
this.emit('stop');
}
isReadyToShow() {
if (this.resource.current === null) {
throw new ReferenceError('Current resource not set');
}
if (this.resources === null) {
throw new ReferenceError('Resources list not set');
}
if (this.resources.list.length === 0) {
throw new RangeError('Resources list empty');
}
if (this.transition.last === null) {
throw new ReferenceError('Last transition not set');
}
if (this.transitions === null) {
throw new ReferenceError('Transitions list not set');
}
if (this.transitions.list.length === 0) {
throw new RangeError('Transitions list empty');
}
if (this.$displayComponent.value === null) {
throw new ReferenceError('Display component not set');
}
return true;
}
async show(
resourceIndex: number | Direction = Directions.next,
transitionIndex: number | Direction = Directions.next,
) {
if (!this.isReadyToShow()) {
return;
}
const { resource, resources, config, transitions } = this;
if (this.transition.current !== null) {
if (config.allowToSkipTransition) {
await this.end(true);
this.show(resourceIndex, transitionIndex);
}
return;
}
const resourceTo: ResourceIndex = resources!.find(resourceIndex, resource.current!.index);
if (resource.currentSameAs(resourceTo)) {
return;
}
resource.prepareTo(resourceTo);
this.timers.clear('transition');
const transition: TransitionIndex =
typeof transitionIndex === 'number'
? transitions!.getByIndex(transitionIndex)
: transitions!.getByOrder(transitionIndex, this.transition.last!.index);
if (transition.options.direction === undefined) {
if (typeof resourceIndex !== 'number') {
transition.options.direction = resourceIndex;
} else {
transition.options.direction =
this.resource.from!.index < this.resource.to!.index
? Directions.next
: Directions.prev;
}
}
this.transition.current = transition;
this.emit('show', this.resource, this.transition);
}
start() {
this.resource.current = this.resource.to;
this.emit('transitionStart', this.resource, this.transition);
}
async end(cancel: boolean = false) {
const { config, resource, resources, timers, transition } = this;
if (resource.current === null || resources === null) {
return;
}
transition.setCurrentFinished();
await nextTick();
if (cancel === true) {
this.emit('transitionCancel', this.resource, this.transition);
} else {
this.emit('transitionEnd', this.resource, this.transition);
}
if (this.shouldStopPlaying(config.infinite, resource.current, resources.list.length - 1)) {
this.stop();
return;
}
if (this.shouldPlayNext()) {
timers.set('transition', resource.current.options.delay || config.delay, () => {
this.show();
});
}
}
private shouldStopPlaying(
infinite: boolean,
currentResource: ResourceIndex,
totalResources: number,
) {
if (
infinite === false &&
currentResource.index >= totalResources &&
this.status.value === Statuses.playing
) {
return true;
}
if (currentResource.options.stop === true) {
return true;
}
return false;
}
private shouldPlayNext() {
if (this.status.value === Statuses.playing) {
return true;
}
return false;
}
}
================================================
FILE: src/controllers/Player/Resource.ts
================================================
import { Resources, type ResourceIndex } from '../../repositories';
export default class PlayerResource {
current: ResourceIndex | null = null;
from: ResourceIndex | null = null;
to: ResourceIndex | null = null;
reset() {
this.current = null;
this.from = null;
this.to = null;
}
init(repository: Resources) {
this.current = repository.getFirst();
}
currentSameAs(resourceTo: ResourceIndex) {
if (this.current!.index === resourceTo.index) {
return true;
}
return false;
}
prepareTo(resourceTo: ResourceIndex) {
this.from = this.current;
this.to = resourceTo;
}
}
================================================
FILE: src/controllers/Player/Statuses.ts
================================================
enum Statuses {
stopped = 'stopped',
playing = 'playing',
}
export default Statuses;
================================================
FILE: src/controllers/Player/Transition.ts
================================================
import { Transitions, type TransitionIndex } from '../../repositories';
export default class PlayerTransition {
current: TransitionIndex | null = null;
last: TransitionIndex | null = null;
reset() {
this.current = null;
this.last = null;
}
init(transitions: Transitions) {
this.last = transitions.getLast();
}
setCurrentFinished() {
this.last = this.current;
this.current = null;
}
}
================================================
FILE: src/controllers/Player/__mocks__/Player.ts
================================================
import { vi } from 'vitest';
import { type Ref, ref, shallowReactive } from 'vue';
import type { VueFluxConfig, VueFluxEmits } from '../../../components/VueFlux/types';
import { PlayerResource, PlayerTransition, Statuses, Timers } from '../..';
import { Resources, Transitions } from '../../../repositories';
import type { FluxComponent } from '../../../components/types';
export default class Player {
resource: PlayerResource;
transition: PlayerTransition;
status: Ref = ref(Statuses.stopped);
config: VueFluxConfig;
timers: Timers;
emit: VueFluxEmits;
resources: Resources;
transitions: Transitions;
$displayComponent: Ref = ref(null);
constructor(config: VueFluxConfig, timers: Timers, emit: VueFluxEmits) {
this.config = config;
this.timers = timers;
this.emit = emit;
this.resources = new Resources(emit);
this.transitions = new Transitions();
this.resource = shallowReactive(new PlayerResource());
this.transition = shallowReactive(new PlayerTransition());
}
setup = vi.fn();
play = vi.fn();
stop = vi.fn();
show = vi.fn();
start = vi.fn();
end = vi.fn();
}
================================================
FILE: src/controllers/Player/__mocks__/Resource.ts
================================================
import type { ResourceIndex } from '../../../repositories';
import { vi } from 'vitest';
export default class PlayerResource {
current: ResourceIndex | null = null;
from: ResourceIndex | null = null;
to: ResourceIndex | null = null;
reset = vi.fn();
init = vi.fn();
currentSameAs = vi.fn();
prepareTo = vi.fn();
}
================================================
FILE: src/controllers/Player/__mocks__/Transitions.ts
================================================
import type { TransitionIndex } from '../../../repositories';
import { vi } from 'vitest';
export default class PlayerTransition {
current: TransitionIndex | null = null;
last: TransitionIndex | null = null;
reset = vi.fn();
init = vi.fn();
setCurrentFinished = vi.fn();
}
================================================
FILE: src/controllers/Player/index.ts
================================================
export { default as Directions } from './Directions';
export { default as Statuses } from './Statuses';
export { default as PlayerResource } from './Resource';
export { default as PlayerTransition } from './Transition';
export { default as Player } from './Player';
export type * from './types';
================================================
FILE: src/controllers/Player/types.ts
================================================
import Directions from './Directions';
export type Direction = Directions.prev | Directions.next;
================================================
FILE: src/controllers/Timers/Timers.ts
================================================
export default class Timers {
timers: {
[index: string]: ReturnType;
} = {};
set(index: string, time: number, cb: () => void) {
this.clear(index);
this.timers[index] = setTimeout(cb, time);
}
clear(index?: string) {
const keys = index !== undefined ? [index] : Object.keys(this.timers);
keys.forEach((key) => {
clearTimeout(this.timers[key]);
delete this.timers[key];
});
}
}
================================================
FILE: src/controllers/Touches/Touches.ts
================================================
import type { VueFluxConfig } from '../../components';
import { Directions, Display, Mouse, Player, Timers } from '../';
export default class Touches {
startX = 0;
startY = 0;
startTime = 0;
endTime = 0;
prevTouchTime = 0;
// Max distance in pixels from start until end
tapThreshold = 5;
// Max time in ms from first to second tap
doubleTapThreshold = 200;
// Distance in percentage to trigger slide
slideTrigger = 0.3;
start(event: TouchEvent, config: VueFluxConfig) {
if (!config.enableGestures) {
return;
}
const touch = event.changedTouches[0];
if (!touch) {
return;
}
this.startTime = Date.now();
this.startX = touch.clientX;
this.startY = touch.clientY;
}
end(
event: TouchEvent,
config: VueFluxConfig,
player: Player,
display: Display,
timers: Timers,
mouse: Mouse,
) {
this.prevTouchTime = this.endTime;
this.endTime = Date.now();
const touch = event.changedTouches[0];
if (!touch) {
return;
}
const offsetX = touch.clientX - this.startX;
const offsetY = touch.clientY - this.startY;
if (this.tap(offsetX, offsetY)) {
mouse.toggle(config, timers, true);
return;
}
if (!config.enableGestures) {
return;
}
if (this.slideRight(offsetX, display)) {
player.show(Directions.prev);
} else if (this.slideLeft(offsetX, display)) {
player.show(Directions.next);
}
}
tap = (offsetX: number, offsetY: number) =>
Math.abs(offsetX) < this.tapThreshold && Math.abs(offsetY) < this.tapThreshold;
doubleTap = () => this.endTime - this.prevTouchTime < this.doubleTapThreshold;
slideLeft = (offsetX: number, display: Display) =>
display.size.isValid() &&
offsetX < 0 &&
offsetX < -(display.size!.width.value! * this.slideTrigger);
slideRight = (offsetX: number, display: Display) =>
display.size.isValid() &&
offsetX > 0 &&
offsetX > display.size.width.value! * this.slideTrigger;
slideUp = (offsetY: number, display: Display) =>
display.size.isValid() &&
offsetY < 0 &&
offsetY < -(display.size.height.value! * this.slideTrigger);
slideDown = (offsetY: number, display: Display) =>
display.size.isValid() &&
offsetY > 0 &&
offsetY > display.size.height.value! * this.slideTrigger;
}
================================================
FILE: src/controllers/index.ts
================================================
export * from './Player';
export { default as Display } from './Display/Display';
export { default as Keys } from './Keys/Keys';
export { default as Mouse } from './Mouse/Mouse';
export { default as Timers } from './Timers/Timers';
export { default as Touches } from './Touches/Touches';
================================================
FILE: src/lib.ts
================================================
export * from './components';
export * from './complements';
export * from './resources';
export * from './transitions';
export {
Player,
Directions,
Statuses,
PlayerResource,
PlayerTransition,
} from './controllers/Player';
export type * from './controllers/Player/types';
export { Size, Position } from './shared';
================================================
FILE: src/main.ts
================================================
import './assets/css/main.css';
import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');
================================================
FILE: src/module.d.ts
================================================
declare module '*';
================================================
FILE: src/playgrounds/PgFluxCaption.vue
================================================
================================================
FILE: src/playgrounds/PgFluxControls.vue
================================================
================================================
FILE: src/playgrounds/PgFluxCube.vue
================================================
================================================
FILE: src/playgrounds/PgFluxGrid.vue
================================================
================================================
FILE: src/playgrounds/PgFluxImage.vue
================================================
================================================
FILE: src/playgrounds/PgFluxIndex.vue
================================================
================================================
FILE: src/playgrounds/PgFluxPagination.vue
================================================
{{ pageProps.index }}
================================================
FILE: src/playgrounds/PgFluxParallax.vue
================================================
================================================
FILE: src/playgrounds/PgFluxParallaxOp.vue
================================================
================================================
FILE: src/playgrounds/PgFluxPreloader.vue
================================================
================================================
FILE: src/playgrounds/PgFluxTransition.vue
================================================
================================================
FILE: src/playgrounds/PgVueFlux.vue
================================================
================================================
FILE: src/playgrounds/components/PgButton.vue
================================================
================================================
FILE: src/repositories/Resources/Resources.test.ts
================================================
import { Directions } from '../../controllers';
import { Resource } from '../../resources';
import { Size } from '../../shared';
import { default as ResourcesRepository } from './Resources';
import ResourceFactory from '../../resources/__test__/ResourceFactory';
import emit from '../../components/VueFlux/__test__/emit';
vi.mock('../../resources/Img/Img');
vi.mock('../../shared/ResourceLoader/ResourceLoader');
describe('repositories: Resources', () => {
let repo: ResourcesRepository;
let resources: Resource[];
const size: Size = new Size({
width: 640,
height: 360,
});
describe('width preloading', () => {
beforeEach(async () => {
vi.clearAllMocks();
repo = new ResourcesRepository(emit);
resources = ResourceFactory.create(5);
await repo.update(resources, 5, size);
});
it('updates the repository transitions', () => {
expect(repo.list).toHaveLength(5);
});
it('emits when preload starts', () => {
expect(emit).toHaveBeenCalledWith('resourcesPreloadStart');
});
it('emits when preload ends', () => {
expect(emit).toHaveBeenCalledWith('resourcesPreloadEnd');
});
it('gets the first resource', () => {
expect(repo.getFirst().rsc).toBe(resources[0]);
});
it('gets the last resource', () => {
expect(repo.getLast().rsc).toBe(resources[4]);
});
it('get resource by index', () => {
expect(repo.getByIndex(2).rsc).toBe(resources[2]);
});
it('throws error because the requested index does not exist', () => {
const index = resources.length + 1;
expect(() => repo.getByIndex(index)).toThrow(
`Resource index ${index} not found`
);
});
it('get resource by order next', () => {
expect(
repo.getByOrder(Directions.next, resources.length - 1).rsc
).toBe(resources[0]);
});
it('get resource by order prev', () => {
expect(repo.getByOrder(Directions.prev, 0).rsc).toBe(
resources[resources.length - 1]
);
});
it('throws an error when trying to find a resource by order without passing the current index', () => {
expect(() => repo.find(Directions.next)).toThrow(
'Missing currentIndex parameter'
);
});
});
describe('with lazy loading', () => {
const numResources = 10;
const resourcesToPreload = 5;
beforeEach(async () => {
vi.clearAllMocks();
repo = new ResourcesRepository(emit);
resources = ResourceFactory.create(numResources);
await repo.update(resources, resourcesToPreload, size);
});
it('emits resourcesLazyloadStart when start lazy loading', () =>
new Promise((done) => {
expect(emit).toHaveBeenCalledWith('resourcesLazyloadStart');
done();
}));
it('emits resourcesLazyloadStart when start lazy loading', () =>
new Promise((done) => {
expect(repo.list).toHaveLength(numResources);
expect(emit).toHaveBeenCalledWith('resourcesLazyloadEnd');
done();
}));
});
});
================================================
FILE: src/repositories/Resources/Resources.ts
================================================
import { type Ref, ref, shallowReactive } from 'vue';
import { Resource, type ResourceWithOptions } from '../../resources';
import { Size, ResourceLoader } from '../../shared';
import { type Direction, Directions } from '../../controllers/Player';
import type { ResourceIndex } from './types';
import ResourcesMapper from './ResourcesMapper';
import type { VueFluxEmits } from '../../components';
export default class Resources {
list: ResourceWithOptions[] = shallowReactive([]);
loader: Ref = ref(null);
emit: VueFluxEmits;
constructor(emit: VueFluxEmits) {
this.emit = emit;
}
private getPrev(currentIndex: number) {
return this.getByIndex(currentIndex > 0 ? currentIndex - 1 : this.list.length - 1);
}
private getNext(currentIndex: number) {
return this.getByIndex(currentIndex === this.list.length - 1 ? 0 : currentIndex + 1);
}
getFirst() {
return this.getByIndex(0);
}
getLast() {
return this.getByOrder(Directions.prev, 0);
}
getByIndex(index: number) {
if (this.list[index] === undefined) {
throw new ReferenceError(`Resource index ${index} not found`);
}
return {
index,
rsc: this.list[index].resource,
options: JSON.parse(JSON.stringify(this.list[index].options)),
} as ResourceIndex;
}
getByOrder(order: Direction, currentIndex: number) {
return {
prev: () => this.getPrev(currentIndex),
next: () => this.getNext(currentIndex),
}[order]();
}
find(by: number | Direction, currentIndex?: number) {
if (typeof by === 'number') {
return this.getByIndex(by);
}
if (currentIndex === undefined) {
throw new ReferenceError('Missing currentIndex parameter');
}
return this.getByOrder(by, currentIndex);
}
update(rscs: (Resource | ResourceWithOptions)[], numToPreload: number, displaySize: Size) {
if (this.loader.value?.hasFinished() === false) {
this.loader.value?.cancel();
}
this.list.splice(0);
const resources = ResourcesMapper.withOptions(rscs);
const updatePromise = new Promise((resolve, reject) => {
this.loader.value = new ResourceLoader(
resources,
numToPreload,
displaySize,
() => this.preloadStart(),
(loaded: ResourceWithOptions[]) => this.preloadEnd(loaded, resolve),
() => this.lazyLoadStart(),
(loaded: ResourceWithOptions[]) => this.lazyLoadEnd(loaded),
reject,
);
});
return updatePromise;
}
preloadStart() {
this.emit('resourcesPreloadStart');
}
preloadEnd(loaded: ResourceWithOptions[], resolve: () => void) {
this.list.push(...loaded);
this.emit('resourcesPreloadEnd');
resolve();
}
lazyLoadStart() {
this.emit('resourcesLazyloadStart');
}
lazyLoadEnd(loaded: ResourceWithOptions[]) {
this.list.push(...loaded);
this.emit('resourcesLazyloadEnd');
}
}
================================================
FILE: src/repositories/Resources/ResourcesMapper.test.ts
================================================
import { Img, type ResourceWithOptions } from '../../resources';
import ResourcesMapper from './ResourcesMapper';
describe('repositories: ResourcesMapper', () => {
it('turns all the transitions array as transitions with options', () => {
const resources = [
new Img('url1'),
{
resource: new Img('url2'),
options: {
delay: 8000,
},
} as ResourceWithOptions,
new Img('url3'),
];
const resourcesWithOptions = ResourcesMapper.withOptions(resources);
expect(resourcesWithOptions[0]!.resource).toBe(resources[0]);
// @ts-expect-error:next-line
expect(resourcesWithOptions[1]!.resource).toBe(resources[1].resource);
expect(resourcesWithOptions[1]!.options.delay).toBe(8000);
expect(resourcesWithOptions[2]!.resource).toBe(resources[2]);
expect(resourcesWithOptions[2]!.options).toStrictEqual({});
});
});
================================================
FILE: src/repositories/Resources/ResourcesMapper.ts
================================================
import { Resource, type ResourceWithOptions } from '../../resources';
export default class ResourcesMapper {
static withOptions(rscs: (Resource | ResourceWithOptions)[]) {
return rscs.map((rsc) => {
let resource = rsc;
let options = {};
if ('resource' in rsc) {
resource = rsc.resource as Resource;
if ('options' in rsc) {
options = rsc.options as object;
}
}
return { resource, options } as ResourceWithOptions;
});
}
}
================================================
FILE: src/repositories/Resources/types.ts
================================================
import Resource from '../../resources/Resource';
export interface ResourceIndex {
index: number;
rsc: Resource;
options: {
delay?: number;
stop?: boolean;
};
}
================================================
FILE: src/repositories/Transitions/Transitions.test.ts
================================================
import { Directions } from '../../controllers';
import { default as TransitionsRepository } from './Transitions';
function transitionsFactory(numTransitions: number) {
return new Array(numTransitions).fill({});
}
describe('repositories: Transitions', () => {
let repo: TransitionsRepository;
let transitions: object[];
beforeEach(() => {
repo = new TransitionsRepository();
});
it('updates the repository transitions', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.list).toHaveLength(5);
});
it('removes the previous transitions on update', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.list).toHaveLength(5);
transitions = transitionsFactory(2);
repo.update(transitions);
expect(repo.list).toHaveLength(2);
});
it('gets the first transition', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.getFirst().component).toBe(transitions[0]);
});
it('gets the last transition', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.getLast().component).toBe(
transitions[transitions.length - 1]
);
});
it('gets the transition by an index number', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.getByIndex(2).component).toBe(transitions[2]);
});
it('gets the transition by order next', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.getByOrder(Directions.next, 2).component).toBe(
transitions[3]
);
});
it('gets fist the transition by order next', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.getByOrder(Directions.next, 4).component).toBe(
transitions[3]
);
});
it('gets the transition by order previous', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.getByOrder(Directions.prev, 2).component).toBe(
transitions[1]
);
});
it('gets the last transition by order previous', () => {
transitions = transitionsFactory(5);
repo.update(transitions);
expect(repo.getByOrder(Directions.prev, 0).component).toBe(
transitions[1]
);
});
});
================================================
FILE: src/repositories/Transitions/Transitions.ts
================================================
import { type Component, shallowReactive } from 'vue';
import { Directions, type Direction } from '../../controllers/Player';
import type { TransitionIndex } from './types';
import type { TransitionWithOptions } from '../../transitions/types';
import TransitionsMapper from './TransitionsMapper';
export default class Transitions {
list: TransitionWithOptions[] = shallowReactive([]);
private getPrev(lastIndex: number) {
return this.getByIndex(lastIndex > 0 ? lastIndex - 1 : this.list.length - 1);
}
private getNext(lastIndex: number) {
return this.getByIndex(lastIndex === this.list.length - 1 ? 0 : lastIndex + 1);
}
getFirst() {
return this.getByIndex(0);
}
getLast() {
return this.getByOrder(Directions.prev, 0);
}
getByIndex(index: number) {
const item = this.list[index];
if (!item) {
throw new Error(`Transition index ${index} out of range`);
}
return {
index,
component: item.component,
options: JSON.parse(JSON.stringify(item.options)),
} as TransitionIndex;
}
getByOrder(direction: Direction, lastIndex: number) {
return {
prev: () => this.getPrev(lastIndex),
next: () => this.getNext(lastIndex),
}[direction]();
}
update(transitions: (Component | TransitionWithOptions)[]) {
this.list.splice(0);
const transitionsWithOptions = TransitionsMapper.withOptions(transitions);
this.list.push(...transitionsWithOptions);
}
}
================================================
FILE: src/repositories/Transitions/TransitionsMapper.test.ts
================================================
import TransitionsMapper from './TransitionsMapper';
import { Fade, Kenburn, Swipe, Slide, type TransitionWithOptions } from '../../transitions';
describe('repositories: TransitionsMapper', () => {
it('turns all the transitions array as transitions with options', () => {
const transitions = [
Fade,
{
component: Kenburn,
options: { totalDuration: 1600 },
} as TransitionWithOptions,
Swipe,
{
component: Slide,
options: { totalDuration: 4600 },
} as TransitionWithOptions,
];
const transitionsWithOptions = TransitionsMapper.withOptions(transitions);
expect(transitionsWithOptions[0]!.component).toBe(Fade);
expect(transitionsWithOptions[1]!.component).toBe(Kenburn);
// @ts-expect-error:next-line
expect(transitionsWithOptions[1]!.options.totalDuration).toBe(1600);
expect(transitionsWithOptions[2]!.component).toBe(Swipe);
expect(transitionsWithOptions[3]!.component).toBe(Slide);
// @ts-expect-error:next-line
expect(transitionsWithOptions[3]!.options.totalDuration).toBe(4600);
});
});
================================================
FILE: src/repositories/Transitions/TransitionsMapper.ts
================================================
import type { Component } from 'vue';
import type { TransitionComponent, TransitionWithOptions } from '../../transitions';
export default class TransitionsMapper {
static withOptions(transitions: (Component | TransitionWithOptions)[]) {
return transitions.map((transition) => {
let component = transition;
let options = {};
if ('component' in transition) {
component = transition.component as TransitionComponent;
if ('options' in transition) {
options = transition.options;
}
}
return { component, options } as TransitionWithOptions;
});
}
}
================================================
FILE: src/repositories/Transitions/types.ts
================================================
import type { Component } from 'vue';
import type { Direction } from '../../controllers/Player';
export interface TransitionIndex {
index: number;
component: Component;
options: {
direction?: Direction;
};
}
================================================
FILE: src/repositories/index.ts
================================================
export { default as Resources } from './Resources/Resources';
export { default as Transitions } from './Transitions/Transitions';
export type { ResourceIndex } from './Resources/types';
export type { TransitionIndex } from './Transitions/types';
================================================
FILE: src/resources/Img/Img.test.ts
================================================
import { FluxImage } from '../../components';
import ResizeTypes from '../ResizeTypes';
import Statuses from '../Statuses';
import type { DisplayParameter, ResizeType, TransitionParameter } from '../types';
import Img from './Img';
describe('resources: Img', () => {
let img,
src: string,
caption: string,
resizeType: ResizeType,
backgroundColor: string,
promise: Promise,
resolve: () => void,
reject: (message: string) => void;
beforeEach(() => {
src = 'src';
caption = 'caption';
resizeType = ResizeTypes.fill;
backgroundColor = '#ccc';
});
it('creates the instance properly with default params', () => {
img = new Img(src);
expect(img.src).toBe(src);
expect(img.caption).toBe('');
expect(img.resizeType).toBe(ResizeTypes.fill);
expect(img.backgroundColor).toBeNull();
});
it('creates the instance properly with custom params', () => {
img = new Img(src, caption, resizeType, backgroundColor);
expect(img.src).toBe(src);
expect(img.caption).toBe(caption);
expect(img.resizeType).toBe(resizeType);
expect(img.backgroundColor).toBe(backgroundColor);
});
it('creates the instance with the required parameters of abstract Resource', () => {
img = new Img(src);
expect(img.display).toStrictEqual({
component: FluxImage,
props: {},
} as DisplayParameter);
expect(img.transition).toStrictEqual({
component: FluxImage,
props: {},
} as TransitionParameter);
expect(img.errorMessage).toBe(`Image ${src} could not be loaded`);
});
it('returns a promise and sets it to property loader', () => {
img = new Img(src);
promise = img.load();
expect(promise).toBeTypeOf('object');
expect(img.loader).toBe(promise);
});
it('changes the status to loading', () => {
img = new Img(src);
img.load();
expect(img.status.value).toBe(Statuses.loading);
});
it('returns the loader if already request to load', () => {
img = new Img(src);
promise = img.load();
expect(img.load()).toBe(promise);
});
it.todo('calls onLoad when load success');
it('sets reals size on load', () => {
src = '/imgs/pixel.png';
img = new Img(src);
const htmlImage = new Image();
htmlImage.width = 640;
htmlImage.height = 480;
resolve = vi.fn();
img.onLoad(htmlImage, resolve);
expect(img.realSize.toValue()).toStrictEqual({ width: 640, height: 480 });
});
it('sets status loaded on load', () => {
src = '/imgs/pixel.png';
img = new Img(src);
const htmlImage = new Image();
htmlImage.width = 640;
htmlImage.height = 480;
resolve = vi.fn();
img.onLoad(htmlImage, resolve);
expect(img.status.value).toBe(Statuses.loaded);
});
it('calls promise resolve on load', () => {
src = '/imgs/pixel.png';
img = new Img(src);
const htmlImage = new Image();
htmlImage.width = 640;
htmlImage.height = 480;
resolve = vi.fn();
img.onLoad(htmlImage, resolve);
expect(resolve).toHaveBeenCalledOnce();
});
it.todo('calls onError when load fails');
it('sets the status to error on error', () => {
img = new Img(src);
reject = vi.fn();
img.onError(reject);
expect(img.status.value).toBe(Statuses.error);
});
it('performs promise reject on error', () => {
img = new Img(src);
reject = vi.fn();
img.onError(reject);
expect(reject).toHaveBeenCalledWith(img.errorMessage);
});
});
================================================
FILE: src/resources/Img/Img.ts
================================================
import { FluxImage } from '../../components';
import { Resource, Statuses, ResizeTypes } from '../';
import { Size } from '../../shared';
import type { DisplayParameter, ResizeType, TransitionParameter } from '../types';
export default class Img extends Resource {
constructor(
src: string,
caption: string = '',
resizeType: ResizeType = ResizeTypes.fill,
backgroundColor: null | string = null,
) {
const display: DisplayParameter = {
component: FluxImage,
props: {},
};
const transition: TransitionParameter = {
component: FluxImage,
props: {},
};
const errorMessage = `Image ${src} could not be loaded`;
super(src, caption, resizeType, backgroundColor, display, transition, errorMessage);
}
load() {
if (this.loader !== null) {
return this.loader;
}
this.loader = new Promise((resolve, reject) => {
this.status.value = Statuses.loading;
const img = new Image();
img.onload = () => this.onLoad(img, resolve);
img.onerror = () => this.onError(reject);
img.src = this.src;
});
return this.loader;
}
onLoad(img: HTMLImageElement, resolve: () => void) {
this.realSize = new Size({
width: img.naturalWidth || img.width,
height: img.naturalHeight || img.height,
});
this.status.value = Statuses.loaded;
resolve();
}
onError(reject: (message: string) => void) {
this.status.value = Statuses.error;
reject(this.errorMessage);
}
}
================================================
FILE: src/resources/Img/__mocks__/Img.ts
================================================
import { vi } from 'vitest';
import { ResizeTypes, Statuses, Resource } from '../../';
import { FluxImage } from '../../../components';
export default class Img extends Resource {
constructor() {
super(
'',
'',
ResizeTypes.fill,
null,
{ component: FluxImage, props: {} },
{ component: FluxImage, props: {} },
''
);
}
load = vi.fn().mockImplementation(() => {
return new Promise((resolve) => {
this.status.value = Statuses.loading;
this.onLoad(null, resolve);
});
});
onLoad = vi
.fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementation((_el: unknown, resolve: () => void) => {
this.status.value = Statuses.loaded;
resolve();
});
onError = vi
.fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementation((_reject: (message: string) => void) => {});
}
================================================
FILE: src/resources/ResizeTypes.ts
================================================
export enum ResizeTypes {
fill = 'fill',
fit = 'fit',
}
export default ResizeTypes;
================================================
FILE: src/resources/Resource.ts
================================================
import { computed, ref, type Ref } from 'vue';
import { Size, Position, ResizeCalculator } from '../shared';
import type { DisplayParameter, ResizedProps, ResizeType, TransitionParameter } from './types';
import { Statuses, ResizeTypes } from './';
export default abstract class Resource {
src: string;
loader: Promise | null = null;
errorMessage: string;
status: Ref = ref(Statuses.notLoaded);
realSize: Size = new Size();
displaySize: Size = new Size();
caption: string = '';
resizeType: ResizeType;
backgroundColor: null | string = null;
display: DisplayParameter;
transition: TransitionParameter;
constructor(
src: string,
caption: string,
resizeType: ResizeType = ResizeTypes.fill,
backgroundColor: null | string = null,
display: DisplayParameter,
transition: TransitionParameter,
errorMessage: string,
) {
this.src = src;
this.caption = caption;
this.resizeType = resizeType;
this.backgroundColor = backgroundColor;
this.display = display;
this.transition = transition;
this.errorMessage = errorMessage;
}
isLoading = () => this.status.value === Statuses.loading;
isLoaded = () => this.status.value === Statuses.loaded;
isError = () => this.status.value === Statuses.error;
abstract load(): Promise;
abstract onLoad(el: unknown, resolve: () => void): void;
abstract onError(reject: (message: string) => void): void;
calcResizeProps(displaySize: Size) {
if ([displaySize.isValid(), this.realSize.isValid()].includes(false)) {
return {};
}
const resCalc = new ResizeCalculator(this.realSize);
const { size, position } = resCalc.resizeTo(displaySize, this.resizeType);
return {
...size.toValue(),
...position.toValue(),
};
}
resizeProps = computed<{
top?: number;
left?: number;
width?: number;
height?: number;
}>(() => this.calcResizeProps(this.displaySize));
getResizeProps(size: Size, offset?: Position) {
const resizedProps: ResizedProps = {
width: 0,
height: 0,
top: 0,
left: 0,
};
if (!this.displaySize.isValid()) {
this.displaySize.update(size.toValue());
}
Object.assign(
resizedProps,
size.equals(this.displaySize) ? this.resizeProps.value : this.calcResizeProps(size),
);
if (offset !== undefined) {
resizedProps.top -= offset.top.value || 0;
resizedProps.left -= offset.left.value || 0;
}
return resizedProps;
}
}
================================================
FILE: src/resources/Statuses.ts
================================================
export enum Statuses {
notLoaded = 'notLoaded',
loading = 'loading',
loaded = 'loaded',
error = 'error',
}
export default Statuses;
================================================
FILE: src/resources/__test__/ResourceFactory.ts
================================================
import { Img } from '../';
export default class ResourceFactory {
static create(amount: number) {
return new Array(amount).fill(new Img(''));
}
}
================================================
FILE: src/resources/index.ts
================================================
export { default as Resource } from './Resource';
export { default as Img } from './Img/Img';
export { default as Statuses } from './Statuses';
export { default as ResizeTypes } from './ResizeTypes';
export type * from './types';
================================================
FILE: src/resources/types.ts
================================================
import type { Component } from 'vue';
import { Resource } from '.';
import ResizeTypes from './ResizeTypes';
export type ResizeType = keyof typeof ResizeTypes;
export interface ResizedProps {
width: number;
height: number;
top: number;
left: number;
}
export interface DisplayParameter {
component: Component;
props: object;
}
export interface TransitionParameter {
component: Component;
props: object;
}
export interface ResourceWithOptions {
resource: Resource;
options: {
delay?: number;
stop?: boolean;
};
}
================================================
FILE: src/shared/Maths/Maths.test.ts
================================================
import * as Maths from './Maths';
describe('shared: Maths', () => {
it('calculates the diagonal', () => {
const size = {
width: 640,
height: 360,
};
expect(Maths.diag(size)).toBe(735);
});
it('calculates the aspect ratio', () => {
const size = {
width: 640,
height: 320,
};
expect(Maths.aspectRatio(size)).toBe(2);
});
});
================================================
FILE: src/shared/Maths/Maths.ts
================================================
export const diag = ({ width, height }: { width: number; height: number }) =>
Math.ceil(Math.sqrt(width * width + height * height));
export const aspectRatio = ({
width,
height,
}: {
width: number;
height: number;
}) => width / height;
================================================
FILE: src/shared/Position/Position.test.ts
================================================
import Position from './Position';
describe('shared: Position', () => {
let pos: Position;
let coords: object;
it('initializes values to null without parameters', () => {
pos = new Position();
expect(pos.top.value).toBeNull();
expect(pos.left.value).toBeNull();
});
it('sets param values', () => {
pos = new Position({ top: 100 });
expect(pos.top.value).toBe(100);
pos = new Position({ left: 100 });
expect(pos.left.value).toBe(100);
pos = new Position({
top: 100,
left: 200,
});
expect(pos.top.value).toBe(100);
expect(pos.left.value).toBe(200);
});
it('reset values', () => {
pos = new Position({
top: 100,
left: 200,
});
pos.reset();
expect(pos.top.value).toBeNull();
expect(pos.left.value).toBeNull();
});
it('is invalid if top or left is null', () => {
pos = new Position({ top: 100 });
expect(pos.isValid()).toBeFalsy();
pos = new Position({ left: 100 });
expect(pos.isValid()).toBeFalsy();
});
it('is valid when top and left have values', () => {
pos = new Position({
top: 100,
left: 200,
});
expect(pos.isValid()).toBeTruthy();
});
it('updates the values', () => {
pos = new Position({
top: 100,
left: 200,
});
pos.update({
top: 50,
});
expect(pos.top.value).toBe(50);
expect(pos.left.value).toBeNull();
pos.update({
left: 100,
});
expect(pos.top.value).toBeNull();
expect(pos.left.value).toBe(100);
pos.update({
top: 200,
left: 400,
});
expect(pos.top.value).toBe(200);
expect(pos.left.value).toBe(400);
});
it('returns the values as plain object', () => {
coords = {
top: 100,
left: 200,
};
pos = new Position(coords);
expect(pos.toValue()).toStrictEqual(coords);
pos = new Position();
expect(pos.toValue()).toStrictEqual({
top: undefined,
left: undefined,
});
});
it('throws exception when trying to get the values with px suffix', () => {
pos = new Position();
expect(() => pos.toPx()).toThrow('Invalid position in pixels');
});
it('returns the values with px suffix', () => {
coords = {
top: 100,
left: 200,
};
pos = new Position(coords);
expect(pos.toPx()).toStrictEqual({
top: coords['top' as keyof object] + 'px',
left: coords['left' as keyof object] + 'px',
});
});
});
================================================
FILE: src/shared/Position/Position.ts
================================================
import { ref, type Ref } from 'vue';
export default class Position {
top: Ref = ref(null);
left: Ref = ref(null);
constructor(
{ top = null, left = null }: { top?: null | number; left?: null | number } = {
top: null,
left: null,
},
) {
this.update({ top, left });
}
reset() {
this.top.value = null;
this.left.value = null;
}
isValid() {
return ![this.top.value, this.left.value].includes(null);
}
update({ top, left }: { top?: null | number; left?: null | number }) {
this.top.value = top ?? null;
this.left.value = left ?? null;
}
toValue() {
const rawPosition: {
top?: number;
left?: number;
} = {
top: undefined,
left: undefined,
};
if (this.top.value !== null) {
rawPosition.top = this.top.value;
}
if (this.left.value !== null) {
rawPosition.left = this.left.value;
}
return rawPosition;
}
toPx() {
if (!this.isValid()) {
throw new RangeError('Invalid position in pixels');
}
return {
top: this.top.value!.toString() + 'px',
left: this.left.value!.toString() + 'px',
};
}
}
================================================
FILE: src/shared/ResizeCalculator/ResizeCalculator.test.ts
================================================
import { Size } from '../';
import { ResizeTypes } from '../../resources';
import ResizeCalculator, { Orientations } from './ResizeCalculator';
describe('shared: ResizeCalculator', () => {
let calc: ResizeCalculator;
let realSize: Size;
let newSize: Size;
beforeEach(() => {
realSize = new Size();
newSize = new Size();
});
it('if the size is invalid throws error', () => {
vi.spyOn(realSize, 'isValid').mockImplementation(() => false);
expect(() => {
calc = new ResizeCalculator(realSize);
}).toThrow('Invalid real size');
expect(realSize.isValid).toHaveBeenCalledWith();
});
it('if the size is valid when creating the calculator', () => {
vi.spyOn(realSize, 'isValid').mockImplementation(() => true);
expect(() => {
calc = new ResizeCalculator(realSize);
}).not.toThrow();
expect(realSize.isValid).toHaveBeenCalledWith();
});
it('detects the orientation', () => {
realSize.update({
width: 640,
height: 360,
});
calc = new ResizeCalculator(realSize);
expect(calc.realOrientation).toBe(Orientations.landscape);
realSize.update({
width: 360,
height: 640,
});
calc = new ResizeCalculator(realSize);
expect(calc.realOrientation).toBe(Orientations.portrait);
});
it('if the new size is valid', () => {
vi.spyOn(newSize, 'isValid').mockImplementation(() => false);
realSize.update({
width: 640,
height: 360,
});
calc = new ResizeCalculator(realSize);
expect(() => {
calc.resizeTo(newSize, ResizeTypes.fill);
}).toThrow('Invalid size to resize');
expect(newSize.isValid).toHaveBeenCalledWith();
});
it('new size L real size L and newAspectRatio >= realAspectRatio and type fill', () => {
newSize.update({
width: 280,
height: 140,
});
realSize.update({
width: 640,
height: 360,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fill
);
expect(adaptedSize.toValue()).toStrictEqual({
width: 280,
height: 157.5,
});
expect(adaptedPosition.toValue()).toStrictEqual({ top: -8.75, left: 0 });
});
it('new size L real size L and newAspectRatio < realAspectRatio and type fill', () => {
newSize.update({
width: 280,
height: 180,
});
realSize.update({
width: 280,
height: 140,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fit
);
expect(adaptedSize.toValue()).toStrictEqual({
width: 280,
height: 140,
});
expect(adaptedPosition.toValue()).toStrictEqual({ top: 20, left: 0 });
});
it('new size L real size L and newAspectRatio >= realAspectRatio and type fit', () => {
newSize.update({
width: 280,
height: 140,
});
realSize.update({
width: 280,
height: 200,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fit
);
expect(adaptedSize.toValue()).toStrictEqual({ width: 196, height: 140 });
expect(adaptedPosition.toValue()).toStrictEqual({ top: 0, left: 42 });
});
it('new size L real size L and newAspectRatio < realAspectRatio and type fit', () => {
newSize.update({
width: 280,
height: 180,
});
realSize.update({
width: 280,
height: 140,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fit
);
expect(adaptedSize.toValue()).toStrictEqual({
width: 280,
height: 140,
});
expect(adaptedPosition.toValue()).toStrictEqual({ top: 20, left: 0 });
});
it('new size L real size P and type fill', () => {
newSize.update({
width: 280,
height: 140,
});
realSize.update({
width: 140,
height: 280,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fill
);
expect(adaptedSize.toValue()).toStrictEqual({ width: 280, height: 560 });
expect(adaptedPosition.toValue()).toStrictEqual({ top: -210, left: 0 });
});
it('new size L real size P and type fit', () => {
newSize.update({
width: 280,
height: 140,
});
realSize.update({
width: 140,
height: 280,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fit
);
expect(adaptedSize.toValue()).toStrictEqual({
width: 70,
height: 140,
});
expect(adaptedPosition.toValue()).toStrictEqual({ top: 0, left: 105 });
});
it('new size P real size L and type fill', () => {
newSize.update({
width: 140,
height: 280,
});
realSize.update({
width: 280,
height: 140,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fill
);
expect(adaptedSize.toValue()).toStrictEqual({ width: 560, height: 280 });
expect(adaptedPosition.toValue()).toStrictEqual({ top: 0, left: -210 });
});
it('new size P real size L and type fit', () => {
newSize.update({
width: 140,
height: 280,
});
realSize.update({
width: 280,
height: 140,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fit
);
expect(adaptedSize.toValue()).toStrictEqual({
width: 140,
height: 70,
});
expect(adaptedPosition.toValue()).toStrictEqual({
top: 105,
left: 0,
});
});
it('new size P real size P and newAspectRatio >= realAspectRatio and type fill', () => {
newSize.update({
width: 140,
height: 280,
});
realSize.update({
width: 180,
height: 280,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fill
);
expect(adaptedSize.toValue()).toStrictEqual({
width: 180,
height: 280,
});
expect(adaptedPosition.toValue()).toStrictEqual({ top: 0, left: -20 });
});
it('new size P real size P and newAspectRatio < realAspectRatio and type fill', () => {
newSize.update({
width: 180,
height: 280,
});
realSize.update({
width: 140,
height: 280,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fill
);
expect(adaptedSize.toValue()).toStrictEqual({
width: 180,
height: 360,
});
expect(adaptedPosition.toValue()).toStrictEqual({ top: -40, left: 0 });
});
it('new size P real size P and newAspectRatio >= realAspectRatio and type fit', () => {
newSize.update({
width: 140,
height: 280,
});
realSize.update({
width: 200,
height: 280,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fit
);
expect(adaptedSize.toValue()).toStrictEqual({ width: 140, height: 196 });
expect(adaptedPosition.toValue()).toStrictEqual({ top: 42, left: 0 });
});
it('new size P real size P and newAspectRatio < realAspectRatio and type fit', () => {
newSize.update({
width: 180,
height: 280,
});
realSize.update({
width: 140,
height: 280,
});
calc = new ResizeCalculator(realSize);
const { size: adaptedSize, position: adaptedPosition } = calc.resizeTo(
newSize,
ResizeTypes.fit
);
expect(adaptedSize.toValue()).toStrictEqual({ width: 140, height: 280 });
expect(adaptedPosition.toValue()).toStrictEqual({ top: 0, left: 20 });
});
});
================================================
FILE: src/shared/ResizeCalculator/ResizeCalculator.ts
================================================
import { type ResizeType, ResizeTypes } from '../../resources';
import { Size, Position } from '../';
export enum Orientations {
landscape = 'landscape',
portrait = 'portrait',
}
const getOrientation = (aspectRatio: number) =>
aspectRatio >= 1 ? Orientations.landscape : Orientations.portrait;
type Orientation = keyof typeof Orientations;
export default class ResizeCalculator {
realSize: Size;
realAspectRatio: number;
realOrientation: Orientation;
constructor(realSize: Size) {
if (realSize.isValid() === false) {
throw new RangeError('Invalid real size');
}
this.realSize = realSize;
this.realAspectRatio = this.realSize.getAspectRatio();
this.realOrientation = getOrientation(this.realAspectRatio);
}
public resizeTo(resizeSize: Size, resizeType: ResizeType) {
if (resizeSize.isValid() === false) {
throw new RangeError('Invalid size to resize');
}
const resizeAspectRatio = resizeSize.getAspectRatio();
const resizeOrientation = getOrientation(resizeAspectRatio);
const adaptedSize: Size = this.getAdaptedSize(
resizeSize,
resizeAspectRatio,
resizeOrientation,
resizeType,
);
const adaptedPosition: Position = this.getAdaptedPosition(
resizeSize,
resizeAspectRatio,
adaptedSize,
resizeType,
);
return {
size: adaptedSize,
position: adaptedPosition,
};
}
private getAdaptedSize(
resizeSize: Size,
resizeAspectRatio: number,
resizeOrientation: Orientation,
resizeType: ResizeType,
) {
if (
resizeOrientation === Orientations.landscape &&
this.realOrientation === Orientations.portrait &&
resizeType === ResizeTypes.fill
) {
return this.getAdaptedSizeByWith(resizeSize);
}
if (
resizeOrientation === Orientations.landscape &&
this.realOrientation === Orientations.landscape &&
resizeAspectRatio >= this.realAspectRatio &&
resizeType === ResizeTypes.fill
) {
return this.getAdaptedSizeByWith(resizeSize);
}
if (
resizeOrientation === Orientations.landscape &&
this.realOrientation === Orientations.landscape &&
resizeAspectRatio < this.realAspectRatio &&
resizeType === ResizeTypes.fit
) {
return this.getAdaptedSizeByWith(resizeSize);
}
if (
resizeOrientation === Orientations.portrait &&
this.realOrientation === Orientations.landscape &&
resizeType === ResizeTypes.fit
) {
return this.getAdaptedSizeByWith(resizeSize);
}
if (
resizeOrientation === Orientations.portrait &&
this.realOrientation === Orientations.portrait &&
resizeAspectRatio > this.realAspectRatio &&
resizeType === ResizeTypes.fill
) {
return this.getAdaptedSizeByWith(resizeSize);
}
if (
resizeOrientation === Orientations.portrait &&
this.realOrientation === Orientations.portrait &&
resizeAspectRatio <= this.realAspectRatio &&
resizeType === ResizeTypes.fit
) {
return this.getAdaptedSizeByWith(resizeSize);
}
return this.getAdaptedSizeByHeight(resizeSize);
}
private getAdaptedSizeByWith(resizeSize: Size) {
return new Size({
width: resizeSize.width.value,
height: resizeSize.width.value! / this.realAspectRatio,
});
}
private getAdaptedSizeByHeight(resizeSize: Size) {
return new Size({
width: this.realAspectRatio * resizeSize.height.value!,
height: resizeSize.height.value,
});
}
private getAdaptedPosition(
resizeSize: Size,
resizeAspectRatio: number,
adaptedSize: Size,
resizeType: ResizeType,
) {
if (this.realAspectRatio <= resizeAspectRatio && resizeType === ResizeTypes.fill) {
return this.getAdaptedPositionVertically(resizeSize, adaptedSize);
}
if (this.realAspectRatio > resizeAspectRatio && resizeType === ResizeTypes.fit) {
return this.getAdaptedPositionVertically(resizeSize, adaptedSize);
}
return this.getAdaptedPositionHorizontally(resizeSize, adaptedSize);
}
getAdaptedPositionVertically(resizeSize: Size, adaptedSize: Size) {
return new Position({
top: (resizeSize.height.value! - adaptedSize.height.value!) / 2,
left: 0,
});
}
getAdaptedPositionHorizontally(resizeSize: Size, adaptedSize: Size) {
return new Position({
top: 0,
left: (resizeSize.width.value! - adaptedSize.width.value!) / 2,
});
}
}
================================================
FILE: src/shared/ResourceLoader/ResourceLoader.test.ts
================================================
import { vi } from 'vitest';
import ResourceLoader from './ResourceLoader';
import ResourceLoaderFactory from './__test__/ResourceLoaderFactory';
import { Statuses } from '../../resources';
vi.mock('../../resources/Img/Img');
describe('shared: ResourceLoader', () => {
let rscLoader: ResourceLoader;
beforeEach(() => {
vi.clearAllMocks();
});
it('calls onPreloadStart when preload starts', () => {
rscLoader = ResourceLoaderFactory.create(10, 5);
expect(rscLoader.onPreloadStart).toHaveBeenCalledOnce();
});
it('preloads all resources if num resources less than num to preload', () => {
rscLoader = ResourceLoaderFactory.create(10, 15);
expect(rscLoader.toPreload).toBe(10);
});
it('start preloading when created', () => {
rscLoader = ResourceLoaderFactory.create(10, 10);
expect(rscLoader.onPreloadStart).toHaveBeenCalledOnce();
});
it('start preloading the resources', () => {
rscLoader = ResourceLoaderFactory.create(15, 10);
expect(
rscLoader.rscs.every((rsc) =>
[Statuses.loading, Statuses.loaded].includes(rsc.resource.status.value),
),
).toBeTruthy();
});
it('checks if resources preloaded are less than to preload and preloads the remaining', () => {
rscLoader = ResourceLoaderFactory.create(15, 6);
rscLoader.counter.success = 4;
rscLoader.counter.error = 2;
rscLoader.counter.total = 6;
rscLoader.preloadEnd();
expect(rscLoader.preLoading).toHaveLength(8);
});
it('calls onPreloadEnd when all preloaded', () =>
new Promise((done) => {
rscLoader = ResourceLoaderFactory.create(5, 5, undefined, () => {
expect(rscLoader.onPreloadStart).toHaveBeenCalledOnce();
expect(rscLoader.onPreloadEnd).toHaveBeenCalledWith(expect.any(Array));
done();
});
}));
it('starts lazy loading when preloading finish', () =>
new Promise((done) => {
rscLoader = ResourceLoaderFactory.create(20, 5, undefined, undefined, () => {
expect(rscLoader.onPreloadStart).toHaveBeenCalledOnce();
expect(rscLoader.onPreloadEnd).toHaveBeenCalledOnce();
expect(rscLoader.counter.total).toBe(5);
expect(rscLoader.onLazyLoadStart).toHaveBeenCalledOnce();
done();
});
}));
it('calls onLazyLoadEnd when lazy loading finish', () =>
new Promise((done) => {
rscLoader = ResourceLoaderFactory.create(20, 5, undefined, undefined, undefined, () => {
expect(rscLoader.onPreloadStart).toHaveBeenCalledOnce();
expect(rscLoader.onPreloadEnd).toHaveBeenCalledOnce();
expect(rscLoader.onLazyLoadStart).toHaveBeenCalledOnce();
expect(rscLoader.onLazyLoadEnd).toHaveBeenCalledWith(expect.any(Array));
expect(rscLoader.counter.total).toBe(20);
done();
});
}));
it('does not update display size if cancelled', () => {
rscLoader = ResourceLoaderFactory.create(10, 5);
rscLoader.cancel();
const rsc = rscLoader.rscs[0];
vi.spyOn(rsc!.resource.displaySize, 'update');
rscLoader.loadSuccess(rsc!);
expect(rsc!.resource.displaySize.update).not.toHaveBeenCalled();
});
it('calculates the progress properly', () => {
rscLoader = ResourceLoaderFactory.create(15, 6);
rscLoader.counter.success = 4;
rscLoader.counter.error = 2;
rscLoader.counter.total = 6;
rscLoader.updateProgress();
expect(rscLoader.progress.value).toBe(67);
});
});
================================================
FILE: src/shared/ResourceLoader/ResourceLoader.ts
================================================
import { type Ref, ref } from 'vue';
import { Size } from '../';
import type { ResourceWithOptions } from '../../resources';
export default class ResourceLoader {
rscs: ResourceWithOptions[] = [];
counter = {
success: 0,
error: 0,
total: 0,
};
toPreload: number;
preLoading: ResourceWithOptions[] = [];
lazyLoading: ResourceWithOptions[] = [];
progress: Ref = ref(0);
displaySize: Size;
onPreloadStart: () => void;
onPreloadEnd: (loaded: ResourceWithOptions[]) => void;
onLazyLoadStart: () => void;
onLazyLoadEnd: (loaded: ResourceWithOptions[]) => void;
isCancelled: boolean = false;
reject: (message: string, rscs: ResourceWithOptions[]) => void;
constructor(
rscs: ResourceWithOptions[],
toPreload: number,
displaySize: Size,
onPreloadStart: () => void,
onPreloadEnd: (loaded: ResourceWithOptions[]) => void,
onLazyLoadStart: () => void,
onLazyLoadEnd: (loaded: ResourceWithOptions[]) => void,
reject: (message: string, rscs: ResourceWithOptions[]) => void,
) {
this.rscs = rscs;
this.toPreload = toPreload > rscs.length ? rscs.length : toPreload;
this.displaySize = displaySize;
this.onPreloadStart = onPreloadStart;
this.onPreloadEnd = onPreloadEnd;
this.onLazyLoadStart = onLazyLoadStart;
this.onLazyLoadEnd = onLazyLoadEnd;
this.reject = reject;
this.preloadStart();
}
preloadStart() {
this.onPreloadStart();
const { counter } = this;
const toLoad = this.rscs.slice(
counter.total,
counter.total + this.toPreload - counter.success,
);
this.preLoading = this.preLoading.concat(toLoad);
toLoad.forEach((rsc) => this.load(rsc));
}
preloadEnd() {
const { counter, toPreload } = this;
if (counter.success < toPreload && counter.total < this.rscs.length) {
this.preloadStart();
return;
}
const preloadedSuccessfully = this.preLoading.filter((rsc) => rsc.resource.isLoaded());
this.onPreloadEnd(preloadedSuccessfully);
this.preLoading.length = 0;
if (counter.total < this.rscs.length) {
this.lazyLoadStart();
}
}
lazyLoadStart() {
this.onLazyLoadStart();
this.lazyLoading = this.rscs.slice(this.counter.total);
this.lazyLoading.forEach((rsc) => this.load(rsc));
}
lazyLoadEnd() {
const lazyLoadedSuccessfully = this.lazyLoading.filter((rsc) => rsc.resource.isLoaded());
this.onLazyLoadEnd(lazyLoadedSuccessfully);
this.lazyLoading.length = 0;
}
load(rsc: ResourceWithOptions) {
rsc.resource
.load()
.then(() => {
this.loadSuccess(rsc);
})
.catch((error) => {
this.loadError(error);
})
.finally(() => {
this.counter.total++;
if (this.isCancelled) {
return;
}
if (this.preLoading.length !== 0) {
this.updateProgress();
}
if (this.counter.total === this.toPreload) {
this.preloadEnd();
} else if (this.counter.total === this.rscs.length) {
this.lazyLoadEnd();
}
});
}
loadSuccess(rsc: ResourceWithOptions) {
this.counter.success++;
if (this.isCancelled) {
return;
}
rsc.resource.displaySize.update(this.displaySize.toValue());
}
loadError(error: string) {
this.counter.error++;
if (this.isCancelled) {
return;
}
console.error(error);
}
updateProgress() {
this.progress.value = Math.ceil((this.counter.success * 100) / this.toPreload);
}
hasFinished() {
return this.counter.total === this.rscs.length;
}
cancel() {
this.isCancelled = true;
this.reject('Resources loading cancelled', this.rscs);
}
}
================================================
FILE: src/shared/ResourceLoader/__mocks__/ResourceLoader.ts
================================================
import { Size } from '../../';
import type { ResourceWithOptions } from '../../../resources';
export default class ResourceLoader {
rscs: ResourceWithOptions[] = [];
counter = {
success: 0,
error: 0,
total: 0,
};
toPreload: number;
preLoading: ResourceWithOptions[] = [];
lazyLoading: ResourceWithOptions[] = [];
displaySize: Size;
onPreloadStart: () => void;
onPreloadEnd: (loaded: ResourceWithOptions[]) => void;
onLazyLoadStart: () => void;
onLazyLoadEnd: (loaded: ResourceWithOptions[]) => void;
reject: (message: string, rscs: ResourceWithOptions[]) => void;
constructor(
rscs: ResourceWithOptions[],
toPreload: number,
displaySize: Size,
onPreloadStart: () => void,
onPreloadEnd: (loaded: ResourceWithOptions[]) => void,
onLazyLoadStart: () => void,
onLazyLoadEnd: (loaded: ResourceWithOptions[]) => void,
reject: (message: string, rscs: ResourceWithOptions[]) => void,
) {
this.rscs = rscs;
this.toPreload = toPreload > rscs.length ? rscs.length : toPreload;
this.displaySize = displaySize;
this.onPreloadStart = onPreloadStart;
this.onPreloadEnd = onPreloadEnd;
this.onLazyLoadStart = onLazyLoadStart;
this.onLazyLoadEnd = onLazyLoadEnd;
this.reject = reject;
this.preloadStart();
}
preloadStart() {
this.onPreloadStart();
const { counter } = this;
const toLoad = this.rscs.slice(
counter.total,
counter.total + this.toPreload - counter.success,
);
this.preLoading = this.preLoading.concat(toLoad);
toLoad.forEach((rsc) => this.load(rsc));
}
preloadEnd() {
const { counter, toPreload } = this;
if (counter.success < toPreload && counter.total < toPreload) {
this.preloadStart();
return;
}
const preloadedSuccessfully = this.preLoading.filter((rsc) => rsc.resource.isLoaded());
this.onPreloadEnd(preloadedSuccessfully);
this.preLoading.length = 0;
if (counter.total < this.rscs.length) {
this.lazyLoadStart();
}
}
lazyLoadStart() {
this.onLazyLoadStart();
this.lazyLoading = this.rscs.slice(this.counter.total);
this.lazyLoading.forEach((rsc) => this.load(rsc));
}
lazyLoadEnd() {
const lazyLoadedSuccessfully = this.lazyLoading.filter((rsc) => rsc.resource.isLoaded());
this.onLazyLoadEnd(lazyLoadedSuccessfully);
this.lazyLoading.length = 0;
}
load(rsc: ResourceWithOptions) {
rsc.resource
.load()
.then(() => {
this.loadSuccess();
})
.finally(() => {
this.counter.total++;
if (this.counter.total === this.toPreload) {
this.preloadEnd();
} else if (this.counter.total === this.rscs.length) {
this.lazyLoadEnd();
}
});
}
loadSuccess() {
this.counter.success++;
}
hasFinished() {
return this.counter.total === this.rscs.length;
}
}
================================================
FILE: src/shared/ResourceLoader/__test__/ResourceLoaderFactory.ts
================================================
import { vi } from 'vitest';
import ResourceFactory from '../../../resources/__test__/ResourceFactory';
import ResourceLoader from '../ResourceLoader';
import Size from '../../Size/Size';
import type { ResourceWithOptions } from '../../../resources/types';
export default class ResourceLoaderFactory {
static create(
numResources: number,
numToPreload: number,
preloadStartMock?: () => void,
preloadEndMock?: () => void,
lazyLoadStartMock?: () => void,
lazyLoadEndMock?: () => void,
) {
const displaySize = new Size({
width: 640,
height: 360,
});
const onPreloadStart = vi.fn();
if (preloadStartMock) {
onPreloadStart.mockImplementation(preloadStartMock);
}
const onPreloadEnd = vi.fn();
if (preloadEndMock) {
onPreloadEnd.mockImplementation(preloadEndMock);
}
const onLazyLoadStart = vi.fn();
if (lazyLoadStartMock) {
onLazyLoadStart.mockImplementation(lazyLoadStartMock);
}
const onLazyLoadEnd = vi.fn();
if (lazyLoadEndMock) {
onLazyLoadEnd.mockImplementation(lazyLoadEndMock);
}
const reject = vi.fn();
const resources = ResourceFactory.create(numResources).map((resource) => {
return {
resource: resource,
options: {},
} as ResourceWithOptions;
});
return new ResourceLoader(
resources,
numToPreload,
displaySize,
onPreloadStart,
onPreloadEnd,
onLazyLoadStart,
onLazyLoadEnd,
reject,
);
}
}
================================================
FILE: src/shared/Size/Size.test.ts
================================================
import Size from './Size';
describe('shared: Size', () => {
let size: Size;
let params: object;
it('initializes values to null without parameters', () => {
size = new Size();
expect(size.width.value).toBeNull();
expect(size.height.value).toBeNull();
});
it('sets param values', () => {
size = new Size({ width: 100 });
expect(size.width.value).toBe(100);
size = new Size({ height: 100 });
expect(size.height.value).toBe(100);
size = new Size({
width: 100,
height: 200,
});
expect(size.width.value).toBe(100);
expect(size.height.value).toBe(200);
});
it('reset values', () => {
size = new Size({
width: 100,
height: 200,
});
size.reset();
expect(size.width.value).toBeNull();
expect(size.height.value).toBeNull();
});
it('is invalid if width or height is null', () => {
size = new Size({ width: 100 });
expect(size.isValid()).toBeFalsy();
size = new Size({ height: 100 });
expect(size.isValid()).toBeFalsy();
});
it('is valid when width and height have values', () => {
size = new Size({
width: 100,
height: 200,
});
expect(size.isValid()).toBeTruthy();
});
it('updates the values', () => {
size = new Size({
width: 100,
height: 200,
});
size.update({
width: 50,
});
expect(size.width.value).toBe(50);
expect(size.height.value).toBeNull();
size.update({
height: 100,
});
expect(size.width.value).toBeNull();
expect(size.height.value).toBe(100);
size.update({
width: 200,
height: 400,
});
expect(size.width.value).toBe(200);
expect(size.height.value).toBe(400);
});
it('throws an exception trying to calc aspect ratio when size is invalid', () => {
size = new Size({ width: 100 });
expect(() => size.getAspectRatio()).toThrow(
'Could not get aspect ratio due to invalid size'
);
});
it('gets the aspect ration when size is valid', () => {
size = new Size({
width: 100,
height: 200,
});
expect(size.getAspectRatio()).toBeTypeOf('number');
});
it('clones the size', () => {
params = {
width: 100,
height: 200,
};
size = new Size({
width: 100,
height: 200,
});
expect(size.clone().toValue()).toStrictEqual(params);
});
it('returns false when width does not match other size', () => {
size = new Size({
width: 100,
height: 200,
});
expect(
size.equals(
new Size({
width: 50,
height: 200,
})
)
).toBeFalsy();
});
it('returns false when height does not match other size', () => {
size = new Size({
width: 100,
height: 200,
});
expect(
size.equals(
new Size({
width: 100,
height: 50,
})
)
).toBeFalsy();
});
it('returns true when size equals another size', () => {
params = {
width: 100,
height: 200,
};
size = new Size(params);
expect(size.equals(new Size(params))).toBeTruthy();
});
it('returns the values as plain object', () => {
params = {
width: 100,
height: 200,
};
size = new Size(params);
expect(size.toValue()).toStrictEqual(params);
size = new Size();
expect(size.toValue()).toStrictEqual({});
});
it('throws exception when trying to get the values with px suffix', () => {
size = new Size();
expect(() => size.toPx()).toThrow('Invalid size in pixels');
});
it('returns the values with px suffix', () => {
params = {
width: 100,
height: 200,
};
size = new Size(params);
expect(size.toPx()).toStrictEqual({
width: params['width' as keyof object] + 'px',
height: params['height' as keyof object] + 'px',
});
});
});
================================================
FILE: src/shared/Size/Size.ts
================================================
import { ref, type Ref } from 'vue';
import { Maths } from '../';
export default class Size {
width: Ref = ref(null);
height: Ref = ref(null);
constructor(
{
width = null,
height = null,
}: {
width?: null | number;
height?: null | number;
} = { width: null, height: null },
) {
this.update({ width, height });
}
reset() {
this.width.value = null;
this.height.value = null;
}
isValid() {
return ![this.width.value, this.height.value].includes(null);
}
update({ width, height }: { width?: null | number; height?: null | number }) {
this.width.value = width ?? null;
this.height.value = height ?? null;
}
getAspectRatio() {
if (!this.isValid()) {
throw new RangeError('Could not get aspect ratio due to invalid size');
}
return Maths.aspectRatio(this.toValue() as { width: number; height: number });
}
clone() {
return new Size(this.toValue());
}
equals(otherSize: Size) {
if (this.width.value !== otherSize.width.value) {
return false;
}
if (this.height.value !== otherSize.height.value) {
return false;
}
return true;
}
toValue() {
const rawSize: {
width?: number;
height?: number;
} = {};
if (this.width.value !== null) {
rawSize.width = this.width.value;
}
if (this.height.value !== null) {
rawSize.height = this.height.value;
}
return rawSize;
}
toPx() {
if (!this.isValid()) {
throw new RangeError('Invalid size in pixels');
}
return {
width: this.width.value!.toString() + 'px',
height: this.height.value!.toString() + 'px',
};
}
}
================================================
FILE: src/shared/index.ts
================================================
export * as Maths from './Maths/Maths';
export { default as Position } from './Position/Position';
export { default as ResizeCalculator } from './ResizeCalculator/ResizeCalculator';
export { default as ResourceLoader } from './ResourceLoader/ResourceLoader';
export { default as Size } from './Size/Size';
================================================
FILE: src/transitions/Blinds2D/Blinds2D.test.ts
================================================
import Blinds2D from './Blinds2D.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Blinds2D', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Blinds2D, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Blinds2D, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'scaleX(0)',
transition: 'all 800ms linear 0ms',
});
expect($tiles[9].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'scaleX(0)',
transition: 'all 800ms linear 900ms',
});
expect(wrapper.vm.totalDuration).toBe(1800);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Blinds2D, {
direction: Directions.prev,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'scaleX(0)',
transition: 'all 400ms ease-out 300ms',
});
expect($tiles[5].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'scaleX(0)',
transition: 'all 400ms ease-out 0ms',
});
expect(wrapper.vm.totalDuration).toBe(760);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Blinds2D, {
direction: Directions.next,
cols: 6,
tileDuration: 700,
tileDelay: 90,
easing: 'ease-in',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'scaleX(0)',
transition: 'all 700ms ease-in 0ms',
});
expect($tiles[5].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'scaleX(0)',
transition: 'all 700ms ease-in 450ms',
});
expect(wrapper.vm.totalDuration).toBe(1240);
});
});
================================================
FILE: src/transitions/Blinds2D/Blinds2D.vue
================================================
================================================
FILE: src/transitions/Blinds2D/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionBlinds2DOptions extends TransitionOptions {
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionBlinds2DProps extends TransitionProps {
options?: TransitionBlinds2DOptions;
}
export interface TransitionBlinds2DConf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
================================================
FILE: src/transitions/Blinds3D/Blinds3D.test.ts
================================================
import Blinds3D from './Blinds3D.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers';
import { Turns } from '../../components/FluxCube';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Blinds3D', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Blinds3D, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('expects to set proper CSS styles before animation', () => {
const wrapper = AnimationWrapper(Blinds3D, {});
const maskStyle = wrapper.props('maskStyle');
expect(maskStyle.overflow).toBe('visible');
const gridCss = wrapper
.getComponent({
ref: '$grid',
})
.props('css');
expect(gridCss.perspective).toBeDefined();
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Blinds3D, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 800ms ease-out 0ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.backr);
expect($tiles[5].setCss).toHaveBeenCalledWith({
transition: 'all 800ms ease-out 750ms',
});
expect($tiles[5].turn).toHaveBeenCalledWith(Turns.backr);
expect(wrapper.vm.totalDuration).toBe(1700);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Blinds3D, {
direction: Directions.prev,
cols: 8,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-in 420ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.backl);
expect($tiles[7].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-in 0ms',
});
expect($tiles[7].turn).toHaveBeenCalledWith(Turns.backl);
expect(wrapper.vm.totalDuration).toBe(880);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Blinds3D, {
direction: Directions.next,
cols: 10,
tileDuration: 400,
tileDelay: 60,
easing: 'linear',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 400ms linear 0ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.backr);
expect($tiles[9].setCss).toHaveBeenCalledWith({
transition: 'all 400ms linear 540ms',
});
expect($tiles[9].turn).toHaveBeenCalledWith(Turns.backr);
expect(wrapper.vm.totalDuration).toBe(1000);
});
});
================================================
FILE: src/transitions/Blinds3D/Blinds3D.vue
================================================
================================================
FILE: src/transitions/Blinds3D/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionBlinds3DOptions extends TransitionOptions {
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionBlinds3DProps extends TransitionProps {
options?: TransitionBlinds3DOptions;
}
export interface TransitionBlinds3DConf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
================================================
FILE: src/transitions/Blocks1/Blocks1.test.ts
================================================
import Blocks1 from './Blocks1.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Blocks1', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Blocks1, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Blocks1, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3, 0.3)',
transition: expect.any(String),
});
expect($tiles[31].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3, 0.3)',
transition: expect.any(String),
});
expect(wrapper.vm.totalDuration).toBe(1300);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Blocks1, {
direction: Directions.prev,
rows: 3,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3, 0.3)',
transition: expect.any(String),
});
expect($tiles[17].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3, 0.3)',
transition: expect.any(String),
});
expect(wrapper.vm.totalDuration).toBe(460);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Blocks1, {
direction: Directions.next,
rows: 4,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3, 0.3)',
transition: expect.any(String),
});
expect($tiles[23].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3, 0.3)',
transition: expect.any(String),
});
expect(wrapper.vm.totalDuration).toBe(460);
});
});
================================================
FILE: src/transitions/Blocks1/Blocks1.vue
================================================
================================================
FILE: src/transitions/Blocks1/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionBlocks1Options extends TransitionOptions {
rows?: number;
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionBlocks1Props extends TransitionProps {
options?: TransitionBlocks1Options;
}
export interface TransitionBlocks1Conf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
================================================
FILE: src/transitions/Blocks2/Blocks2.test.ts
================================================
import Blocks2 from './Blocks2.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Blocks2', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Blocks2, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Blocks2, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3)',
transition: 'all 800ms ease 0ms',
});
expect($tiles[31].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3)',
transition: 'all 800ms ease 800ms',
});
expect(wrapper.vm.totalDuration).toBe(2080);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Blocks2, {
direction: Directions.prev,
rows: 3,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '1',
transform: 'scale(1)',
transition: 'all 400ms ease-in-out 480ms',
});
expect($tiles[17].transform).toHaveBeenCalledWith({
opacity: '1',
transform: 'scale(1)',
transition: 'all 400ms ease-in-out 60ms',
});
expect(wrapper.vm.totalDuration).toBe(940);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Blocks2, {
direction: Directions.next,
rows: 4,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3)',
transition: 'all 400ms ease-in-out 0ms',
});
expect($tiles[23].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'scale(0.3)',
transition: 'all 400ms ease-in-out 480ms',
});
expect(wrapper.vm.totalDuration).toBe(1000);
});
});
================================================
FILE: src/transitions/Blocks2/Blocks2.vue
================================================
================================================
FILE: src/transitions/Blocks2/types.ts
================================================
import type { CSSProperties } from 'vue';
import { Resource } from '../../resources';
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionBlocks2Options extends TransitionOptions {
rows?: number;
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionBlocks2Props extends TransitionProps {
options?: TransitionBlocks2Options;
}
export interface TransitionBlocks2Conf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
export interface BackgroundProps {
rsc: null | Resource;
css: CSSProperties;
}
================================================
FILE: src/transitions/Book/Book.test.ts
================================================
import Book from './Book.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxImage/FluxImage.vue');
vi.mock('../../components/FluxCube/FluxCube.vue');
describe('transition: Book', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Book, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Book, {});
const size = wrapper.props('size').toValue();
const $from = wrapper.getComponent({
ref: '$from',
});
const viewSize = $from.props('viewSize').toValue();
expect(viewSize).toStrictEqual({
width: Math.ceil(size.width / 2),
height: size.height,
});
expect($from.props('offset').toValue()).toStrictEqual({
top: 0,
left: 0,
});
expect($from.props('css')).toStrictEqual({
top: 0,
left: 0,
position: 'absolute',
});
const $cube = wrapper.getComponent({
ref: '$cube',
});
const cubeOffsets = $cube.props('offsets');
expect(cubeOffsets.front.toValue()).toStrictEqual({
top: 0,
left: 320,
});
expect(cubeOffsets.back.toValue()).toStrictEqual({
top: 0,
left: 0,
});
expect($cube.props('origin')).toBe('left center');
expect($cube.props('css')).toStrictEqual({
top: 0,
left: '320px',
position: 'absolute',
});
wrapper.vm.onPlay();
expect($cube.vm.transform).toHaveBeenCalledWith({
transform: 'rotateY(-180deg)',
transition: 'transform 1200ms ease-out',
});
expect(wrapper.vm.totalDuration).toBe(1200);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Book, {
direction: Directions.prev,
totalDuration: 900,
easing: 'linear',
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('offset').toValue()).toStrictEqual({
top: 0,
left: 320,
});
expect($from.props('css')).toStrictEqual({
top: 0,
left: '320px',
position: 'absolute',
});
const $cube = wrapper.getComponent({
ref: '$cube',
});
const cubeOffsets = $cube.props('offsets');
expect(cubeOffsets.front.toValue()).toStrictEqual({
top: 0,
left: 0,
});
expect(cubeOffsets.back.toValue()).toStrictEqual({
top: 0,
left: 320,
});
expect($cube.props('origin')).toBe('right center');
expect($cube.props('css')).toStrictEqual({
top: 0,
left: 0,
position: 'absolute',
});
wrapper.vm.onPlay();
expect($cube.vm.transform).toHaveBeenCalledWith({
transform: 'rotateY(180deg)',
transition: 'transform 900ms linear',
});
expect(wrapper.vm.totalDuration).toBe(900);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Book, {
direction: Directions.next,
totalDuration: 1000,
easing: 'ease-out',
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('offset').toValue()).toStrictEqual({
top: 0,
left: 0,
});
expect($from.props('css')).toStrictEqual({
top: 0,
left: 0,
position: 'absolute',
});
const $cube = wrapper.getComponent({
ref: '$cube',
});
const cubeOffsets = $cube.props('offsets');
expect(cubeOffsets.front.toValue()).toStrictEqual({
top: 0,
left: 320,
});
expect(cubeOffsets.back.toValue()).toStrictEqual({
top: 0,
left: 0,
});
expect($cube.props('origin')).toBe('left center');
expect($cube.props('css')).toStrictEqual({
top: 0,
left: '320px',
position: 'absolute',
});
wrapper.vm.onPlay();
expect($cube.vm.transform).toHaveBeenCalledWith({
transform: 'rotateY(-180deg)',
transition: 'transform 1000ms ease-out',
});
expect(wrapper.vm.totalDuration).toBe(1000);
});
});
================================================
FILE: src/transitions/Book/Book.vue
================================================
================================================
FILE: src/transitions/Book/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionBookOptions extends TransitionOptions {
totalDuration?: number;
}
export interface TransitionBookProps extends TransitionProps {
options?: TransitionBookOptions;
}
export interface TransitionBookConf extends TransitionConf {
totalDuration: number;
}
================================================
FILE: src/transitions/Camera/Camera.test.ts
================================================
import Camera from './Camera.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
import { Maths, Size } from '../../shared';
vi.mock('../../components/FluxWrapper/FluxWrapper.vue');
describe('transition: Camera', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Camera, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Camera, {});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
const size = wrapper.props('size').toValue() as {
width: number;
height: number;
};
const diagSize = Maths.diag(size);
expect($wrapper.props('size')).toStrictEqual(
new Size({ width: diagSize, height: diagSize })
);
expect($wrapper.props('css')).toStrictEqual({
boxSizing: 'border-box',
position: 'absolute',
display: 'flex',
justifyContent: 'center',
overflow: 'hidden',
borderRadius: '50%',
border: '0 solid #111',
top: (size.height - diagSize) / 2 + 'px',
left: (size.width - diagSize) / 2 + 'px',
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('css')).toStrictEqual({
alignSelf: 'center',
flex: 'none',
});
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
borderWidth: '367.5px',
transition: 'all 400ms cubic-bezier(0.385, 0, 0.795, 0.560) 0ms',
});
expect(wrapper.vm.totalDuration).toBe(900);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Camera, {
direction: Directions.prev,
totalDuration: 800,
easing: 'ease-out',
});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
const size = wrapper.props('size').toValue() as {
width: number;
height: number;
};
const diagSize = Maths.diag(size);
expect($wrapper.props('size')).toStrictEqual(
new Size({ width: diagSize, height: diagSize })
);
expect($wrapper.props('css')).toStrictEqual({
boxSizing: 'border-box',
position: 'absolute',
display: 'flex',
justifyContent: 'center',
overflow: 'hidden',
borderRadius: '50%',
border: '0 solid #111',
top: (size.height - diagSize) / 2 + 'px',
left: (size.width - diagSize) / 2 + 'px',
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('css')).toStrictEqual({
alignSelf: 'center',
flex: 'none',
});
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
borderWidth: '367.5px',
transition: 'all 350ms ease-out 0ms',
});
expect(wrapper.vm.totalDuration).toBe(800);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Camera, {
direction: Directions.next,
totalDuration: 1000,
easing: 'ease-in',
});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
const size = wrapper.props('size').toValue() as {
width: number;
height: number;
};
const diagSize = Maths.diag(size);
expect($wrapper.props('size')).toStrictEqual(
new Size({ width: diagSize, height: diagSize })
);
expect($wrapper.props('css')).toStrictEqual({
boxSizing: 'border-box',
position: 'absolute',
display: 'flex',
justifyContent: 'center',
overflow: 'hidden',
borderRadius: '50%',
border: '0 solid #111',
top: (size.height - diagSize) / 2 + 'px',
left: (size.width - diagSize) / 2 + 'px',
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('css')).toStrictEqual({
alignSelf: 'center',
flex: 'none',
});
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
borderWidth: '367.5px',
transition: 'all 450ms ease-in 0ms',
});
expect(wrapper.vm.totalDuration).toBe(1000);
});
});
================================================
FILE: src/transitions/Camera/Camera.vue
================================================
================================================
FILE: src/transitions/Camera/types.ts
================================================
import type { CSSProperties } from 'vue';
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionCameraOptions extends TransitionOptions {
totalDuration?: number;
backgroundColor?: CSSProperties['color'];
}
export interface TransitionCameraProps extends TransitionProps {
options?: TransitionCameraOptions;
}
export interface TransitionCameraConf extends TransitionConf {
totalDuration: number;
backgroundColor: CSSProperties['color'];
}
================================================
FILE: src/transitions/Concentric/Concentric.test.ts
================================================
import Concentric from './Concentric.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxVortex/FluxVortex.vue');
describe('transition: Concentric', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Concentric, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Concentric, {});
const $vortex = wrapper.getComponent({
ref: '$vortex',
});
wrapper.vm.onPlay();
expect($vortex.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $vortex.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(90deg)',
transition: 'all 800ms linear 0ms',
});
expect($tiles[6].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(90deg)',
transition: 'all 800ms linear 900ms',
});
expect(wrapper.vm.totalDuration).toBe(1850);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Concentric, {
direction: Directions.prev,
circles: 5,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $vortex = wrapper.getComponent({
ref: '$vortex',
});
wrapper.vm.onPlay();
expect($vortex.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $vortex.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[4].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 400ms ease-out 240ms',
});
expect(wrapper.vm.totalDuration).toBe(700);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Concentric, {
direction: Directions.next,
circles: 10,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $vortex = wrapper.getComponent({
ref: '$vortex',
});
wrapper.vm.onPlay();
expect($vortex.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $vortex.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(90deg)',
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[9].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(90deg)',
transition: 'all 400ms ease-out 540ms',
});
expect(wrapper.vm.totalDuration).toBe(1000);
});
});
================================================
FILE: src/transitions/Concentric/Concentric.vue
================================================
================================================
FILE: src/transitions/Concentric/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionConcentricOptions extends TransitionOptions {
circles?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionConcentricProps extends TransitionProps {
options?: TransitionConcentricOptions;
}
export interface TransitionConcentricConf extends TransitionConf {
circles: number;
tileDuration: number;
tileDelay: number;
}
================================================
FILE: src/transitions/Cube/Cube.test.ts
================================================
import Cube from './Cube.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers';
import { Turns } from '../../components/FluxCube';
vi.mock('../../components/FluxCube/FluxCube.vue');
describe('transition: Cube', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Cube, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('expects to set proper CSS styles before animation', () => {
const wrapper = AnimationWrapper(Cube, {});
const maskStyle = wrapper.props('maskStyle');
expect(maskStyle.overflow).toBe('visible');
expect(maskStyle.perspective).toBeDefined();
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Cube, {});
const $cube = wrapper.getComponent({
ref: '$cube',
});
wrapper.vm.onPlay();
expect($cube.vm.turn).toHaveBeenCalledWith(Turns.left);
expect(wrapper.vm.totalDuration).toBe(1400);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Cube, {
direction: Directions.prev,
totalDuration: 900,
easing: 'ease-in',
});
const $cube = wrapper.getComponent({
ref: '$cube',
});
wrapper.vm.onPlay();
expect($cube.vm.turn).toHaveBeenCalledWith(Turns.right);
expect(wrapper.vm.totalDuration).toBe(900);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Cube, {
direction: Directions.next,
totalDuration: 300,
easing: 'ease-in-out',
});
const $cube = wrapper.getComponent({
ref: '$cube',
});
wrapper.vm.onPlay();
expect($cube.vm.turn).toHaveBeenCalledWith(Turns.left);
expect(wrapper.vm.totalDuration).toBe(300);
});
});
================================================
FILE: src/transitions/Cube/Cube.vue
================================================
================================================
FILE: src/transitions/Cube/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionCubeOptions extends TransitionOptions {
totalDuration?: number;
}
export interface TransitionCubeProps extends TransitionProps {
options?: TransitionCubeOptions;
}
export interface TransitionCubeConf extends TransitionConf {
totalDuration: number;
}
================================================
FILE: src/transitions/Explode/Explode.test.ts
================================================
import Explode from './Explode.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Explode', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Explode, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Explode, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
const maskStyle = wrapper.props('maskStyle');
expect(maskStyle.overflow).toBe('visible');
const gridCss = wrapper
.getComponent({
ref: '$grid',
})
.props('css');
expect(gridCss.overflow).toBe('visible');
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
borderRadius: '100%',
opacity: '0',
transform: 'scale(2)',
transition: 'all 300ms linear 500ms',
});
expect($tiles[21].transform).toHaveBeenCalledWith({
borderRadius: '100%',
opacity: '0',
transform: 'scale(2)',
transition: 'all 300ms linear 0ms',
});
expect(wrapper.vm.totalDuration).toBe(1400);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Explode, {
direction: Directions.prev,
rows: 3,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
borderRadius: '100%',
opacity: '0',
transform: 'scale(2)',
transition: 'all 400ms ease-in-out 150ms',
});
expect($tiles[8].transform).toHaveBeenCalledWith({
borderRadius: '100%',
opacity: '0',
transform: 'scale(2)',
transition: 'all 400ms ease-in-out -30ms',
});
expect(wrapper.vm.totalDuration).toBe(540);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Explode, {
direction: Directions.next,
rows: 4,
cols: 7,
tileDuration: 200,
tileDelay: 80,
easing: 'ease-in',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
borderRadius: '100%',
opacity: '0',
transform: 'scale(2)',
transition: 'all 200ms ease-in 280ms',
});
expect($tiles[13].transform).toHaveBeenCalledWith({
borderRadius: '100%',
opacity: '0',
transform: 'scale(2)',
transition: 'all 200ms ease-in 200ms',
});
expect(wrapper.vm.totalDuration).toBe(880);
});
});
================================================
FILE: src/transitions/Explode/Explode.vue
================================================
================================================
FILE: src/transitions/Explode/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionExplodeOptions extends TransitionOptions {
rows?: number;
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionExplodeProps extends TransitionProps {
options?: TransitionExplodeOptions;
}
export interface TransitionExplodeConf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
================================================
FILE: src/transitions/Fade/Fade.test.ts
================================================
import Fade from './Fade.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxImage/FluxImage.vue');
describe('transition: Fade', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Fade, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Fade, {});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('css')).toStrictEqual({
zIndex: 1,
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
opacity: 0,
transition: 'opacity 1200ms ease-in',
});
expect(wrapper.vm.totalDuration).toBe(1200);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Fade, {
direction: Directions.prev,
totalDuration: 1800,
easing: 'linear',
});
const $from = wrapper.getComponent({
ref: '$from',
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
opacity: 0,
transition: 'opacity 1800ms linear',
});
expect(wrapper.vm.totalDuration).toBe(1800);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Fade, {
direction: Directions.next,
totalDuration: 600,
easing: 'ease-out',
});
const $from = wrapper.getComponent({
ref: '$from',
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
opacity: 0,
transition: 'opacity 600ms ease-out',
});
expect(wrapper.vm.totalDuration).toBe(600);
});
});
================================================
FILE: src/transitions/Fade/Fade.vue
================================================
================================================
FILE: src/transitions/Fade/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionFadeOptions extends TransitionOptions {
totalDuration?: number;
}
export interface TransitionFadeProps extends TransitionProps {
options?: TransitionFadeOptions;
}
export interface TransitionFadeConf extends TransitionConf {
totalDuration: number;
}
================================================
FILE: src/transitions/Fall/Fall.test.ts
================================================
import Fall from './Fall.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxImage/FluxImage.vue');
describe('transition: Fall', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Fall, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Fall, {});
const $from = wrapper.getComponent({
ref: '$from',
});
const maskStyle = wrapper.props('maskStyle');
expect(maskStyle.overflow).toBe('visible');
expect(maskStyle.perspective).toBeDefined();
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
transform: 'rotateX(-83.6deg)',
transition: 'transform 1600ms ease-in',
});
expect(wrapper.vm.totalDuration).toBe(1600);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Fall, {
direction: Directions.prev,
totalDuration: 1200,
easing: 'linear',
});
const $from = wrapper.getComponent({
ref: '$from',
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
transform: 'rotateX(-83.6deg)',
transition: 'transform 1200ms linear',
});
expect(wrapper.vm.totalDuration).toBe(1200);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Fall, {
direction: Directions.next,
totalDuration: 1000,
easing: 'ease-out',
});
const $from = wrapper.getComponent({
ref: '$from',
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
transform: 'rotateX(-83.6deg)',
transition: 'transform 1000ms ease-out',
});
expect(wrapper.vm.totalDuration).toBe(1000);
});
});
================================================
FILE: src/transitions/Fall/Fall.vue
================================================
================================================
FILE: src/transitions/Fall/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionFallOptions extends TransitionOptions {
totalDuration?: number;
}
export interface TransitionFallProps extends TransitionProps {
options?: TransitionFallOptions;
}
export interface TransitionFallConf extends TransitionConf {
totalDuration: number;
}
================================================
FILE: src/transitions/Kenburn/Kenburn.test.ts
================================================
import Kenburn from './Kenburn.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxImage/FluxImage.vue');
describe('transition: Kenburn', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Kenburn, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Kenburn, {});
const $from = wrapper.getComponent({
ref: '$from',
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
opacity: 0,
transform: expect.any(String),
transition: 'all 1500ms linear',
});
expect(wrapper.vm.totalDuration).toBe(1500);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Kenburn, {
direction: Directions.prev,
totalDuration: 800,
easing: 'ease-in',
});
const $from = wrapper.getComponent({
ref: '$from',
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
opacity: 0,
transform: expect.any(String),
transition: 'all 800ms ease-in',
});
expect(wrapper.vm.totalDuration).toBe(800);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Kenburn, {
direction: Directions.next,
totalDuration: 800,
easing: 'ease-in',
});
const $from = wrapper.getComponent({
ref: '$from',
});
wrapper.vm.onPlay();
expect($from.vm.transform).toHaveBeenCalledWith({
opacity: 0,
transform: expect.any(String),
transition: 'all 800ms ease-in',
});
expect(wrapper.vm.totalDuration).toBe(800);
});
});
================================================
FILE: src/transitions/Kenburn/Kenburn.vue
================================================
================================================
FILE: src/transitions/Kenburn/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionKenburnOptions extends TransitionOptions {
totalDuration?: number;
}
export interface TransitionKenburnProps extends TransitionProps {
options?: TransitionKenburnOptions;
}
export interface TransitionKenburnConf extends TransitionConf {
totalDuration: number;
}
================================================
FILE: src/transitions/Round1/Round1.test.ts
================================================
import Round1 from './Round1.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
import { Turns } from '../../components/FluxCube';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Round1', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Round1, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Round1, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
const maskStyle = wrapper.props('maskStyle');
expect(maskStyle.overflow).toBe('visible');
const gridCss = wrapper
.getComponent({
ref: '$grid',
})
.props('css');
expect(gridCss.perspective).toBeDefined();
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 800ms ease-out 0ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.backr);
expect($tiles[9].setCss).toHaveBeenCalledWith({
transition: 'all 800ms ease-out 300ms',
});
expect($tiles[9].turn).toHaveBeenCalledWith(Turns.backr);
expect(wrapper.vm.totalDuration).toBe(2400);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Round1, {
direction: Directions.prev,
rows: 3,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-in-out 480ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.backl);
expect($tiles[17].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-in-out 60ms',
});
expect($tiles[17].turn).toHaveBeenCalledWith(Turns.backl);
expect(wrapper.vm.totalDuration).toBe(720);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Round1, {
direction: Directions.next,
rows: 3,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-in-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-in-out 0ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.backr);
expect($tiles[17].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-in-out 420ms',
});
expect($tiles[17].turn).toHaveBeenCalledWith(Turns.backr);
expect(wrapper.vm.totalDuration).toBe(720);
});
});
================================================
FILE: src/transitions/Round1/Round1.vue
================================================
================================================
FILE: src/transitions/Round1/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionRound1Options extends TransitionOptions {
rows?: number;
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionRound1Props extends TransitionProps {
options?: TransitionRound1Options;
}
export interface TransitionRound1Conf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
================================================
FILE: src/transitions/Round2/Round2.test.ts
================================================
import Round2 from './Round2.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Round2', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Round2, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Round2, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
const maskStyle = wrapper.props('maskStyle');
expect(maskStyle.overflow).toBe('visible');
const gridCss = wrapper
.getComponent({
ref: '$grid',
})
.props('css');
expect(gridCss.perspective).toBeDefined();
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateY(-540deg)',
transition: 'all 800ms linear 100ms',
});
expect($tiles[9].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateY(-540deg)',
transition: 'all 800ms linear 0ms',
});
expect(wrapper.vm.totalDuration).toBe(1900);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Round2, {
direction: Directions.prev,
rows: 3,
cols: 6,
tileDuration: 400,
tileDelay: 60,
rotateX: -310,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateY(-310deg)',
transition: 'all 400ms ease-out 360ms',
});
expect($tiles[17].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateY(-310deg)',
transition: 'all 400ms ease-out 60ms',
});
expect(wrapper.vm.totalDuration).toBe(720);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Round2, {
direction: Directions.next,
rows: 3,
cols: 6,
tileDuration: 400,
tileDelay: 60,
rotateX: -310,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateY(-310deg)',
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[17].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateY(-310deg)',
transition: 'all 400ms ease-out 300ms',
});
expect(wrapper.vm.totalDuration).toBe(720);
});
});
================================================
FILE: src/transitions/Round2/Round2.vue
================================================
================================================
FILE: src/transitions/Round2/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionRound2Options extends TransitionOptions {
rows?: number;
cols?: number;
tileDuration?: number;
tileDelay?: number;
rotateX?: number;
}
export interface TransitionRound2Props extends TransitionProps {
options?: TransitionRound2Options;
}
export interface TransitionRound2Conf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
rotateX: number;
}
================================================
FILE: src/transitions/Slide/Slide.test.ts
================================================
import Slide from './Slide.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
import { Size } from '../../shared';
vi.mock('../../components/FluxWrapper/FluxWrapper.vue');
describe('transition: Slide', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Slide, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Slide, {});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
expect($wrapper.props('size')).toStrictEqual(
new Size({ width: 1280, height: 360 })
);
expect($wrapper.props('css')).toStrictEqual({
display: 'flex',
flexWrap: 'nowrap',
});
const $left = wrapper.getComponent({
ref: '$left',
});
expect($left.props('size')).toStrictEqual(wrapper.props('size'));
const $right = wrapper.getComponent({
ref: '$right',
});
expect($right.props('size')).toStrictEqual(wrapper.props('size'));
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
transform: 'translateX(-50%)',
transition: 'transform 1400ms ease-in-out',
});
expect(wrapper.vm.totalDuration).toBe(1400);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Slide, {
direction: Directions.prev,
totalDuration: 800,
easing: 'ease-out',
});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
expect($wrapper.props('size')).toStrictEqual(
new Size({ width: 1280, height: 360 })
);
expect($wrapper.props('css')).toStrictEqual({
display: 'flex',
flexWrap: 'nowrap',
transform: 'translateX(-50%)',
});
const $left = wrapper.getComponent({
ref: '$left',
});
expect($left.props('size')).toStrictEqual(wrapper.props('size'));
expect($left.props('rsc')).toStrictEqual(wrapper.props('to'));
const $right = wrapper.getComponent({
ref: '$right',
});
expect($right.props('size')).toStrictEqual(wrapper.props('size'));
expect($right.props('rsc')).toStrictEqual(wrapper.props('from'));
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
transform: 'translateX(0)',
transition: 'transform 800ms ease-out',
});
expect(wrapper.vm.totalDuration).toBe(800);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Slide, {
direction: Directions.next,
totalDuration: 800,
easing: 'ease-out',
});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
expect($wrapper.props('size')).toStrictEqual(
new Size({ width: 1280, height: 360 })
);
expect($wrapper.props('css')).toStrictEqual({
display: 'flex',
flexWrap: 'nowrap',
});
const $left = wrapper.getComponent({
ref: '$left',
});
expect($left.props('size')).toStrictEqual(wrapper.props('size'));
expect($left.props('rsc')).toStrictEqual(wrapper.props('from'));
const $right = wrapper.getComponent({
ref: '$right',
});
expect($right.props('size')).toStrictEqual(wrapper.props('size'));
expect($right.props('rsc')).toStrictEqual(wrapper.props('to'));
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
transform: 'translateX(-50%)',
transition: 'transform 800ms ease-out',
});
expect(wrapper.vm.totalDuration).toBe(800);
});
});
================================================
FILE: src/transitions/Slide/Slide.vue
================================================
================================================
FILE: src/transitions/Slide/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionSlideOptions extends TransitionOptions {
totalDuration?: number;
}
export interface TransitionSlideProps extends TransitionProps {
options?: TransitionSlideOptions;
}
export interface TransitionSlideConf extends TransitionConf {
totalDuration: number;
}
================================================
FILE: src/transitions/Swipe/Swipe.test.ts
================================================
import Swipe from './Swipe.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxWrapper/FluxWrapper.vue');
describe('transition: Swipe', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Swipe, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Swipe, {});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
expect($wrapper.props('size')).toStrictEqual(wrapper.props('size'));
expect($wrapper.props('css')).toStrictEqual({
display: 'flex',
flexWrap: 'nowrap',
justifyContent: 'flex-start',
left: 0,
position: 'absolute',
top: 0,
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('css')).toStrictEqual({
flex: '0 0 auto',
});
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
transition: 'width 1400ms ease-in-out',
width: 0,
});
expect(wrapper.vm.totalDuration).toBe(1400);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Swipe, {
direction: Directions.prev,
totalDuration: 800,
easing: 'ease-out',
});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
expect($wrapper.props('size')).toStrictEqual(wrapper.props('size'));
expect($wrapper.props('css')).toStrictEqual({
display: 'flex',
flexWrap: 'nowrap',
justifyContent: 'flex-end',
right: 0,
position: 'absolute',
top: 0,
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('css')).toStrictEqual({
flex: '0 0 auto',
});
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
transition: 'width 800ms ease-out',
width: 0,
});
expect(wrapper.vm.totalDuration).toBe(800);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Swipe, {
direction: Directions.next,
totalDuration: 800,
easing: 'ease-out',
});
const $wrapper = wrapper.getComponent({
ref: '$wrapper',
});
expect($wrapper.props('size')).toStrictEqual(wrapper.props('size'));
expect($wrapper.props('css')).toStrictEqual({
display: 'flex',
flexWrap: 'nowrap',
justifyContent: 'flex-start',
left: 0,
position: 'absolute',
top: 0,
});
const $from = wrapper.getComponent({
ref: '$from',
});
expect($from.props('css')).toStrictEqual({
flex: '0 0 auto',
});
wrapper.vm.onPlay();
expect($wrapper.vm.transform).toHaveBeenCalledWith({
transition: 'width 800ms ease-out',
width: 0,
});
expect(wrapper.vm.totalDuration).toBe(800);
});
});
================================================
FILE: src/transitions/Swipe/Swipe.vue
================================================
================================================
FILE: src/transitions/Swipe/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionSwipeOptions extends TransitionOptions {
totalDuration?: number;
}
export interface TransitionSwipeProps extends TransitionProps {
options?: TransitionSwipeOptions;
}
export interface TransitionSwipeConf extends TransitionConf {
totalDuration: number;
}
================================================
FILE: src/transitions/Warp/Warp.test.ts
================================================
import Warp from './Warp.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxVortex/FluxVortex.vue');
describe('transition: Warp', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Warp, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Warp, {});
const $vortex = wrapper.getComponent({
ref: '$vortex',
});
wrapper.vm.onPlay();
expect($vortex.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $vortex.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 800ms linear 0ms',
});
expect($tiles[6].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 800ms linear 900ms',
});
expect(wrapper.vm.totalDuration).toBe(1850);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Warp, {
direction: Directions.prev,
circles: 10,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $vortex = wrapper.getComponent({
ref: '$vortex',
});
wrapper.vm.onPlay();
expect($vortex.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $vortex.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 400ms ease-out 540ms',
});
expect($tiles[6].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 400ms ease-out 180ms',
});
expect(wrapper.vm.totalDuration).toBe(1000);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Warp, {
direction: Directions.next,
circles: 10,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $vortex = wrapper.getComponent({
ref: '$vortex',
});
wrapper.vm.onPlay();
expect($vortex.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $vortex.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[6].transform).toHaveBeenCalledWith({
opacity: '0',
transform: 'rotateZ(-90deg)',
transition: 'all 400ms ease-out 360ms',
});
expect(wrapper.vm.totalDuration).toBe(1000);
});
});
================================================
FILE: src/transitions/Warp/Warp.vue
================================================
================================================
FILE: src/transitions/Warp/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionWarpOptions extends TransitionOptions {
circles?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionWarpProps extends TransitionProps {
options?: TransitionWarpOptions;
}
export interface TransitionWarpConf extends TransitionConf {
circles: number;
tileDuration: number;
tileDelay: number;
}
================================================
FILE: src/transitions/Waterfall/Waterfall.test.ts
================================================
import Waterfall from './Waterfall.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Waterfall', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Waterfall, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Waterfall, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 600ms cubic-bezier(0.55, 0.055, 0.675, 0.19) 0ms',
});
expect($tiles[9].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 600ms cubic-bezier(0.55, 0.055, 0.675, 0.19) 810ms',
});
expect(wrapper.vm.totalDuration).toBe(1500);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Waterfall, {
direction: Directions.prev,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 400ms ease-out 300ms',
});
expect($tiles[5].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 400ms ease-out 0ms',
});
expect(wrapper.vm.totalDuration).toBe(760);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Waterfall, {
direction: Directions.next,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[5].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 400ms ease-out 300ms',
});
expect(wrapper.vm.totalDuration).toBe(760);
});
});
================================================
FILE: src/transitions/Waterfall/Waterfall.vue
================================================
================================================
FILE: src/transitions/Waterfall/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionWaterfallOptions extends TransitionOptions {
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionWaterfallProps extends TransitionProps {
options?: TransitionWaterfallOptions;
}
export interface TransitionWaterfallConf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
================================================
FILE: src/transitions/Wave/Wave.test.ts
================================================
import Wave from './Wave.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers';
import { Turns } from '../../components/FluxCube';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Wave', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Wave, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('expects to set proper CSS styles before animation', () => {
const wrapper = AnimationWrapper(Wave, {});
const maskStyle = wrapper.props('maskStyle');
expect(maskStyle.overflow).toBe('visible');
const gridCss = wrapper
.getComponent({
ref: '$grid',
})
.props('css');
expect(gridCss.perspective).toBeDefined();
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Wave, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 900ms cubic-bezier(0.3, -0.3, 0.735, 0.285) 0ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.bottom);
expect($tiles[7].setCss).toHaveBeenCalledWith({
transition: 'all 900ms cubic-bezier(0.3, -0.3, 0.735, 0.285) 770ms',
});
expect($tiles[7].turn).toHaveBeenCalledWith(Turns.bottom);
expect(wrapper.vm.totalDuration).toBe(1780);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Wave, {
direction: Directions.prev,
cols: 6,
tileDuration: 400,
tileDelay: 60,
sideColor: '#999',
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-out 300ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.bottom);
expect($tiles[5].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[5].turn).toHaveBeenCalledWith(Turns.bottom);
expect(wrapper.vm.totalDuration).toBe(760);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Wave, {
direction: Directions.next,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[0].turn).toHaveBeenCalledWith(Turns.bottom);
expect($tiles[5].setCss).toHaveBeenCalledWith({
transition: 'all 400ms ease-out 300ms',
});
expect($tiles[5].turn).toHaveBeenCalledWith(Turns.bottom);
expect(wrapper.vm.totalDuration).toBe(760);
});
});
================================================
FILE: src/transitions/Wave/Wave.vue
================================================
================================================
FILE: src/transitions/Wave/types.ts
================================================
import type { CSSProperties } from 'vue';
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionWaveOptions extends TransitionOptions {
cols?: number;
tileDuration?: number;
tileDelay?: number;
sideColor?: CSSProperties['color'];
}
export interface TransitionWaveProps extends TransitionProps {
options?: TransitionWaveOptions;
}
export interface TransitionWaveConf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
sideColor: CSSProperties['color'];
}
================================================
FILE: src/transitions/Zip/Zip.test.ts
================================================
import Zip from './Zip.vue';
import AnimationWrapper from '../__test__/AnimationWrapper';
import { Directions } from '../../controllers/Player';
vi.mock('../../components/FluxGrid/FluxGrid.vue');
describe('transition: Zip', () => {
it('exposes onPlay and totalDuration', () => {
const wrapper = AnimationWrapper(Zip, {});
const { onPlay, totalDuration } = wrapper.vm;
expect(typeof onPlay).toBe('function');
expect(typeof totalDuration).toBe('number');
});
it('performs the transition with default options', () => {
const wrapper = AnimationWrapper(Zip, {});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 600ms ease-in 0ms',
});
expect($tiles[9].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(-100%)',
transition: 'all 600ms ease-in 720ms',
});
expect(wrapper.vm.totalDuration).toBe(1400);
});
it('performs the transition with custom options prev', () => {
const wrapper = AnimationWrapper(Zip, {
direction: Directions.prev,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 400ms ease-out 300ms',
});
expect($tiles[5].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(-100%)',
transition: 'all 400ms ease-out 0ms',
});
expect(wrapper.vm.totalDuration).toBe(760);
});
it('performs the transition with custom options next', () => {
const wrapper = AnimationWrapper(Zip, {
direction: Directions.next,
cols: 6,
tileDuration: 400,
tileDelay: 60,
easing: 'ease-out',
});
const $grid = wrapper.getComponent({
ref: '$grid',
});
wrapper.vm.onPlay();
expect($grid.vm.transform).toHaveBeenCalledOnce();
const { $tiles } = $grid.vm;
expect($tiles[0].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(100%)',
transition: 'all 400ms ease-out 0ms',
});
expect($tiles[5].transform).toHaveBeenCalledWith({
opacity: '0.1',
transform: 'translateY(-100%)',
transition: 'all 400ms ease-out 300ms',
});
expect(wrapper.vm.totalDuration).toBe(760);
});
});
================================================
FILE: src/transitions/Zip/Zip.vue
================================================
================================================
FILE: src/transitions/Zip/types.ts
================================================
import type { TransitionConf, TransitionOptions, TransitionProps } from '../types';
export interface TransitionZipOptions extends TransitionOptions {
cols?: number;
tileDuration?: number;
tileDelay?: number;
}
export interface TransitionZipProps extends TransitionProps {
options?: TransitionZipOptions;
}
export interface TransitionZipConf extends TransitionConf {
rows: number;
cols: number;
tileDuration: number;
tileDelay: number;
}
================================================
FILE: src/transitions/__test__/AnimationWrapper.ts
================================================
import { mount } from '@vue/test-utils';
import { type Component, markRaw } from 'vue';
import { type FluxComponent, FluxImage } from '../../components';
import { Img } from '../../resources';
import { Size } from '../../shared';
const size = markRaw(
new Size({
width: 640,
height: 360,
}),
);
const from = new Img('from');
const to = new Img('to');
const maskStyle = {
overflow: 'hidden',
perspective: 'none',
zIndex: 3,
};
const displayComponent = mount(markRaw(FluxImage), {
props: {
color: '#ccc',
size: size,
},
});
export default (component: Component, options: object = {}) => {
return mount(component, {
props: {
size,
from,
to,
options,
maskStyle,
displayComponent: displayComponent.vm as FluxComponent,
},
});
};
================================================
FILE: src/transitions/index.ts
================================================
export { default as Fade } from './Fade/Fade.vue';
export { default as Kenburn } from './Kenburn/Kenburn.vue';
export { default as Swipe } from './Swipe/Swipe.vue';
export { default as Slide } from './Slide/Slide.vue';
export { default as Waterfall } from './Waterfall/Waterfall.vue';
export { default as Zip } from './Zip/Zip.vue';
export { default as Blinds2D } from './Blinds2D/Blinds2D.vue';
export { default as Blocks1 } from './Blocks1/Blocks1.vue';
export { default as Blocks2 } from './Blocks2/Blocks2.vue';
export { default as Concentric } from './Concentric/Concentric.vue';
export { default as Warp } from './Warp/Warp.vue';
export { default as Camera } from './Camera/Camera.vue';
export { default as Cube } from './Cube/Cube.vue';
export { default as Book } from './Book/Book.vue';
export { default as Fall } from './Fall/Fall.vue';
export { default as Wave } from './Wave/Wave.vue';
export { default as Blinds3D } from './Blinds3D/Blinds3D.vue';
export { default as Round1 } from './Round1/Round1.vue';
export { default as Round2 } from './Round2/Round2.vue';
export { default as Explode } from './Explode/Explode.vue';
export { default as useTransition } from './useTransition';
export type * from './types';
export type * from './Blinds2D/types';
export type * from './Blinds3D/types';
export type * from './Blocks1/types';
export type * from './Blocks2/types';
export type * from './Book/types';
export type * from './Camera/types';
export type * from './Concentric/types';
export type * from './Cube/types';
export type * from './Explode/types';
export type * from './Fade/types';
export type * from './Fall/types';
export type * from './Kenburn/types';
export type * from './Round1/types';
export type * from './Round2/types';
export type * from './Slide/types';
export type * from './Swipe/types';
export type * from './Warp/types';
export type * from './Waterfall/types';
export type * from './Wave/types';
export type * from './Zip/types';
================================================
FILE: src/transitions/types.ts
================================================
import { Resource } from '../resources';
import type { CSSProperties, Component } from 'vue';
import type { Direction } from '../controllers/Player';
import { Size } from '../shared';
import type { FluxComponent } from '../components';
export interface TransitionProps {
size: Size;
from: Resource;
to?: Resource;
options?: object;
maskStyle: CSSProperties;
displayComponent: FluxComponent;
}
export interface TransitionOptions {
direction?: Direction;
easing?: CSSProperties['animation-timing-function'];
}
export interface TransitionConf {
totalDuration?: number;
direction?: Direction;
easing: CSSProperties['animation-timing-function'];
}
export type TransitionComponent = Component & {
totalDuration: number;
onPlay: () => void;
};
export interface TransitionWithOptions {
component: Component;
options: object;
}
================================================
FILE: src/transitions/useTransition.ts
================================================
import { Directions } from '../controllers/Player';
import type { TransitionConf } from './types';
export default function useTransition(conf: TransitionConf, options?: object) {
Object.assign(conf, { direction: Directions.next }, options);
}
================================================
FILE: tsconfig.app.json
================================================
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.spec.ts",
"src/**/*.spec.tsx",
"src/**/__tests__/**"
],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}
================================================
FILE: tsconfig.build.json
================================================
{
"extends": "./tsconfig.app.json",
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
"exclude": ["src/**/__tests__/**", "src/**/__mocks__/**", "src/playgrounds/**"],
"compilerOptions": {
"types": ["vitest/globals", "node", "jsdom"]
}
}
================================================
FILE: tsconfig.json
================================================
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
],
"compilerOptions": {
"types": ["vitest/globals"]
},
"exclude": [
"src/App.vue",
"src/main.ts",
"node_modules",
"dist",
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.spec.ts",
"src/**/*.spec.tsx"
]
}
================================================
FILE: tsconfig.node.json
================================================
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}
================================================
FILE: tsconfig.vitest.json
================================================
{
"extends": "./tsconfig.app.json",
"include": ["src/**/*.test.ts", "env.d.ts"],
"exclude": [],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
"types": ["vitest/globals", "node", "jsdom"],
"lib": []
}
}
================================================
FILE: vite.config.ts
================================================
import { fileURLToPath, URL } from 'node:url';
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import tailwindcss from '@tailwindcss/vite';
import vueDevTools from 'vite-plugin-vue-devtools';
import dts from 'vite-plugin-dts';
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
tailwindcss(),
dts({
tsconfigPath: './tsconfig.build.json',
rollupTypes: true,
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
copyPublicDir: false,
lib: {
entry: resolve(__dirname, 'src/lib.ts'),
name: 'VueFlux',
fileName: 'vue-flux',
},
rollupOptions: {
external: ['vue'],
output: {
globals: {
vue: 'Vue',
},
},
},
},
});
================================================
FILE: vitest.config.ts
================================================
import { fileURLToPath } from 'node:url';
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config';
import viteConfig from './vite.config';
export default mergeConfig(
viteConfig,
defineConfig({
test: {
globals: true,
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
);