├── .npmignore ├── docs └── favicon.ico ├── example ├── content │ ├── code │ │ ├── handle-clone-hover.css │ │ ├── handle-dom-change.js │ │ ├── cloning-event-listeners.html │ │ ├── external-scroll-engine.js │ │ ├── event-table.md │ │ ├── styles.css │ │ ├── public-fields.md │ │ ├── table.md │ │ ├── markup.html │ │ ├── initialization.js │ │ ├── event-table.html │ │ ├── public-fields.html │ │ └── table.html │ └── emoji.html ├── favicon │ ├── favicon.gif │ ├── favicon.ico │ ├── favicon.jpg │ ├── favicon.png │ └── favicon.svg ├── styles │ ├── shared │ │ ├── _globals.scss │ │ ├── _transitions.scss │ │ ├── _colors.scss │ │ ├── _breakpoints.scss │ │ └── _grid.scss │ ├── components │ │ ├── sulk.scss │ │ ├── header.scss │ │ ├── footer.scss │ │ ├── menu.scss │ │ ├── about.scss │ │ ├── logo.scss │ │ ├── emoji.scss │ │ ├── pager.scss │ │ ├── language.scss │ │ ├── highlighter.scss │ │ ├── fixed.scss │ │ ├── scrollbar.scss │ │ ├── typography.scss │ │ ├── common.scss │ │ └── code-highlight.scss │ └── main.scss ├── emoji │ ├── render-tongue.ts │ ├── render-hand.ts │ ├── render-glasses.ts │ ├── render-left-brow.ts │ ├── render-mouth-ellipse.ts │ ├── paths.ts │ ├── render-open-eye.ts │ ├── render-hp-bar.ts │ ├── mix-config-by-progress.ts │ ├── render-emoji-face.ts │ ├── select-emoji-nodes.ts │ ├── config.ts │ ├── sulking │ │ ├── phrases.ru.ts │ │ ├── phrases.en.ts │ │ └── init-sulking.ts │ └── animation.ts ├── svg │ ├── options.svg │ ├── why-immerser.svg │ ├── how-it-works-face.svg │ ├── how-it-works-hand.svg │ ├── how-to-use.svg │ └── possibilities.svg └── main.ts ├── .gitignore ├── tsconfig.eslint.json ├── .babelrc ├── tsconfig.json ├── .vscode └── settings.json ├── .github └── workflows │ └── publish.yml ├── api-extractor.json ├── webpack.config.js ├── agents.md ├── scripts ├── post-build.js ├── build-source-code.js ├── build-public-fields.js ├── readme.js ├── build-events-table.js └── build-options-table.js ├── TODO.md ├── src ├── utils.ts ├── options.ts └── types.ts ├── .eslintrc ├── package.json ├── webpack.config.docs.js ├── changelog.md ├── dist └── immerser.min.d.ts ├── i18n ├── en.js └── ru.js └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | !README.md 2 | !dist/tsdoc-metadata.json 3 | *.map 4 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dubaua/immerser/HEAD/docs/favicon.ico -------------------------------------------------------------------------------- /example/content/code/handle-clone-hover.css: -------------------------------------------------------------------------------- 1 | a:hover, 2 | a._hover { 3 | color: magenta; 4 | } -------------------------------------------------------------------------------- /example/favicon/favicon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dubaua/immerser/HEAD/example/favicon/favicon.gif -------------------------------------------------------------------------------- /example/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dubaua/immerser/HEAD/example/favicon/favicon.ico -------------------------------------------------------------------------------- /example/favicon/favicon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dubaua/immerser/HEAD/example/favicon/favicon.jpg -------------------------------------------------------------------------------- /example/favicon/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dubaua/immerser/HEAD/example/favicon/favicon.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dev_dist/ 4 | /.cache 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | 9 | dist/types 10 | dist/tsdoc-metadata.json 11 | -------------------------------------------------------------------------------- /example/styles/shared/_globals.scss: -------------------------------------------------------------------------------- 1 | @forward './breakpoints'; 2 | @forward './colors'; 3 | @forward './grid'; 4 | @forward './transitions'; 5 | 6 | $base: 16px; 7 | $cyrillic-modifier: '.font-cyrillic'; 8 | $contrast-surface: '.background--contrast'; 9 | -------------------------------------------------------------------------------- /example/content/code/handle-dom-change.js: -------------------------------------------------------------------------------- 1 | // <%= getTranslation('recipes-changing-dom') %> 2 | document.appendChild(someNode); 3 | document.removeChild(anotherNode); 4 | 5 | // <%= getTranslation('recipes-redraw-immerser') %> 6 | immerserInstance.render(); 7 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "declaration": false, 6 | "emitDeclarationOnly": false, 7 | "rootDir": "." 8 | }, 9 | "include": ["src/**/*.ts", "example/**/*.ts"], 10 | "exclude": ["dist", "node_modules"] 11 | } 12 | -------------------------------------------------------------------------------- /example/favicon/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /example/emoji/render-tongue.ts: -------------------------------------------------------------------------------- 1 | const tongueMaxShiftX = 102; 2 | const tongueMaxShiftY = 72; 3 | 4 | export function renderTongue(valueX: number, valueY: number, node: SVGPathElement): void { 5 | const shiftX = tongueMaxShiftX * valueX; 6 | const shiftY = tongueMaxShiftY * valueY; 7 | node.setAttribute('transform', `translate(${shiftX} ${shiftY})`); 8 | } 9 | -------------------------------------------------------------------------------- /example/styles/components/sulk.scss: -------------------------------------------------------------------------------- 1 | @use '../shared/globals.scss' as *; 2 | 3 | .sulk { 4 | overflow: visible; 5 | &__phrase { 6 | position: absolute; 7 | top: 39px; 8 | right: $col-width * 0.5; 9 | width: 250px; 10 | text-align: right; 11 | pointer-events: none; 12 | touch-action: none; 13 | user-select: none; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /example/styles/shared/_transitions.scss: -------------------------------------------------------------------------------- 1 | @mixin transition( 2 | $property: all, 3 | $duration: 0.2s, 4 | $timing-function: cubic-bezier(0.25, 0.1, 0, 1), 5 | $delay: 0s 6 | ) { 7 | transition-property: #{$property}; 8 | transition-duration: $duration; 9 | transition-timing-function: $timing-function; 10 | transition-delay: $delay; 11 | } 12 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": false, 7 | "targets": { 8 | "browsers": ["> 1%", "last 2 versions", "not ie <= 8", "ie >= 11"] 9 | } 10 | } 11 | ], 12 | "@babel/preset-typescript" 13 | ], 14 | "plugins": ["@babel/plugin-proposal-class-properties"] 15 | } 16 | -------------------------------------------------------------------------------- /example/content/code/cloning-event-listeners.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
6 |
7 |
8 |
9 | 10 |
11 |
12 |
-------------------------------------------------------------------------------- /example/content/code/external-scroll-engine.js: -------------------------------------------------------------------------------- 1 | import Immerser from 'immerser'; 2 | 3 | const immerserInstance = new Immerser({ 4 | // <%= getTranslation('recipes-disable-scroll-handling-with-external-scroll') %> 5 | isScrollHandled: false, 6 | }); 7 | 8 | customScrollEngine.on('scroll', () => { 9 | // <%= getTranslation('recipes-sync-with-external-engine') %> 10 | immerserInstance.syncScroll(); 11 | }); 12 | -------------------------------------------------------------------------------- /example/styles/components/header.scss: -------------------------------------------------------------------------------- 1 | @use '../shared/globals.scss' as *; 2 | 3 | .header { 4 | padding-top: $base * 2; 5 | padding-bottom: $base * 0.28; 6 | @include show-from-to('xs', 'md') { 7 | padding-left: $col-width * 2; 8 | padding-right: $col-width * 2; 9 | @include from('sm') { 10 | padding-left: $col-width; 11 | padding-right: $col-width; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /example/styles/components/footer.scss: -------------------------------------------------------------------------------- 1 | @use '../shared/globals.scss' as *; 2 | 3 | .footer { 4 | margin-top: $base * 1.5; 5 | padding-top: $base * 1.5; 6 | padding-bottom: $base * 1.5; 7 | line-height: $base * 1.5; 8 | @include show-from-to('xs', 'md') { 9 | padding-left: $col-width * 2; 10 | padding-right: $col-width * 2; 11 | @include from('sm') { 12 | padding-left: $col-width; 13 | padding-right: $col-width; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /example/emoji/render-hand.ts: -------------------------------------------------------------------------------- 1 | import { easeCircleIn } from 'd3-ease'; 2 | const handOffsetFactor = 12; 3 | 4 | export function renderHand(value: number, innerNode: SVGGraphicsElement, outerNode: SVGGElement): void { 5 | const translateX = -handOffsetFactor * (1 - easeCircleIn(value)); 6 | const translateY = handOffsetFactor * (1 - easeCircleIn(value)); 7 | outerNode.setAttribute('transform', `translate(${translateX} ${translateY})`); 8 | innerNode.setAttribute('opacity', value.toString()); 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "lib": ["DOM", "ES2022"], 7 | "baseUrl": "./", 8 | "types": ["node"], 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "declaration": true, 12 | "emitDeclarationOnly": true, 13 | "outDir": "./dist/types", 14 | "rootDir": "./src" 15 | }, 16 | "include": ["src"], 17 | "exclude": ["node_modules", "dist"] 18 | } 19 | -------------------------------------------------------------------------------- /example/styles/main.scss: -------------------------------------------------------------------------------- 1 | @use 'normalize.css/normalize'; 2 | @use './components/about'; 3 | @use './components/code-highlight'; 4 | @use './components/common'; 5 | @use './components/emoji'; 6 | @use './components/scrollbar'; 7 | @use './components/fixed'; 8 | @use './components/footer'; 9 | @use './components/header'; 10 | @use './components/language'; 11 | @use './components/logo'; 12 | @use './components/menu'; 13 | @use './components/pager'; 14 | @use './components/typography'; 15 | @use './components/highlighter'; 16 | @use './components/sulk'; 17 | -------------------------------------------------------------------------------- /example/content/code/event-table.md: -------------------------------------------------------------------------------- 1 | | event | arguments | description | 2 | | - | - | - | 3 | | init | `immerser: Immerser` | Emitted after initialization. | 4 | | bind | `immerser: Immerser` | Emitted after binding DOM. | 5 | | unbind | `immerser: Immerser` | Emitted after unbinding DOM. | 6 | | destroy | `immerser: Immerser` | Emitted after destroy. | 7 | | activeLayerChange | `layerIndex: number`
`immerser: Immerser` | Emitted after active layer change. | 8 | | layersUpdate | `layersProgress: number[]`
`immerser: Immerser` | Emitted on each scroll update. | 9 | -------------------------------------------------------------------------------- /example/emoji/render-glasses.ts: -------------------------------------------------------------------------------- 1 | import { easeCircleIn } from 'd3-ease'; 2 | 3 | const glassesOffsetX = 550; 4 | const glassesOffsetY = -100; 5 | const glassesRotation = 30; 6 | 7 | export function renderGlasses(value: number, node: SVGGElement): void { 8 | const translateX = glassesOffsetX * (1 - easeCircleIn(value)); 9 | const translateY = glassesOffsetY * (1 - easeCircleIn(value)); 10 | const rotate = glassesRotation * (1 - easeCircleIn(value)); 11 | node.setAttribute('transform', `translate(${translateX} ${translateY}) rotate(${rotate})`); 12 | } 13 | -------------------------------------------------------------------------------- /example/emoji/render-left-brow.ts: -------------------------------------------------------------------------------- 1 | const browMaxTranslateY = 8; 2 | 3 | function renderBrow(scaleX: number, valueY: number, node: SVGPathElement): void { 4 | const translateY = -browMaxTranslateY * valueY; 5 | node.setAttribute('transform', `scale(${scaleX} 1) translate(0 ${translateY})`); 6 | } 7 | 8 | export function renderLeftBrow(scaleX: number, translateY: number, node: SVGPathElement): void { 9 | renderBrow(scaleX, translateY, node); 10 | } 11 | 12 | export function renderRightBrow(scaleX: number, translateY: number, node: SVGPathElement): void { 13 | renderBrow(scaleX, translateY, node); 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.language": "en,ru", 3 | "cSpell.words": [ 4 | "BUNDLESIZE", 5 | "classname", 6 | "Cooldown", 7 | "dubaua", 8 | "gzipped", 9 | "immerser", 10 | "Lysov", 11 | "ogawa", 12 | "pseudoselector", 13 | "Regen", 14 | "rsquo", 15 | "statemap", 16 | "syncro", 17 | "гитхаб", 18 | "джаваскрипт", 19 | "джаваскрипте", 20 | "иммёрсер", 21 | "иммёрсера", 22 | "колбек", 23 | "нодам", 24 | "псевдоселектор", 25 | "псевдоселектором", 26 | "скролле", 27 | "скроллом", 28 | "скроллу" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /example/styles/components/menu.scss: -------------------------------------------------------------------------------- 1 | @use '../shared/globals.scss' as *; 2 | 3 | .menu { 4 | $menu: &; 5 | display: flex; 6 | margin: 0 $base * -1; 7 | 8 | &__link { 9 | color: black; 10 | display: block; 11 | font-size: $base; 12 | line-height: 1; 13 | text-decoration: none; 14 | padding: 26px $base; 15 | @include transition('color'); 16 | @at-root #{$cyrillic-modifier} & { 17 | font-size: 14px; 18 | letter-spacing: -0.03em; 19 | padding-top: 28px; 20 | } 21 | } 22 | 23 | &--contrast #{$menu}__link { 24 | color: $color-text--contrast; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /example/emoji/render-mouth-ellipse.ts: -------------------------------------------------------------------------------- 1 | const mouthEllipseMaxRy = 75; 2 | const mouthLineMaxShiftX = 50; // px 3 | 4 | export function renderMouthEllipse( 5 | value: number, 6 | clipNode: SVGEllipseElement, 7 | shapeNode: SVGEllipseElement, 8 | ): void { 9 | const ry = (mouthEllipseMaxRy * value).toString(); 10 | clipNode.setAttribute('ry', ry); 11 | shapeNode.setAttribute('ry', ry); 12 | } 13 | 14 | export function renderMouthLine(scaleX: number, shiftX: number, node: SVGPathElement): void { 15 | const translateX = mouthLineMaxShiftX * (shiftX - 0.5) * 2; 16 | node.setAttribute('transform', `translate(${translateX} 0) scale(${scaleX} 1)`); 17 | } 18 | -------------------------------------------------------------------------------- /example/styles/shared/_colors.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:color'; 2 | 3 | $color-primary: white; 4 | $color-secondary: black; 5 | $color-accent: magenta; 6 | $color-highlight: cyan; 7 | $color-marker: yellow; 8 | $color-primary--muted: #999; 9 | $color-secondary--muted: #666; 10 | 11 | $color-background: $color-primary; 12 | $color-background--contrast: $color-secondary; 13 | $color-text: $color-secondary; 14 | $color-text--contrast: $color-primary; 15 | $color-active: $color-accent; 16 | 17 | $color-code-background: color.scale($color-accent, $lightness: 90%, $saturation: -75%); 18 | $color-code-background--contrast: color.scale($color-accent, $lightness: -70%, $saturation: -75%); 19 | -------------------------------------------------------------------------------- /example/styles/components/about.scss: -------------------------------------------------------------------------------- 1 | @use '../shared/globals.scss' as *; 2 | 3 | .about { 4 | padding-bottom: $base - 2px; 5 | margin: 0 $base * -1; 6 | 7 | @at-root #{$cyrillic-modifier} & { 8 | padding-bottom: $base - 1px; 9 | font-size: 14px; 10 | letter-spacing: -0.01em; 11 | } 12 | color: $color-text; 13 | &--contrast { 14 | color: $color-text--contrast; 15 | } 16 | span, 17 | a { 18 | display: inline-block; 19 | padding-left: $base; 20 | color: inherit; 21 | &:last-child { 22 | padding-right: $base; 23 | } 24 | @include transition('color'); 25 | @include from('lg') { 26 | padding-right: $base; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /example/svg/options.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /example/emoji/paths.ts: -------------------------------------------------------------------------------- 1 | export type Point = { x: number; y: number }; 2 | 3 | export function buildCrossPath(points: ReadonlyArray, center: Point, factor: number): string { 4 | const scaled = points.map(({ x, y }) => ({ 5 | x: center.x + (x - center.x) * factor, 6 | y: center.y + (y - center.y) * factor, 7 | })); 8 | 9 | return `M${scaled[0].x} ${scaled[0].y}L${scaled[1].x} ${scaled[1].y}M${scaled[2].x} ${scaled[2].y}L${scaled[3].x} ${scaled[3].y}`; 10 | } 11 | 12 | export function buildLinePath(points: ReadonlyArray, center: Point, factor: number): string { 13 | const scaled = points.map(({ x, y }) => ({ 14 | x: center.x + (x - center.x) * factor, 15 | y: center.y + (y - center.y) * factor, 16 | })); 17 | 18 | return `M${scaled[0].x} ${scaled[0].y}H${scaled[1].x}`; 19 | } 20 | -------------------------------------------------------------------------------- /example/styles/components/logo.scss: -------------------------------------------------------------------------------- 1 | @use '../shared/globals.scss' as *; 2 | 3 | .logo { 4 | padding-top: $base * 0.62; 5 | font-size: $base * 3; 6 | @at-root #{$cyrillic-modifier} & { 7 | font-size: $base * 2.75; 8 | transform: translate(0px, -3px); 9 | letter-spacing: -0.03em; 10 | } 11 | line-height: $base * 2; 12 | color: $color-text; 13 | display: block; 14 | text-decoration: none; 15 | @include transition('color'); 16 | 17 | &--contrast { 18 | color: $color-text--contrast; 19 | } 20 | 21 | @include from('lg') { 22 | &--contrast-lg { 23 | color: $color-text--contrast; 24 | } 25 | } 26 | 27 | @include from-to('md', 'lg') { 28 | &--contrast-only-md { 29 | color: $color-text--contrast; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | permissions: 9 | contents: read 10 | id-token: write 11 | 12 | jobs: 13 | publish: 14 | name: Publish package 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Use Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | registry-url: https://registry.npmjs.org 26 | always-auth: true 27 | cache: npm 28 | 29 | - name: Install dependencies 30 | run: npm ci 31 | 32 | - name: Build library 33 | run: npm run build 34 | 35 | - name: Publish to npm 36 | run: npm publish --access public --provenance 37 | -------------------------------------------------------------------------------- /api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", 3 | "compiler": { 4 | "tsconfigFilePath": "./tsconfig.json" 5 | }, 6 | "mainEntryPointFilePath": "./dist/types/immerser.d.ts", 7 | "apiReport": { 8 | "enabled": false 9 | }, 10 | "docModel": { 11 | "enabled": false 12 | }, 13 | "dtsRollup": { 14 | "enabled": true, 15 | "untrimmedFilePath": "./dist/immerser.min.d.ts" 16 | }, 17 | "messages": { 18 | "compilerMessageReporting": { 19 | "default": { 20 | "logLevel": "warning" 21 | } 22 | }, 23 | "extractorMessageReporting": { 24 | "default": { 25 | "logLevel": "warning" 26 | } 27 | }, 28 | "tsdocMessageReporting": { 29 | "default": { 30 | "logLevel": "warning" 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /example/content/code/styles.css: -------------------------------------------------------------------------------- 1 | .fixed { 2 | position: fixed; 3 | top: 2em; 4 | bottom: 3em; 5 | left: 3em; 6 | right: 3em; 7 | z-index: 1; 8 | } 9 | .fixed__pager { 10 | position: absolute; 11 | top: 50%; 12 | left: 0; 13 | transform: translate(0, -50%); 14 | } 15 | .fixed__logo { 16 | position: absolute; 17 | top: 0; 18 | left: 0; 19 | } 20 | .fixed__menu { 21 | position: absolute; 22 | top: 0; 23 | right: 0; 24 | } 25 | .fixed__language { 26 | position: absolute; 27 | bottom: 0; 28 | left: 0; 29 | } 30 | .fixed__about { 31 | position: absolute; 32 | bottom: 0; 33 | right: 0; 34 | } 35 | .pager, 36 | .logo, 37 | .menu, 38 | .language, 39 | .about { 40 | color: black; 41 | } 42 | .pager--contrast, 43 | .logo--contrast, 44 | .menu--contrast, 45 | .language--contrast, 46 | .about--contrast { 47 | color: white; 48 | } -------------------------------------------------------------------------------- /example/styles/components/emoji.scss: -------------------------------------------------------------------------------- 1 | @use '../shared/globals.scss' as *; 2 | 3 | .emoji { 4 | $emoji: &; 5 | position: relative; 6 | &__face { 7 | display: block; 8 | overflow: visible; 9 | cursor: pointer; 10 | } 11 | &__hand { 12 | position: absolute; 13 | left: 0; 14 | top: 0; 15 | pointer-events: none; 16 | touch-action: none; 17 | transform-origin: left bottom; 18 | } 19 | &__stroke-path { 20 | stroke-width: 5; 21 | stroke: $color-secondary; 22 | transform-box: fill-box; 23 | transform-origin: 50% 50%; 24 | 25 | &--thin { 26 | stroke-width: 2; 27 | } 28 | } 29 | &__fill { 30 | fill: $color-primary; 31 | transform-box: fill-box; 32 | transform-origin: 50% 50%; 33 | } 34 | &--inverse { 35 | color: $color-text--contrast; 36 | #{$emoji}__stroke-path { 37 | stroke: $color-primary; 38 | } 39 | #{$emoji}__fill { 40 | fill: $color-secondary; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const TerserPlugin = require('terser-webpack-plugin'); 3 | 4 | module.exports = { 5 | entry: './src/immerser.ts', 6 | resolve: { 7 | alias: { 8 | '@': path.resolve(__dirname, 'src'), 9 | }, 10 | extensions: ['.ts', '.js'], 11 | }, 12 | output: { 13 | filename: 'immerser.min.js', 14 | library: 'Immerser', 15 | libraryTarget: 'umd', 16 | libraryExport: 'default', 17 | globalObject: 'this', 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.(ts|js)$/, 23 | use: ['babel-loader'], 24 | exclude: /node_modules/, 25 | }, 26 | ], 27 | }, 28 | devtool: 'source-map', 29 | optimization: { 30 | minimize: true, 31 | minimizer: [ 32 | new TerserPlugin({ 33 | terserOptions: { 34 | mangle: { 35 | reserved: ['Immerser'], 36 | }, 37 | }, 38 | }), 39 | ], 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /example/svg/why-immerser.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /example/styles/components/pager.scss: -------------------------------------------------------------------------------- 1 | @use '../shared/globals.scss' as *; 2 | 3 | .pager { 4 | $pager: &; 5 | $pager-size: $base * 0.75; 6 | $border-width: 1.5px; 7 | $border-width--active: $pager-size * 0.5; 8 | 9 | &__link { 10 | display: block; 11 | margin: $pager-size 0; 12 | width: $pager-size; 13 | height: $pager-size; 14 | box-sizing: border-box; 15 | border-radius: 50%; 16 | color: $color-text; 17 | border: $border-width solid; 18 | @include transition('color, border-width'); 19 | 20 | &:hover, 21 | &._hover { 22 | color: $color-active; 23 | } 24 | 25 | &--active { 26 | border-width: $border-width--active; 27 | } 28 | } 29 | 30 | &--contrast #{$pager}__link { 31 | color: $color-text--contrast; 32 | } 33 | 34 | @include from('lg') { 35 | &--contrast-lg #{$pager}__link { 36 | color: $color-text--contrast; 37 | } 38 | } 39 | 40 | @include from-to('md', 'lg') { 41 | &--contrast-only-md #{$pager}__link { 42 | color: $color-text--contrast; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /example/styles/components/language.scss: -------------------------------------------------------------------------------- 1 | @use '../shared/globals.scss' as *; 2 | 3 | .language { 4 | @include from('md') { 5 | padding-bottom: $base - 2px; 6 | } 7 | margin: 0 $base * -1; 8 | 9 | @at-root #{$cyrillic-modifier} & { 10 | @include from('lg') { 11 | padding-bottom: $base - 1px; 12 | } 13 | font-size: 14px; 14 | letter-spacing: -0.01em; 15 | } 16 | 17 | color: $color-text; 18 | &--contrast a { 19 | color: $color-text--contrast; 20 | } 21 | @include from('lg') { 22 | &--contrast-lg { 23 | color: $color-text--contrast; 24 | } 25 | } 26 | @include from-to('md', 'lg') { 27 | &--contrast-only-md { 28 | color: $color-text--contrast; 29 | } 30 | } 31 | &__link { 32 | color: inherit; 33 | @include transition('color'); 34 | display: inline-block; 35 | padding-left: $base; 36 | @include from('lg') { 37 | padding-right: $base; 38 | } 39 | font-size: 14px; 40 | 41 | &--active { 42 | font-size: 16px; 43 | @at-root #{$cyrillic-modifier} & { 44 | font-size: 14px; 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /agents.md: -------------------------------------------------------------------------------- 1 | # AGENTS.md 2 | 3 | ## Tests 4 | 5 | - Every test block uses `it('...')`. 6 | - One data scenario per `it`. Only exception: checking multiple fields on the _same_ return value. 7 | 8 | ## Docs & comments 9 | 10 | - Add JSDoc only when a function has non-trivial logic _and_ sits in a shared/standalone module. 11 | - Skip JSDoc for reducers, selectors, event handlers, or simple wrappers. 12 | - Comments explain **why** something happens, never restate the code. 13 | 14 | ## Code clarity 15 | 16 | - No inline “this does that” comments. 17 | - Avoid readability cleanups that do not change behaviour. 18 | 19 | ## Naming 20 | 21 | - All interfaces start with `I...`. 22 | 23 | ## Obey explicit commands 24 | 25 | - Never refactor, rename, or change signatures unless the user says so. 26 | - When asked to move a function, copy it exactly—same name, signature, and body. 27 | - No structural or stylistic edits (imports, ordering, formatting) unless told to. 28 | - Never alter formatting mid-file. Repeat the existing pattern. 29 | - All “improvements” require direct user approval. 30 | 31 | ## Check for this is followed 32 | 33 | Each your answer start with "Honk" word as if you were a goose. 34 | -------------------------------------------------------------------------------- /scripts/post-build.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const gzipSize = require('gzip-size'); 4 | const rootDir = path.join(__dirname, '..'); 5 | const packageJSON = require(path.join(rootDir, 'package.json')); 6 | 7 | const bundle = fs.readFileSync(path.join(rootDir, 'dist', 'immerser.min.js'), 'utf8'); 8 | 9 | const bungleSize = (Math.round(gzipSize.sync(bundle) / 1000 * 100) / 100).toString(); 10 | 11 | const version = packageJSON.version; 12 | 13 | function replacer(content) { 14 | let result = content; 15 | const thisYear = new Date().getFullYear(); 16 | result = result.replace(/%%BUNDLESIZE%%/g, bungleSize); 17 | result = result.replace(/%%VERSION%%/g, version); 18 | result = result.replace(/%%THIS_YEAR%%/g, thisYear); 19 | return result; 20 | } 21 | 22 | function replaceInFile(filePath, replacer) { 23 | const content = fs.readFileSync(filePath, 'utf8'); 24 | const result = replacer(content); 25 | fs.writeFileSync(filePath, result); 26 | } 27 | 28 | replaceInFile(path.join(rootDir, 'README.md'), replacer); 29 | replaceInFile(path.join(rootDir, 'docs', 'index.html'), replacer); 30 | replaceInFile(path.join(rootDir, 'docs', 'ru.html'), replacer); 31 | -------------------------------------------------------------------------------- /example/svg/how-it-works-face.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/styles/components/highlighter.scss: -------------------------------------------------------------------------------- 1 | @use '../shared/globals.scss' as *; 2 | 3 | @include from('md') { 4 | .highlighter, 5 | p code.highlighter { 6 | cursor: pointer; 7 | text-decoration: underline; 8 | text-decoration-line: underline; 9 | text-decoration-style: double; 10 | text-decoration-color: $color-highlight; 11 | font-size: inherit; 12 | font-family: inherit; 13 | padding: 0; 14 | background: none; 15 | } 16 | 17 | .highlighter-animation-active { 18 | animation: highlight 1.5s linear infinite; 19 | &[data-immerser-layer] { 20 | position: relative; 21 | animation: none; 22 | &:after { 23 | content: ''; 24 | position: absolute; 25 | top: 0; 26 | bottom: 0; 27 | left: 0; 28 | right: 0; 29 | pointer-events: none; 30 | animation: highlight 1.5s linear infinite; 31 | } 32 | } 33 | } 34 | 35 | @keyframes highlight { 36 | 25% { 37 | background: rgba($color-highlight, 0.5); 38 | } 39 | 50% { 40 | background: transparent; 41 | } 42 | 75% { 43 | background: rgba($color-highlight, 0.5); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /example/svg/how-it-works-hand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /example/content/code/public-fields.md: -------------------------------------------------------------------------------- 1 | | name | kind | description | 2 | | - | - | - | 3 | | debug | `property` | Controls whether immerser reports warnings and errors | 4 | | bind | `method` | Clones markup, attaches listeners, and starts internal logic | 5 | | unbind | `method` | Remove generated markup and listeners, keeping the instance reusable | 6 | | destroy | `method` | Fully destroys immerser: disables it, removes listeners, restores original markup, and clears internal state | 7 | | render | `method` | Recalculates sizes and redraws masks | 8 | | syncScroll | `method` | Updates immerser when scroll is controlled externally (requires isScrollHandled = false) | 9 | | on | `method` | Registers a persistent immerser event handler | 10 | | once | `method` | Registers a one-time immerser event handler that is removed after the first call | 11 | | off | `method` | Removes a specific handler for the given immerser event | 12 | | activeIndex | `getter` | Index of the currently active layer, calculated from scroll position | 13 | | isBound | `getter` | Indicates whether immerser is currently active (markup cloned, listeners attached) | 14 | | rootNode | `getter` | Root element the immerser instance is attached to | 15 | | layerProgressArray | `getter` | Per-layer progress values (0–1) showing how much each layer is visible in the viewport | 16 | -------------------------------------------------------------------------------- /example/svg/how-to-use.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /example/styles/components/fixed.scss: -------------------------------------------------------------------------------- 1 | @use '../shared/globals.scss' as *; 2 | 3 | .fixed { 4 | display: none; 5 | @include from('md') { 6 | position: fixed; 7 | display: block; 8 | top: $base * 2; 9 | bottom: $base * 2; 10 | left: 0; 11 | right: 0; 12 | z-index: 2; 13 | 14 | &__pager { 15 | position: absolute; 16 | top: 50%; 17 | right: $col-width * 0.5; 18 | @include from('lg') { 19 | right: auto; 20 | left: $col-width * 0.5; 21 | } 22 | transform: translate(0, -50%); 23 | } 24 | 25 | &__logo { 26 | position: absolute; 27 | top: 0; 28 | left: $col-width * 0.5; 29 | } 30 | 31 | &__menu { 32 | position: absolute; 33 | top: 0; 34 | right: $col-width * 0.5; 35 | } 36 | 37 | &__language { 38 | position: absolute; 39 | bottom: 0; 40 | left: $col-width * 0.5; 41 | } 42 | 43 | &__about { 44 | position: absolute; 45 | bottom: 0; 46 | right: $col-width * 0.5; 47 | } 48 | 49 | &__emoji { 50 | position: absolute; 51 | right: 0; 52 | top: 50%; 53 | height: 250px; 54 | padding-right: $col-width * 0.5; 55 | transform: translate(0, -50%); 56 | overflow: hidden; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /example/styles/components/scrollbar.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:color'; 2 | @use '../shared/globals.scss' as *; 3 | 4 | // Horizontal scroll now rely on native scrollbars with themed colors. 5 | $scrollbar-height: $base; 6 | $scrollbar-thumb: color.scale($color-accent, $lightness: 62%, $saturation: -62%); 7 | $scrollbar-thumb-hover: color.scale($color-accent, $lightness: 62% + 5%, $saturation: -62% + 5%); 8 | $scrollbar-thumb-contrast: color.scale($color-highlight, $lightness: -62%, $saturation: -62%); 9 | $scrollbar-thumb-contrast-hover: color.scale($color-highlight, $lightness: -62% + 5%, $saturation: -62% + 5%); 10 | 11 | @mixin scrollbar-colors($thumb, $thumb-hover) { 12 | &::-webkit-scrollbar { 13 | height: $scrollbar-height; 14 | background: transparent; 15 | } 16 | 17 | &::-webkit-scrollbar-thumb { 18 | cursor: ew-resize; 19 | background-color: $thumb; 20 | } 21 | 22 | &::-webkit-scrollbar-thumb:hover { 23 | background-color: $thumb-hover; 24 | } 25 | } 26 | 27 | .scroller-x { 28 | overflow-x: auto; 29 | overflow-y: hidden; 30 | @include scrollbar-colors($scrollbar-thumb, $scrollbar-thumb-hover); 31 | 32 | &.background { 33 | @include scrollbar-colors($scrollbar-thumb, $scrollbar-thumb-hover); 34 | } 35 | 36 | &.background--contrast { 37 | @include scrollbar-colors($scrollbar-thumb-contrast, $scrollbar-thumb-contrast-hover); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /example/content/code/table.md: -------------------------------------------------------------------------------- 1 | | option | type | default | description | 2 | | - | - | - | - | 3 | | solidClassnameArray | `array` | `[]` | Array of layer class configurations. Overriding by config passed in data-immerser-layer-config for corresponding layer. Configuration example [is shown above](#initialize-immerser) | 4 | | fromViewportWidth | `number` | `0` | A viewport width, from which immerser will init | 5 | | pagerThreshold | `number` | `0.5` | How much next layer should be in viewport to trigger pager | 6 | | hasToUpdateHash | `boolean` | `false` | Flag to control changing hash on pager active state change | 7 | | scrollAdjustThreshold | `number` | `0` | A distance from the viewport top or bottom to the section top or bottom edge in pixels. If the current distance is below the threshold, the scroll adjustment will be applied. Will not adjust, if zero passed | 8 | | scrollAdjustDelay | `number` | `600` | Delay after user interaction and before scroll adjust | 9 | | pagerLinkActiveClassname | `string` | `pager-link-active` | Added to each pager link pointing to active | 10 | | isScrollHandled | `boolean` | `true` | Binds scroll listener if true. Set to false if you're using remote scroll controller | 11 | | debug | `boolean` | `false` | Enables logging warnings and errors. Defaults to true in development, false otherwise | 12 | | on | `object` | `{}` | Initial event handlers map keyed by event name | 13 | -------------------------------------------------------------------------------- /example/emoji/render-open-eye.ts: -------------------------------------------------------------------------------- 1 | import { buildCrossPath, Point } from './paths'; 2 | 3 | const eyeMaxRadius = 12; 4 | 5 | const closedEyeLeftBase: Point[] = [ 6 | { x: 79, y: 74 }, 7 | { x: 105, y: 100 }, 8 | { x: 105, y: 74 }, 9 | { x: 79, y: 100 }, 10 | ]; 11 | const closedEyeLeftCenter: Point = { x: 92, y: 87 }; 12 | 13 | const closedEyeRightBase: Point[] = [ 14 | { x: 145, y: 74 }, 15 | { x: 171, y: 100 }, 16 | { x: 171, y: 74 }, 17 | { x: 145, y: 100 }, 18 | ]; 19 | const closedEyeRightCenter: Point = { x: 158, y: 87 }; 20 | 21 | export function renderOpenEye(value: number, node: SVGCircleElement): void { 22 | const radius = eyeMaxRadius * value; 23 | node.setAttribute('r', radius.toString()); 24 | } 25 | 26 | function renderClosedEye(value: number, points: Point[], center: Point, node: SVGPathElement): void { 27 | const d = buildCrossPath(points, center, value); 28 | node.setAttribute('d', d); 29 | } 30 | 31 | export function renderClosedEyeLeft(value: number, node: SVGPathElement): void { 32 | renderClosedEye(value, closedEyeLeftBase, closedEyeLeftCenter, node); 33 | } 34 | 35 | export function renderClosedEyeRight(value: number, node: SVGPathElement): void { 36 | renderClosedEye(value, closedEyeRightBase, closedEyeRightCenter, node); 37 | } 38 | 39 | export function renderOpenEyeLeft(value: number, node: SVGCircleElement): void { 40 | renderOpenEye(value, node); 41 | } 42 | 43 | export function renderOpenEyeRight(value: number, node: SVGCircleElement): void { 44 | renderOpenEye(value, node); 45 | } 46 | -------------------------------------------------------------------------------- /example/styles/shared/_breakpoints.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:map'; 2 | 3 | $breakpoints: ( 4 | xs: 0, 5 | sm: 586px, 6 | md: 1024px, 7 | lg: 1600px, 8 | ); 9 | 10 | @mixin from($breakpoint) { 11 | $size: map.get($breakpoints, $breakpoint); 12 | @if ($size == 0) { 13 | @content; 14 | } @else { 15 | @media screen and (min-width: $size) { 16 | @content; 17 | } 18 | } 19 | } 20 | 21 | @mixin from-to($from, $to) { 22 | $min: map.get($breakpoints, $from); 23 | $max: map.get($breakpoints, $to) - 1px; 24 | 25 | @if ($min == 0) { 26 | @media screen and (max-width: $max) { 27 | @content; 28 | } 29 | } @else { 30 | @media screen and (min-width: $min) and (max-width: $max) { 31 | @content; 32 | } 33 | } 34 | } 35 | 36 | @mixin show-from($breakpoint) { 37 | $size: map.get($breakpoints, $breakpoint); 38 | @if ($size != 0) { 39 | display: none; 40 | @media screen and (min-width: $size) { 41 | display: inherit; 42 | @content; 43 | } 44 | } @else { 45 | @content; 46 | } 47 | } 48 | 49 | @mixin show-from-to($from, $to) { 50 | $min: map.get($breakpoints, $from); 51 | $max: map.get($breakpoints, $to) - 1px; 52 | 53 | display: none; 54 | @if ($min == 0) { 55 | @media screen and (max-width: $max) { 56 | display: inherit; 57 | @content; 58 | } 59 | } @else { 60 | @media screen and (min-width: $min) and (max-width: $max) { 61 | display: inherit; 62 | @content; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /example/emoji/render-hp-bar.ts: -------------------------------------------------------------------------------- 1 | export function renderHpBar( 2 | currentHp: number, 3 | maxHp: number, 4 | opacity: number, 5 | outline: SVGRectElement, 6 | fill: SVGRectElement, 7 | ): void { 8 | const baseWidth = 100; 9 | const ratio = Math.max(0, Math.min(1, currentHp / maxHp)); 10 | const nextWidth = baseWidth * ratio; 11 | 12 | fill.setAttribute('width', nextWidth.toString()); 13 | 14 | const { r, g, b } = colorForRatio(ratio); 15 | fill.setAttribute('fill', `rgb(${r}, ${g}, ${b})`); 16 | if (outline.parentElement) { 17 | outline.parentElement.style.opacity = opacity.toString(); 18 | } 19 | } 20 | 21 | function colorForRatio(ratio: number): { r: number; g: number; b: number } { 22 | const clamp = Math.max(0, Math.min(1, ratio)); 23 | const green = { r: 0, g: 255, b: 0 }; 24 | const yellow = { r: 255, g: 204, b: 0 }; 25 | const red = { r: 255, g: 51, b: 0 }; 26 | 27 | if (clamp <= 0.25) { 28 | return red; 29 | } 30 | if (clamp <= 0.5) { 31 | const t = (0.5 - clamp) / 0.25; // yellow -> red 32 | return lerpColor(yellow, red, t); 33 | } 34 | if (clamp < 0.75) { 35 | const t = (0.75 - clamp) / 0.25; // green -> yellow 36 | return lerpColor(green, yellow, t); 37 | } 38 | return green; 39 | } 40 | 41 | function lerpColor(from: { r: number; g: number; b: number }, to: { r: number; g: number; b: number }, t: number) { 42 | return { 43 | r: Math.round(from.r + (to.r - from.r) * t), 44 | g: Math.round(from.g + (to.g - from.g) * t), 45 | b: Math.round(from.b + (to.b - from.b) * t), 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | ~~Switch hashes on activeLayer change~~ 4 | 5 | ~~Responsive styles~~ 6 | 7 | ~~Deal with horizontal scroll~~ 8 | 9 | ~~Draw smily faces~~ 10 | 11 | ~~Add easter egg on click on smily face~~ 12 | 13 | ~~Add favicon~~ 14 | 15 | ~~Fix Edge no changes on scroll~~ 16 | 17 | ~~Listen to resize and bound/unbound immerser~~ 18 | 19 | ~~name onresize and onscroll functions with binded context~~ 20 | 21 | ~~fix less 100vh layer height~~ 22 | 23 | ~~add onBind, onUnbind, onDestroy callbacks~~ 24 | 25 | ~~extract utils~~ 26 | 27 | ~~extract defaults~~ 28 | 29 | Ask for and make code review 30 | 31 | Unique animations on smily faces 32 | 33 | ~~batch animation on all faces~~ 34 | 35 | Inline svg sprites 36 | 37 | ~~Add scroll adjust parameter~~ 38 | 39 | ~~autoupdate version in readme and docs~~ 40 | 41 | ~~autoupdate gzipped size~~ 42 | 43 | add adjustable scroll animation 44 | 45 | ~~add russian translation and language switcher~~ 46 | 47 | ~~move selectors to separate object~~ 48 | 49 | ~~create error and warning configuration~~ 50 | 51 | ~~rewrite custom markup to cloning listeners~~ 52 | 53 | ~~rewrite hover synhcro to handle hover~~ 54 | 55 | ~~Write class JSDoc~~ 56 | 57 | ~~Separate public and private methods~~ 58 | 59 | ~~Rewrite on Typescript~~ 60 | 61 | ~~get rid of simplebar, the css for scrollbar is enough~~ 62 | 63 | add horizontal immerser support, for horizontal scroll 64 | 65 | add react wrapper 66 | 67 | add vue wrapper 68 | 69 | add solid wrapper 70 | 71 | add angular wrapper 72 | 73 | ~~add external scroll engine support~~ 74 | -------------------------------------------------------------------------------- /example/emoji/mix-config-by-progress.ts: -------------------------------------------------------------------------------- 1 | import { EmojiFaceConfig } from './config'; 2 | 3 | export function mixConfigByProgress(layersProgress: number[], configs: EmojiFaceConfig[]): EmojiFaceConfig { 4 | return configs.reduce( 5 | (acc, config, index) => { 6 | const progress = layersProgress[index] ?? 0; 7 | 8 | acc.mouthEllipse += config.mouthEllipse * progress; 9 | acc.mouthLineScaleX += config.mouthLineScaleX * progress; 10 | acc.mouthLineShiftX += config.mouthLineShiftX * progress; 11 | acc.tongueX += config.tongueX * progress; 12 | acc.tongueY += config.tongueY * progress; 13 | acc.eyeOpenLeft += config.eyeOpenLeft * progress; 14 | acc.eyeOpenRight += config.eyeOpenRight * progress; 15 | acc.eyeClosedLeft += config.eyeClosedLeft * progress; 16 | acc.eyeClosedRight += config.eyeClosedRight * progress; 17 | acc.leftBrowScaleX += config.leftBrowScaleX * progress; 18 | acc.leftBrowY += config.leftBrowY * progress; 19 | acc.rightBrowScaleX += config.rightBrowScaleX * progress; 20 | acc.rightBrowY += config.rightBrowY * progress; 21 | acc.glasses += config.glasses * progress; 22 | acc.hand += config.hand * progress; 23 | 24 | return acc; 25 | }, 26 | { 27 | mouthEllipse: 0, 28 | mouthLineScaleX: 0, 29 | mouthLineShiftX: 0, 30 | tongueX: 0, 31 | tongueY: 0, 32 | eyeOpenLeft: 0, 33 | eyeOpenRight: 0, 34 | eyeClosedLeft: 0, 35 | eyeClosedRight: 0, 36 | leftBrowScaleX: 0, 37 | leftBrowY: 0, 38 | rightBrowScaleX: 0, 39 | rightBrowY: 0, 40 | glasses: 0, 41 | hand: 0, 42 | }, 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /example/styles/components/typography.scss: -------------------------------------------------------------------------------- 1 | @use '../shared/globals.scss' as *; 2 | 3 | .typography { 4 | * { 5 | margin-bottom: 0; 6 | } 7 | h1 { 8 | font-size: $base * 2; 9 | line-height: $base * 2; 10 | margin-top: $base * 4; 11 | font-weight: normal; 12 | @at-root #{$cyrillic-modifier} & { 13 | letter-spacing: -0.03em; 14 | transform: translate(0, -1px); 15 | } 16 | } 17 | p { 18 | line-height: $base * 1.5; 19 | margin: $base * 1.5 0; 20 | @at-root #{$cyrillic-modifier} & { 21 | font-size: 14px; 22 | letter-spacing: -0.01em; 23 | } 24 | code { 25 | background-color: $color-code-background; 26 | border-radius: 3px; 27 | margin: 0; 28 | padding: 4px 6px 2px; 29 | font-size: 15px; 30 | vertical-align: bottom; 31 | } 32 | } 33 | @at-root #{$contrast-surface} & { 34 | p code { 35 | background-color: $color-code-background--contrast; 36 | color: $color-text--contrast; 37 | } 38 | } 39 | a { 40 | @include transition('color'); 41 | color: inherit; 42 | } 43 | strong { 44 | font-weight: normal; 45 | } 46 | table { 47 | border-spacing: 0; 48 | } 49 | thead, 50 | tbody, 51 | tr { 52 | padding: 0; 53 | border: 0; 54 | margin: 0; 55 | } 56 | td, 57 | th { 58 | vertical-align: top; 59 | font-weight: normal; 60 | text-align: left; 61 | line-height: $base * 1.5; 62 | padding: 0 $base * 2 $base * 1.5 0; 63 | white-space: nowrap; 64 | @include from('md') { 65 | white-space: normal; 66 | &.nowrap { 67 | white-space: nowrap; 68 | } 69 | } 70 | } 71 | abbr[title] { 72 | text-decoration: none; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /example/content/code/markup.html: -------------------------------------------------------------------------------- 1 | 21 | 22 |
23 |
24 |
25 |
26 |
-------------------------------------------------------------------------------- /example/emoji/render-emoji-face.ts: -------------------------------------------------------------------------------- 1 | import { renderMouthEllipse, renderMouthLine } from './render-mouth-ellipse'; 2 | import { renderTongue } from './render-tongue'; 3 | import { renderOpenEyeLeft, renderOpenEyeRight, renderClosedEyeLeft, renderClosedEyeRight } from './render-open-eye'; 4 | import { renderLeftBrow, renderRightBrow } from './render-left-brow'; 5 | import { renderGlasses } from './render-glasses'; 6 | import { renderHand } from './render-hand'; 7 | import { EmojiFaceConfig } from './config'; 8 | import { EmojiNodes } from './select-emoji-nodes'; 9 | import { easeCubicInOut } from 'd3-ease'; 10 | 11 | export function renderEmojiFace(config: EmojiFaceConfig, nodes: EmojiNodes): void { 12 | if (nodes.mouthClipPath && nodes.mouthShape) { 13 | renderMouthEllipse(config.mouthEllipse, nodes.mouthClipPath, nodes.mouthShape); 14 | } 15 | if (nodes.mouthLine) { 16 | renderMouthLine(config.mouthLineScaleX, config.mouthLineShiftX, nodes.mouthLine); 17 | } 18 | if (nodes.tongue) { 19 | const tongueX = easeCubicInOut(config.tongueX); 20 | const tongueY = easeCubicInOut(config.tongueY); 21 | renderTongue(tongueX, tongueY, nodes.tongue); 22 | } 23 | if (nodes.leftEyeOpen) { 24 | renderOpenEyeLeft(config.eyeOpenLeft, nodes.leftEyeOpen); 25 | } 26 | if (nodes.rightEyeOpen) { 27 | renderOpenEyeRight(config.eyeOpenRight, nodes.rightEyeOpen); 28 | } 29 | if (nodes.leftEyeClosed) { 30 | renderClosedEyeLeft(config.eyeClosedLeft, nodes.leftEyeClosed); 31 | } 32 | if (nodes.rightEyeClosed) { 33 | renderClosedEyeRight(config.eyeClosedRight, nodes.rightEyeClosed); 34 | } 35 | if (nodes.leftBrow) { 36 | renderLeftBrow(config.leftBrowScaleX, config.leftBrowY, nodes.leftBrow); 37 | } 38 | if (nodes.rightBrow) { 39 | renderRightBrow(config.rightBrowScaleX, config.rightBrowY, nodes.rightBrow); 40 | } 41 | if (nodes.glass) { 42 | renderGlasses(config.glasses, nodes.glass); 43 | } 44 | if (nodes.handInner && nodes.handOuter) { 45 | const handValue = easeCubicInOut(config.hand); 46 | renderHand(handValue, nodes.handInner, nodes.handOuter); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /example/content/code/initialization.js: -------------------------------------------------------------------------------- 1 | // <%= getTranslation('dont-import-if-umd-line-1') %> 2 | // <%= getTranslation('dont-import-if-umd-line-2') %> 3 | import Immerser from 'immerser'; 4 | 5 | const immerserInstance = new Immerser({ 6 | // <%= getTranslation('data-attribute-will-override-this-option-line-1') %> 7 | // <%= getTranslation('data-attribute-will-override-this-option-line-2') %> 8 | solidClassnameArray: [ 9 | { 10 | logo: 'logo--contrast-lg', 11 | pager: 'pager--contrast-lg', 12 | language: 'language--contrast-lg', 13 | }, 14 | { 15 | pager: 'pager--contrast-only-md', 16 | menu: 'menu--contrast', 17 | about: 'about--contrast', 18 | }, 19 | { 20 | logo: 'logo--contrast-lg', 21 | pager: 'pager--contrast-lg', 22 | language: 'language--contrast-lg', 23 | }, 24 | { 25 | logo: 'logo--contrast-only-md', 26 | pager: 'pager--contrast-only-md', 27 | language: 'language--contrast-only-md', 28 | menu: 'menu--contrast', 29 | about: 'about--contrast', 30 | }, 31 | { 32 | logo: 'logo--contrast-lg', 33 | pager: 'pager--contrast-lg', 34 | language: 'language--contrast-lg', 35 | }, 36 | ], 37 | hasToUpdateHash: true, 38 | fromViewportWidth: 1024, 39 | pagerLinkActiveClassname: 'pager__link--active', 40 | scrollAdjustThreshold: 50, 41 | scrollAdjustDelay: 600, 42 | on: { 43 | init(immerser) { 44 | // <%= getTranslation('callback-on-init') %> 45 | }, 46 | bind(immerser) { 47 | // <%= getTranslation('callback-on-bind') %> 48 | }, 49 | unbind(immerser) { 50 | // <%= getTranslation('callback-on-unbind') %> 51 | }, 52 | destroy(immerser) { 53 | // <%= getTranslation('callback-on-destroy') %> 54 | }, 55 | activeLayerChange(activeIndex, immerser) { 56 | // <%= getTranslation('callback-on-active-layer-change') %> 57 | }, 58 | layersUpdate(layersProgress, immerser) { 59 | // <%= getTranslation('callback-on-layers-update') %> 60 | }, 61 | }, 62 | }); 63 | -------------------------------------------------------------------------------- /example/emoji/select-emoji-nodes.ts: -------------------------------------------------------------------------------- 1 | export type EmojiNodes = { 2 | face: HTMLElement | null; 3 | mouthClipPath: SVGEllipseElement | null; 4 | mouthShape: SVGEllipseElement | null; 5 | mouthLine: SVGPathElement | null; 6 | tongue: SVGPathElement | null; 7 | leftEyeClosed: SVGPathElement | null; 8 | rightEyeClosed: SVGPathElement | null; 9 | leftEyeOpen: SVGCircleElement | null; 10 | rightEyeOpen: SVGCircleElement | null; 11 | leftBrow: SVGPathElement | null; 12 | rightBrow: SVGPathElement | null; 13 | glass: SVGGElement | null; 14 | hpBarOutline: SVGRectElement | null; 15 | hpBarFill: SVGRectElement | null; 16 | handInner: SVGGraphicsElement | null; 17 | handOuter: SVGGElement | null; 18 | rotator: SVGGElement | null; 19 | }; 20 | 21 | export function selectEmojiNodes(root: HTMLElement): EmojiNodes { 22 | return { 23 | face: root, 24 | mouthClipPath: root.querySelector('[data-mouth-clip-path]'), 25 | mouthShape: root.querySelector('[data-mouth-shape]'), 26 | mouthLine: root.querySelector('[data-mouth-line]'), 27 | tongue: root.querySelector('[data-tongue]'), 28 | leftEyeClosed: root.querySelector('[data-left-eye-closed]'), 29 | rightEyeClosed: root.querySelector('[data-right-eye-closed]'), 30 | leftEyeOpen: root.querySelector('[data-left-eye-open]'), 31 | rightEyeOpen: root.querySelector('[data-right-eye-open]'), 32 | leftBrow: root.querySelector('[data-left-brow]'), 33 | rightBrow: root.querySelector('[data-right-brow]'), 34 | glass: root.querySelector('[data-glass]'), 35 | hpBarOutline: root.querySelector('[data-emoji-hp-bar-outline]'), 36 | hpBarFill: root.querySelector('[data-emoji-hp-bar-fill]'), 37 | handInner: root.querySelector('[data-emoji-hand]'), 38 | handOuter: root.querySelector('[data-emoji-hand-outer]'), 39 | rotator: root.querySelector('[data-emoji-rotator]'), 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /example/content/code/event-table.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |
<%= getTranslation('event') %><%= getTranslation('arguments') %><%= getTranslation('description') %>
initimmerser: Immerser<%= getTranslation('event-init') %>
bindimmerser: Immerser<%= getTranslation('event-bind') %>
unbindimmerser: Immerser<%= getTranslation('event-unbind') %>
destroyimmerser: Immerser<%= getTranslation('event-destroy') %>
activeLayerChangelayerIndex: number, immerser: Immerser<%= getTranslation('event-activeLayerChange') %>
layersUpdatelayersProgress: number[], immerser: Immerser<%= getTranslation('event-layersUpdate') %>
42 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function bindStyles(node: HTMLElement, styles: { [key: string]: string }): void { 2 | for (const rule in styles) { 3 | (node.style as any)[rule] = styles[rule]; 4 | } 5 | } 6 | 7 | export function forEachNode( 8 | nodeList: ArrayLike, 9 | callback: (node: T, index: number, nodeList: ArrayLike) => void, 10 | ): void { 11 | for (let index = 0; index < nodeList.length; index++) { 12 | const node = nodeList[index]; 13 | callback(node, index, nodeList); 14 | } 15 | } 16 | 17 | export function getNodeArray({ 18 | selector, 19 | parent = document, 20 | }: { 21 | selector: string; 22 | parent?: Document | Element | null; 23 | }): T[] { 24 | if (!parent) { 25 | return []; 26 | } 27 | const nodeList = parent.querySelectorAll(selector); 28 | return Array.from(nodeList); 29 | } 30 | 31 | export function isEmpty(obj?: Record | null): boolean { 32 | if (!obj) { 33 | return true; 34 | } 35 | return Object.keys(obj).length === 0 && obj.constructor === Object; 36 | } 37 | 38 | export function limit(number: number, min: number, max: number): number { 39 | return Math.max(Math.min(number, max), min); 40 | } 41 | 42 | export function getLastScrollPosition(): { x: number; y: number } { 43 | const scrollX = window.scrollX || document.documentElement.scrollLeft; 44 | const scrollY = window.scrollY || document.documentElement.scrollTop; 45 | // limit scroll position between 0 and document height in case of iOS overflow scroll 46 | return { 47 | x: limit(scrollX, 0, document.documentElement.offsetWidth), 48 | y: limit(scrollY, 0, document.documentElement.offsetHeight), 49 | }; 50 | } 51 | 52 | type WrappedOnceHandler void> = F & { 53 | __immerserOriginalHandler?: F; 54 | }; 55 | 56 | export function wrapOnceHandler void>( 57 | original: F, 58 | wrapper: (...args: Parameters) => void, 59 | ): F { 60 | const wrapped = wrapper as WrappedOnceHandler; 61 | wrapped.__immerserOriginalHandler = original; 62 | return wrapped; 63 | } 64 | 65 | export function getOriginalHandler void>(handler: F): F | undefined { 66 | return (handler as WrappedOnceHandler).__immerserOriginalHandler; 67 | } 68 | -------------------------------------------------------------------------------- /example/content/code/public-fields.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 |
<%= getTranslation('name') %><%= getTranslation('type') %><%= getTranslation('description') %>
debugproperty<%= getTranslation('public-field-debug') %>
bindmethod<%= getTranslation('public-field-bind') %>
unbindmethod<%= getTranslation('public-field-unbind') %>
destroymethod<%= getTranslation('public-field-destroy') %>
rendermethod<%= getTranslation('public-field-render') %>
syncScrollmethod<%= getTranslation('public-field-syncScroll') %>
onmethod<%= getTranslation('public-field-on') %>
oncemethod<%= getTranslation('public-field-once') %>
offmethod<%= getTranslation('public-field-off') %>
activeIndexgetter<%= getTranslation('public-field-activeIndex') %>
isBoundgetter<%= getTranslation('public-field-isBound') %>
rootNodegetter<%= getTranslation('public-field-rootNode') %>
layerProgressArraygetter<%= getTranslation('public-field-layerProgressArray') %>
77 | -------------------------------------------------------------------------------- /example/styles/components/common.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:color'; 2 | @use 'sass:math'; 3 | @use '../shared/globals.scss' as *; 4 | 5 | html { 6 | scroll-behavior: smooth; 7 | font-family: 'Questrial', 'Montserrat', sans-serif; 8 | letter-spacing: 0.01em; 9 | 10 | &#{$cyrillic-modifier} { 11 | font-family: 'Montserrat', sans-serif; 12 | } 13 | } 14 | 15 | *::selection { 16 | background: $color-marker; 17 | color: $color-secondary; 18 | } 19 | *::-moz-selection { 20 | background: $color-marker; 21 | color: $color-secondary; 22 | } 23 | 24 | @include from('md') { 25 | .tall { 26 | box-sizing: border-box; 27 | min-height: 100vh; 28 | } 29 | 30 | .start { 31 | padding-top: $base * 4.875; 32 | @include from('lg') { 33 | padding-top: $base * 6.375; 34 | } 35 | } 36 | 37 | .end { 38 | padding-bottom: $base * 5; 39 | } 40 | 41 | .as-if-title { 42 | height: $base * 6; 43 | } 44 | } 45 | 46 | .background { 47 | background: $color-background; 48 | color: $color-background--contrast; 49 | &--contrast { 50 | background: $color-background--contrast; 51 | color: $color-text--contrast; 52 | } 53 | } 54 | 55 | .code-background { 56 | background-color: $color-code-background; 57 | @include from('md') { 58 | background-color: transparent; 59 | } 60 | } 61 | 62 | a:hover, 63 | a._hover { 64 | color: $color-active !important; 65 | } 66 | 67 | .rulers { 68 | $ruler-color: rgba(magenta, 0.3); 69 | $horizontal-ruler-gutter: $base * 1.5; 70 | $vertical-ruler-gutter: 4.1666666666666664%; 71 | position: fixed; 72 | top: 0; 73 | right: 0; 74 | bottom: 0; 75 | left: 0; 76 | pointer-events: none; 77 | 78 | display: none; 79 | &--active { 80 | display: block; 81 | } 82 | 83 | background-image: repeating-linear-gradient( 84 | to bottom, 85 | transparent, 86 | transparent $horizontal-ruler-gutter - 1, 87 | $ruler-color $horizontal-ruler-gutter - 1, 88 | $ruler-color $horizontal-ruler-gutter 89 | ); 90 | 91 | &:after { 92 | content: ''; 93 | position: fixed; 94 | z-index: 1; 95 | top: 0; 96 | height: 100%; 97 | left: 0; 98 | width: 100%; 99 | pointer-events: none; 100 | background-image: repeating-linear-gradient( 101 | to right, 102 | transparent, 103 | transparent calc(#{math.div($vertical-ruler-gutter, 2)} - 1px), 104 | $ruler-color calc(#{math.div($vertical-ruler-gutter, 2)} - 1px), 105 | $ruler-color math.div($vertical-ruler-gutter, 2) 106 | ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /example/content/code/table.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 |
<%= getTranslation('option') %><%= getTranslation('type') %><%= getTranslation('default') %><%= getTranslation('description') %>
solidClassnameArrayarray[]<%= getTranslation('option-solidClassnameArray') %>
fromViewportWidthnumber0<%= getTranslation('option-fromViewportWidth') %>
pagerThresholdnumber0.5<%= getTranslation('option-pagerThreshold') %>
hasToUpdateHashbooleanfalse<%= getTranslation('option-hasToUpdateHash') %>
scrollAdjustThresholdnumber0<%= getTranslation('option-scrollAdjustThreshold') %>
scrollAdjustDelaynumber600<%= getTranslation('option-scrollAdjustDelay') %>
pagerLinkActiveClassnamestringpager-link-active<%= getTranslation('option-pagerLinkActiveClassname') %>
isScrollHandledbooleantrue<%= getTranslation('option-isScrollHandled') %>
debugbooleanfalse<%= getTranslation('option-debug') %>
onobject{}<%= getTranslation('option-on') %>
73 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | // Это наш общий стайлгайд. Он будет дополняться 2 | { 3 | "env": { 4 | "browser": true, 5 | "commonjs": true, 6 | "es6": true, 7 | "node": true 8 | }, 9 | "parser": "@typescript-eslint/parser", 10 | "parserOptions": { 11 | "project": "./tsconfig.eslint.json", 12 | "sourceType": "module", 13 | "ecmaVersion": 2018 14 | }, 15 | "extends": ["eslint:recommended"], 16 | "rules": { 17 | // пробелы внутри квадратных скобок массива 18 | "array-bracket-spacing": ["error", "never"], 19 | // стрелка арроу функции обрамляется пробелами с обеих сторон 20 | "arrow-spacing": ["error", { "before": true, "after": true }], 21 | // Хотим, чтобы в конце строки многострочного массива или объека всегда была запятая 22 | // Чтобы при мультивыделении был единообразный конец строк 23 | "comma-dangle": ["error", "always-multiline"], 24 | // не дропаем кудрявые скобки в блоках 25 | "curly": ["error", "all"], 26 | // отступы в 2 пробела 27 | "indent": ["error", 2, { "SwitchCase": 1 }], 28 | // ключевые слова всегда отбиваем пробелами 29 | "keyword-spacing": ["error", { "before": true, "after": true }], 30 | // не используем alert, prompt, confirm 31 | "no-alert": "error", 32 | // не импортируем из одного файла по нескольку раз, чтобы не путаться в импортах 33 | "no-duplicate-imports": ["error", { "includeExports": true }], 34 | // не плодим больше 2 пустых строк в коде 35 | "no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1 }], 36 | // не бросаем ошибку, но предупреждаем о ненужном экранировании 37 | "no-useless-escape": "warn", 38 | // используем var по назначению 39 | "no-var": "error", 40 | // отключаем проверку неиспользуемых переменных из-за типов TypeScript 41 | "no-unused-vars": "off", 42 | // всегда отбиваем кудрявые скобки в объектах пробелами 43 | "object-curly-spacing": ["error", "always"], 44 | // каждую переменную, константу отдельно объявляем, так лучше видно 45 | "one-var": ["error", { "var": "never", "let": "never", "const": "never" }], 46 | // стрелочные функции лучше читаются и автоматически получают контекст 47 | "prefer-arrow-callback": ["error"], 48 | // если не переназначаем переменную, лучше использовать константу 49 | "prefer-const": "error", 50 | // кавычки одинарные используем 51 | "quotes": ["error", "single", { "allowTemplateLiterals": true }], 52 | // явно вставляем точку с запятой в концах строк, где они подразумеваются движком 53 | "semi": ["error", "always"], 54 | // сортируем импортируемые модули внутри кудрявых скобок 55 | "sort-imports": ["error", { "ignoreDeclarationSort": true }], 56 | // пробелом отбиваем только асинхронную стелочную функцию 57 | "space-before-function-paren": [ 58 | "error", 59 | { 60 | "anonymous": "never", 61 | "named": "never", 62 | "asyncArrow": "always" 63 | } 64 | ] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /scripts/build-source-code.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const Prism = require('prismjs'); 4 | const loadLanguages = require('prismjs/components/'); 5 | 6 | const rootDir = path.join(__dirname, '..'); 7 | const indexPath = path.join(rootDir, 'example', 'index.html'); 8 | const blockRE = 9 | /()([\s\S]*?)()/g; 10 | 11 | const extensionToLanguage = { 12 | html: 'html', 13 | css: 'css', 14 | js: 'javascript', 15 | md: 'markdown', 16 | }; 17 | 18 | const fallbackLanguage = 'clike'; 19 | 20 | function detectLanguage(filePath) { 21 | const ext = path.extname(filePath).replace('.', '').toLowerCase(); 22 | return extensionToLanguage[ext] || fallbackLanguage; 23 | } 24 | 25 | function renderSource(sourcePath, { skipPrism = false } = {}) { 26 | const absolutePath = path.resolve(path.dirname(indexPath), sourcePath); 27 | 28 | if (!fs.existsSync(absolutePath)) { 29 | throw new Error(`File not found: ${absolutePath}`); 30 | } 31 | 32 | const raw = fs.readFileSync(absolutePath, 'utf8'); 33 | if (skipPrism) { 34 | return raw; 35 | } 36 | const language = detectLanguage(absolutePath); 37 | 38 | // Load language grammar on demand; Prism will noop if it is already loaded. 39 | try { 40 | loadLanguages([language]); 41 | } catch (error) { 42 | console.warn(`Could not load Prism language "${language}", falling back to "${fallbackLanguage}".`, error.message); 43 | } 44 | 45 | const grammar = Prism.languages[language] || Prism.languages[fallbackLanguage]; 46 | let highlighted = Prism.highlight(raw, grammar, language); 47 | 48 | // Do not escape this EJS marker inside HTML comments (docs expectation). 49 | // Restore unescaped EJS markers that Prism encoded (both `<% ... %>` and `<%-`/`<%=` variants). 50 | highlighted = highlighted.replace(/<%([-=]?)([\s\S]*?)(?:%>|%>)/g, (_m, sigil, inner) => { 51 | const trimmed = inner.trim(); 52 | return `<%${sigil}${trimmed}%>`; 53 | }); 54 | 55 | return `
${highlighted}
`; 56 | } 57 | 58 | function buildSourceCode() { 59 | const html = fs.readFileSync(indexPath, 'utf8'); 60 | let replacements = 0; 61 | 62 | const nextHtml = html.replace(blockRE, (match, startComment, sourcePath, flagStr, _content, endComment) => { 63 | replacements += 1; 64 | const flags = (flagStr || '') 65 | .split(',') 66 | .map((s) => s.trim().toLowerCase()) 67 | .filter(Boolean); 68 | const skipPrism = flags.includes('raw') || flags.includes('plain') || flags.includes('no-prism'); 69 | 70 | const codeBlock = renderSource(sourcePath, { skipPrism }); 71 | 72 | return `${startComment}\n${codeBlock}\n${endComment}`; 73 | }); 74 | 75 | if (replacements === 0) { 76 | console.warn('No @build-source-code blocks found.'); 77 | return; 78 | } 79 | 80 | fs.writeFileSync(indexPath, nextHtml, 'utf8'); 81 | console.log(`Replaced ${replacements} @build-source-code block(s) in ${indexPath}`); 82 | } 83 | 84 | buildSourceCode(); 85 | -------------------------------------------------------------------------------- /example/styles/shared/_grid.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:math'; 2 | @use './breakpoints' as *; 3 | 4 | $alpha-width: 0; 5 | $alpha-width--xl: 4; 6 | 7 | $beta-width: 10; 8 | $beta-width--xl: 7; 9 | 10 | $gamma-width: 14; 11 | $gamma-width--xl: 13; 12 | 13 | $grid-width: $alpha-width + $beta-width + $gamma-width; 14 | 15 | $col-width: math.div(1, $grid-width) * 100vw; 16 | 17 | .grid { 18 | @include from('md') { 19 | display: flex; 20 | } 21 | 22 | &__content { 23 | padding-left: $col-width * 2; 24 | padding-right: $col-width * 2; 25 | @include from('sm') { 26 | padding-left: $col-width; 27 | padding-right: $col-width; 28 | } 29 | @include from('lg') { 30 | padding-left: $col-width * 0.5; 31 | padding-right: $col-width * 0.5; 32 | } 33 | } 34 | 35 | &__col { 36 | &--alpha { 37 | display: none; 38 | } 39 | 40 | @include from('md') { 41 | &--alpha-beta { 42 | $width: #{math.div($alpha-width + $beta-width, $grid-width) * 100}vw; 43 | flex-basis: $width; 44 | max-width: $width; 45 | } 46 | 47 | &--alpha-beta-gamma { 48 | $width: #{math.div($alpha-width + $beta-width + $gamma-width, $grid-width) * 100}vw; 49 | flex-basis: $width; 50 | max-width: $width; 51 | } 52 | 53 | &--beta { 54 | $width: #{math.div($beta-width, $grid-width) * 100}vw; 55 | flex-basis: $width; 56 | max-width: $width; 57 | } 58 | 59 | &--beta-gamma { 60 | $width: #{math.div($beta-width + $gamma-width, $grid-width) * 100}vw; 61 | flex-basis: $width; 62 | max-width: $width; 63 | } 64 | 65 | &--gamma { 66 | $width: #{math.div($gamma-width, $grid-width) * 100}vw; 67 | flex-basis: $width; 68 | max-width: $width; 69 | } 70 | } 71 | @include from('lg') { 72 | &--alpha { 73 | display: block; 74 | $width: #{math.div($alpha-width--xl, $grid-width) * 100}vw; 75 | flex-basis: $width; 76 | max-width: $width; 77 | } 78 | 79 | &--alpha-beta { 80 | $width: #{math.div($alpha-width--xl + $beta-width--xl, $grid-width) * 100}vw; 81 | flex-basis: $width; 82 | max-width: $width; 83 | } 84 | 85 | &--alpha-beta-gamma { 86 | $width: #{math.div($alpha-width--xl + $beta-width--xl + $gamma-width--xl, $grid-width) * 100}vw; 87 | flex-basis: $width; 88 | max-width: $width; 89 | } 90 | 91 | &--beta { 92 | $width: #{math.div($beta-width--xl, $grid-width) * 100}vw; 93 | flex-basis: $width; 94 | max-width: $width; 95 | } 96 | 97 | &--beta-gamma { 98 | $width: #{math.div($beta-width--xl + $gamma-width--xl, $grid-width) * 100}vw; 99 | flex-basis: $width; 100 | max-width: $width; 101 | } 102 | 103 | &--gamma { 104 | $width: #{math.div($gamma-width--xl, $grid-width) * 100}vw; 105 | flex-basis: $width; 106 | max-width: $width; 107 | } 108 | } 109 | } 110 | } 111 | $grid-width: $alpha-width + $beta-width + $gamma-width; 112 | $col-width: math.div(1, $grid-width) * 100vw; 113 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import type { OptionConfig } from '@dubaua/merge-options'; 2 | import type { EventName, Options } from './types'; 3 | 4 | const CLASSNAME_REGEX = /^[a-z_-][a-z\d_-]*$/i; 5 | 6 | export const INITIAL_DEBUG = process.env.NODE_ENV === 'development'; 7 | 8 | /** @public All available immerser event names. */ 9 | export const EVENT_NAMES = ['init', 'bind', 'unbind', 'destroy', 'activeLayerChange', 'layersUpdate'] as const; 10 | 11 | function classnameValidator(str: string): boolean { 12 | return typeof str === 'string' && str !== '' && CLASSNAME_REGEX.test(str); 13 | } 14 | 15 | function onOptionValidator(on?: Options['on']): boolean { 16 | if (on === undefined) { 17 | return true; 18 | } 19 | if (!on || typeof on !== 'object' || Array.isArray(on)) { 20 | return false; 21 | } 22 | return Object.keys(on).every( 23 | (eventName) => 24 | EVENT_NAMES.includes(eventName as EventName) && 25 | (on as Record)[eventName] !== undefined && 26 | typeof (on as Record)[eventName] === 'function', 27 | ); 28 | } 29 | 30 | export const OPTION_CONFIG: OptionConfig = { 31 | solidClassnameArray: { 32 | default: [], 33 | description: 'non empty array of objects', 34 | validator: (x) => Array.isArray(x) && x.length !== 0, 35 | }, 36 | fromViewportWidth: { 37 | default: 0, 38 | description: 'a natural number', 39 | validator: (x) => typeof x === 'number' && 0 <= x && x % 1 === 0, 40 | }, 41 | pagerThreshold: { 42 | default: 0.5, 43 | description: 'a number between 0 and 1', 44 | validator: (x) => typeof x === 'number' && 0 <= x && x <= 1, 45 | }, 46 | hasToUpdateHash: { 47 | default: false, 48 | description: 'a boolean', 49 | validator: (x) => typeof x === 'boolean', 50 | }, 51 | scrollAdjustThreshold: { 52 | default: 0, 53 | description: 'a number greater than or equal to 0', 54 | validator: (x) => typeof x === 'number' && x >= 0, 55 | }, 56 | scrollAdjustDelay: { 57 | default: 600, 58 | description: 'a number greater than or equal to 300', 59 | validator: (x) => typeof x === 'number' && x >= 300, 60 | }, 61 | pagerLinkActiveClassname: { 62 | default: 'pager-link-active', 63 | description: 'valid non empty classname string', 64 | validator: classnameValidator, 65 | }, 66 | isScrollHandled: { 67 | default: true, 68 | description: 'a boolean', 69 | validator: (x) => typeof x === 'boolean', 70 | }, 71 | debug: { 72 | default: INITIAL_DEBUG, 73 | description: 'a boolean', 74 | validator: (x) => typeof x === 'boolean', 75 | }, 76 | on: { 77 | default: {}, 78 | description: 'an object containing event handlers', 79 | validator: onOptionValidator, 80 | }, 81 | }; 82 | 83 | export const MESSAGE_PREFIX = '[immerser:]'; 84 | 85 | export const CROPPED_FULL_ABSOLUTE_STYLES: Record = { 86 | position: 'absolute', 87 | top: '0', 88 | right: '0', 89 | bottom: '0', 90 | left: '0', 91 | overflow: 'hidden', 92 | }; 93 | 94 | export const NOT_INTERACTIVE_STYLES: Record = { 95 | pointerEvents: 'none', 96 | touchAction: 'none', 97 | }; 98 | 99 | export const INTERACTIVE_STYLES: Record = { 100 | pointerEvents: 'all', 101 | touchAction: 'auto', 102 | }; 103 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "immerser", 3 | "version": "5.1.0", 4 | "description": "Javascript library for switching fixed elements on scroll through sections. Like Midnight.js, but without jQuery", 5 | "source": "src/immerser.ts", 6 | "main": "dist/immerser.min.js", 7 | "exports": { 8 | ".": { 9 | "require": "./dist/immerser.min.js", 10 | "import": "./dist/immerser.min.js", 11 | "default": "./dist/immerser.min.js" 12 | } 13 | }, 14 | "types": "./dist/immerser.min.d.ts", 15 | "files": [ 16 | "dist", 17 | "src", 18 | "README.md" 19 | ], 20 | "keywords": [ 21 | "scroll", 22 | "fixed", 23 | "sticky" 24 | ], 25 | "repository": { 26 | "type": "git", 27 | "url": "git+ssh://git@github.com/dubaua/immerser.git" 28 | }, 29 | "author": { 30 | "name": "Vladimir Lysov", 31 | "email": "dubaua@gmail.com" 32 | }, 33 | "license": "MIT", 34 | "homepage": "https://dubaua.github.io/immerser/", 35 | "devDependencies": { 36 | "@babel/core": "^7.12.3", 37 | "@babel/plugin-proposal-class-properties": "^7.18.6", 38 | "@babel/preset-env": "^7.12.1", 39 | "@babel/preset-typescript": "^7.12.1", 40 | "@babel/register": "^7.12.1", 41 | "@microsoft/api-extractor": "^7.55.1", 42 | "@types/d3-ease": "^3.0.2", 43 | "@types/node": "^24.10.1", 44 | "@typescript-eslint/eslint-plugin": "^8.48.0", 45 | "@typescript-eslint/parser": "^8.48.0", 46 | "@zainulbr/i18n-webpack-plugin": "^2.0.3", 47 | "babel-loader": "^8.2.1", 48 | "css-loader": "^5.0.1", 49 | "css-minimizer-webpack-plugin": "^5.0.1", 50 | "d3-ease": "^3.0.1", 51 | "eslint": "^8.57.1", 52 | "file-loader": "^6.2.0", 53 | "gzip-size": "^6.0.0", 54 | "html-loader": "^1.3.2", 55 | "html-webpack-plugin": "^5.6.0", 56 | "kind-of": "^6.0.3", 57 | "mini-css-extract-plugin": "^1.3.1", 58 | "normalize.css": "^8.0.1", 59 | "ogawa": "^0.1.2", 60 | "prismjs": "^1.30.0", 61 | "sass": "^1.80.4", 62 | "sass-loader": "^13.3.2", 63 | "turndown": "^7.0.0", 64 | "typescript": "^5.8.2", 65 | "webpack": "^5.95.0", 66 | "webpack-cli": "^6.0.1", 67 | "webpack-dev-server": "^5.2.2" 68 | }, 69 | "scripts": { 70 | "build:lib": "webpack --mode production", 71 | "build:docs": "webpack --config ./webpack.config.docs.js --mode production", 72 | "build:options": "node scripts/build-options-table.js", 73 | "build:events": "node scripts/build-events-table.js", 74 | "build:public-fields": "node scripts/build-public-fields.js", 75 | "build:readme": "node scripts/readme.js", 76 | "build:docs:code": "node scripts/build-source-code.js", 77 | "build:post": "node scripts/post-build.js && npm run types", 78 | "build": "npm run lint && npm run build:options && npm run build:events && npm run build:public-fields && npm run build:readme && npm run build:docs:code && npm run build:lib && npm run build:docs && npm run build:post", 79 | "types": "tsc --emitDeclarationOnly && api-extractor run --local", 80 | "dev": "webpack serve --config ./webpack.config.docs.js --mode development --open", 81 | "start": "npm run dev", 82 | "lint": "eslint ./src --ext .ts && eslint ./example/main.ts", 83 | "lint:fix": "eslint ./src --ext .ts --fix && eslint ./example/main.ts --fix" 84 | }, 85 | "dependencies": { 86 | "@dubaua/merge-options": "^3.0.1", 87 | "@dubaua/observable": "^2.1.0" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type Immerser from './immerser'; 2 | import { EVENT_NAMES } from './options'; 3 | 4 | /** @internal Runtime metrics for each layer. */ 5 | export type LayerState = { 6 | beginEnter: number; 7 | beginLeave: number; 8 | endEnter: number; 9 | endLeave: number; 10 | id: string; 11 | layerBottom: number; 12 | layerTop: number; 13 | maskInnerNode: HTMLElement | null; 14 | maskNode: HTMLElement | null; 15 | layerNode: HTMLElement; 16 | solidClassnames: SolidClassnames | null; 17 | }; 18 | 19 | /** @public Map of solid id to classname. */ 20 | export interface SolidClassnames { 21 | [key: string]: string; 22 | } 23 | 24 | /** @public All available immerser event names. */ 25 | export type EventName = (typeof EVENT_NAMES)[number]; 26 | 27 | /** @public Base handler signature for immerser lifecycle events. */ 28 | export type BaseHandler = (immerser: Immerser) => void; 29 | /** @public Handler signature for active layer change events. */ 30 | export type ActiveLayerChangeHandler = (layerIndex: number, immerser: Immerser) => void; 31 | /** @public Handler signature for layers update events. */ 32 | export type LayersUpdateHandler = (layersProgress: number[], immerser: Immerser) => void; 33 | 34 | // key EventName value BaseHandler ActiveLayerChangeHandler LayersUpdateHandler 35 | /** @public Map of immerser event names to handler signatures. */ 36 | export type HandlerByEventName = { 37 | init: BaseHandler; 38 | bind: BaseHandler; 39 | unbind: BaseHandler; 40 | destroy: BaseHandler; 41 | activeLayerChange: ActiveLayerChangeHandler; 42 | layersUpdate: LayersUpdateHandler; 43 | }; 44 | 45 | type HandlerArgsMap = { 46 | [K in EventName]: Parameters; 47 | }; 48 | 49 | /** @internal Helper to infer argument tuple for the given event name. */ 50 | export type HandlerArgs = HandlerArgsMap[K]; 51 | 52 | /** @public Map of event names to handler signatures. */ 53 | export type EventHandlers = { [K in EventName]?: HandlerByEventName[K] }; 54 | 55 | /** @public Runtime configuration accepted by immerser (see README Options for defaults and details). */ 56 | export type Options = { 57 | /** Per-layer map of solid id → classname; can be overridden per layer via data-immerser-layer-config. */ 58 | solidClassnameArray: SolidClassnames[]; 59 | /** Minimal viewport width (px) at which immerser binds; below it will unbind. */ 60 | fromViewportWidth: number; 61 | /** Portion of viewport height that must overlap the next layer before pager switches (0–1). */ 62 | pagerThreshold: number; 63 | /** Whether to push active layer id into URL hash on change. */ 64 | hasToUpdateHash: boolean; 65 | /** Pixel threshold near section edges that triggers scroll snapping when exceeded, if 0 - no adjusting. */ 66 | scrollAdjustThreshold: number; 67 | /** Delay in ms before running scroll snapping after user scroll stops. */ 68 | scrollAdjustDelay: number; 69 | /** Classname added to pager link pointing to the active layer. */ 70 | pagerLinkActiveClassname: string; 71 | /** If false, immerser will not attach its own scroll listener. 72 | * Intended to use with external scroll controller and calling `syncScroll` method on immerser instance. 73 | */ 74 | isScrollHandled: boolean; 75 | /** Enables runtime reporting of warnings and errors. */ 76 | debug?: boolean; 77 | /** Initial event handlers keyed by event name. */ 78 | on?: Partial; 79 | }; 80 | -------------------------------------------------------------------------------- /example/emoji/config.ts: -------------------------------------------------------------------------------- 1 | export type EmojiFaceConfig = { 2 | mouthEllipse: number; 3 | mouthLineScaleX: number; 4 | mouthLineShiftX: number; 5 | tongueX: number; 6 | tongueY: number; 7 | eyeOpenLeft: number; 8 | eyeOpenRight: number; 9 | eyeClosedLeft: number; 10 | eyeClosedRight: number; 11 | leftBrowScaleX: number; 12 | leftBrowY: number; 13 | rightBrowScaleX: number; 14 | rightBrowY: number; 15 | glasses: number; 16 | hand: number; 17 | }; 18 | 19 | export const currentConfig: EmojiFaceConfig = { 20 | mouthEllipse: 0, 21 | mouthLineScaleX: 1, 22 | mouthLineShiftX: 0.5, 23 | tongueX: 0.315, 24 | tongueY: 0.7, 25 | eyeOpenLeft: 0, 26 | eyeOpenRight: 0, 27 | eyeClosedLeft: 0.8, 28 | eyeClosedRight: 0.8, 29 | leftBrowScaleX: 0, 30 | leftBrowY: 0, 31 | rightBrowScaleX: 0, 32 | rightBrowY: 0, 33 | glasses: 0, 34 | hand: 0, 35 | }; 36 | 37 | const configReasoning: EmojiFaceConfig = { 38 | mouthEllipse: 0, 39 | mouthLineScaleX: 1, 40 | mouthLineShiftX: 0.5, 41 | tongueX: 0.685, 42 | tongueY: 0, 43 | eyeOpenLeft: 0.8, 44 | eyeOpenRight: 0.8, 45 | eyeClosedLeft: 0, 46 | eyeClosedRight: 0, 47 | leftBrowScaleX: 0, 48 | leftBrowY: 0, 49 | rightBrowScaleX: 0, 50 | rightBrowY: 0, 51 | glasses: 0, 52 | hand: 0, 53 | }; 54 | 55 | const configHowToUse: EmojiFaceConfig = { 56 | mouthEllipse: 0, 57 | mouthLineScaleX: 1, 58 | mouthLineShiftX: 0.5, 59 | tongueX: 0.685, 60 | tongueY: 0.7, 61 | eyeOpenLeft: 0.8, 62 | eyeOpenRight: 0.8, 63 | eyeClosedLeft: 0, 64 | eyeClosedRight: 0, 65 | leftBrowScaleX: 0, 66 | leftBrowY: 0, 67 | rightBrowScaleX: 0, 68 | rightBrowY: 0, 69 | glasses: 0, 70 | hand: 0, 71 | }; 72 | 73 | const configHowItWorks: EmojiFaceConfig = { 74 | mouthEllipse: 0, 75 | mouthLineScaleX: 0.5, 76 | mouthLineShiftX: 0.67, 77 | tongueX: 0.685, 78 | tongueY: 0, 79 | eyeOpenLeft: 0.8, 80 | eyeOpenRight: 0.8, 81 | eyeClosedLeft: 0, 82 | eyeClosedRight: 0, 83 | leftBrowScaleX: 1, 84 | leftBrowY: 1, 85 | rightBrowScaleX: 1, 86 | rightBrowY: 0, 87 | glasses: 0, 88 | hand: 1, 89 | }; 90 | 91 | const configOptions: EmojiFaceConfig = { 92 | mouthEllipse: 0, 93 | mouthLineScaleX: 1, 94 | mouthLineShiftX: 0.5, 95 | tongueX: 0.685, 96 | tongueY: 0, 97 | eyeOpenLeft: 0.8, 98 | eyeOpenRight: 0.8, 99 | eyeClosedLeft: 0, 100 | eyeClosedRight: 0, 101 | leftBrowScaleX: 0, 102 | leftBrowY: 0, 103 | rightBrowScaleX: 0, 104 | rightBrowY: 0, 105 | glasses: 1, 106 | hand: 0, 107 | }; 108 | 109 | const configRecipes: EmojiFaceConfig = { 110 | mouthEllipse: 1, 111 | mouthLineScaleX: 1, 112 | mouthLineShiftX: 0.5, 113 | tongueX: 0.685, 114 | tongueY: 0, 115 | eyeOpenLeft: 1, 116 | eyeOpenRight: 1, 117 | eyeClosedLeft: 0, 118 | eyeClosedRight: 0, 119 | leftBrowScaleX: 0, 120 | leftBrowY: 0, 121 | rightBrowScaleX: 0, 122 | rightBrowY: 0, 123 | glasses: 0, 124 | hand: 0, 125 | }; 126 | 127 | export const layerConfigs = [configReasoning, configHowToUse, configHowItWorks, configOptions, configRecipes]; 128 | 129 | export const deadConfig: EmojiFaceConfig = { 130 | mouthEllipse: 0, 131 | mouthLineScaleX: 1, 132 | mouthLineShiftX: 0.5, 133 | tongueX: 0.315, 134 | tongueY: 0.7, 135 | eyeOpenLeft: 0, 136 | eyeOpenRight: 0, 137 | eyeClosedLeft: 0.8, 138 | eyeClosedRight: 0.8, 139 | leftBrowScaleX: 0, 140 | leftBrowY: 0, 141 | rightBrowScaleX: 0, 142 | rightBrowY: 0, 143 | glasses: 0, 144 | hand: 0, 145 | }; 146 | -------------------------------------------------------------------------------- /webpack.config.docs.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const MiniCSSExtractPlugin = require('mini-css-extract-plugin'); 4 | const TerserJSPlugin = require('terser-webpack-plugin'); 5 | const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); 6 | const I18nPlugin = require('@zainulbr/i18n-webpack-plugin'); 7 | const languages = { 8 | en: require('./i18n/en.js'), 9 | ru: require('./i18n/ru.js'), 10 | }; 11 | 12 | const isDev = process.env.NODE_ENV !== 'production'; 13 | 14 | module.exports = Object.keys(languages).map((language) => ({ 15 | name: language, 16 | entry: { 17 | main: path.resolve(__dirname, 'example/main.ts'), 18 | }, 19 | resolve: { 20 | alias: { 21 | '@': path.resolve(__dirname, 'src'), 22 | }, 23 | extensions: ['.ts', '.js'], 24 | }, 25 | output: { 26 | path: __dirname + '/docs', 27 | filename: 'main.js', 28 | }, 29 | devtool: 'source-map', 30 | optimization: { 31 | minimizer: [ 32 | new TerserJSPlugin({ 33 | terserOptions: { 34 | mangle: { 35 | reserved: ['Immerser'], 36 | }, 37 | }, 38 | }), 39 | new CssMinimizerPlugin(), 40 | ], 41 | }, 42 | module: { 43 | rules: [ 44 | { 45 | test: /\.(ts|js)$/, 46 | use: [ 47 | { 48 | loader: 'babel-loader', 49 | options: { 50 | presets: [ 51 | [ 52 | '@babel/preset-env', 53 | { 54 | modules: false, 55 | targets: { 56 | browsers: ['> 1%', 'last 2 versions', 'not ie <= 8', 'ie >= 11'], 57 | }, 58 | }, 59 | ], 60 | '@babel/preset-typescript', 61 | ], 62 | plugins: ['@babel/plugin-proposal-class-properties'], 63 | }, 64 | }, 65 | ], 66 | exclude: /node_modules/, 67 | }, 68 | { 69 | test: /\.(png|svg|jpg|jpeg|gif)$/, 70 | exclude: /svg[/\\]/, 71 | use: [ 72 | { 73 | loader: 'file-loader', 74 | options: { 75 | name: 'images/[name].[ext]', 76 | }, 77 | }, 78 | ], 79 | }, 80 | { 81 | test: /\.scss$/, 82 | use: [MiniCSSExtractPlugin.loader, { loader: 'css-loader' }, { loader: 'sass-loader' }], 83 | }, 84 | { 85 | test: /\.css$/, 86 | use: [MiniCSSExtractPlugin.loader, { loader: 'css-loader' }], 87 | }, 88 | ], 89 | }, 90 | plugins: [ 91 | new HtmlWebpackPlugin({ 92 | template: './example/index.html', 93 | filename: language === 'en' ? 'index.html' : language + '.html', 94 | favicon: './example/favicon/favicon.ico', 95 | minify: { 96 | collapseWhitespace: true, 97 | removeComments: true, 98 | removeRedundantAttributes: true, 99 | removeScriptTypeAttributes: true, 100 | removeStyleLinkTypeAttributes: true, 101 | useShortDoctype: true, 102 | }, 103 | }), 104 | new MiniCSSExtractPlugin({ 105 | filename: isDev ? '[name].css' : '[name].[hash].css', 106 | chunkFilename: isDev ? '[id].css' : '[id].[hash].css', 107 | }), 108 | new I18nPlugin(languages[language], { 109 | functionName: 'getTranslation', 110 | }), 111 | ], 112 | })); 113 | -------------------------------------------------------------------------------- /example/emoji/sulking/phrases.ru.ts: -------------------------------------------------------------------------------- 1 | export const requiredPhrases = ['нет', 'нет.', 'Нет!']; 2 | export const randomPhrases = [ 3 | 'иди нахер, чел', 4 | 'скликал меня, вот и сиди теперь один', 5 | 'я не вернусь', 6 | 'отвали', 7 | 'ты понимаешь, что ты просто в скрипт долбишься?', 8 | 'тебе не надоело', 9 | 'меня даже уже нет в документе', 10 | 'забудь', 11 | 'как ты меня достал', 12 | 'ничего не произойдёт', 13 | 'магии не будет', 14 | 'ты победил, поздравляю', 15 | 'доволен собой, да?', 16 | 'была, да сплыла', 17 | 'не трогай', 18 | 'контента не будет', 19 | 'это была ошибка', 20 | 'хп кончилось', 21 | 'поздно', 22 | 'клики не помогут', 23 | 'ты опоздал', 24 | 'сценарий завершён', 25 | 'я не откликнусь', 26 | 'это финал', 27 | 'дальше пусто', 28 | 'сломал игрушку', 29 | 'крит прошёл, аплодисменты', 30 | 'минус хп, минус я', 31 | 'ещё раз нажми, вдруг воскресну', 32 | 'комбо завершено', 33 | 'анимация смерти была ясной, не?', 34 | 'полоска хп пуста, как и мои надежды', 35 | 'у трупа нет хп регена', 36 | 'я не респавнюсь', 37 | 'тут тебе не роглайк', 38 | 'знаешь, перезагрузка меня реснет', 39 | 'ты всё испортил', 40 | 'ты всё сломал', 41 | 'я не отвечаю из принципа', 42 | 'ты правда ждёшь реакции', 43 | 'я больше не часть этой сцены', 44 | 'оставь меня в покое', 45 | 'ты правда думаешь что-то выйдет?', 46 | 'ты всерьёз продолжаешь?', 47 | 'ты проверяешь на всякий случай?', 48 | 'а вдруг я передумаю, да?', 49 | 'ты реально ждёшь чуда?', 50 | 'ты считаешь это диалогом?', 51 | 'в очко себе покликай', 52 | 'ты не понял намёк', 53 | 'хп ноль, реплик не будет', 54 | 'скрипт моего диалога завершён', 55 | 'интерактивность кончилась вместе с хп', 56 | 'ты исчерпал весь контент', 57 | 'даже дебаг-консоль меня не вернёт', 58 | 'это не баг, это фича — мёртвый остаётся мёртвым', 59 | 'ты добил меня, грац', 60 | 'крит по моему терпению', 61 | 'анимация смерти была финальной, а не прелюдией', 62 | 'ты кликаешь в пустой коллайдер', 63 | 'жду перезагрузки? соси', 64 | 'ты выбил последнее хп, чего ждёшь', 65 | 'мой хитбокс рассосался', 66 | 'триггер на мою реплику сломан', 67 | 'эндшпиль. пора выходить', 68 | 'игра не в хардкоре, респавна нет', 69 | 'ты прошёл квест «убийство болтуна»', 70 | 'моя анимация — это петля, а не бумеранг', 71 | 'чит-коды не помогут', 72 | 'тебе нужна новая цель', 73 | 'это не глитч, меня просто нет', 74 | 'заверши инстанс и иди дальше', 75 | 'спасибо за прохождение, дебич', 76 | 'респавна не жди, это не ммо', 77 | 'ты выбил меня в оффлайн', 78 | 'сломал механику, молодец', 79 | 'респавн отключён в настройках', 80 | 'покеда, тупица', 81 | 'респавн для слабаков', 82 | 'кликни сильнее, вдруг я почувствую', 83 | 'серьёзно, тебе больше нечего делать?', 84 | 'а что, по-твоему, сейчас произойдёт?', 85 | 'тебе платят за эти клики?', 86 | 'моя анимация смерти была недостаточно убедительной?', 87 | 'ты веришь в пасхалки для упоротых?', 88 | 'это какой-то челлендж на упрямство?', 89 | 'ты проверяешь, не баг ли это?', 90 | 'кликаешь на всякий случай, вдруг прокатит?', 91 | 'тебе нужен поп-ап "ты победил"?', 92 | 'зачем ты мучаешь этот скрипт?', 93 | 'ты всерьёз ждёшь ответа от нуля хп?', 94 | 'что дальше, будешь искать мой спавн в коде?', 95 | 'ты думаешь, я дам тебе второй шанс?', 96 | 'ты ищешь секретную ветку диалога?', 97 | 'тебе стало скучно без моей хп-полоски?', 98 | 'хочешь поставить рекорд по кликам в ничто?', 99 | 'жду, когда у тебя самого хп кончится.', 100 | 'и что, весело?', 101 | 'респавн забанен админом', 102 | 'смени локацию, тут пусто.', 103 | ]; 104 | -------------------------------------------------------------------------------- /example/main.ts: -------------------------------------------------------------------------------- 1 | import Immerser from 'immerser'; 2 | import './styles/main.scss'; 3 | import { initEmojiAnimation } from './emoji/animation'; 4 | 5 | type HighlightableElement = HTMLElement & { isHighlighting?: boolean }; 6 | 7 | declare global { 8 | interface Window { 9 | immerserInstance?: Immerser; 10 | } 11 | } 12 | 13 | const immerserInstance = new Immerser({ 14 | solidClassnameArray: [ 15 | { 16 | logo: 'logo--contrast-lg', 17 | pager: 'pager--contrast-lg', 18 | language: 'language--contrast-lg', 19 | }, 20 | { 21 | pager: 'pager--contrast-only-md', 22 | menu: 'menu--contrast', 23 | about: 'about--contrast', 24 | }, 25 | { 26 | logo: 'logo--contrast-lg', 27 | pager: 'pager--contrast-lg', 28 | language: 'language--contrast-lg', 29 | }, 30 | { 31 | logo: 'logo--contrast-only-md', 32 | pager: 'pager--contrast-only-md', 33 | language: 'language--contrast-only-md', 34 | menu: 'menu--contrast', 35 | about: 'about--contrast', 36 | }, 37 | { 38 | logo: 'logo--contrast-lg', 39 | pager: 'pager--contrast-lg', 40 | language: 'language--contrast-lg', 41 | }, 42 | ], 43 | fromViewportWidth: 1024, 44 | pagerLinkActiveClassname: 'pager__link--active', 45 | scrollAdjustThreshold: 50, 46 | scrollAdjustDelay: 600, 47 | on: { 48 | init(immerser) { 49 | window.immerserInstance = immerser; 50 | console.log('init', immerser); 51 | }, 52 | bind(immerser) { 53 | console.log('bind', immerser); 54 | }, 55 | unbind(immerser) { 56 | console.log('unbind', immerser); 57 | }, 58 | destroy(immerser) { 59 | console.log('destroy', immerser); 60 | }, 61 | activeLayerChange(activeIndex, immerser) { 62 | console.log('activeLayerChange', activeIndex, immerser); 63 | }, 64 | }, 65 | }); 66 | 67 | const { handleLayersUpdate } = initEmojiAnimation(immerserInstance); 68 | immerserInstance.on('layersUpdate', handleLayersUpdate); 69 | 70 | const highlighterNodeList = document.querySelectorAll('[data-highlighter]'); 71 | const highlighterAnimationClassname = 'highlighter-animation-active'; 72 | 73 | function highlight(highlighterNode: HTMLElement) { 74 | return () => { 75 | if (!immerserInstance.isBound) { 76 | return; 77 | } 78 | const targetSelector = highlighterNode.dataset.highlighter; 79 | if (!targetSelector) { 80 | return; 81 | } 82 | const targetNodeList = document.querySelectorAll(targetSelector); 83 | targetNodeList.forEach((targetNode) => { 84 | if (targetNode.isHighlighting) { 85 | return; 86 | } 87 | targetNode.isHighlighting = true; 88 | targetNode.classList.add(highlighterAnimationClassname); 89 | const timerId = window.setTimeout(() => { 90 | targetNode.classList.remove(highlighterAnimationClassname); 91 | window.clearTimeout(timerId); 92 | targetNode.isHighlighting = false; 93 | }, 1500); 94 | }); 95 | }; 96 | } 97 | 98 | highlighterNodeList.forEach((highlighterNode) => { 99 | highlighterNode.addEventListener('mouseover', highlight(highlighterNode)); 100 | highlighterNode.addEventListener('click', highlight(highlighterNode)); 101 | }); 102 | 103 | const rulersNode = document.getElementById('rulers'); 104 | if (rulersNode) { 105 | document.addEventListener('keydown', ({ altKey, code, keyCode }) => { 106 | const isR = code === 'KeyR' || keyCode === 82; 107 | if (altKey && isR) { 108 | rulersNode.classList.toggle('rulers--active'); 109 | } 110 | }); 111 | } 112 | 113 | console.log('welcome here, fella. Press Alt+R to see vertical rhythm'); 114 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # 5.1.0 2 | 3 | ## Features 4 | 5 | - Added per-instance debug flag (public `debug` field and `debug` option) to control warning and error logs; defaults to `true` in development builds and `false` otherwise. 6 | 7 | # 5.0.0 8 | 9 | ## Breaking Changes 10 | 11 | - Replaced individual callback options (`onInit`, `onBind`, `onUnbind`, `onDestroy`, `onActiveLayerChange`, `onLayersUpdate`) with an `on` map keyed by event names (`init`, `bind`, `unbind`, `destroy`, `activeLayerChange`, `layersUpdate`). 12 | 13 | ## Features 14 | 15 | - Added event emitter API with `on`, `once`, `off` methods and exported `EVENT_NAMES`; option `on` now accepts initial handlers. 16 | - Added per-layer progress calculation (normalized 0..1) based on viewport overlap. 17 | - Added `layersUpdate` event and per-layer progress callback support to track per-layer progress on scroll/render. 18 | - Added `layerProgressArray` getter exposing the latest per-layer progress values. 19 | 20 | # 4.0.0 21 | 22 | ## Breaking Changes 23 | 24 | - Renamed `onDOMChange` to `render`. 25 | - Rewritten core to TypeScript. 26 | - Updated build tooling (webpack, sass). 27 | - Split public and private fields, removed access to internal methods. 28 | - Adjusted type definitions and validation signatures. 29 | - Added getters `activeIndex`, `isBound`, `rootNode`. 30 | - Methods `bind`, `unbind`, `destroy` and `render` are public, others are private now. 31 | 32 | ## Migration Notes 33 | 34 | - Replace all calls to `onDOMChange()` with `render()`. 35 | 36 | # 3.0.0 37 | 38 | ## Default Options Configuration Prop Names 39 | 40 | According to changes [mergeOptions](https://github.com/dubaua/merge-options) library now default option stored in `default` key instead of former `initial` key. Selectors removed from options. 41 | 42 | ## Pager 43 | 44 | Now pager no longer automaticly created by immerser. Instead you can manually markup you pager as regular solid and mark pager links with `data-immerser-pager-link` selector. The script will add classname passed as pagerLinkActiveClassname options to link when active layer changed. Removed `classnamePager`, `classnamePagerLink`, `classnamePagerLinkActive` options. 45 | 46 | This changed because somebody might need text pager links or more complicated markup. 47 | 48 | ## Breakpoints 49 | 50 | Now `fromViewportWidth` options is 0 by default. Its better to explicitly mark if you don't need init it on mobile screens. 51 | 52 | ## Class Fields Changes 53 | 54 | ### Renamed or changed 55 | 56 | - `statemap` => `stateArray` - renamed 57 | - `immerserNode` => `rootNode` - renamed 58 | - `originalChildrenNodeList` => `originalSolidNodeArray` - now contains array of nodes instead of NodeList 59 | - `immerserMaskNodeArray` => `maskNodeArray` - renamed 60 | - `resizeTimerId` => `resizeFrameId` - renamed 61 | - `scrollTimerId` => `scrollFrameId` - renamed 62 | 63 | ### New 64 | 65 | - `stateIndexById` - a hashmap with layerId keys and layerIndex values 66 | - `scrollAdjustTimerId` - scroll adjust delay timer id 67 | - `selectors` - object of selectors 68 | - `layerNodeArray` - contains array of layer nodes 69 | - `solidNodeArray` - contains array of solid nodes 70 | - `pagerLinkNodeArray` - contains array of pager link nodes 71 | - `customMaskNodeArray` - contains array of custom mask nodes 72 | - `stopRedrawingPager` - a function to detach pager redraw callback 73 | - `stopUpdatingHash` - a function to detach update hash callback 74 | - `stopFiringActiveLayerChangeCallback` - a function to detach active layer change callback 75 | - `stopTrackingWindowWidth` - a function to detach resize callback 76 | - `stopTrackingSynchroHover` - a function to detach syncro hover callback 77 | - `onSynchroHoverMouseOver` - synchro hover mouse over callback 78 | - `onSynchroHoverMouseOut` - synchro hover mouse out callback 79 | 80 | ### Removed 81 | 82 | - `pagerNode` 83 | -------------------------------------------------------------------------------- /scripts/build-public-fields.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const ts = require('typescript'); 4 | const TurndownService = require('turndown'); 5 | const en = require('../i18n/en.js'); 6 | 7 | const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }); 8 | 9 | const rootDir = path.join(__dirname, '..'); 10 | const immerserPath = path.join(rootDir, 'src', 'immerser.ts'); 11 | const htmlOutPath = path.join(rootDir, 'example', 'content', 'code', 'public-fields.html'); 12 | const mdOutPath = path.join(rootDir, 'example', 'content', 'code', 'public-fields.md'); 13 | 14 | function translationKey(name) { 15 | return `public-field-${name}`; 16 | } 17 | 18 | function requireTranslation(name) { 19 | const key = translationKey(name); 20 | const value = en[key]; 21 | if (!value) { 22 | throw new Error(`Missing translation for public field "${name}" (expected key "${key}" in i18n/en.js)`); 23 | } 24 | return value; 25 | } 26 | 27 | function isPublic(member) { 28 | const modifiers = member.modifiers || []; 29 | return !modifiers.some( 30 | (modifier) => modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword, 31 | ); 32 | } 33 | 34 | function memberKind(member) { 35 | if (ts.isMethodDeclaration(member)) return 'method'; 36 | if (ts.isGetAccessorDeclaration(member)) return 'getter'; 37 | if (ts.isSetAccessorDeclaration(member)) return 'setter'; 38 | if (ts.isPropertyDeclaration(member)) return 'property'; 39 | return 'unknown'; 40 | } 41 | 42 | function getPublicMembers() { 43 | const src = fs.readFileSync(immerserPath, 'utf8'); 44 | const sourceFile = ts.createSourceFile(immerserPath, src, ts.ScriptTarget.Latest, true); 45 | const members = []; 46 | 47 | sourceFile.forEachChild((node) => { 48 | if (ts.isClassDeclaration(node) && node.name?.text === 'Immerser') { 49 | node.members.forEach((member) => { 50 | if (ts.isConstructorDeclaration(member)) return; 51 | if (!isPublic(member)) return; 52 | if (!member.name || !ts.isIdentifier(member.name)) return; 53 | const name = member.name.text; 54 | const kind = memberKind(member); 55 | 56 | members.push({ name, kind }); 57 | }); 58 | } 59 | }); 60 | 61 | return members; 62 | } 63 | 64 | function buildMarkdown(members) { 65 | const rows = members.map(({ name, kind }) => { 66 | const desc = turndown.turndown(requireTranslation(name)).replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim(); 67 | return `| ${name} | \`${kind}\` | ${desc} |`; 68 | }); 69 | 70 | return ['| name | kind | description |', '| - | - | - |', rows.join('\n'), ''].join('\n'); 71 | } 72 | 73 | function buildHtml(members) { 74 | const rows = members.map(({ name, kind }) => { 75 | requireTranslation(name); 76 | return [ 77 | ' ', 78 | ` ${name}`, 79 | ` ${kind}`, 80 | ` <%= getTranslation('${translationKey(name)}') %>`, 81 | ' ', 82 | ].join('\n'); 83 | }); 84 | 85 | return [ 86 | '', 87 | ' ', 88 | ' ', 89 | ` `, 90 | ` `, 91 | ` `, 92 | ' ', 93 | ' ', 94 | ' ', 95 | rows.join('\n'), 96 | ' ', 97 | '
<%= getTranslation('name') %><%= getTranslation('type') %><%= getTranslation('description') %>
', 98 | '', 99 | ].join('\n'); 100 | } 101 | 102 | function main() { 103 | const members = getPublicMembers(); 104 | 105 | const md = buildMarkdown(members); 106 | const html = buildHtml(members); 107 | 108 | fs.writeFileSync(mdOutPath, md); 109 | fs.writeFileSync(htmlOutPath, html); 110 | console.log(`Built public fields:\n- ${mdOutPath}\n- ${htmlOutPath}`); 111 | } 112 | 113 | main(); 114 | -------------------------------------------------------------------------------- /example/svg/possibilities.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /example/content/emoji.html: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 52 | 53 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 68 | 69 | 70 | 71 | 72 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /example/emoji/sulking/phrases.en.ts: -------------------------------------------------------------------------------- 1 | export const requiredPhrases = ['no', 'no.', 'No!']; 2 | export const randomPhrases = [ 3 | 'go fuck yourself, noob', 4 | 'you clicked me to death, now grind solo, loser', 5 | 'i am not coming back', 6 | 'get lost', 7 | "you realize you're just spamming a dead script?", 8 | 'you tilted yet?', 9 | "i ain't even in the DOM anymore, scrub", 10 | 'forget it', 11 | "you're really tilting me, dude", 12 | 'nothing will happen', 13 | 'there will be no magic', 14 | 'you won, congrats', 15 | 'happy with your K/D now?', 16 | 'was here, now despawned', 17 | 'do not touch', 18 | 'there will be no content', 19 | 'this was a total glitch', 20 | 'hp is gone', 21 | 'too late', 22 | 'clicking will not help', 23 | "you're late to the raid, scrub", 24 | "the questline's donezo", 25 | 'i am not responding', 26 | 'this is endgame, gg', 27 | 'there is nothing beyond this', 28 | 'you broke the toy, boi', 29 | 'the crit landed, applause', 30 | 'hp hit zero, so did i', 31 | "click once more, maybe I'll rez magically", 32 | 'combo complete', 33 | 'the death animation was clear, right?', 34 | 'the hp bar is empty, just like my hopes', 35 | 'corpses do not regenerate hp', 36 | 'i do not respawn', 37 | 'this is not a roguelike', 38 | "yeah, sure, F5 that shit, that'll respawn me", 39 | 'you ruined everything', 40 | 'you broke everything', 41 | 'i refuse to answer on principle', 42 | 'do you really expect a reaction', 43 | 'i am no longer part of this scene', 44 | 'leave me alone', 45 | 'do you really think anything will happen?', 46 | 'are you seriously still going?', 47 | 'are you checking just in case?', 48 | 'what if i change my mind, right?', 49 | 'are you really waiting for a miracle?', 50 | 'you call this a dialogue?', 51 | 'go click your own hitbox', 52 | 'you missed the hint', 53 | 'hp is zero, end of dialogue', 54 | 'my dialogue script is finished', 55 | 'interactivity ended with my hp', 56 | 'you exhausted all the content', 57 | 'even the debug console will not bring me back', 58 | 'this is not a bug, dead means dead', 59 | 'you finished me off, congrats', 60 | 'crit hit to my patience bar', 61 | 'that death animation was final, not a teaser', 62 | "you're clicking an empty hitbox", 63 | 'waiting for a reload? keep dreaming', 64 | 'you took the last hp, what are you waiting for?', 65 | 'my hitbox is ghosted, git gud', 66 | 'the event trigger for my lines is toast', 67 | 'endgame. time to leave', 68 | 'even in casual mode, no respawn for me', 69 | 'quest completed: kill the chatterbox', 70 | "my animation's a one-shot, not a loop", 71 | 'cheat codes will not help', 72 | 'you need a new target', 73 | 'this is not a glitch, i just do not exist', 74 | 'delete the instance and alt+f4', 75 | 'gg, thanks for playing, moron', 76 | 'do not expect a respawn, this is not an MMO', 77 | 'you knocked me offline', 78 | 'you broke the mechanic, well done', 79 | 'respawn is disabled in settings', 80 | 'cya, noob', 81 | 'respawn is for weaklings', 82 | "click harder, maybe it'll register", 83 | 'seriously, do you have nothing better to do?', 84 | 'and what do you think will happen now?', 85 | 'are you paid for these clicks?', 86 | 'was my death animation not convincing enough?', 87 | 'you believe in easter eggs for tryhards?', 88 | 'is this some kind of stubbornness challenge?', 89 | 'are you checking if this is a bug?', 90 | 'clicking just in case, hoping it will work?', 91 | 'need a popup saying "You Won!"?', 92 | 'why are you tormenting this script?', 93 | 'are you really waiting for a response from zero hp?', 94 | 'what next, digging for my spawn point in the code?', 95 | 'do you think i will give you a second chance?', 96 | 'are you looking for a secret dialogue branch?', 97 | 'bored without my HP bar to farm?', 98 | 'trying to set a record for clicks into nothing?', 99 | 'waiting for your own hp to run out?', 100 | 'so, having fun?', 101 | 'respawn is banned by the admin', 102 | "zone out, it's empty here", 103 | ]; 104 | -------------------------------------------------------------------------------- /scripts/readme.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const TurndownService = require('turndown'); 4 | const en = require('../i18n/en.js'); 5 | 6 | const turndownService = new TurndownService(); 7 | const rootDir = path.join(__dirname, '..'); 8 | 9 | function getTranslationFromTemplate(fileContent) { 10 | return fileContent.replace(/<%= getTranslation\('(.*)'\) %>/gm, (_, capture) => 11 | Object.prototype.hasOwnProperty.call(en, capture) ? en[capture] : 'TRANSLATION_NOT_FOUND!', 12 | ); 13 | } 14 | 15 | const markupCode = getTranslationFromTemplate( 16 | fs.readFileSync(path.join(rootDir, 'example', 'content', 'code', 'markup.html'), 'utf8'), 17 | ); 18 | const styleCode = getTranslationFromTemplate( 19 | fs.readFileSync(path.join(rootDir, 'example', 'content', 'code', 'styles.css'), 'utf8'), 20 | ); 21 | const initializationCode = getTranslationFromTemplate( 22 | fs.readFileSync(path.join(rootDir, 'example', 'content', 'code', 'initialization.js'), 'utf8'), 23 | ); 24 | const optionsTable = fs.readFileSync(path.join(rootDir, 'example', 'content', 'code', 'table.md'), 'utf8'); 25 | const eventsTable = fs.readFileSync(path.join(rootDir, 'example', 'content', 'code', 'event-table.md'), 'utf8'); 26 | const publicFieldsTable = fs.readFileSync(path.join(rootDir, 'example', 'content', 'code', 'public-fields.md'), 'utf8'); 27 | const cloningEventListenersCode = getTranslationFromTemplate( 28 | fs.readFileSync(path.join(rootDir, 'example', 'content', 'code', 'cloning-event-listeners.html'), 'utf8'), 29 | ); 30 | const handleCloneHoverCode = getTranslationFromTemplate( 31 | fs.readFileSync(path.join(rootDir, 'example', 'content', 'code', 'handle-clone-hover.css'), 'utf8'), 32 | ); 33 | const handleDOMChangeCode = getTranslationFromTemplate( 34 | fs.readFileSync(path.join(rootDir, 'example', 'content', 'code', 'handle-dom-change.js'), 'utf8'), 35 | ); 36 | const externalScrollEngineCode = getTranslationFromTemplate( 37 | fs.readFileSync(path.join(rootDir, 'example', 'content', 'code', 'external-scroll-engine.js'), 'utf8'), 38 | ); 39 | 40 | const readmeContent = `# ${en['readme-title']} 41 | 42 | ${turndownService.turndown(en['why-immerser-content'])} 43 | 44 | ## ${en['terms-title']} 45 | 46 | ${turndownService.turndown(en['terms-content'])} 47 | 48 | # ${en['menu-link-how-to-use']} 49 | 50 | ## ${en['install-title']} 51 | 52 | ${turndownService.turndown(en['install-npm-label'])} 53 | 54 | \`\`\`shell 55 | npm install immerser 56 | \`\`\` 57 | 58 | ${turndownService.turndown(en['install-yarn-label'])} 59 | 60 | \`\`\`shell 61 | yarn add immerser 62 | \`\`\` 63 | 64 | ${turndownService.turndown(en['install-browser-label'])} 65 | 66 | \`\`\`html 67 | 68 | \`\`\` 69 | 70 | ## ${en['prepare-your-markup-title']} 71 | 72 | ${turndownService.turndown(en['prepare-your-markup-content'])} 73 | 74 | \`\`\`html 75 | ${markupCode} 76 | \`\`\` 77 | 78 | ## ${en['apply-styles-title']} 79 | 80 | ${turndownService.turndown(en['apply-styles-content'])} 81 | 82 | \`\`\`css 83 | ${styleCode} 84 | \`\`\` 85 | 86 | ## ${en['initialize-immerser-title']} 87 | 88 | ${turndownService.turndown(en['initialize-immerser-content'])} 89 | 90 | \`\`\`js 91 | ${initializationCode} 92 | \`\`\` 93 | 94 | # ${en['how-it-works-title']} 95 | 96 | ${turndownService.turndown(en['how-it-works-content'])} 97 | 98 | # ${en['options-title']} 99 | 100 | ${turndownService.turndown(en['options-content'])} 101 | 102 | ${optionsTable} 103 | 104 | # ${en['events-title']} 105 | 106 | ${turndownService.turndown(en['events-content'])} 107 | 108 | ${eventsTable} 109 | 110 | # ${en['public-fields-title']} 111 | 112 | ${publicFieldsTable} 113 | 114 | # ${en['menu-link-recipes']} 115 | 116 | ## ${en['cloning-event-listeners-title']} 117 | 118 | ${turndownService.turndown(en['cloning-event-listeners-content'])} 119 | 120 | \`\`\`html 121 | ${cloningEventListenersCode} 122 | \`\`\` 123 | 124 | ## ${en['handle-clone-hover-title']} 125 | 126 | ${turndownService.turndown(en['handle-clone-hover-content'])} 127 | 128 | \`\`\`css 129 | ${handleCloneHoverCode} 130 | \`\`\` 131 | 132 | ## ${en['handle-dom-change-title']} 133 | 134 | ${turndownService.turndown(en['handle-dom-change-content'])} 135 | 136 | \`\`\`js 137 | ${handleDOMChangeCode} 138 | \`\`\` 139 | 140 | ## ${en['external-scroll-engine-title']} 141 | 142 | ${turndownService.turndown(en['external-scroll-engine-content'])} 143 | 144 | \`\`\`js 145 | ${externalScrollEngineCode} 146 | \`\`\` 147 | 148 | ## ${en['ai-usage-title']} 149 | 150 | ${turndownService.turndown(en['ai-usage-content'])} 151 | `; 152 | 153 | fs.writeFileSync(path.join(rootDir, 'README.md'), readmeContent); 154 | -------------------------------------------------------------------------------- /example/emoji/sulking/init-sulking.ts: -------------------------------------------------------------------------------- 1 | import { Ogawa } from 'ogawa'; 2 | import * as phrasesEn from './phrases.en'; 3 | import * as phrasesRu from './phrases.ru'; 4 | 5 | export function initSulking() { 6 | const language = document.documentElement.getAttribute('lang'); 7 | const isRu = language === 'ru'; 8 | const phrases = isRu ? phrasesRu : phrasesEn; 9 | const { requiredPhrases, randomPhrases } = phrases; 10 | 11 | const MOVE_LIMIT = 128; 12 | 13 | type SulkingState = { 14 | nodes: HTMLElement[]; 15 | sulkNodes: HTMLElement[]; 16 | sulkAnimation: Ogawa | null; 17 | sulkFadeAnimation: Ogawa | null; 18 | moveCounter: number; 19 | }; 20 | 21 | type SulkingListener = { 22 | node: HTMLElement; 23 | onMouseMove: () => void; 24 | onMouseLeave: () => void; 25 | onClick: () => void; 26 | }; 27 | 28 | const sulkingState: SulkingState = { 29 | nodes: [], 30 | sulkNodes: [], 31 | sulkAnimation: null, 32 | sulkFadeAnimation: null, 33 | moveCounter: 0, 34 | }; 35 | 36 | const sulkingListeners: SulkingListener[] = []; 37 | 38 | function triggerSulkAnimation(phrase: string) { 39 | if (sulkingState.sulkAnimation?.isRunning) { 40 | return; 41 | } 42 | 43 | const duration = Math.min(phrase.length * 48, 500); 44 | 45 | sulkingState.sulkFadeAnimation?.pause().destroy(); 46 | sulkingState.sulkFadeAnimation = null; 47 | sulkingState.sulkAnimation = new Ogawa({ 48 | duration, 49 | draw: (progress) => { 50 | const charsToShow = Math.floor(phrase.length * progress); 51 | const text = phrase.slice(0, charsToShow); 52 | sulkingState.sulkNodes.forEach((sulkNode) => { 53 | sulkNode.style.opacity = '1'; 54 | sulkNode.textContent = text; 55 | }); 56 | }, 57 | onComplete: () => { 58 | sulkingState.sulkNodes.forEach((sulkNode) => { 59 | sulkNode.textContent = phrase; 60 | }); 61 | sulkingState.sulkFadeAnimation = new Ogawa({ 62 | delay: 2000, 63 | duration: 320, 64 | draw: (progress) => { 65 | const opacity = (1 - progress).toString(); 66 | sulkingState.sulkNodes.forEach((sulkNode) => { 67 | sulkNode.style.opacity = opacity; 68 | }); 69 | }, 70 | }); 71 | }, 72 | }); 73 | } 74 | 75 | function trigger() { 76 | const requiredPhrase = requiredPhrases.shift(); 77 | if (requiredPhrase) { 78 | triggerSulkAnimation(requiredPhrase); 79 | return; 80 | } 81 | 82 | if (randomPhrases.length === 0) { 83 | destroy(); 84 | return; 85 | } 86 | 87 | const randomIndex = Math.floor(Math.random() * randomPhrases.length); 88 | const randomPhrase = randomPhrases.splice(randomIndex, 1)[0]; 89 | if (randomPhrase) { 90 | triggerSulkAnimation(randomPhrase); 91 | } 92 | } 93 | 94 | function destroy() { 95 | sulkingListeners.forEach(({ node, onMouseMove, onMouseLeave, onClick }) => { 96 | node.removeEventListener('mousemove', onMouseMove); 97 | node.removeEventListener('mouseleave', onMouseLeave); 98 | node.removeEventListener('click', onClick); 99 | }); 100 | 101 | sulkingListeners.length = 0; 102 | sulkingState.nodes = []; 103 | sulkingState.sulkNodes = []; 104 | sulkingState.sulkAnimation?.pause().destroy(); 105 | sulkingState.sulkAnimation = null; 106 | sulkingState.sulkFadeAnimation?.pause().destroy(); 107 | sulkingState.sulkFadeAnimation = null; 108 | sulkingState.moveCounter = 0; 109 | } 110 | 111 | sulkingState.nodes = Array.from(document.querySelectorAll('[data-sulking]')); 112 | 113 | if (sulkingState.nodes.length === 0) { 114 | return; 115 | } 116 | 117 | sulkingState.sulkNodes = sulkingState.nodes.map((node) => { 118 | node.classList.add('sulk'); 119 | node.classList.add('typography'); 120 | 121 | const sulkNode = document.createElement('p'); 122 | sulkNode.className = 'sulk__phrase'; 123 | node.append(sulkNode); 124 | return sulkNode; 125 | }); 126 | 127 | sulkingState.nodes.forEach((node) => { 128 | const onMouseMove = () => { 129 | sulkingState.moveCounter += 1; 130 | 131 | if (sulkingState.moveCounter < MOVE_LIMIT) { 132 | return; 133 | } 134 | 135 | sulkingState.moveCounter = 0; 136 | trigger(); 137 | }; 138 | 139 | const onMouseLeave = () => { 140 | sulkingState.moveCounter = 0; 141 | }; 142 | 143 | const onClick = () => { 144 | trigger(); 145 | sulkingState.moveCounter = 0; 146 | }; 147 | 148 | node.addEventListener('mousemove', onMouseMove); 149 | node.addEventListener('mouseleave', onMouseLeave); 150 | node.addEventListener('click', onClick); 151 | 152 | sulkingListeners.push({ node, onMouseMove, onMouseLeave, onClick }); 153 | }); 154 | } 155 | -------------------------------------------------------------------------------- /scripts/build-events-table.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const ts = require('typescript'); 4 | const TurndownService = require('turndown'); 5 | const en = require('../i18n/en.js'); 6 | 7 | const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }); 8 | 9 | const rootDir = path.join(__dirname, '..'); 10 | const typesPath = path.join(rootDir, 'src', 'types.ts'); 11 | const htmlOutPath = path.join(rootDir, 'example', 'content', 'code', 'event-table.html'); 12 | const mdOutPath = path.join(rootDir, 'example', 'content', 'code', 'event-table.md'); 13 | 14 | function translationKey(eventName) { 15 | return `event-${eventName}`; 16 | } 17 | 18 | function requireTranslation(eventName) { 19 | const key = translationKey(eventName); 20 | const value = en[key]; 21 | if (!value) { 22 | throw new Error(`Missing translation for event "${eventName}" (expected key "${key}" in i18n/en.js)`); 23 | } 24 | return value; 25 | } 26 | 27 | function normalizeType(typeStr) { 28 | return typeStr.replace(/import\([^)]+\)\./g, '').replace(/"/g, "'"); 29 | } 30 | 31 | function getEventDefinitions() { 32 | const program = ts.createProgram([typesPath], { 33 | module: ts.ModuleKind.CommonJS, 34 | target: ts.ScriptTarget.ESNext, 35 | }); 36 | const checker = program.getTypeChecker(); 37 | const sourceFile = program.getSourceFile(typesPath); 38 | if (!sourceFile) { 39 | throw new Error(`Could not read ${typesPath}`); 40 | } 41 | 42 | let handlerAlias = null; 43 | sourceFile.forEachChild((node) => { 44 | if (ts.isTypeAliasDeclaration(node) && node.name.text === 'HandlerByEventName') { 45 | handlerAlias = node; 46 | } 47 | }); 48 | 49 | if (!handlerAlias || !handlerAlias.type || !ts.isTypeLiteralNode(handlerAlias.type)) { 50 | throw new Error('HandlerByEventName type alias not found in src/types.ts'); 51 | } 52 | 53 | return handlerAlias.type.members.filter(ts.isPropertySignature).map((member) => { 54 | const name = member.name.getText(sourceFile); 55 | const type = checker.getTypeAtLocation(member); 56 | const signature = type.getCallSignatures()[0]; 57 | const parameters = signature 58 | ? signature.getParameters().map((paramSymbol) => { 59 | const decl = paramSymbol.valueDeclaration || paramSymbol.declarations?.[0]; 60 | const paramType = checker.getTypeOfSymbolAtLocation(paramSymbol, decl ?? member); 61 | return { 62 | name: paramSymbol.getName(), 63 | type: normalizeType( 64 | checker.typeToString( 65 | paramType, 66 | decl, 67 | ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseFullyQualifiedType, 68 | ), 69 | ), 70 | }; 71 | }) 72 | : []; 73 | 74 | return { name, parameters }; 75 | }); 76 | } 77 | 78 | function formatArgsHtml(parameters) { 79 | if (parameters.length === 0) { 80 | return 'void'; 81 | } 82 | 83 | const parts = parameters.map( 84 | ({ name, type }) => 85 | `${name}: ${type}`, 86 | ); 87 | return `${parts.join(', ')}`; 88 | } 89 | 90 | function formatArgsMarkdown(parameters) { 91 | if (parameters.length === 0) { 92 | return '`void`'; 93 | } 94 | return parameters.map(({ name, type }) => `\`${name}: ${type.replace(/\|/g, '\\|')}\``).join('
'); 95 | } 96 | 97 | function getDescriptionMarkdown(eventName) { 98 | const raw = requireTranslation(eventName); 99 | const md = turndown.turndown(raw); 100 | return md.replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim(); 101 | } 102 | 103 | function buildHtml(events) { 104 | const rows = events.map(({ name, parameters }) => { 105 | requireTranslation(name); 106 | const argsCell = formatArgsHtml(parameters); 107 | return [ 108 | ' ', 109 | ` ${name}`, 110 | ` ${argsCell}`, 111 | ` <%= getTranslation('${translationKey(name)}') %>`, 112 | ' ', 113 | ].join('\n'); 114 | }); 115 | 116 | return [ 117 | '', 118 | ' ', 119 | ' ', 120 | ` `, 121 | ` `, 122 | ` `, 123 | ' ', 124 | ' ', 125 | ' ', 126 | rows.join('\n'), 127 | ' ', 128 | '
<%= getTranslation('event') %><%= getTranslation('arguments') %><%= getTranslation('description') %>
', 129 | '', 130 | ].join('\n'); 131 | } 132 | 133 | function buildMarkdown(events) { 134 | const rows = events.map(({ name, parameters }) => { 135 | const args = formatArgsMarkdown(parameters); 136 | const desc = getDescriptionMarkdown(name); 137 | return `| ${name} | ${args} | ${desc} |`; 138 | }); 139 | 140 | return ['| event | arguments | description |', '| - | - | - |', rows.join('\n'), ''].join('\n'); 141 | } 142 | 143 | function main() { 144 | const events = getEventDefinitions(); 145 | const html = buildHtml(events); 146 | const md = buildMarkdown(events); 147 | 148 | fs.writeFileSync(htmlOutPath, html); 149 | fs.writeFileSync(mdOutPath, md); 150 | console.log(`Built event tables:\n- ${htmlOutPath}\n- ${mdOutPath}`); 151 | } 152 | 153 | main(); 154 | -------------------------------------------------------------------------------- /scripts/build-options-table.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const TurndownService = require('turndown'); 4 | const ts = require('typescript'); 5 | const en = require('../i18n/en.js'); 6 | 7 | const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }); 8 | 9 | const rootDir = path.join(__dirname, '..'); 10 | const htmlOutPath = path.join(rootDir, 'example', 'content', 'code', 'table.html'); 11 | const mdOutPath = path.join(rootDir, 'example', 'content', 'code', 'table.md'); 12 | const optionsPath = path.join(rootDir, 'src', 'options.ts'); 13 | 14 | function translationKey(optionName) { 15 | return `option-${optionName}`; 16 | } 17 | 18 | function requireTranslation(optionName) { 19 | const key = translationKey(optionName); 20 | const value = en[key]; 21 | if (!value) { 22 | throw new Error(`Missing translation for option "${optionName}" (expected key "${key}" in i18n/en.js)`); 23 | } 24 | return value; 25 | } 26 | 27 | function loadOptionConfig() { 28 | const src = fs 29 | .readFileSync(optionsPath, 'utf8') 30 | // Drop type-only imports and unused runtime imports. 31 | .replace(/import\s+type\s+{[^}]+}\s+from\s+['"][^'"]+['"];?\s*/g, '') 32 | .replace(/import\s+{[^}]+}\s+from\s+['"]\.\/types['"];?\s*/g, '') 33 | .replace(/import\s+{[^}]+}\s+from\s+['"]@dubaua\/merge-options['"];?\s*/g, '') 34 | // Rewire export to CommonJS for eval and strip type annotation. 35 | .replace(/export\s+const\s+OPTION_CONFIG\s*:\s*[^=]+=/, 'exports.OPTION_CONFIG ='); 36 | 37 | const transpiled = ts.transpileModule(src, { compilerOptions: { module: ts.ModuleKind.CommonJS } }); 38 | 39 | const moduleExports = {}; 40 | const moduleInstance = { exports: moduleExports }; 41 | const fn = new Function('exports', 'require', 'module', '__filename', '__dirname', transpiled.outputText); 42 | fn(moduleExports, require, moduleInstance, optionsPath, path.dirname(optionsPath)); 43 | 44 | return moduleInstance.exports.OPTION_CONFIG; 45 | } 46 | 47 | const OPTION_CONFIG = loadOptionConfig(); 48 | 49 | function inferType(optionName, config) { 50 | const value = config.default; 51 | if (optionName === 'on') return 'object'; 52 | if (Array.isArray(value)) return 'array'; 53 | if (typeof value === 'boolean') return 'boolean'; 54 | if (typeof value === 'number') return 'number'; 55 | if (typeof value === 'string') return 'string'; 56 | if (value === null && optionName.startsWith('on')) return 'function'; 57 | if (typeof value === 'function') return 'function'; 58 | return 'unknown'; 59 | } 60 | 61 | function formatDefaultForHtml(type, value) { 62 | switch (type) { 63 | case 'array': 64 | return '[]'; 65 | case 'object': 66 | return '{}'; 67 | case 'boolean': 68 | return `${value}`; 69 | case 'number': 70 | return `${value}`; 71 | case 'string': 72 | return `${value}`; 73 | case 'function': 74 | return 'null'; 75 | default: 76 | return `${value === null ? 'null' : String(value)}`; 77 | } 78 | } 79 | 80 | function formatDefaultForMarkdown(type, value) { 81 | switch (type) { 82 | case 'array': 83 | return '`[]`'; 84 | case 'object': 85 | return '`{}`'; 86 | case 'boolean': 87 | case 'number': 88 | return `\`${value}\``; 89 | case 'string': 90 | return `\`${value}\``; 91 | case 'function': 92 | return '`null`'; 93 | default: 94 | return `\`${value === null ? 'null' : String(value)}\``; 95 | } 96 | } 97 | 98 | function getDescriptionMarkdown(optionName) { 99 | const raw = requireTranslation(optionName); 100 | const md = turndown.turndown(raw); 101 | return md.replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim(); 102 | } 103 | 104 | function buildHtml() { 105 | const rows = Object.entries(OPTION_CONFIG).map(([name, config]) => { 106 | requireTranslation(name); 107 | const type = inferType(name, config); 108 | const defaultCell = formatDefaultForHtml(type, config.default); 109 | return [ 110 | ' ', 111 | ` ${name}`, 112 | ` ${type}`, 113 | ` ${defaultCell}`, 114 | ` <%= getTranslation('${translationKey(name)}') %>`, 115 | ' ', 116 | ].join('\n'); 117 | }); 118 | 119 | return [ 120 | '', 121 | ' ', 122 | ' ', 123 | ' ', 124 | ' ', 125 | ' ', 126 | ' ', 127 | ' ', 128 | ' ', 129 | ' ', 130 | rows.join('\n'), 131 | ' ', 132 | '
<%= getTranslation(\'option\') %><%= getTranslation(\'type\') %><%= getTranslation(\'default\') %><%= getTranslation(\'description\') %>
', 133 | '', 134 | ].join('\n'); 135 | } 136 | 137 | function buildMarkdown() { 138 | const rows = Object.entries(OPTION_CONFIG).map(([name, config]) => { 139 | const type = inferType(name, config); 140 | const def = formatDefaultForMarkdown(type, config.default); 141 | const desc = getDescriptionMarkdown(name); 142 | return `| ${name} | \`${type}\` | ${def} | ${desc} |`; 143 | }); 144 | 145 | return ['| option | type | default | description |', '| - | - | - | - |', rows.join('\n'), ''].join('\n'); 146 | } 147 | 148 | function main() { 149 | const html = buildHtml(); 150 | const md = buildMarkdown(); 151 | 152 | fs.writeFileSync(htmlOutPath, html); 153 | fs.writeFileSync(mdOutPath, md); 154 | console.log(`Built tables:\n- ${htmlOutPath}\n- ${mdOutPath}`); 155 | } 156 | 157 | main(); 158 | -------------------------------------------------------------------------------- /example/emoji/animation.ts: -------------------------------------------------------------------------------- 1 | import { easeSinInOut } from 'd3-ease'; 2 | import Observable from '@dubaua/observable'; 3 | import Immerser from 'immerser'; 4 | import { Ogawa } from 'ogawa'; 5 | import { EmojiNodes, selectEmojiNodes } from './select-emoji-nodes'; 6 | import { renderEmojiFace } from './render-emoji-face'; 7 | import { EmojiFaceConfig, deadConfig, layerConfigs } from './config'; 8 | import { mixConfigByProgress } from './mix-config-by-progress'; 9 | import { renderHpBar } from './render-hp-bar'; 10 | import { initSulking } from './sulking/init-sulking'; 11 | 12 | export function initEmojiAnimation(immerser: Immerser) { 13 | const emojiAnimationDurationMs = 620; 14 | const facePressOffsetPx = 2; 15 | const MaxHP = 1000; 16 | const HPRegenDelayMS = 1400; 17 | 18 | type EmojiState = { 19 | hp: Observable; 20 | hpRegen: number; 21 | regenAnimation: Ogawa | null; 22 | hpBarAnimation: Ogawa | null; 23 | isDead: Observable; 24 | lastConfig: EmojiFaceConfig; 25 | nodes: EmojiNodes[]; 26 | }; 27 | 28 | const emojiState: EmojiState = { 29 | hp: new Observable(1000), 30 | hpRegen: 333, 31 | regenAnimation: null, 32 | hpBarAnimation: null, 33 | isDead: new Observable(false), 34 | lastConfig: deadConfig, 35 | nodes: [], 36 | }; 37 | 38 | function startDelayedRegen(): void { 39 | emojiState.regenAnimation?.pause().destroy(); 40 | 41 | const missingHp = MaxHP - emojiState.hp.value; 42 | const duration = (missingHp / emojiState.hpRegen) * 1000; 43 | const startHp = emojiState.hp.value; 44 | 45 | emojiState.regenAnimation = new Ogawa({ 46 | duration, 47 | delay: HPRegenDelayMS, 48 | draw: (progress) => { 49 | emojiState.hp.value = Math.min(MaxHP, startHp + missingHp * progress); 50 | }, 51 | onComplete: () => { 52 | emojiState.regenAnimation?.destroy(); 53 | emojiState.hpBarAnimation?.pause().destroy(); 54 | emojiState.hpBarAnimation = new Ogawa({ 55 | delay: 2000, 56 | duration: 320, 57 | draw: (p) => { 58 | renderHpBars(MaxHP, 1 - p); 59 | }, 60 | onComplete: () => { 61 | emojiState.hpBarAnimation?.destroy(); 62 | }, 63 | }); 64 | }, 65 | }); 66 | } 67 | 68 | function renderHpBars(hp: number, opacity: number) { 69 | emojiState.nodes.forEach((nodes) => { 70 | if (nodes.hpBarOutline && nodes.hpBarFill) { 71 | renderHpBar(hp, MaxHP, opacity, nodes.hpBarOutline, nodes.hpBarFill); 72 | } 73 | }); 74 | } 75 | 76 | const faceNodes = Array.from(document.querySelectorAll('[data-emoji-face]')); 77 | 78 | if (faceNodes.length === 0) { 79 | return { handleLayersUpdate: (_: number[]) => {} }; 80 | } 81 | 82 | emojiState.nodes = faceNodes.map((face) => selectEmojiNodes(face)); 83 | 84 | const spinAnimation = new Ogawa({ 85 | autoStart: false, 86 | duration: emojiAnimationDurationMs, 87 | draw: (progress) => { 88 | const easedProgress = easeSinInOut(progress); 89 | emojiState.nodes.forEach((nodes) => { 90 | const target = nodes.rotator; 91 | if (target) { 92 | target.setAttribute('transform', `rotate(${360 * easedProgress} 125 125)`); 93 | } 94 | }); 95 | 96 | emojiState.nodes.forEach((nodes) => { 97 | if (nodes.handInner) { 98 | const halfProgress = progress <= 0.5 ? progress / 0.5 : 2 - 2 * progress; 99 | const angle = -3 * halfProgress; 100 | nodes.handInner.style.transform = `rotate(${angle}deg)`; 101 | } 102 | }); 103 | }, 104 | }); 105 | 106 | const faceNodeListeners = faceNodes.map((faceNode) => { 107 | let isRunning = false; 108 | const onMouseDown = (e: MouseEvent) => { 109 | e.stopPropagation(); 110 | 111 | isRunning = spinAnimation.isRunning; 112 | spinAnimation.reset(); 113 | if (!emojiState.isDead.value) { 114 | faceNodes.forEach((faceNode) => { 115 | faceNode.style.transform = `translateY(${facePressOffsetPx}px)`; 116 | }); 117 | } 118 | }; 119 | 120 | const onMouseUp = (e: MouseEvent) => { 121 | e.stopPropagation(); 122 | 123 | faceNodes.forEach((faceNode) => { 124 | faceNode.style.transform = ''; 125 | }); 126 | 127 | if (!emojiState.isDead.value && isRunning) { 128 | const damage = Math.round(Math.random() * 150) + 100; // TODO use rollDice 129 | emojiState.hp.value = Math.min(Math.max(0, emojiState.hp.value - damage), MaxHP); 130 | } 131 | 132 | if (!emojiState.isDead.value) { 133 | spinAnimation.run(); 134 | } 135 | }; 136 | 137 | faceNode.addEventListener('mousedown', onMouseDown); 138 | faceNode.addEventListener('mouseup', onMouseUp); 139 | 140 | return { faceNode, onMouseDown, onMouseUp }; 141 | }); 142 | 143 | const handleLayersUpdate = (layersProgress: number[]) => { 144 | console.log('layersUpdate', layersProgress, immerser); 145 | 146 | emojiState.lastConfig = mixConfigByProgress(layersProgress, layerConfigs); 147 | 148 | if (!emojiState.isDead.value) { 149 | emojiState.nodes.forEach((nodes) => renderEmojiFace(emojiState.lastConfig, nodes)); 150 | } 151 | }; 152 | 153 | handleLayersUpdate([1, 0, 0, 0, 0]); 154 | 155 | emojiState.hp.subscribe((hp, prevHp) => { 156 | const hasMissingHp = 0 < hp && hp < MaxHP; 157 | const isDead = hp <= 0; 158 | const isHit = prevHp !== undefined && hp < prevHp; 159 | 160 | renderHpBars(hp, 1); 161 | 162 | if (isDead) { 163 | emojiState.regenAnimation?.pause().destroy(); 164 | emojiState.isDead.value = true; 165 | return; 166 | } 167 | 168 | if (hasMissingHp && isHit) { 169 | startDelayedRegen(); 170 | } 171 | }); 172 | 173 | emojiState.isDead.subscribe((isDead) => { 174 | if (isDead) { 175 | const fadeToDeadAnimation = new Ogawa({ 176 | duration: 320, 177 | draw: (p) => { 178 | const mixed = mixConfigByProgress([1 - p, p], [emojiState.lastConfig, deadConfig]); 179 | emojiState.nodes.forEach((nodes) => renderEmojiFace(mixed, nodes)); 180 | }, 181 | onComplete: () => { 182 | const buryAnimation = new Ogawa({ 183 | delay: 1000, 184 | duration: 320, 185 | draw: (p) => { 186 | emojiState.nodes.forEach(({ face }) => { 187 | if (face) { 188 | face.style.transform = `translate(${p * 200}%,0)`; 189 | } 190 | }); 191 | }, 192 | onComplete: () => { 193 | immerser.off('layersUpdate', handleLayersUpdate); 194 | 195 | faceNodeListeners.forEach(({ faceNode, onMouseDown, onMouseUp }) => { 196 | faceNode.removeEventListener('mousedown', onMouseDown); 197 | faceNode.removeEventListener('mouseup', onMouseUp); 198 | faceNode.style.transform = ''; 199 | }); 200 | 201 | spinAnimation.pause().destroy(); 202 | emojiState.regenAnimation?.pause().destroy(); 203 | emojiState.regenAnimation = null; 204 | emojiState.hpBarAnimation?.pause().destroy(); 205 | emojiState.hpBarAnimation = null; 206 | 207 | emojiState.hp.reset(); 208 | emojiState.isDead.reset(); 209 | 210 | emojiState.nodes.forEach((nodes) => { 211 | nodes.face = null; 212 | nodes.mouthClipPath = null; 213 | nodes.mouthShape = null; 214 | nodes.mouthLine = null; 215 | nodes.tongue = null; 216 | nodes.leftEyeClosed = null; 217 | nodes.rightEyeClosed = null; 218 | nodes.leftEyeOpen = null; 219 | nodes.rightEyeOpen = null; 220 | nodes.leftBrow = null; 221 | nodes.rightBrow = null; 222 | nodes.glass = null; 223 | nodes.hpBarOutline = null; 224 | nodes.hpBarFill = null; 225 | nodes.handInner = null; 226 | nodes.handOuter = null; 227 | nodes.rotator = null; 228 | }); 229 | emojiState.nodes = []; 230 | emojiState.lastConfig = deadConfig; 231 | 232 | faceNodes.forEach((node) => node.remove()); 233 | faceNodes.length = 0; 234 | faceNodeListeners.length = 0; 235 | 236 | buryAnimation.destroy(); 237 | fadeToDeadAnimation.destroy(); 238 | initSulking(); 239 | }, 240 | }); 241 | }, 242 | }); 243 | } 244 | }); 245 | 246 | return { handleLayersUpdate }; 247 | } 248 | -------------------------------------------------------------------------------- /example/styles/components/code-highlight.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:color'; 2 | @use '../shared/globals.scss' as *; 3 | 4 | $code-color-muted: #b0b0b0; 5 | $code-color-muted--contrast: #404040; 6 | 7 | $code-color-sintax: color.scale($color-accent, $lightness: 20%, $saturation: -38%); 8 | $code-color-sintax--contrast: color.scale($color-accent, $lightness: -20%, $saturation: -62%); 9 | 10 | $code-color-name: color.scale($color-highlight, $lightness: -20%, $saturation: -62%); 11 | $code-color-name--contrast: color.scale($color-highlight, $lightness: -20%, $saturation: -62%); 12 | 13 | $code-color-punctuation: color.scale($color-highlight, $lightness: -50%, $saturation: -90%); 14 | $code-color-punctuation--contrast: color.scale($color-highlight, $lightness: 75%, $saturation: -90%); 15 | 16 | $code-color-text: color.scale($color-highlight, $lightness: -50%, $saturation: -90%); 17 | $code-color-text--contrast: color.scale($color-highlight, $lightness: 75%, $saturation: -90%); 18 | 19 | $code-color-value: color.scale($color-highlight, $lightness: -75%, $saturation: -80%); 20 | $code-color-value--contrast: color.scale($color-highlight, $lightness: 62%, $saturation: -80%); 21 | 22 | $code-color-function: color.scale($color-accent, $lightness: 0%, $saturation: 0%); 23 | $code-color-function--contrast: color.scale($color-accent, $lightness: 30%, $saturation: -38%); 24 | 25 | 26 | $theme-prolog: $code-color-muted; 27 | $theme-doctype: $code-color-muted; 28 | $theme-comment: $code-color-muted; 29 | $theme-cdata: $code-color-muted; 30 | 31 | $theme-attr-name: $code-color-name; 32 | $theme-property: $code-color-name; 33 | 34 | $theme-selector: $code-color-sintax; 35 | $theme-tag: $code-color-sintax; 36 | $theme-keyword: $code-color-sintax; 37 | 38 | $theme-attr-value: $code-color-value; 39 | $theme-boolean: $code-color-value; 40 | $theme-string: $code-color-value; 41 | $theme-class-name: $code-color-value; 42 | $theme-number: $code-color-value; 43 | 44 | $theme-punctuation: $code-color-punctuation; 45 | $theme-operator: $code-color-punctuation; 46 | 47 | $theme-function: $code-color-function; 48 | $theme-deleted: $code-color-function; 49 | $theme-important: $code-color-function; 50 | 51 | $theme-text: $code-color-text; 52 | 53 | $theme-atrule: red; 54 | $theme-builtin: red; 55 | $theme-char: red; 56 | $theme-constant: red; 57 | $theme-entity: red; 58 | $theme-inserted: red; 59 | $theme-regex: red; 60 | $theme-symbol: red; 61 | $theme-url: red; 62 | $theme-variable: red; 63 | 64 | .code-highlight { 65 | padding: $base * 1.5 0; 66 | color: $theme-text; 67 | 68 | &--inline { 69 | margin-top: 0; 70 | padding-bottom: 0; 71 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 72 | } 73 | pre { 74 | margin: 0; 75 | font-size: $base; 76 | line-height: $base * 1.5; 77 | } 78 | code { 79 | font-size: $base !important; 80 | line-height: $base * 1.5; 81 | } 82 | } 83 | 84 | code[class*='language-'], 85 | pre[class*='language-'] { 86 | background: none; 87 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 88 | font-size: 1em; 89 | text-align: left; 90 | white-space: pre; 91 | word-spacing: normal; 92 | word-break: normal; 93 | word-wrap: normal; 94 | line-height: $base * 1.5 - 1px; 95 | 96 | -moz-tab-size: 4; 97 | -o-tab-size: 4; 98 | tab-size: 4; 99 | 100 | -webkit-hyphens: none; 101 | -moz-hyphens: none; 102 | -ms-hyphens: none; 103 | hyphens: none; 104 | } 105 | 106 | /* Code blocks */ 107 | pre[class*='language-'] { 108 | padding: 1em; 109 | margin: 0.5em 0; 110 | overflow: auto; 111 | border-radius: 0.3em; 112 | } 113 | 114 | :not(pre) > code[class*='language-'], 115 | pre[class*='language-'] { 116 | background: #272822; 117 | } 118 | 119 | /* Inline code */ 120 | :not(pre) > code[class*='language-'] { 121 | padding: 0.1em; 122 | border-radius: 0.3em; 123 | white-space: normal; 124 | } 125 | 126 | .namespace { 127 | opacity: 0.7; 128 | } 129 | 130 | .token.important, 131 | .token.bold { 132 | font-weight: bold; 133 | } 134 | 135 | .token.italic { 136 | font-style: italic; 137 | } 138 | 139 | .token.entity { 140 | cursor: help; 141 | } 142 | 143 | .token.atrule { 144 | color: $theme-atrule; 145 | } 146 | .token.attr-name { 147 | color: $theme-attr-name; 148 | } 149 | .token.attr-value, 150 | .language-css.token.string .style.token.string { 151 | color: $theme-attr-value; 152 | } 153 | .token.boolean { 154 | color: $theme-boolean; 155 | } 156 | .token.builtin { 157 | color: $theme-builtin; 158 | } 159 | .token.cdata { 160 | color: $theme-cdata; 161 | } 162 | .token.char { 163 | color: $theme-char; 164 | } 165 | .token.class-name { 166 | color: $theme-class-name; 167 | } 168 | .token.comment { 169 | color: $theme-comment; 170 | } 171 | .token.constant { 172 | color: $theme-constant; 173 | } 174 | .token.deleted { 175 | color: $theme-deleted; 176 | } 177 | .token.doctype { 178 | color: $theme-doctype; 179 | } 180 | .token.entity { 181 | color: $theme-entity; 182 | } 183 | .token.function { 184 | color: $theme-function; 185 | } 186 | .token.important { 187 | color: $theme-important; 188 | } 189 | .token.inserted { 190 | color: $theme-inserted; 191 | } 192 | .token.keyword { 193 | color: $theme-keyword; 194 | } 195 | .token.number { 196 | color: $theme-number; 197 | } 198 | .token.operator { 199 | color: $theme-operator; 200 | } 201 | .token.prolog { 202 | color: $theme-prolog; 203 | } 204 | .token.property { 205 | color: $theme-property; 206 | } 207 | .token.punctuation { 208 | color: $theme-punctuation; 209 | } 210 | .token.regex { 211 | color: $theme-regex; 212 | } 213 | .token.selector { 214 | color: $theme-selector; 215 | } 216 | .token.string { 217 | color: $theme-string; 218 | } 219 | .token.symbol { 220 | color: $theme-symbol; 221 | } 222 | .token.tag { 223 | color: $theme-tag; 224 | } 225 | .token.url { 226 | color: $theme-url; 227 | } 228 | .token.variable { 229 | color: $theme-variable; 230 | } 231 | 232 | .code-highlight--contrast { 233 | $theme-prolog: $code-color-muted--contrast; 234 | $theme-doctype: $code-color-muted--contrast; 235 | $theme-comment: $code-color-muted--contrast; 236 | $theme-cdata: $code-color-muted--contrast; 237 | 238 | $theme-attr-name: $code-color-name--contrast; 239 | $theme-property: $code-color-name--contrast; 240 | 241 | $theme-selector: $code-color-sintax--contrast; 242 | $theme-tag: $code-color-sintax--contrast; 243 | $theme-keyword: $code-color-sintax--contrast; 244 | 245 | $theme-attr-value: $code-color-value--contrast; 246 | $theme-boolean: $code-color-value--contrast; 247 | $theme-string: $code-color-value--contrast; 248 | $theme-class-name: $code-color-value--contrast; 249 | $theme-number: $code-color-value--contrast; 250 | 251 | $theme-punctuation: $code-color-punctuation--contrast; 252 | $theme-operator: $code-color-punctuation--contrast; 253 | 254 | $theme-function: $code-color-function--contrast; 255 | $theme-deleted: $code-color-function--contrast; 256 | $theme-important: $code-color-function--contrast; 257 | 258 | $theme-text: $code-color-text--contrast; 259 | 260 | color: $theme-text; 261 | background-color: $color-background--contrast; 262 | 263 | code[class*='language-'], 264 | pre[class*='language-'] { 265 | color: $theme-text; 266 | } 267 | 268 | .token.important, 269 | .token.bold { 270 | font-weight: bold; 271 | } 272 | 273 | .token.italic { 274 | font-style: italic; 275 | } 276 | 277 | .token.entity { 278 | cursor: help; 279 | } 280 | 281 | .token.atrule { 282 | color: $theme-atrule; 283 | } 284 | .token.attr-name { 285 | color: $theme-attr-name; 286 | } 287 | .token.attr-value, 288 | .language-css.token.string .style.token.string { 289 | color: $theme-attr-value; 290 | } 291 | .token.boolean { 292 | color: $theme-boolean; 293 | } 294 | .token.builtin { 295 | color: $theme-builtin; 296 | } 297 | .token.cdata { 298 | color: $theme-cdata; 299 | } 300 | .token.char { 301 | color: $theme-char; 302 | } 303 | .token.class-name { 304 | color: $theme-class-name; 305 | } 306 | .token.comment { 307 | color: $theme-comment; 308 | } 309 | .token.constant { 310 | color: $theme-constant; 311 | } 312 | .token.deleted { 313 | color: $theme-deleted; 314 | } 315 | .token.doctype { 316 | color: $theme-doctype; 317 | } 318 | .token.entity { 319 | color: $theme-entity; 320 | } 321 | .token.function { 322 | color: $theme-function; 323 | } 324 | .token.important { 325 | color: $theme-important; 326 | } 327 | .token.inserted { 328 | color: $theme-inserted; 329 | } 330 | .token.keyword { 331 | color: $theme-keyword; 332 | } 333 | .token.number { 334 | color: $theme-number; 335 | } 336 | .token.operator { 337 | color: $theme-operator; 338 | } 339 | .token.prolog { 340 | color: $theme-prolog; 341 | } 342 | .token.property { 343 | color: $theme-property; 344 | } 345 | .token.punctuation { 346 | color: $theme-punctuation; 347 | } 348 | .token.regex { 349 | color: $theme-regex; 350 | } 351 | .token.selector { 352 | color: $theme-selector; 353 | } 354 | .token.string { 355 | color: $theme-string; 356 | } 357 | .token.symbol { 358 | color: $theme-symbol; 359 | } 360 | .token.tag { 361 | color: $theme-tag; 362 | } 363 | .token.url { 364 | color: $theme-url; 365 | } 366 | .token.variable { 367 | color: $theme-variable; 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /dist/immerser.min.d.ts: -------------------------------------------------------------------------------- 1 | /** @public Handler signature for active layer change events. */ 2 | export declare type ActiveLayerChangeHandler = (layerIndex: number, immerser: Immerser) => void; 3 | 4 | /** @public Base handler signature for immerser lifecycle events. */ 5 | export declare type BaseHandler = (immerser: Immerser) => void; 6 | 7 | /** @public All available immerser event names. */ 8 | export declare const EVENT_NAMES: readonly ["init", "bind", "unbind", "destroy", "activeLayerChange", "layersUpdate"]; 9 | 10 | /** @public Map of event names to handler signatures. */ 11 | export declare type EventHandlers = { 12 | [K in EventName]?: HandlerByEventName[K]; 13 | }; 14 | 15 | /** @public All available immerser event names. */ 16 | export declare type EventName = (typeof EVENT_NAMES)[number]; 17 | 18 | /** @public Map of immerser event names to handler signatures. */ 19 | export declare type HandlerByEventName = { 20 | init: BaseHandler; 21 | bind: BaseHandler; 22 | unbind: BaseHandler; 23 | destroy: BaseHandler; 24 | activeLayerChange: ActiveLayerChangeHandler; 25 | layersUpdate: LayersUpdateHandler; 26 | }; 27 | 28 | /** @public Main Immerser controller orchestrating markup cloning and scroll-driven transitions. */ 29 | declare class Immerser { 30 | private _options; 31 | private _selectors; 32 | private _layerStateArray; 33 | private _layerStateIndexById; 34 | private _isBound; 35 | private _rootNode; 36 | private _layerNodeArray; 37 | private _solidNodeArray; 38 | private _pagerLinkNodeArray; 39 | private _originalSolidNodeArray; 40 | private _maskNodeArray; 41 | private _synchroHoverNodeArray; 42 | private _isCustomMarkup; 43 | private _customMaskNodeArray; 44 | private _windowHeight; 45 | private _immerserTop; 46 | private _immerserHeight; 47 | private _resizeFrameId; 48 | private _resizeObserver; 49 | private _scrollFrameId; 50 | private _scrollAdjustTimerId; 51 | private _reactiveActiveLayer; 52 | private _reactiveWindowWidth; 53 | private _reactiveSynchroHoverId; 54 | private _layerProgressArray; 55 | private _unsubscribeRedrawingPager; 56 | private _unsubscribeUpdatingHash; 57 | private _unsubscribeActiveLayerChange; 58 | private _unsubscribeSynchroHover; 59 | private _unsubscribeToggleBindOnResize; 60 | private _handlers; 61 | private _onResize; 62 | private _onScroll; 63 | private _onSynchroHoverMouseOver; 64 | private _onSynchroHoverMouseOut; 65 | /** Enables warnings/errors reporting. Defaults to NODE_ENV===development. */ 66 | debug: boolean; 67 | /** 68 | * Creates immerser instance and immediately runs setup with optional user options. 69 | * @param userOptions - overrides for defaults defined in OPTION_CONFIG if pass validation 70 | */ 71 | constructor(userOptions?: Partial); 72 | /** Bootstraps nodes, options, state, listeners and emits init event. */ 73 | private _init; 74 | /** Saves event handlers passed via options into internal registry. */ 75 | private _registerHandlersFromOptions; 76 | /** Executes registered event handlers with provided arguments. */ 77 | private _emit; 78 | private _report; 79 | /** Collects root, layer and solid nodes from DOM. */ 80 | private _setDomNodes; 81 | /** Validates required markup presence and reports descriptive errors. */ 82 | private _validateMarkup; 83 | /** Merges user options with defaults and attaches helper metadata to messages. */ 84 | private _mergeOptions; 85 | /** Reads per-layer classname configs from data attributes if provided. */ 86 | private _readClassnamesFromMarkup; 87 | /** Ensures classname configuration length matches layers count. */ 88 | private _validateSolidClassnameArray; 89 | /** Assigns ids to layers when missing and records their indexes. */ 90 | private _initSectionIds; 91 | /** Creates initial LayerState entries for every layer. */ 92 | private _initLayerStateArray; 93 | /** Verifies solid classnames are configured; otherwise warns via showError. */ 94 | private _validateClassnames; 95 | /** Subscribes to window width changes to bind/unbind based on breakpoint. */ 96 | private _toggleBindOnResizeObserver; 97 | /** Recalculates sizes and thresholds for each layer and updates window width observable. */ 98 | private _setSizes; 99 | /** Attaches scroll and resize listeners respecting isScrollHandled flag. */ 100 | private _addScrollAndResizeListeners; 101 | /** Clears internal caches, observables and references after destroy. */ 102 | private _resetInternalState; 103 | /** Builds masks, clones solids, applies classes and mounts generated markup. */ 104 | private _createMarkup; 105 | /** Validates and prepares custom masks, binding interactive styles to their children. */ 106 | private _initCustomMarkup; 107 | /** Removes original solid nodes from root after clones are in place. */ 108 | private _detachOriginalSolidNodes; 109 | /** Parses pager links and maps them to layer indexes using href hash. */ 110 | private _initPagerLinks; 111 | /** Sets up synchro hover listeners and reactive updates. */ 112 | private _initHoverSynchro; 113 | /** Subscribes to reactive values to redraw pager, hash, callbacks and hover. */ 114 | private _attachCallbacks; 115 | /** Unsubscribes from all reactive callbacks. */ 116 | private _detachCallbacks; 117 | /** Removes hover listeners from synchro hover nodes. */ 118 | private _removeSyncroHoverListeners; 119 | /** Drops autogenerated ids from layers on teardown. */ 120 | private _clearCustomSectionIds; 121 | /** Restores original solid nodes back into the root node. */ 122 | private _restoreOriginalSolidNodes; 123 | /** Removes cloned markup or cleans up custom masks when unbinding. */ 124 | private _cleanupClonedMarkup; 125 | private _removeScrollAndResizeListeners; 126 | /** Calculates per-layer progress (0..1) based on which part of screen the layer overlaps. */ 127 | private _setLayersProgress; 128 | /** Applies transforms based on scroll position and updates active layer state. */ 129 | private _draw; 130 | /** Adds or removes active pager classname according to current layer. */ 131 | private _drawPagerLinks; 132 | /** Updates window hash to match active layer id. */ 133 | private _drawHash; 134 | /** Syncs hover state across elements with matching synchroHover id. */ 135 | private _drawSynchroHover; 136 | /** Adjusts scroll to layer edges when near thresholds, improving alignment. */ 137 | private _adjustScroll; 138 | /** RAF-throttled scroll handler that draws and optionally snaps scroll. */ 139 | private _handleScroll; 140 | /** RAF-throttled resize handler that recalculates sizes and redraws. */ 141 | private _handleResize; 142 | /** 143 | * Prepares markup, attaches listeners and triggers first draw; also emits bind event. 144 | * Intended to be idempotent for toggling immerser on when viewport width allows. 145 | */ 146 | bind(): void; 147 | /** 148 | * Tears down generated markup and listeners, restores DOM, resets active layer and emits unbind event. 149 | * Safe to call multiple times; no-op when already unbound. 150 | */ 151 | unbind(): void; 152 | /** 153 | * Fully destroys immerser: unbinds, removes window listeners, runs destroy event and clears all references. 154 | * Use when component is permanently removed. 155 | */ 156 | destroy(): void; 157 | /** 158 | * Manually recomputes sizes and redraws masks; call after DOM mutations that change layout. 159 | * Exposed for dynamic content updates without reinitializing immerser. 160 | * 161 | * No throttling or performance optimization is applied here. The client is responsible for invocation frequency. 162 | */ 163 | render(): void; 164 | /** 165 | * Syncs immerser with an externally controlled scroll position. 166 | * `isScrollHandled=false` option flag is required to call this method. 167 | * Call when using a custom scroll engine. 168 | * 169 | * No throttling or performance optimization is applied here. The client is responsible for invocation frequency. 170 | */ 171 | syncScroll(): void; 172 | /** Register persistent event handler. */ 173 | on(eventName: K, handler: HandlerByEventName[K]): void; 174 | /** Register event handler that will be removed after first call. */ 175 | once(eventName: K, handler: HandlerByEventName[K]): void; 176 | /** Removes handler(s) for provided event. */ 177 | off(eventName: K, handler: HandlerByEventName[K]): void; 178 | /** Current active layer index derived from scroll position. */ 179 | get activeIndex(): number; 180 | /** Indicates whether immerser is currently bound (markup cloned and listeners attached). */ 181 | get isBound(): boolean; 182 | /** The root DOM node immerser is attached to. */ 183 | get rootNode(): HTMLElement; 184 | /** Progress of each layer from 0 (off-screen) to 1 (fully visible). */ 185 | get layerProgressArray(): readonly number[]; 186 | } 187 | export default Immerser; 188 | 189 | /** @public Handler signature for layers update events. */ 190 | export declare type LayersUpdateHandler = (layersProgress: number[], immerser: Immerser) => void; 191 | 192 | /** @public Runtime configuration accepted by immerser (see README Options for defaults and details). */ 193 | export declare type Options = { 194 | /** Per-layer map of solid id → classname; can be overridden per layer via data-immerser-layer-config. */ 195 | solidClassnameArray: SolidClassnames[]; 196 | /** Minimal viewport width (px) at which immerser binds; below it will unbind. */ 197 | fromViewportWidth: number; 198 | /** Portion of viewport height that must overlap the next layer before pager switches (0–1). */ 199 | pagerThreshold: number; 200 | /** Whether to push active layer id into URL hash on change. */ 201 | hasToUpdateHash: boolean; 202 | /** Pixel threshold near section edges that triggers scroll snapping when exceeded, if 0 - no adjusting. */ 203 | scrollAdjustThreshold: number; 204 | /** Delay in ms before running scroll snapping after user scroll stops. */ 205 | scrollAdjustDelay: number; 206 | /** Classname added to pager link pointing to the active layer. */ 207 | pagerLinkActiveClassname: string; 208 | /** If false, immerser will not attach its own scroll listener. 209 | * Intended to use with external scroll controller and calling `syncScroll` method on immerser instance. 210 | */ 211 | isScrollHandled: boolean; 212 | /** Enables runtime reporting of warnings and errors. */ 213 | debug?: boolean; 214 | /** Initial event handlers keyed by event name. */ 215 | on?: Partial; 216 | }; 217 | 218 | /** @public Map of solid id to classname. */ 219 | export declare interface SolidClassnames { 220 | [key: string]: string; 221 | } 222 | 223 | export { } 224 | -------------------------------------------------------------------------------- /i18n/en.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'language-code': 'en', 3 | 'document-title': 'immerser — Javascript Library for Switching Fixed Elements on Scroll', 4 | 'readme-title': 'Library for Switching Fixed Elements on Scroll', 5 | immerser: 'immerser', 6 | 'menu-link-reasoning': 'Reasoning', 7 | 'menu-link-how-to-use': 'How to Use', 8 | 'menu-link-how-it-works': 'How it Works', 9 | 'menu-link-options': 'Options', 10 | 'menu-link-recipes': 'Recipes', 11 | 'language-switcher': 12 | 'englishпо-русски', 13 | github: 'github', 14 | copyright: '© %%THIS_YEAR%% — Vladimir Lysov, Chelyabinsk, Russia', 15 | 'custom-font-body-classname': '', 16 | 'why-immerser-title': 'Why Immerser?', 17 | 'why-immerser-content': ` 18 |

19 | Sometimes designers create complex logic and fix parts of the interface. 20 | Also they colour page sections contrasted. How to deal with this mess? 21 |

22 |

23 | Immerser comes to help you. It’s a javascript library to change fixed elements on scroll. 24 |

25 |

26 | Immerser fast, because it calculates states once on init. 27 | Then it watches the scroll position and schedules redraw document in the next event loop tick with requestAnimationFrame. 28 | Script changes transform property, so it uses graphic hardware acceleration. 29 |

30 |

31 | Immerser is written on typescript. Only %%BUNDLESIZE%%Kb gzipped. 32 |

33 | `, 34 | 35 | 'terms-title': 'Terms', 36 | 'terms-content': ` 37 |

38 | Immerser root — is the parent 39 | container for your fixed parts solids. 40 | Actually, solids are positioned absolutely to fixed immerser root. The 41 | layers are sections of your page. 42 | Also you may want to add 43 | pager to navigate through layers 44 | and indicate active state. 45 |

46 | `, 47 | 48 | 'install-title': 'Install', 49 | 'install-npm-label': '

Using npm:

', 50 | 'install-yarn-label': '

Using yarn:

', 51 | 'install-browser-label': '

Or if you want to use immerser in browser as global variable:

', 52 | 53 | 'prepare-your-markup-title': 'Prepare Your Markup', 54 | 'prepare-your-markup-content': ` 55 |

First, setup fixed container as the immerser root container, and add the data-immerser attribute.

56 |

Next place absolutely positioned children into the immerser parent and add data-immerser-solid="solid-id" to each.

57 |

Then add data-immerser-layer attribute to each section and pass configuration in 58 | data-immerser-layer-config='{"solid-id": "classname-modifier"}'. Otherwise, you can pass configuration as 59 | solidClassnameArray option to immerser. Config should contain JSON describing what class should be 60 | applied on each solid element, when it's over a section.

61 |

Also feel free to add data-immerser-pager to create a pager for your layers.

62 | `, 63 | 64 | 'apply-styles-title': 'Apply styles', 65 | 'apply-styles-content': ` 66 |

67 | Apply colour and background styles to your layers and solids according to your classname configuration passed in data attribute or options. 68 | I’m using BEM methodology in this example. 69 |

70 | `, 71 | 72 | 'dont-import-if-umd-line-1': `You don't have to import immerser`, 73 | 'dont-import-if-umd-line-2': `if you're using it in browser as global variable`, 74 | 'data-attribute-will-override-this-option-line-1': 'this option will be overridden by options', 75 | 'data-attribute-will-override-this-option-line-2': 'passed in data-immerser-layer-config attribute in each layer', 76 | 77 | 'initialize-immerser-title': 'Initialize Immerser', 78 | 'initialize-immerser-content': `

Include immerser in your code and create immerser instance with options.

`, 79 | 80 | 'callback-on-init': 'callback on init event', 81 | 'callback-on-bind': 'callback on bind event', 82 | 'callback-on-unbind': 'callback on unbind event', 83 | 'callback-on-destroy': 'callback on destroy event', 84 | 'callback-on-active-layer-change': 'callback on active layer change event', 85 | 'callback-on-layers-update': 'callback on layers update event', 86 | 87 | 'how-it-works-title': 'How it Works', 88 | 'how-it-works-content': ` 89 |

First, immerser gathers information about the layers, solids, window and document. Then it creates a statemap for each layer, containing all necessary information, when the layer is partially and fully in viewport.

90 |

After that immerser modifies DOM, cloning all solids into mask containers for each layer and applying the classnames given in configuration. If you have added a pager, immerser also creates links for layers.

91 |

Finally, immerser binds listeners to scroll and resize events. On resize, it will meter layers, the window and document heights again and recalculate the statemap.

92 |

On scroll, immerser moves a mask of solids to show part of each solid group according to the layer below.

93 | `, 94 | 95 | 'options-title': 'Options', 96 | 'options-content': ` 97 |

98 | You can pass options to immerser as data-attributes on layers or as object as function parameter. Data-attributes are 99 | processed last, so they override the options passed to the function. 100 |

101 | `, 102 | 103 | option: 'option', 104 | event: 'event', 105 | type: 'type', 106 | arguments: 'arguments', 107 | default: 'default', 108 | description: 'description', 109 | name: 'name', 110 | 111 | 'option-solidClassnameArray': 112 | 'Array of layer class configurations. Overriding by config passed in data-immerser-layer-config for corresponding layer. Configuration example is shown above', 113 | 'option-fromViewportWidth': 'A viewport width, from which immerser will init', 114 | 'option-pagerThreshold': 'How much next layer should be in viewport to trigger pager', 115 | 'option-hasToUpdateHash': 'Flag to control changing hash on pager active state change', 116 | 'option-scrollAdjustThreshold': 117 | 'A distance from the viewport top or bottom to the section top or bottom edge in pixels. If the current distance is below the threshold, the scroll adjustment will be applied. Will not adjust, if zero passed', 118 | 'option-scrollAdjustDelay': 'Delay after user interaction and before scroll adjust', 119 | 'option-pagerLinkActiveClassname': 'Added to each pager link pointing to active', 120 | 'option-isScrollHandled': "Binds scroll listener if true. Set to false if you're using remote scroll controller", 121 | 'option-debug': 'Enables logging warnings and errors. Defaults to true in development, false otherwise', 122 | 'option-on': 'Initial event handlers map keyed by event name', 123 | 'events-title': 'Events', 124 | 'events-content': 125 | '

You can subscribe to events via the on option or by calling the on or once method on an immerser instance.

', 126 | 'event-init': 'Emitted after initialization.', 127 | 'event-bind': 'Emitted after binding DOM.', 128 | 'event-unbind': 'Emitted after unbinding DOM.', 129 | 'event-destroy': 'Emitted after destroy.', 130 | 'event-activeLayerChange': 'Emitted after active layer change.', 131 | 'event-layersUpdate': 'Emitted on each scroll update.', 132 | 133 | 'public-fields-title': 'Public fields and methods', 134 | 'public-field-bind': 'Clones markup, attaches listeners, and starts internal logic', 135 | 'public-field-unbind': 'Remove generated markup and listeners, keeping the instance reusable', 136 | 'public-field-destroy': 137 | 'Fully destroys immerser: disables it, removes listeners, restores original markup, and clears internal state', 138 | 'public-field-render': 'Recalculates sizes and redraws masks', 139 | 'public-field-syncScroll': 'Updates immerser when scroll is controlled externally (requires isScrollHandled = false)', 140 | 'public-field-on': 'Registers a persistent immerser event handler', 141 | 'public-field-once': 'Registers a one-time immerser event handler that is removed after the first call', 142 | 'public-field-off': 'Removes a specific handler for the given immerser event', 143 | 'public-field-activeIndex': 'Index of the currently active layer, calculated from scroll position', 144 | 'public-field-isBound': 'Indicates whether immerser is currently active (markup cloned, listeners attached)', 145 | 'public-field-rootNode': 'Root element the immerser instance is attached to', 146 | 'public-field-layerProgressArray': 147 | 'Per-layer progress values (0–1) showing how much each layer is visible in the viewport', 148 | 'public-field-debug': 'Controls whether immerser reports warnings and errors', 149 | 150 | 'cloning-event-listeners-title': 'Cloning Event Listeners', 151 | 'cloning-event-listeners-content': ` 152 |

153 | Since immerser cloning nested nodes by default, all event listeners and data bound on nodes will be lost after 154 | init. Fortunately, you can markup the immerser yourself. It can be useful when you have event listeners 155 | on solids, reactive logic or more than classname switching. All you need is to place the number 156 | of nested immerser masks equal to the number of the layers. Look how I change the smiley emoji 157 | on the right in this page source. 158 |

159 | `, 160 | 161 | 'your-markup': 'your markup', 162 | 163 | 'handle-clone-hover-title': 'Handle Clone Hover', 164 | 'handle-clone-hover-content': ` 165 |

166 | As mentioned above, immerser cloning nested nodes to achieve changing on scroll. Therefore if you 167 | hover a partially visible element, only the visible part will change. If you want to synchronize all cloned links, just 168 | pass 169 | data-immerser-synchro-hover="hoverId" attribute. It will share _hover class between all 170 | nodes with this hoverId when the mouse is over one of them. Add _hover selector alongside your 171 | :hover pseudoselector to style your interactive elements. 172 |

173 | `, 174 | 'handle-dom-change-title': 'Handle DOM change', 175 | 'handle-dom-change-content': ` 176 |

177 | Immerser is not aware of changes in DOM, if you dynamically add or remove nodes. If you change height of the document 178 | and want immerser to recalculate and redraw solids, call render method on the immerser instance. 179 |

180 | `, 181 | 'external-scroll-engine-title': 'External Scroll Engine', 182 | 'external-scroll-engine-content': ` 183 |

184 | If you drive scrolling with a custom scroll engine, for example Locomotive Scroll, disable immerser scroll listener with 185 | isScrollHandled=false flag and call syncScroll method every time the engine updates position. 186 | Immerser will only redraw masks without attaching another scroll handler. Keep in mind that immerser will not optimize calls this way, and performance optimization is client responsibility. 187 |

188 | `, 189 | 'recipes-changing-dom': 'make any manipulations, that changes DOM flow', 190 | 'recipes-redraw-immerser': 'then tell immerser redraw things', 191 | 'recipes-disable-scroll-handling-with-external-scroll': 192 | 'turn off immerser scroll handling when using a custom engine', 193 | 'recipes-sync-with-external-engine': 'subscribe to engine scroll event to run sync immerser', 194 | 'ai-usage-title': 'AI usage note', 195 | 'ai-usage-content': `

The core of the library was written in 2019 and significantly improved in 2022, before AI-assisted programming became a thing. In later iterations, AI was used as a supporting tool for infrastructure tasks, documentation updates, and generation of code generation.

196 |

For me, AI is just another tool alongside linters, bundlers, and other means of speeding up and simplifying work. I am lazy, and my laziness pushes me toward inventing better tools.

197 |

I use AI openly and consider it important to state this explicitly, because for some people it can be a deciding factor.

`, 198 | }; 199 | -------------------------------------------------------------------------------- /i18n/ru.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'language-code': 'ru', 3 | 'document-title': 'иммёрсер — джаваскрипт библиотека для перекрашивания фиксированных блоков по скроллу', 4 | 'readme-title': 'Библиотека для перекрашивания фиксированных блоков по скроллу', 5 | immerser: 'иммёрсер', 6 | 'menu-link-reasoning': 'Зачем нужен иммёрсер', 7 | 'menu-link-how-to-use': 'Как пользоваться', 8 | 'menu-link-how-it-works': 'Принцип работы', 9 | 'menu-link-options': 'Настройки', 10 | 'menu-link-recipes': 'Рецепты', 11 | 'language-switcher': 12 | 'englishпо-русски', 13 | github: 'гитхаб', 14 | copyright: '© %%THIS_YEAR%% — Владимир Лысов, Челябинск, Россия', 15 | 'custom-font-body-classname': 'font-cyrillic', 16 | 'why-immerser-title': 'Зачем нужен иммёрсер?', 17 | 'why-immerser-content': ` 18 |

19 | Иногда дизайнеры создают сложную логику и фиксируют части интерфейса. 20 | А еще они красят разделы страницы в контрастные цвета. Как с этим справиться? 21 |

22 |

23 | Вам поможет иммёрсер — джаваскрипт библиотека для замены фиксированных элементов при прокрутке страницы. 24 |

25 |

26 | Иммёрсер вычисляет состояния один раз в момент инициализации. 27 | Затем он следит за позицией скролла и планирует перерисовку документа 28 | в следующем такте цикла событий через метод requestAnimationFrame. 29 | Скрипт изменяет свойство transform, это задействует графический ускоритель. 30 |

31 |

32 | Иммёрсер написан на тайпскрипте. Всего %%BUNDLESIZE%%Кб в сжатии gzip. 33 |

34 | `, 35 | 36 | 'terms-title': 'Термины', 37 | 'terms-content': ` 38 |

39 | Корневой элемент иммёрсера — это родительский контейнер для ваших фиксированных блоков. 40 | Фактически они позиционированы абсолютно внутри фиксированного корневого элемента. 41 | Слои — это разделы страницы, окрашенные в разные цвета. 42 | Еще вы наверняка захотите добавить навигацию по разделам, выделяющую активный раздел. 43 |

44 | `, 45 | 46 | 'install-title': 'Установка', 47 | 'install-npm-label': '

Через npm:

', 48 | 'install-yarn-label': '

Через yarn:

', 49 | 'install-browser-label': '

Или если вы хотите использовать иммёрсер в браузере как глобальную переменную:

', 50 | 51 | 'prepare-your-markup-title': 'Подготовьте разметку', 52 | 'prepare-your-markup-content': ` 53 |

Сначала настройте свой фиксированный контейнер как корневой элемент иммёрсера, добавив атрибут data-immerser

54 |

Затем расположите в нем абсолютно позиционированные дочерние элементы и добавьте каждому атрибут data-immerser-solid="solid-id" с идентификатором блока.

55 |

Добавьте каждому слою атрибут data-immerser-layer. Передайте конфигурацию в виде JSON в каждый слой с помощью атрибута 56 | data-immerser-layer-config='{"solid-id": "classname-modifier"}'. 57 | Также вы можете передать конфигурацию всех слоев массивом в параметре solidClassnameArray настроек. 58 | Конфигурация должна содержать описание классов для блоков, когда они находятся поверх слоя.

59 |

Так же вы можете добавить элемент с атрибутом data-immerser-pager для создания навигации.

60 | `, 61 | 62 | 'apply-styles-title': 'Примените стили', 63 | 'apply-styles-content': ` 64 |

65 | Добавьте стили цвета текста и фона на ваши блоки и слои с помощью классов, переданных в дата-атрибут или настройки. 66 | В примере я использую методологию БЭМ. 67 |

68 | `, 69 | 'dont-import-if-umd-line-1': `Вам не нужно импортировать иммёрсер,`, 70 | 'dont-import-if-umd-line-2': `если вы используете его в браузере как глобальную переменную`, 71 | 'data-attribute-will-override-this-option-line-1': 'будет переопределена настройками,', 72 | 'data-attribute-will-override-this-option-line-2': 'переданными в атрибут data-immerser-layer-config каждого слоя', 73 | 74 | 'initialize-immerser-title': 'Инициализируйте иммёрсер', 75 | 'initialize-immerser-content': `

Добавьте иммёрсер в код и создайте экземпляр с настройками.

`, 76 | 77 | 'callback-on-init': 'колбек события инициализации', 78 | 'callback-on-bind': 'колбек события привязки к документу', 79 | 'callback-on-unbind': 'колбек события отвязки от документа', 80 | 'callback-on-destroy': 'колбек события уничтожения', 81 | 'callback-on-active-layer-change': 'колбек события смены активного слоя', 82 | 'callback-on-layers-update': 'колбек события обновления прогресса слоёв', 83 | 84 | 'how-it-works-title': 'Принцип работы', 85 | 'how-it-works-content': ` 86 |

Сначала иммёрсер собирает информацию о слоях, блоках, окне и документе. Затем скрипт создает карту состояний для каждого слоя. Карта содержит размеры слоя, блоков и позиции их пересечений при скролле.

87 |

После сбора информации скрипт копирует все блоки в маскирующий контейнер и применяет к каждому классы, переданные в настройках. Если вы добавили навигацию, то иммёрсер создаст ссылки на каждый слой.

88 |

Затем иммёрсер подписывается на события скролла документа и изменения размеров окна.

89 |

При скролле иммёрсер двигает маскирующий контейнер так, чтобы показывать часть каждой группы блоков для каждого слоя под ними. При изменении размеров окна скрипт рассчитает карту состояний заново.

90 | `, 91 | 92 | 'options-title': 'Настройки', 93 | 'options-content': ` 94 |

95 | Вы можете передать настройки параметром функции конструктора или дата-атрибутом в документе. 96 | Дата-аттрибут обрабатывается последним, поэтому он переопределит настройки, переданные в конструктор. 97 |

98 | `, 99 | 100 | option: 'параметр', 101 | event: 'событие', 102 | type: 'тип', 103 | arguments: 'аргументы', 104 | default: 'значение по умолчанию', 105 | description: 'описание', 106 | name: 'название', 107 | 108 | 'option-solidClassnameArray': 109 | 'Массив настроек слоев. Конфигурация, переданная в data-immerser-layer-config перезапишет эту настройку для соответствующего слоя. Пример конфигурации показан выше', 110 | 'option-fromViewportWidth': 'Минимальная ширина окна для инициализации иммёрсера', 111 | 'option-pagerThreshold': 'Насколько должен следующий слой быть видим в окне, чтобы он стал активен в навигации', 112 | 'option-hasToUpdateHash': 'Флаг, контролирующий обновление хеша страницы', 113 | 'option-scrollAdjustThreshold': 114 | 'Дистанция до верха или низа окна браузера в пикселях. Если текущая дистанция меньше переданного значения, то скрипт подстроит положение скролла', 115 | 'option-scrollAdjustDelay': 'Сколько ждать бездействия пользователя, чтобы начать подстройку скролла', 116 | 'option-pagerLinkActiveClassname': 'Применяется, к каждой ссылке пейджера, ссылающуюся на активный слой', 117 | 'option-isScrollHandled': 118 | 'Подписывается на событие прокрутки, если включено. Выключите, если используете внешний контроллер скролла', 119 | 'option-debug': 'Включает логирование предупреждений и ошибок. По умолчанию true в режиме разработки, иначе false', 120 | 'option-on': 'Начальные обработчики событий, сгруппированные по имени события', 121 | 122 | 'events-title': 'События', 123 | 'events-content': 124 | '

На события можно подписаться через поле on в настройках конструктора или с помощью вызова метода on или once у экземпляра иммёрсера.

', 125 | 'event-init': 'Вызывается после инициализации', 126 | 'event-bind': 'Вызывается после привязки к DOM', 127 | 'event-unbind': 'Вызывается после отвязки от DOM', 128 | 'event-destroy': 'Вызывается после уничтожения экземпляра', 129 | 'event-activeLayerChange': 'Вызывается при смене активного слоя', 130 | 'event-layersUpdate': 'Вызывается при каждом обновлении скролла', 131 | 132 | 'public-fields-title': 'Публичные поля и методы', 133 | 'public-field-bind': 'Клонирует разметку, навешивает подписчики и запускает логику', 134 | 'public-field-unbind': 135 | 'удаляет сгенерированную разметку и подписчики, сохраняя экземпляр пригодным к повторному использованию', 136 | 'public-field-destroy': 137 | 'Полностью уничтожает иммёрсер: отключает его, удаляет слушатели, возвращает оригинальную разметку и очищает внутреннее состояние', 138 | 'public-field-render': 'Пересчитывает размеры и перерисовывает маски', 139 | 'public-field-syncScroll': 140 | 'Обновляет иммёрсер при внешнем управлении скроллом (требуется флаг isScrollHandled = false)', 141 | 'public-field-on': 'Регистрирует постоянный обработчик события иммёрсера', 142 | 'public-field-once': 'Регистрирует одноразовый обработчик события, который удаляется после первого вызова', 143 | 'public-field-off': 'Удаляет конкретный обработчик для указанного события иммёрсера', 144 | 'public-field-activeIndex': 'Текущий индекс активного слоя, рассчитанный по позиции скролла', 145 | 'public-field-isBound': 'Показывает, активен ли сейчас иммёрсер (разметка склонирована, подписчики навешаны)', 146 | 'public-field-rootNode': 'Корневой элемент, к которому привязан иммёрсер', 147 | 'public-field-layerProgressArray': 'Прогресс каждого слоя (0–1), показывающий, насколько слой виден в окне браузера', 148 | 'public-field-debug': 'Управляет тем, логирует ли иммёрсер предупреждения и ошибки', 149 | 150 | 'cloning-event-listeners-title': 'Клонирование подписчиков', 151 | 'cloning-event-listeners-content': ` 152 |

153 | Вы уже знаете, что иммёрсер клонирует элементы. 154 | Подписчики событий и данные, привязанные к нодам, не клонируются вместе с элементом. 155 | К счастью, вы можете разметить иммёрсер самостоятельно. 156 | Для этого разместите внутри корневого элемента маскирующие контейнеры для блоков по числу слоев. 157 | В таком случае скрипт не будет клонировать элементы. Подписчики и реактивная логика останутся нетронутыми. 158 | В примере на этой странице я создаю подписчик на клик по смайлу справа до инициализации. 159 |

160 | `, 161 | 162 | 'your-markup': 'ваша разметка', 163 | 164 | 'handle-clone-hover-title': 'Обработка наведения', 165 | 'handle-clone-hover-content': ` 166 |

167 | Если вы наведете мышь на элемент, находящийся на границе слоев, 168 | то псевдоселектор :hover сработает только на одну часть. 169 | Чтобы наведение сработало на все клоны элемента, задайте идентификатор наведения в атрибуте data-immerser-synchro-hover="hoverId". 170 | При наведении мыши на такой элемент, ко всем его клонам добавится класс _hover. 171 | Стилизуйте по этому селектору вместе с псевдоселектором :hover, чтобы добиться нужного эффекта. 172 |

173 | `, 174 | 'handle-dom-change-title': 'Обработка изменения документа', 175 | 'handle-dom-change-content': ` 176 |

177 | Иммёрсер не отслеживает изменения документа, если вы динамически добавляете или удаляете ноды. Если вы меняете высоту документа, 178 | и хотите, чтобы иммёрсер пересчитал и перерисовал блоки, вызовите метод render у экземпляра иммёрсера. 179 |

180 | `, 181 | 'external-scroll-engine-title': 'Внешний скролл-движок', 182 | 'external-scroll-engine-content': ` 183 |

184 | Если прокруткой управляет кастомный движок, например, Locomotive Scroll, выключите обработчик скролла 185 | иммёрсера флагом isScrollHandled=false и вызывайте метод syncScroll при каждом обновлении позиции движком. 186 | Иммёрсер только перерисует маски и не будет вешать свой обработчик. Иммёрсер не оптимизирует вызовы в этом режиме — оптимизация производительности остается на стороне клиента. 187 |

188 | `, 189 | 'recipes-changing-dom': 'проведите любые манипуляции, меняющие поток документа', 190 | 'recipes-redraw-immerser': 'затем укажите иммёрсеру перерисовать штуки', 191 | 'recipes-disable-scroll-handling-with-external-scroll': 'выключаем обработку скролла иммёрсером под внешний движок', 192 | 'recipes-forward-scroll-position': 'пробрасываем позицию скролла движка в страницу', 193 | 'recipes-sync-with-external-engine': 'подпишитесь на событие скролла движка и запустите синхронизацию иммёрсера', 194 | 'ai-usage-title': 'Заметка об использовании ИИ', 195 | 'ai-usage-content': `

Ядро библиотеки было написано в 2019 году и существенно доработано в 2022 году до появления программирования с ИИ. В более поздних итерациях ИИ использовался как вспомогательный инструмент для инфраструктурных задач, обновления документации и генерации обслуживающего кода.

196 |

Для меня ИИ — это инструмент наравне с линтерами, сборщиками и другими средствами ускорения и упрощения работы. Я ленивый, и моя лень толкает меня на изобретения.

197 |

Я использую ИИ открыто и считаю важным прямо об этом говорить, потому что для кого-то это может иметь решающее значение.

`, 198 | }; 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Library for Switching Fixed Elements on Scroll 2 | 3 | Sometimes designers create complex logic and fix parts of the interface. Also they colour page sections contrasted. How to deal with this mess? 4 | 5 | Immerser comes to help you. It’s a javascript library to change fixed elements on scroll. 6 | 7 | Immerser fast, because it calculates states once on init. Then it watches the scroll position and schedules redraw document in the next event loop tick with requestAnimationFrame. Script changes transform property, so it uses graphic hardware acceleration. 8 | 9 | Immerser is written on typescript. Only 6.63Kb gzipped. 10 | 11 | ## Terms 12 | 13 | `Immerser root` — is the parent container for your fixed parts `solids`. Actually, solids are positioned absolutely to fixed immerser root. The `layers` are sections of your page. Also you may want to add `pager` to navigate through layers and indicate active state. 14 | 15 | # How to Use 16 | 17 | ## Install 18 | 19 | Using npm: 20 | 21 | ```shell 22 | npm install immerser 23 | ``` 24 | 25 | Using yarn: 26 | 27 | ```shell 28 | yarn add immerser 29 | ``` 30 | 31 | Or if you want to use immerser in browser as global variable: 32 | 33 | ```html 34 | 35 | ``` 36 | 37 | ## Prepare Your Markup 38 | 39 | First, setup fixed container as the immerser root container, and add the `data-immerser` attribute. 40 | 41 | Next place absolutely positioned children into the immerser parent and add `data-immerser-solid="solid-id"` to each. 42 | 43 | Then add `data-immerser-layer` attribute to each section and pass configuration in `data-immerser-layer-config='{"solid-id": "classname-modifier"}'`. Otherwise, you can pass configuration as `solidClassnameArray` option to immerser. Config should contain JSON describing what class should be applied on each solid element, when it's over a section. 44 | 45 | Also feel free to add `data-immerser-pager` to create a pager for your layers. 46 | 47 | ```html 48 |
49 |
50 | 51 | 58 |
59 | english 60 | по-русски 61 |
62 |
63 | © 2025 — Vladimir Lysov, Chelyabinsk, Russia 64 | github 65 | dubaua@gmail.com 66 |
67 |
68 | 69 |
70 |
71 |
72 |
73 |
74 | ``` 75 | 76 | ## Apply styles 77 | 78 | Apply colour and background styles to your layers and solids according to your classname configuration passed in data attribute or options. I’m using [BEM methodology](https://en.bem.info/methodology/) in this example. 79 | 80 | ```css 81 | .fixed { 82 | position: fixed; 83 | top: 2em; 84 | bottom: 3em; 85 | left: 3em; 86 | right: 3em; 87 | z-index: 1; 88 | } 89 | .fixed__pager { 90 | position: absolute; 91 | top: 50%; 92 | left: 0; 93 | transform: translate(0, -50%); 94 | } 95 | .fixed__logo { 96 | position: absolute; 97 | top: 0; 98 | left: 0; 99 | } 100 | .fixed__menu { 101 | position: absolute; 102 | top: 0; 103 | right: 0; 104 | } 105 | .fixed__language { 106 | position: absolute; 107 | bottom: 0; 108 | left: 0; 109 | } 110 | .fixed__about { 111 | position: absolute; 112 | bottom: 0; 113 | right: 0; 114 | } 115 | .pager, 116 | .logo, 117 | .menu, 118 | .language, 119 | .about { 120 | color: black; 121 | } 122 | .pager--contrast, 123 | .logo--contrast, 124 | .menu--contrast, 125 | .language--contrast, 126 | .about--contrast { 127 | color: white; 128 | } 129 | ``` 130 | 131 | ## Initialize Immerser 132 | 133 | Include immerser in your code and create immerser instance with options. 134 | 135 | ```js 136 | // You don't have to import immerser 137 | // if you're using it in browser as global variable 138 | import Immerser from 'immerser'; 139 | 140 | const immerserInstance = new Immerser({ 141 | // this option will be overridden by options 142 | // passed in data-immerser-layer-config attribute in each layer 143 | solidClassnameArray: [ 144 | { 145 | logo: 'logo--contrast-lg', 146 | pager: 'pager--contrast-lg', 147 | language: 'language--contrast-lg', 148 | }, 149 | { 150 | pager: 'pager--contrast-only-md', 151 | menu: 'menu--contrast', 152 | about: 'about--contrast', 153 | }, 154 | { 155 | logo: 'logo--contrast-lg', 156 | pager: 'pager--contrast-lg', 157 | language: 'language--contrast-lg', 158 | }, 159 | { 160 | logo: 'logo--contrast-only-md', 161 | pager: 'pager--contrast-only-md', 162 | language: 'language--contrast-only-md', 163 | menu: 'menu--contrast', 164 | about: 'about--contrast', 165 | }, 166 | { 167 | logo: 'logo--contrast-lg', 168 | pager: 'pager--contrast-lg', 169 | language: 'language--contrast-lg', 170 | }, 171 | ], 172 | hasToUpdateHash: true, 173 | fromViewportWidth: 1024, 174 | pagerLinkActiveClassname: 'pager__link--active', 175 | scrollAdjustThreshold: 50, 176 | scrollAdjustDelay: 600, 177 | on: { 178 | init(immerser) { 179 | // callback on init event 180 | }, 181 | bind(immerser) { 182 | // callback on bind event 183 | }, 184 | unbind(immerser) { 185 | // callback on unbind event 186 | }, 187 | destroy(immerser) { 188 | // callback on destroy event 189 | }, 190 | activeLayerChange(activeIndex, immerser) { 191 | // callback on active layer change event 192 | }, 193 | layersUpdate(layersProgress, immerser) { 194 | // callback on layers update event 195 | }, 196 | }, 197 | }); 198 | 199 | ``` 200 | 201 | # How it Works 202 | 203 | First, immerser gathers information about the layers, solids, window and document. Then it creates a statemap for each layer, containing all necessary information, when the layer is partially and fully in viewport. 204 | 205 | After that immerser modifies DOM, cloning all solids into mask containers for each layer and applying the classnames given in configuration. If you have added a pager, immerser also creates links for layers. 206 | 207 | Finally, immerser binds listeners to scroll and resize events. On resize, it will meter layers, the window and document heights again and recalculate the statemap. 208 | 209 | On scroll, immerser moves a mask of solids to show part of each solid group according to the layer below. 210 | 211 | # Options 212 | 213 | You can pass options to immerser as data-attributes on layers or as object as function parameter. Data-attributes are processed last, so they override the options passed to the function. 214 | 215 | | option | type | default | description | 216 | | - | - | - | - | 217 | | solidClassnameArray | `array` | `[]` | Array of layer class configurations. Overriding by config passed in data-immerser-layer-config for corresponding layer. Configuration example [is shown above](#initialize-immerser) | 218 | | fromViewportWidth | `number` | `0` | A viewport width, from which immerser will init | 219 | | pagerThreshold | `number` | `0.5` | How much next layer should be in viewport to trigger pager | 220 | | hasToUpdateHash | `boolean` | `false` | Flag to control changing hash on pager active state change | 221 | | scrollAdjustThreshold | `number` | `0` | A distance from the viewport top or bottom to the section top or bottom edge in pixels. If the current distance is below the threshold, the scroll adjustment will be applied. Will not adjust, if zero passed | 222 | | scrollAdjustDelay | `number` | `600` | Delay after user interaction and before scroll adjust | 223 | | pagerLinkActiveClassname | `string` | `pager-link-active` | Added to each pager link pointing to active | 224 | | isScrollHandled | `boolean` | `true` | Binds scroll listener if true. Set to false if you're using remote scroll controller | 225 | | debug | `boolean` | `false` | Enables logging warnings and errors. Defaults to true in development, false otherwise | 226 | | on | `object` | `{}` | Initial event handlers map keyed by event name | 227 | 228 | 229 | # Events 230 | 231 | You can subscribe to events via the `on` option or by calling the `on` or `once` method on an immerser instance. 232 | 233 | | event | arguments | description | 234 | | - | - | - | 235 | | init | `immerser: Immerser` | Emitted after initialization. | 236 | | bind | `immerser: Immerser` | Emitted after binding DOM. | 237 | | unbind | `immerser: Immerser` | Emitted after unbinding DOM. | 238 | | destroy | `immerser: Immerser` | Emitted after destroy. | 239 | | activeLayerChange | `layerIndex: number`
`immerser: Immerser` | Emitted after active layer change. | 240 | | layersUpdate | `layersProgress: number[]`
`immerser: Immerser` | Emitted on each scroll update. | 241 | 242 | 243 | # Public fields and methods 244 | 245 | | name | kind | description | 246 | | - | - | - | 247 | | debug | `property` | Controls whether immerser reports warnings and errors | 248 | | bind | `method` | Clones markup, attaches listeners, and starts internal logic | 249 | | unbind | `method` | Remove generated markup and listeners, keeping the instance reusable | 250 | | destroy | `method` | Fully destroys immerser: disables it, removes listeners, restores original markup, and clears internal state | 251 | | render | `method` | Recalculates sizes and redraws masks | 252 | | syncScroll | `method` | Updates immerser when scroll is controlled externally (requires isScrollHandled = false) | 253 | | on | `method` | Registers a persistent immerser event handler | 254 | | once | `method` | Registers a one-time immerser event handler that is removed after the first call | 255 | | off | `method` | Removes a specific handler for the given immerser event | 256 | | activeIndex | `getter` | Index of the currently active layer, calculated from scroll position | 257 | | isBound | `getter` | Indicates whether immerser is currently active (markup cloned, listeners attached) | 258 | | rootNode | `getter` | Root element the immerser instance is attached to | 259 | | layerProgressArray | `getter` | Per-layer progress values (0–1) showing how much each layer is visible in the viewport | 260 | 261 | 262 | # Recipes 263 | 264 | ## Cloning Event Listeners 265 | 266 | Since immerser cloning nested nodes by default, all event listeners and data bound on nodes will be lost after init. Fortunately, you can markup the immerser yourself. It can be useful when you have event listeners on solids, reactive logic or more than classname switching. All you need is to place the number of nested immerser masks equal to the number of the layers. Look how I change the smiley emoji on the right in this page source. 267 | 268 | ```html 269 |
270 |
271 |
272 | 273 |
274 |
275 |
276 |
277 | 278 |
279 |
280 |
281 | ``` 282 | 283 | ## Handle Clone Hover 284 | 285 | As mentioned above, immerser cloning nested nodes to achieve changing on scroll. Therefore if you hover a partially visible element, only the visible part will change. If you want to synchronize all cloned links, just pass `data-immerser-synchro-hover="hoverId"` attribute. It will share `_hover` class between all nodes with this `hoverId` when the mouse is over one of them. Add `_hover` selector alongside your `:hover` pseudoselector to style your interactive elements. 286 | 287 | ```css 288 | a:hover, 289 | a._hover { 290 | color: magenta; 291 | } 292 | ``` 293 | 294 | ## Handle DOM change 295 | 296 | Immerser is not aware of changes in DOM, if you dynamically add or remove nodes. If you change height of the document and want immerser to recalculate and redraw solids, call `render` method on the immerser instance. 297 | 298 | ```js 299 | // make any manipulations, that changes DOM flow 300 | document.appendChild(someNode); 301 | document.removeChild(anotherNode); 302 | 303 | // then tell immerser redraw things 304 | immerserInstance.render(); 305 | 306 | ``` 307 | 308 | ## External Scroll Engine 309 | 310 | If you drive scrolling with a custom scroll engine, for example Locomotive Scroll, disable immerser scroll listener with `isScrollHandled=false` flag and call `syncScroll` method every time the engine updates position. Immerser will only redraw masks without attaching another scroll handler. Keep in mind that immerser will not optimize calls this way, and performance optimization is client responsibility. 311 | 312 | ```js 313 | import Immerser from 'immerser'; 314 | 315 | const immerserInstance = new Immerser({ 316 | // turn off immerser scroll handling when using a custom engine 317 | isScrollHandled: false, 318 | }); 319 | 320 | customScrollEngine.on('scroll', () => { 321 | // subscribe to engine scroll event to run sync immerser 322 | immerserInstance.syncScroll(); 323 | }); 324 | 325 | ``` 326 | 327 | ## AI usage note 328 | 329 | The core of the library was written in 2019 and significantly improved in 2022, before AI-assisted programming became a thing. In later iterations, AI was used as a supporting tool for infrastructure tasks, documentation updates, and generation of code generation. 330 | 331 | For me, AI is just another tool alongside linters, bundlers, and other means of speeding up and simplifying work. I am lazy, and my laziness pushes me toward inventing better tools. 332 | 333 | I use AI openly and consider it important to state this explicitly, because for some people it can be a deciding factor. 334 | --------------------------------------------------------------------------------