Showing preview only (261K chars total). Download the full file or copy to clipboard to get everything.
Repository: deoostfreese/Parvus
Branch: main
Commit: 3a3ddefc5d5d
Files: 32
Total size: 249.1 KB
Directory structure:
gitextract_wwaefxjx/
├── .github/
│ └── FUNDING.yml
├── .gitignore
├── .stylelintrc
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── dist/
│ ├── css/
│ │ └── parvus.css
│ └── js/
│ ├── parvus.esm.js
│ └── parvus.js
├── package.json
├── rollup.config.js
├── src/
│ ├── js/
│ │ ├── core/
│ │ │ ├── config.js
│ │ │ ├── events.js
│ │ │ ├── navigation.js
│ │ │ ├── plugins.js
│ │ │ ├── state.js
│ │ │ └── utils.js
│ │ ├── handlers/
│ │ │ ├── gestures.js
│ │ │ ├── images.js
│ │ │ ├── keyboard.js
│ │ │ └── pointer.js
│ │ ├── helpers/
│ │ │ └── dom.js
│ │ ├── parvus.js
│ │ └── ui/
│ │ ├── lightbox.js
│ │ └── zoom-indicator.js
│ ├── l10n/
│ │ ├── de.js
│ │ ├── en.js
│ │ ├── fr.js
│ │ ├── it.js
│ │ └── nl.js
│ └── scss/
│ └── parvus.scss
└── test/
└── test.html
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: [deoostfrees]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
================================================
FILE: .gitignore
================================================
.DS_Store
.vscode
node_modules
================================================
FILE: .stylelintrc
================================================
{
"extends": [
"stylelint-config-standard-scss"
],
"plugins": [
"stylelint-scss",
"stylelint-use-logical"
],
"rules": {
"at-rule-no-unknown": null,
"scss/at-rule-no-unknown": true,
"color-hex-length": "long",
"comment-whitespace-inside": null,
"no-descending-specificity": null,
"shorthand-property-no-redundant-values": [true, {"severity": "warning"}],
"declaration-no-important": true,
"no-duplicate-at-import-rules": true,
"selector-max-id": 0,
"declaration-block-no-duplicate-properties": true,
"rule-empty-line-before": ["always-multi-line", {"ignore": ["after-comment"]}],
"value-keyword-case": "lower",
"selector-class-pattern": ["^([a-z][a-z0-9]*)(-[a-z0-9]+)*(_[a-z0-9]+)*(__[a-z]((_|-)?[a-z0-9])*)?(--[a-z0-9]((_|-)?[a-z0-9\\\\\\/])*)?$", { "resolveNestedSelectors": true }],
"declaration-block-no-redundant-longhand-properties": null,
"csstools/use-logical": true
}
}
================================================
FILE: CHANGELOG.md
================================================
# Changelog
## [3.1.0] - 2026-04-18
### Added
- Add copyright information to an image e598627 @deoostfrees
- Add plugin system with lifecycle hooks ae8203d 2ea0794 86f1056 @deoostfrees
- Add support for captions via ID reference f6e1b8c @deoostfrees
- Add Italian translations 30c42e2 ea54ca2 @conlaccento
- Add French translations 4d04d8d @slolo2000
### Changed
- Use `hsl()` instead of `hsla()` 4ac8cb6 @deoostfrees
- Modularize codebase and improve maintainability 951d30a @deoostfrees
## [3.0.0] - 2025-03-16
### Added
- Pinch zoom gestures 4a591e7 4a8355a fd4ebf1 4e472ef 49c5b16 d27efd9 @deoostfrees #42
- Option to make the zoom indicator optional e65d5c7 @deoostfrees #62
### Changed
- Use the native HTML `dialog` element e703293 @deoostfrees #60
- Use the View Transitions API for the zoom in/ out animation 11e183f @deoostfrees
- Use pointer events instead of mouse and touch events b4941cf @deoostfrees
### Removed
- **Breaking:** The custom event `detail` property 4ea8e38 @deoostfrees
- The `transitionDuration` option. This option is now also set via the available CSS custom property 11e183f @deoostfrees
- The `transitionTimingFunction` option. This option is now also set via the available CSS custom property 11e183f @deoostfrees
- The `loadEmpty` option. The internal `add` function now creates the lightbox 98e41b5 @deoostfrees
- The custom `close` event. The native HTML `dialog` element has its own `close` event dba4678 @deoostfrees
## [2.6.0] - 2024-06-05
### Changed
- Run `change` event listener for `reducedMotionCheck` only when the lightbox is open 083a0e7 @deoostfrees
### Fixed
- Avoid unintentionally moving the image when dragging 96ff56e @deoostfrees #59
- Relationship between caption and image 76df207 @deoostfrees
## [2.5.3] - 2024-04-27
### Fixed
- Remove optional files field in package.json to include all files via NPM 819e132 @deoostfrees
## [2.5.2] - 2024-04-27
### Fixed
- Language file import afe86dc @deoostfrees #55
## [2.5.1] - 2024-04-10
### Fixed
- Issue if no language options are set 2dbed4a @deoostfrees
## [2.5.0] - 2024-04-07
### Added
- Option to load an empty lightbox (even if there are no elements) 9a180fc @deoostfrees a436a81 @drhino
- Fallback to the default language 39e1ae0 @drhino
- Dutch translation 7476426 @drhino
### Changed
- **Breaking:** Rename some CSS custom properties 8b43c66 8ba1f00 @deoostfrees
### Removed
- Slide animation when first/ last slide is visible 4df766b @deoostfrees #52
## [2.4.0] - 2023-07-20
### Added
- Option to hide the browser scrollbar #47
### Changed
- Added an internal function to create and dispatch a new event
- Disabled buttons are no longer visually hidden
- Focus is no longer moved automatically
- CSS styles are now moved from SVG to the actual elements
### Removed
- Custom typography styles
### Fixed
- Load the srcset before the src, add sizes attribute #49
## [2.3.3] - 2023-05-30
### Fixed
- Animate current image and set focus back to the correct element in the default behavior of the `backFocus` option
## [2.3.2] - 2023-05-30
### Fixed
- Set focus back to the correct element in the default behavior of the `backFocus` option
## [2.3.1] - 2023-05-29
### Fixed
- The navigation buttons' visibility
## [2.3.0] - 2023-05-27
### Added
- Changelog section to keep track of changes
- Necessary outputs for screen reader support
- CSS custom properties for captions and image loading error messages
### Changed
- Replaced the custom `copyObject()` function with the built-in `structuredClone()` method
- Refactored code and comments to improve readability and optimize performance
### Removed
- The option for supported image file types as it is no longer necessary
- The `scrollClose` option
### Fixed
- Non standard URLs can break Parvus #43
================================================
FILE: LICENSE.md
================================================
# The MIT License (MIT)
Copyright (c) 2020-2026 Benjamin de Oostfrees
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.md
================================================
# Parvus
Overlays suck, but if you need one, consider using Parvus. Parvus is an open source, dependency free image lightbox with the goal of being accessible.

[Open in CodePen](https://codepen.io/collection/DwLBpz)
## Table of Contents
- [Installation](#installation)
- [Download](#download)
- [Package Managers](#package-managers)
- [Usage](#usage)
- [Captions](#captions)
- [Copyright](#copyright)
- [Gallery](#gallery)
- [Responsive Images](#responsive-images)
- [Localization](#localization)
- [Options](#options)
- [API](#api)
- [Events](#events)
- [Plugins](#plugins)
- [Using Plugins](#using-plugins)
- [Creating Plugins](#creating-plugins)
- [Plugin Hooks](#plugin-hooks)
- [Browser Support](#browser-support)
## Installation
### Download
- CSS:
- `dist/css/parvus.min.css` (minified) or
- `dist/css/parvus.css` (un-minified)
- JavaScript:
- `dist/js/parvus.min.js` (minified) or
- `dist/js/parvus.js` (un-minified)
Link the `.css` and `.js` files in your HTML:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page title</title>
<!-- CSS -->
<link href="path/to/parvus.min.css" rel="stylesheet">
</head>
<body>
<!-- HTML content -->
<!-- JS -->
<script src="path/to/parvus.min.js"></script>
</body>
</html>
```
### Package Managers
You can also install Parvus using npm or yarn:
```sh
npm install parvus
```
or
```sh
yarn add parvus
```
After installation, import Parvus into your JavaScript codebase:
```js
import Parvus from 'parvus'
```
Be sure to include the corresponding SCSS or CSS file.
## Usage
Link a thumbnail image with the class `lightbox` to a larger image:
```html
<a href="path/to/image.jpg" class="lightbox">
<img src="path/to/thumbnail.jpg" alt="">
</a>
```
Initialize the script:
```js
const prvs = new Parvus()
```
### Captions
There are three ways to add a caption to an image:
#### Reference by ID
You can add an ID to your caption element and reference it from the trigger element using the `data-caption-id` attribute.
```html
<figure>
<a href="path/to/image.jpg" class="lightbox" data-caption-id="caption-1">
<img src="path/to/thumbnail.jpg" alt="">
</a>
<figcaption id="caption-1">
I'm a caption, and I live outside the link.
</figcaption>
</figure>
```
#### Direct Attribute
You can add a `data-caption` attribute directly to the trigger element.
```html
<a href="path/to/image.jpg" class="lightbox" data-caption="I'm a simple caption">
<img src="path/to/thumbnail.jpg" alt="">
</a>
```
#### Child Element
Alternatively, set the option `captionsSelector` to select a caption from a child element's `innerHTML`.
```html
<a href="path/to/image.jpg" class="lightbox">
<figure class="figure">
<img src="path/to/thumbnail.jpg" alt="">
<figcaption class="figure__caption">
I'm a caption inside a child element
</figcaption>
</figure>
</a>
```
```js
const prvs = new Parvus({
captionsSelector: '.figure__caption',
})
```
### Copyright
There are three ways to add copyright information to an image:
#### Reference by ID
You can add an ID to your copyright element and reference it from the trigger element using the `data-copyright-id` attribute.
```html
<a href="path/to/image.jpg" class="lightbox" data-copyright-id="copyright-1">
<img src="path/to/thumbnail.jpg" alt="">
</a>
<small id="copyright-1" hidden>
© 2026 Photographer Name
</small>
```
#### Direct Attribute
You can add a `data-copyright` attribute directly to the trigger element.
```html
<a href="path/to/image.jpg" class="lightbox" data-copyright="© 2026 Photographer Name">
<img src="path/to/thumbnail.jpg" alt="">
</a>
```
#### Child Element
Alternatively, set the option `copyrightSelector` to select a copyright from a child element's `innerHTML`.
```html
<a href="path/to/image.jpg" class="lightbox">
<figure class="figure">
<img src="path/to/thumbnail.jpg" alt="">
<small class="figure__copyright">
© 2026 Photographer Name
</small>
</figure>
</a>
```
```js
const prvs = new Parvus({
copyrightSelector: '.figure__copyright',
})
```
### Gallery
To group related images into a set, add a `data-group` attribute:
```html
<a href="path/to/image.jpg" class="lightbox" data-group="Berlin">
<img src="path/to/thumbnail.jpg" alt="">
</a>
<a href="path/to/image_2.jpg" class="lightbox" data-group="Berlin">
<img src="path/to/thumbnail_2.jpg" alt="">
</a>
//...
<a href="path/to/image_8.jpg" class="lightbox" data-group="Kassel">
<img src="path/to/thumbnail_8.jpg" alt="">
</a>
```
Alternatively, set the option `gallerySelector` to group all images with a specific class within a selector:
```html
<div class="gallery">
<a href="path/to/image.jpg" class="lightbox">
<img src="path/to/thumbnail.jpg" alt="">
</a>
<a href="path/to/image_2.jpg" class="lightbox">
<img src="path/to/thumbnail_2.jpg" alt="">
</a>
// ...
</div>
```
```js
const prvs = new Parvus({
gallerySelector: '.gallery',
})
```
### Responsive Images
Specify different image sources and sizes using the `data-srcset` and `data-sizes` attributes:
```html
<a href="path/to/image.jpg" class="lightbox"
data-srcset="path/to/small.jpg 700w,
path/to/medium.jpg 1000w,
path/to/large.jpg 1200w"
data-sizes="(max-width: 75em) 100vw,
75em"
>
<img src="path/to/thumbnail.jpg" alt="">
</a>
```
### Localization
Import the language module and set it as an option for localization:
```js
import de from 'parvus/src/l10n/de'
const prvs = new Parvus({
l10n: de
})
```
## Options
Customize Parvus by passing an options object when initializing:
```js
const prvs = new Parvus({
// Clicking outside does not close Parvus
docClose: false
})
```
Available options include:
```js
{
// Selector for elements that trigger Parvus
selector: '.lightbox',
// Selector for a group of elements combined as a gallery, overrides the `data-group` attribute.
gallerySelector: null,
// Display zoom indicator
zoomIndicator: true,
// Display captions if available
captions: true,
// Selector for the element where the caption is displayed; use "self" for the `a` tag itself.
captionsSelector: 'self',
// Attribute to get the caption from
captionsAttribute: 'data-caption',
// Display copyright if available
copyright: true,
// Selector for the element where the copyright is displayed; use "self" for the `a` tag itself.
copyrightSelector: 'self',
// Attribute to get the copyright from
copyrightAttribute: 'data-copyright',
// Clicking outside closes Parvus
docClose: true,
// Close Parvus by swiping up/down
swipeClose: true,
// Accept mouse events like touch events (click and drag to change slides)
simulateTouch: true,
// Touch dragging threshold in pixels
threshold: 100,
// Hide browser scrollbar
hideScrollbar: true,
// Icons
lightboxIndicatorIcon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>',
previousButtonIcon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path stroke="none" d="M0 0h24v24H0z"/><polyline points="15 6 9 12 15 18" /></svg>',
nextButtonIcon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path stroke="none" d="M0 0h24v24H0z"/><polyline points="9 6 15 12 9 18" /></svg>',
closeButtonIcon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M18 6L6 18M6 6l12 12"/></svg>',
// Localization of strings
l10n: en
}
```
## API
Parvus provides the following API functions:
| Function | Description |
| --- | --- |
| `open(element)` | Open the specified `element` (DOM element) |
| `close()` | Close Parvus |
| `previous()` | Show the previous image |
| `next()` | Show the next image |
| `select(index)` | Select a slide with the specified `index` (integer) |
| `add(element)` | Add the specified `element` (DOM element) |
| `remove(element)` | Remove the specified `element` (DOM element) |
| `destroy()` | Destroy Parvus |
| `isOpen()` | Check if Parvus is currently open |
| `currentIndex()` | Get the index of the currently displayed slide |
| `use(plugin, options)` | Register a plugin |
| `addHook(hookName, callback)` | Add a hook callback |
| `getPlugins()` | Get list of registered plugins |
## Events
Bind and unbind events using the `.on()` and `.off()` methods:
```js
const prvs = new Parvus()
const listener = () => {
console.log('eventName happened')
}
// Bind event listener
prvs.on(eventName, listener)
// Unbind event listener
prvs.off(eventName, listener)
```
Available events:
| eventName | Description |
| --- | --- |
| `open` | Triggered after Parvus has opened |
| `select` | Triggered when a slide is selected |
| `close` | Triggered after Parvus has closed |
| `destroy` | Triggered after Parvus has destroyed |
## Plugins
Parvus supports a plugin system that allows you to extend its functionality.
### Using Plugins
To use a plugin, call the `.use()` method after initialization:
```js
import Parvus from 'parvus'
import MyPlugin from './my-plugin.js'
const prvs = new Parvus()
// Register plugin
prvs.use(MyPlugin, {
// Plugin-specific options
option1: 'value1',
option2: 'value2'
})
```
### Creating Plugins
A plugin is an object with a `name` and an `install` function:
```js
const MyPlugin = {
name: 'MyPlugin',
install(parvus, options = {}) {
// Plugin initialization code
console.log('Plugin installed with options: ', options)
}
}
export default MyPlugin
```
### Plugin Hooks
Plugins can hook into various lifecycle events:
| Hook Name | When Triggered | Provided Data |
| --- | --- | --- |
| `afterInit` | After lightbox DOM is created (once) | `{ state }` |
| `afterOpen` | After lightbox opens | `{ element, state }` |
| `afterClose` | After lightbox closes | `{ state }` |
| `slideChange` | When slide changes | `{ index, oldIndex, state }` |
Example using hooks:
```js
const MyPlugin = {
name: 'MyPlugin',
install(parvus, options) {
// Add a custom button on init
parvus.addHook('afterInit', ({ state }) => {
const btn = document.createElement('button')
btn.classList.add('parvus__btn')
btn.classList.add('parvus__btn--my-plugin')
btn.textContent = 'Custom'
btn.type = 'button'
// Add to controls as first element
if (state.controls) {
state.controls.prepend(btn)
}
})
// Track slide changes
parvus.addHook('slideChange', ({ index, oldIndex }) => {
console.log(`Changed from slide ${oldIndex} to ${index}`)
})
}
}
```
## Browser Support
Parvus is supported on the latest versions of the following browsers:
- Chrome
- Edge
- Firefox
- Safari
================================================
FILE: dist/css/parvus.css
================================================
:root {
--parvus-transition-duration: 0.3s;
--parvus-transition-timing-function: cubic-bezier(0.62, 0.16, 0.13, 1.01);
--parvus-background-color: hsl(23deg 44% 96%);
--parvus-color: hsl(228deg 24% 23%);
--parvus-btn-background-color: hsl(228deg 24% 23%);
--parvus-btn-color: hsl(0deg 0% 100%);
--parvus-btn-hover-background-color: hsl(229deg 24% 33%);
--parvus-btn-hover-color: hsl(0deg 0% 100%);
--parvus-btn-disabled-background-color: hsl(229deg 24% 33% / 60%);
--parvus-btn-disabled-color: hsl(0deg 0% 100%);
--parvus-caption-background-color: transparent;
--parvus-caption-color: hsl(228deg 24% 23%);
--parvus-copyright-background-color: hsl(0deg 0% 100% / 80%);
--parvus-copyright-color: hsl(228deg 24% 23%);
--parvus-loading-error-background-color: hsl(0deg 0% 100%);
--parvus-loading-error-color: hsl(228deg 24% 23%);
--parvus-loader-background-color: hsl(23deg 40% 96%);
--parvus-loader-color: hsl(228deg 24% 23%);
}
::view-transition-group(lightboximage) {
animation-duration: var(--parvus-transition-duration);
animation-timing-function: var(--parvus-transition-timing-function);
z-index: 7;
}
::view-transition-group(toolbar) {
z-index: 8;
}
body:has(.parvus[open]) {
touch-action: none;
}
/**
* Parvus trigger
*
*/
.parvus-trigger:has(img) {
display: block;
position: relative;
}
.parvus-trigger:has(img) .parvus-zoom__indicator {
align-items: center;
background-color: var(--parvus-btn-background-color);
color: var(--parvus-btn-color);
display: flex;
justify-content: center;
padding: 0.5rem;
position: absolute;
inset-inline-end: 0.5rem;
inset-block-start: 0.5rem;
}
.parvus-trigger:has(img) img {
display: block;
}
/**
* Parvus
*
*/
.parvus {
background-color: transparent;
block-size: 100%;
border: 0;
box-sizing: border-box;
color: var(--parvus-color);
contain: strict;
inline-size: 100%;
inset: 0;
margin: 0;
max-block-size: unset;
max-inline-size: unset;
overflow: hidden;
overscroll-behavior: contain;
padding: 0;
position: fixed;
}
.parvus::backdrop {
display: none;
}
.parvus *, .parvus *::before, .parvus *::after {
box-sizing: border-box;
}
.parvus__overlay {
background-color: var(--parvus-background-color);
color: var(--parvus-color);
inset: 0;
position: absolute;
}
.parvus__slider {
inset: 0;
position: absolute;
transform: translateZ(0);
}
@media screen and (prefers-reduced-motion: no-preference) {
.parvus__slider--animate:not(.parvus__slider--is-dragging) {
transition: transform var(--parvus-transition-duration) var(--parvus-transition-timing-function);
will-change: transform;
}
}
.parvus__slider--is-draggable {
cursor: grab;
touch-action: pan-y pinch-zoom;
}
.parvus__slider--is-dragging {
cursor: grabbing;
touch-action: none;
}
.parvus__slide {
block-size: 100%;
contain: layout;
display: grid;
inline-size: 100%;
padding-block: 1rem;
padding-inline: 1rem;
place-items: center;
}
.parvus__slide img {
block-size: auto;
display: block;
inline-size: auto;
margin-inline: auto;
transform: translateZ(0);
}
.parvus__content {
position: relative;
}
.parvus__content--error {
background-color: var(--parvus-loading-error-background-color);
color: var(--parvus-loading-error-color);
padding-block: 0.5rem;
padding-inline: 1rem;
}
.parvus__caption {
background-color: var(--parvus-caption-background-color);
color: var(--parvus-caption-color);
padding-block-start: 0.5rem;
text-align: start;
}
.parvus__copyright {
background-color: var(--parvus-copyright-background-color);
color: var(--parvus-copyright-color);
inset-block-end: 0;
inset-inline-end: 0;
padding-inline: 0.25rem;
position: absolute;
}
.parvus__loader {
display: inline-block;
block-size: 6.25rem;
inset-inline-start: 50%;
position: absolute;
inset-block-start: 50%;
transform: translate(-50%, -50%);
inline-size: 6.25rem;
}
.parvus__loader::before {
animation: spin 1s infinite linear;
border-radius: 100%;
border: 0.25rem solid var(--parvus-loader-background-color);
border-block-start-color: var(--parvus-loader-color);
content: "";
inset: 0;
position: absolute;
z-index: 1;
}
.parvus__toolbar {
align-items: center;
display: flex;
inset-block-start: 1rem;
inset-inline: 1rem;
justify-content: space-between;
pointer-events: none;
position: absolute;
view-transition-name: toolbar;
z-index: 8;
}
.parvus__toolbar > * {
pointer-events: auto;
}
.parvus__controls {
align-items: center;
display: flex;
gap: 0.5rem;
}
.parvus__btn {
appearance: none;
background-color: var(--parvus-btn-background-color);
background-image: none;
border-radius: 0;
border: 0.0625rem solid transparent;
color: var(--parvus-btn-color);
cursor: pointer;
display: flex;
font: inherit;
padding: 0.3125rem;
position: relative;
touch-action: manipulation;
will-change: transform, opacity;
z-index: 7;
}
.parvus__btn:hover, .parvus__btn:focus-visible {
background-color: var(--parvus-btn-hover-background-color);
color: var(--parvus-btn-hover-color);
}
.parvus__btn--previous {
inset-inline-start: 0;
position: absolute;
inset-block-start: calc(50svh - 1rem);
transform: translateY(-50%);
}
.parvus__btn--next {
position: absolute;
inset-inline-end: 0;
inset-block-start: calc(50svh - 1rem);
transform: translateY(-50%);
}
.parvus__btn svg {
pointer-events: none;
}
.parvus__btn[aria-hidden=true] {
display: none;
}
.parvus__btn[aria-disabled=true] {
background-color: var(--parvus-btn-disabled-background-color);
color: var(--parvus-btn-disabled-color);
}
.parvus__counter {
position: relative;
z-index: 7;
}
.parvus__counter[aria-hidden=true] {
display: none;
}
@media screen and (prefers-reduced-motion: no-preference) {
.parvus__overlay, .parvus__counter, .parvus__btn, .parvus__caption, .parvus__copyright {
transition: transform var(--parvus-transition-duration) var(--parvus-transition-timing-function), opacity var(--parvus-transition-duration) var(--parvus-transition-timing-function);
will-change: transform, opacity;
}
.parvus__copyright {
transition-delay: var(--parvus-transition-duration);
}
.parvus--is-closing .parvus__copyright, .parvus--is-vertical-closing .parvus__copyright, .parvus--is-zooming .parvus__copyright {
transition-delay: 0s;
transition-duration: 0s;
}
.parvus--is-opening .parvus__overlay, .parvus--is-opening .parvus__counter, .parvus--is-opening .parvus__btn, .parvus--is-opening .parvus__caption, .parvus--is-opening .parvus__copyright, .parvus--is-closing .parvus__overlay, .parvus--is-closing .parvus__counter, .parvus--is-closing .parvus__btn, .parvus--is-closing .parvus__caption, .parvus--is-closing .parvus__copyright {
opacity: 0;
}
.parvus--is-vertical-closing .parvus__counter, .parvus--is-vertical-closing .parvus__btn:not(.parvus__btn--previous, .parvus__btn--next), .parvus--is-zooming .parvus__counter, .parvus--is-zooming .parvus__btn:not(.parvus__btn--previous, .parvus__btn--next) {
transform: translateY(-100%);
opacity: 0;
}
.parvus--is-vertical-closing .parvus__btn--previous, .parvus--is-zooming .parvus__btn--previous {
transform: translate(-100%, -50%);
opacity: 0;
}
.parvus--is-vertical-closing .parvus__btn--next, .parvus--is-zooming .parvus__btn--next {
transform: translate(100%, -50%);
opacity: 0;
}
.parvus--is-vertical-closing .parvus__caption, .parvus--is-zooming .parvus__caption {
transform: translateY(100%);
opacity: 0;
}
.parvus--is-vertical-closing .parvus__copyright, .parvus--is-zooming .parvus__copyright {
opacity: 0;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
================================================
FILE: dist/js/parvus.esm.js
================================================
/**
* Parvus
*
* @author Benjamin de Oostfrees
* @version 3.1.0
* @url https://github.com/deoostfrees/parvus
*
* MIT license
*/
const BROWSER_WINDOW = window;
/**
* Get scrollbar width
*
* @return {Number} - The scrollbar width
*/
const getScrollbarWidth = () => {
return BROWSER_WINDOW.innerWidth - document.documentElement.clientWidth;
};
const FOCUSABLE_ELEMENTS = ['a:not([inert]):not([tabindex^="-"])', 'button:not([inert]):not([tabindex^="-"]):not(:disabled)', '[tabindex]:not([inert]):not([tabindex^="-"])'];
/**
* Get the focusable children of the given element
*
* @return {Array<Element>} - An array of focusable children
*/
const getFocusableChildren = targetEl => {
return Array.from(targetEl.querySelectorAll(FOCUSABLE_ELEMENTS.join(', '))).filter(child => child.offsetParent !== null);
};
var en = {
lightboxLabel: 'This is a dialog window that overlays the main content of the page. The modal displays the enlarged image. Pressing the Escape key will close the modal and bring you back to where you were on the page.',
lightboxLoadingIndicatorLabel: 'Image loading',
lightboxLoadingError: 'The requested image cannot be loaded.',
controlsLabel: 'Controls',
previousButtonLabel: 'Previous image',
nextButtonLabel: 'Next image',
closeButtonLabel: 'Close dialog window',
sliderLabel: 'Images',
slideLabel: 'Image'
};
/**
* Default configuration options
*/
const DEFAULT_OPTIONS = {
selector: '.lightbox',
gallerySelector: null,
zoomIndicator: true,
captions: true,
captionsSelector: 'self',
captionsAttribute: 'data-caption',
copyright: true,
copyrightSelector: 'self',
copyrightAttribute: 'data-copyright',
docClose: true,
swipeClose: true,
simulateTouch: true,
threshold: 50,
hideScrollbar: true,
lightboxIndicatorIcon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>',
previousButtonIcon: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" stroke="currentColor"><path stroke="none" d="M0 0h24v24H0z"/><polyline points="15 6 9 12 15 18" /></svg>',
nextButtonIcon: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" stroke="currentColor"><path stroke="none" d="M0 0h24v24H0z"/><polyline points="9 6 15 12 9 18" /></svg>',
closeButtonIcon: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" stroke="currentColor"><path d="M18 6L6 18M6 6l12 12"/></svg>',
l10n: en
};
/**
* Merge default options with user-provided options
*
* @param {Object} userOptions - User-provided options
* @returns {Object} - Merged options object
*/
const mergeOptions = userOptions => {
const MERGED_OPTIONS = {
...DEFAULT_OPTIONS,
...userOptions
};
if (userOptions && userOptions.l10n) {
MERGED_OPTIONS.l10n = {
...DEFAULT_OPTIONS.l10n,
...userOptions.l10n
};
}
return MERGED_OPTIONS;
};
/**
* State management for Parvus
*
* Centralizes all mutable state variables
*/
class ParvusState {
constructor() {
// Group management
this.GROUP_ATTRIBUTES = {
triggerElements: [],
slider: null,
sliderElements: [],
contentElements: []
};
this.GROUPS = {};
this.groupIdCounter = 0;
this.newGroup = null;
this.activeGroup = null;
this.currentIndex = 0;
// Configuration
this.config = {};
// DOM elements
this.lightbox = null;
this.lightboxOverlay = null;
this.lightboxOverlayOpacity = 1;
this.toolbar = null;
this.toolbarLeft = null;
this.toolbarRight = null;
this.controls = null;
this.previousButton = null;
this.nextButton = null;
this.closeButton = null;
this.counter = null;
// Drag & interaction state
this.drag = {};
this.isDraggingX = false;
this.isDraggingY = false;
this.pointerDown = false;
this.activePointers = new Map();
// Zoom state
this.currentScale = 1;
this.isPinching = false;
this.isTap = false;
this.pinchStartDistance = 0;
this.lastPointersId = null;
// Offset & animation
this.offset = null;
this.offsetTmp = null;
this.resizeTicking = false;
this.isReducedMotion = true;
}
/**
* Clear drag state
*/
clearDrag() {
this.drag = {
startX: 0,
endX: 0,
startY: 0,
endY: 0
};
}
/**
* Get the active group
*
* @returns {Object} The active group
*/
getActiveGroup() {
return this.GROUPS[this.activeGroup];
}
/**
* Reset zoom state
*/
resetZoomState() {
this.isPinching = false;
this.isTap = false;
this.currentScale = 1;
this.pinchStartDistance = 0;
this.lastPointersId = '';
}
}
/**
* Event System Module
*
* Handles custom event dispatching and listeners
*/
/**
* Dispatch a custom event
*
* @param {HTMLElement} lightbox - The lightbox element
* @param {String} type - The type of the event to dispatch
* @returns {void}
*/
const dispatchCustomEvent = (lightbox, type) => {
const CUSTOM_EVENT = new CustomEvent(type, {
cancelable: true
});
lightbox.dispatchEvent(CUSTOM_EVENT);
};
/**
* Bind a specific event listener
*
* @param {HTMLElement} lightbox - The lightbox element
* @param {String} eventName - The name of the event to bind
* @param {Function} callback - The callback function
* @returns {void}
*/
const on = (lightbox, eventName, callback) => {
if (lightbox) {
lightbox.addEventListener(eventName, callback);
}
};
/**
* Unbind a specific event listener
*
* @param {HTMLElement} lightbox - The lightbox element
* @param {String} eventName - The name of the event to unbind
* @param {Function} callback - The callback function
* @returns {void}
*/
const off = (lightbox, eventName, callback) => {
if (lightbox) {
lightbox.removeEventListener(eventName, callback);
}
};
/**
* Navigation Module
*
* Handles slide navigation and transitions
*/
/**
* Update offset
*
* @param {Object} state - The application state
* @returns {void}
*/
const updateOffset = state => {
state.activeGroup = state.activeGroup !== null ? state.activeGroup : state.newGroup;
state.offset = -state.currentIndex * state.lightbox.offsetWidth;
state.GROUPS[state.activeGroup].slider.style.transform = `translate3d(${state.offset}px, 0, 0)`;
state.offsetTmp = state.offset;
};
/**
* Load slide with the specified index
*
* @param {Object} state - The application state
* @param {Number} index - The index of the slide to be loaded
* @returns {void}
*/
const loadSlide = (state, index) => {
state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'false');
};
/**
* Leave slide
*
* @param {Object} state - The application state
* @param {Number} index - The index of the slide to leave
* @returns {void}
*/
const leaveSlide = (state, index) => {
if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'true');
}
};
/**
* Preload slide with the specified index
*
* @param {Object} state - The application state
* @param {Function} createSlide - Create slide function
* @param {Function} createImage - Create image function
* @param {Function} loadImage - Load image function
* @param {Number} index - The index of the slide to be preloaded
* @returns {void}
*/
const preload = (state, createSlide, createImage, loadImage, index) => {
if (index < 0 || index >= state.GROUPS[state.activeGroup].triggerElements.length || state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
return;
}
createSlide(state, index);
createImage(state, state.GROUPS[state.activeGroup].triggerElements[index], index, () => {
loadImage(state, index);
});
};
/**
* Utils Module
*
* Utility functions
*/
/**
* Check prefers reduced motion
*
* @param {Object} state - The application state
* @param {MediaQueryList} motionQuery - The media query list
* @returns {void}
*/
const reducedMotionCheck = (state, motionQuery) => {
if (motionQuery.matches) {
state.isReducedMotion = true;
} else {
state.isReducedMotion = false;
}
};
/**
* Retrieves or creates a group identifier for the given element
*
* @param {Object} state - The application state
* @param {HTMLElement} el - DOM element to get or assign a group to
* @returns {string} The group identifier associated with the element
*/
const getGroup = (state, el) => {
// Return existing group identifier if already assigned
if (el.dataset.group) {
return el.dataset.group;
}
// Generate new unique group identifier using counter
const EL_GROUP = `default-${state.groupIdCounter++}`;
// Assign the new group identifier to element's dataset
el.dataset.group = EL_GROUP;
return EL_GROUP;
};
/**
* Plugin management for Parvus
*
* Provides a system for registering and managing plugins
*/
class PluginManager {
constructor() {
this.plugins = [];
this.hooks = {};
this.context = null;
this.isInitialized = false;
}
/**
* Register a plugin
*
* @param {Object} plugin - Plugin object with name and install function
* @param {Object} options - Plugin-specific options
*/
register(plugin, options = {}) {
if (!plugin || typeof plugin.install !== 'function') {
throw new Error('Plugin must have an install function');
}
if (!plugin.name) {
throw new Error('Plugin must have a name');
}
// Check if plugin is already registered
const existingPlugin = this.plugins.find(p => p.name === plugin.name);
if (existingPlugin) {
console.warn(`Plugin "${plugin.name}" is already registered`);
return;
}
this.plugins.push({
plugin,
options
});
// If already initialized, install immediately
if (this.isInitialized && this.context) {
this.installPlugin(plugin, options);
}
}
/**
* Install a single plugin
*
* @param {Object} plugin - Plugin object
* @param {Object} options - Plugin options
*/
installPlugin(plugin, options) {
try {
plugin.install(this.context, options);
// If lightbox already exists, execute afterInit hook for this plugin immediately
if (this.context && this.context.state && this.context.state.lightbox) {
this.executeHook('afterInit', {
state: this.context.state
});
}
} catch (error) {
console.error(`Failed to install plugin "${plugin.name}":`, error);
}
}
/**
* Install all registered plugins
*
* @param {Object} context - Parvus instance context
*/
install(context) {
this.context = context;
this.isInitialized = true;
this.plugins.forEach(({
plugin,
options
}) => {
this.installPlugin(plugin, options);
});
}
/**
* Execute a hook
*
* @param {String} hookName - Name of the hook
* @param {*} data - Data to pass to hook callbacks
*/
executeHook(hookName, data) {
const callbacks = this.hooks[hookName] || [];
callbacks.forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`Error in hook "${hookName}":`, error);
}
});
}
/**
* Register a hook callback
*
* @param {String} hookName - Name of the hook
* @param {Function} callback - Callback function
*/
addHook(hookName, callback) {
if (!this.hooks[hookName]) {
this.hooks[hookName] = [];
}
this.hooks[hookName].push(callback);
}
/**
* Remove a hook callback
*
* @param {String} hookName - Name of the hook
* @param {Function} callback - Callback function to remove
*/
removeHook(hookName, callback) {
if (!this.hooks[hookName]) return;
this.hooks[hookName] = this.hooks[hookName].filter(cb => cb !== callback);
}
/**
* Get all registered plugins
*
* @returns {Array} Array of plugin names
*/
getPlugins() {
return this.plugins.map(p => p.plugin.name);
}
}
/**
* UI Components Module
*
* Handles creation of lightbox, toolbar, slider and slides
*/
/**
* Create the lightbox
*
* @param {Object} state - The application state
* @returns {void}
*/
const createLightbox = state => {
const {
config
} = state;
// Use DocumentFragment to batch DOM operations
const fragment = document.createDocumentFragment();
// Create the lightbox container
state.lightbox = document.createElement('dialog');
state.lightbox.setAttribute('role', 'dialog');
state.lightbox.setAttribute('aria-modal', 'true');
state.lightbox.setAttribute('aria-label', config.l10n.lightboxLabel);
state.lightbox.classList.add('parvus');
// Create the lightbox overlay container
state.lightboxOverlay = document.createElement('div');
state.lightboxOverlay.classList.add('parvus__overlay');
// Create the toolbar
state.toolbar = document.createElement('div');
state.toolbar.className = 'parvus__toolbar';
// Create the toolbar items
state.toolbarLeft = document.createElement('div');
state.toolbarRight = document.createElement('div');
// Create the controls
state.controls = document.createElement('div');
state.controls.className = 'parvus__controls';
state.controls.setAttribute('role', 'group');
state.controls.setAttribute('aria-label', config.l10n.controlsLabel);
// Create the close button
state.closeButton = document.createElement('button');
state.closeButton.className = 'parvus__btn parvus__btn--close';
state.closeButton.setAttribute('type', 'button');
state.closeButton.setAttribute('aria-label', config.l10n.closeButtonLabel);
state.closeButton.innerHTML = config.closeButtonIcon;
// Create the previous button
state.previousButton = document.createElement('button');
state.previousButton.className = 'parvus__btn parvus__btn--previous';
state.previousButton.setAttribute('type', 'button');
state.previousButton.setAttribute('aria-label', config.l10n.previousButtonLabel);
state.previousButton.innerHTML = config.previousButtonIcon;
// Create the next button
state.nextButton = document.createElement('button');
state.nextButton.className = 'parvus__btn parvus__btn--next';
state.nextButton.setAttribute('type', 'button');
state.nextButton.setAttribute('aria-label', config.l10n.nextButtonLabel);
state.nextButton.innerHTML = config.nextButtonIcon;
// Create the counter
state.counter = document.createElement('div');
state.counter.className = 'parvus__counter';
// Add the control buttons to the controls
state.controls.append(state.closeButton, state.previousButton, state.nextButton);
// Add the counter to the left toolbar item
state.toolbarLeft.appendChild(state.counter);
// Add the controls to the right toolbar item
state.toolbarRight.appendChild(state.controls);
// Add the toolbar items to the toolbar
state.toolbar.append(state.toolbarLeft, state.toolbarRight);
// Add the overlay and the toolbar to the lightbox
state.lightbox.append(state.lightboxOverlay, state.toolbar);
fragment.appendChild(state.lightbox);
// Add to document body
document.body.appendChild(fragment);
};
/**
* Create a slider
*
* @param {Object} state - The application state
* @returns {void}
*/
const createSlider = state => {
const SLIDER = document.createElement('div');
SLIDER.className = 'parvus__slider';
// Update the slider reference in GROUPS
state.GROUPS[state.activeGroup].slider = SLIDER;
// Add the slider to the lightbox container
state.lightbox.appendChild(SLIDER);
};
/**
* Get next slide index
*
* @param {Object} state - The application state
* @param {Number} currentIndex - Current slide index
* @returns {number} Index of the next available slide or -1 if none found
*/
const getNextSlideIndex = (state, currentIndex) => {
const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;
const TOTAL_SLIDE_ELEMENTS = SLIDE_ELEMENTS.length;
for (let i = currentIndex + 1; i < TOTAL_SLIDE_ELEMENTS; i++) {
if (SLIDE_ELEMENTS[i] !== undefined) {
return i;
}
}
return -1;
};
/**
* Get previous slide index
*
* @param {Object} state - The application state
* @param {number} currentIndex - Current slide index
* @returns {number} Index of the previous available slide or -1 if none found
*/
const getPreviousSlideIndex = (state, currentIndex) => {
const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;
for (let i = currentIndex - 1; i >= 0; i--) {
if (SLIDE_ELEMENTS[i] !== undefined) {
return i;
}
}
return -1;
};
/**
* Create a slide
*
* @param {Object} state - The application state
* @param {Number} index - The index of the slide
* @returns {void}
*/
const createSlide = (state, index) => {
if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
return;
}
const FRAGMENT = document.createDocumentFragment();
const SLIDE_ELEMENT = document.createElement('div');
const SLIDE_ELEMENT_CONTENT = document.createElement('div');
const GROUP = state.GROUPS[state.activeGroup];
const TOTAL_TRIGGER_ELEMENTS = GROUP.triggerElements.length;
SLIDE_ELEMENT.className = 'parvus__slide';
SLIDE_ELEMENT.style.cssText = `
position: absolute;
left: ${index * 100}%;
`;
SLIDE_ELEMENT.setAttribute('aria-hidden', 'true');
// Add accessibility attributes if gallery has multiple slides
if (TOTAL_TRIGGER_ELEMENTS > 1) {
SLIDE_ELEMENT.setAttribute('role', 'group');
SLIDE_ELEMENT.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`);
}
SLIDE_ELEMENT.appendChild(SLIDE_ELEMENT_CONTENT);
FRAGMENT.appendChild(SLIDE_ELEMENT);
GROUP.sliderElements[index] = SLIDE_ELEMENT;
// Insert the slide element based on index position
if (index >= state.currentIndex) {
// Insert the slide element after the current slide
const NEXT_SLIDE_INDEX = getNextSlideIndex(state, index);
if (NEXT_SLIDE_INDEX !== -1) {
GROUP.sliderElements[NEXT_SLIDE_INDEX].before(SLIDE_ELEMENT);
} else {
GROUP.slider.appendChild(SLIDE_ELEMENT);
}
} else {
// Insert the slide element before the current slide
const PREVIOUS_SLIDE_INDEX = getPreviousSlideIndex(state, index);
if (PREVIOUS_SLIDE_INDEX !== -1) {
GROUP.sliderElements[PREVIOUS_SLIDE_INDEX].after(SLIDE_ELEMENT);
} else {
GROUP.slider.prepend(SLIDE_ELEMENT);
}
}
};
/**
* Update counter
*
* @param {Object} state - The application state
* @returns {void}
*/
const updateCounter = state => {
state.counter.textContent = `${state.currentIndex + 1}/${state.GROUPS[state.activeGroup].triggerElements.length}`;
};
/**
* Update Attributes
*
* @param {Object} state - The application state
* @returns {void}
*/
const updateAttributes = state => {
const TRIGGER_ELEMENTS = state.GROUPS[state.activeGroup].triggerElements;
const TOTAL_TRIGGER_ELEMENTS = TRIGGER_ELEMENTS.length;
const SLIDER = state.GROUPS[state.activeGroup].slider;
const SLIDER_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;
const IS_DRAGGABLE = SLIDER.classList.contains('parvus__slider--is-draggable');
// Add draggable class if necessary
if (state.config.simulateTouch && state.config.swipeClose && !IS_DRAGGABLE || state.config.simulateTouch && TOTAL_TRIGGER_ELEMENTS > 1 && !IS_DRAGGABLE) {
SLIDER.classList.add('parvus__slider--is-draggable');
} else {
SLIDER.classList.remove('parvus__slider--is-draggable');
}
// Add extra output for screen reader if there is more than one slide
if (TOTAL_TRIGGER_ELEMENTS > 1) {
SLIDER.setAttribute('role', 'region');
SLIDER.setAttribute('aria-roledescription', 'carousel');
SLIDER.setAttribute('aria-label', state.config.l10n.sliderLabel);
SLIDER_ELEMENTS.forEach((sliderElement, index) => {
sliderElement.setAttribute('role', 'group');
sliderElement.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`);
});
} else {
SLIDER.removeAttribute('role');
SLIDER.removeAttribute('aria-roledescription');
SLIDER.removeAttribute('aria-label');
SLIDER_ELEMENTS.forEach(sliderElement => {
sliderElement.removeAttribute('role');
sliderElement.removeAttribute('aria-label');
});
}
// Show or hide buttons
if (TOTAL_TRIGGER_ELEMENTS === 1) {
state.counter.setAttribute('aria-hidden', 'true');
state.previousButton.setAttribute('aria-hidden', 'true');
state.nextButton.setAttribute('aria-hidden', 'true');
} else {
state.counter.removeAttribute('aria-hidden');
state.previousButton.removeAttribute('aria-hidden');
state.nextButton.removeAttribute('aria-hidden');
}
};
/**
* Update slider navigation status
*
* @param {Object} state - The application state
* @returns {void}
*/
const updateSliderNavigationStatus = state => {
const {
triggerElements
} = state.GROUPS[state.activeGroup];
const TOTAL_TRIGGER_ELEMENTS = triggerElements.length;
if (TOTAL_TRIGGER_ELEMENTS <= 1) {
return;
}
// Determine navigation state
const FIRST_SLIDE = state.currentIndex === 0;
const LAST_SLIDE = state.currentIndex === TOTAL_TRIGGER_ELEMENTS - 1;
// Set previous button state
const PREV_DISABLED = FIRST_SLIDE ? 'true' : null;
if (state.previousButton.getAttribute('aria-disabled') === 'true' !== !!PREV_DISABLED) {
PREV_DISABLED ? state.previousButton.setAttribute('aria-disabled', 'true') : state.previousButton.removeAttribute('aria-disabled');
}
// Set next button state
const NEXT_DISABLED = LAST_SLIDE ? 'true' : null;
if (state.nextButton.getAttribute('aria-disabled') === 'true' !== !!NEXT_DISABLED) {
NEXT_DISABLED ? state.nextButton.setAttribute('aria-disabled', 'true') : state.nextButton.removeAttribute('aria-disabled');
}
};
/**
* Add zoom indicator to element
*
* @param {HTMLElement} el - The element to add the zoom indicator to
* @param {Object} config - Options object
*/
const addZoomIndicator = (el, config) => {
if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') === null) {
const LIGHTBOX_INDICATOR_ICON = document.createElement('div');
LIGHTBOX_INDICATOR_ICON.className = 'parvus-zoom__indicator';
LIGHTBOX_INDICATOR_ICON.innerHTML = config.lightboxIndicatorIcon;
el.appendChild(LIGHTBOX_INDICATOR_ICON);
}
};
/**
* Remove zoom indicator for element
*
* @param {HTMLElement} el - The element to remove the zoom indicator to
*/
const removeZoomIndicator = el => {
if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') !== null) {
const LIGHTBOX_INDICATOR_ICON = el.querySelector('.parvus-zoom__indicator');
el.removeChild(LIGHTBOX_INDICATOR_ICON);
}
};
/**
* Keyboard Event Handler Module
*
* Handles all keyboard interactions
*/
/**
* Create keyboard event handler
*
* @param {Object} state - The application state
* @param {Object} actions - Actions object with navigation functions
* @returns {Function} Keyboard event handler
*/
const createKeydownHandler = (state, actions) => {
return event => {
const FOCUSABLE_CHILDREN = getFocusableChildren(state.lightbox);
const FOCUSED_ITEM_INDEX = FOCUSABLE_CHILDREN.indexOf(document.activeElement);
const lastIndex = FOCUSABLE_CHILDREN.length - 1;
switch (event.code) {
case 'Tab':
{
// Use the TAB key to navigate backwards and forwards
if (event.shiftKey) {
// Navigate backwards
if (FOCUSED_ITEM_INDEX === 0) {
FOCUSABLE_CHILDREN[lastIndex].focus();
event.preventDefault();
}
} else {
// Navigate forwards
if (FOCUSED_ITEM_INDEX === lastIndex) {
FOCUSABLE_CHILDREN[0].focus();
event.preventDefault();
}
}
break;
}
case 'Escape':
{
// Close Parvus when the ESC key is pressed
actions.close();
event.preventDefault();
break;
}
case 'ArrowLeft':
{
// Show the previous slide when the PREV key is pressed
actions.previous();
event.preventDefault();
break;
}
case 'ArrowRight':
{
// Show the next slide when the NEXT key is pressed
actions.next();
event.preventDefault();
break;
}
}
};
};
/**
* Pointer Event Handler Module
*
* Handles all pointer interactions (mouse, touch, pen)
*/
/**
* Create pointerdown event handler
*
* @param {Object} state - The application state
* @returns {Function} Pointerdown event handler
*/
const createPointerdownHandler = state => {
return event => {
event.preventDefault();
event.stopPropagation();
state.isDraggingX = false;
state.isDraggingY = false;
state.pointerDown = true;
state.activePointers.set(event.pointerId, event);
state.drag.startX = event.pageX;
state.drag.startY = event.pageY;
state.drag.endX = event.pageX;
state.drag.endY = event.pageY;
const {
slider
} = state.GROUPS[state.activeGroup];
slider.classList.add('parvus__slider--is-dragging');
slider.style.willChange = 'transform';
state.isTap = state.activePointers.size === 1;
if (state.config.swipeClose) {
state.lightboxOverlayOpacity = getComputedStyle(state.lightboxOverlay).opacity;
}
};
};
/**
* Create pointermove event handler
*
* @param {Object} state - The application state
* @param {Function} pinchZoom - Pinch zoom function
* @param {Function} doSwipe - Swipe function
* @returns {Function} Pointermove event handler
*/
const createPointermoveHandler = (state, pinchZoom, doSwipe) => {
return event => {
event.preventDefault();
if (!state.pointerDown) {
return;
}
const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex];
// Update pointer position
state.activePointers.set(event.pointerId, event);
// Zoom
if (CURRENT_IMAGE && CURRENT_IMAGE.tagName === 'IMG') {
if (state.activePointers.size === 2) {
pinchZoom(CURRENT_IMAGE);
return;
}
if (state.currentScale > 1) {
return;
}
}
state.drag.endX = event.pageX;
state.drag.endY = event.pageY;
doSwipe();
};
};
/**
* Create pointerup event handler
*
* @param {Object} state - The application state
* @param {Function} resetZoom - Reset zoom function
* @param {Function} updateAfterDrag - Update after drag function
* @returns {Function} Pointerup event handler
*/
const createPointerupHandler = (state, resetZoom, updateAfterDrag) => {
return event => {
event.stopPropagation();
const {
slider
} = state.GROUPS[state.activeGroup];
state.activePointers.delete(event.pointerId);
if (state.activePointers.size > 0) {
return;
}
state.pointerDown = false;
const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex];
// Reset zoom state by one tap
const MOVEMENT_X = Math.abs(state.drag.endX - state.drag.startX);
const MOVEMENT_Y = Math.abs(state.drag.endY - state.drag.startY);
const IS_TAP = MOVEMENT_X < 8 && MOVEMENT_Y < 8 && !state.isDraggingX && !state.isDraggingY && state.isTap;
slider.classList.remove('parvus__slider--is-dragging');
slider.style.willChange = '';
if (state.currentScale > 1) {
if (IS_TAP) {
resetZoom(CURRENT_IMAGE);
} else {
CURRENT_IMAGE.style.transform = `
scale(${state.currentScale})
`;
}
} else {
if (state.isPinching) {
resetZoom(CURRENT_IMAGE);
}
if (state.drag.endX || state.drag.endY) {
updateAfterDrag();
}
}
state.clearDrag();
};
};
/**
* Create click event handler
*
* @param {Object} state - The application state
* @param {Object} actions - Actions object with navigation functions
* @returns {Function} Click event handler
*/
const createClickHandler = (state, actions) => {
return event => {
const {
target
} = event;
if (target === state.previousButton) {
actions.previous();
} else if (target === state.nextButton) {
actions.next();
} else if (target === state.closeButton || state.config.docClose && !state.isDraggingY && !state.isDraggingX && target.classList.contains('parvus__slide')) {
actions.close();
}
event.stopPropagation();
};
};
/**
* Gesture Handler Module
*
* Handles gestures like pinch-to-zoom and swipe
*/
/**
* Reset image zoom
*
* @param {Object} state - The application state
* @param {HTMLImageElement} currentImg - The image
* @returns {void}
*/
const resetZoom = (state, currentImg) => {
currentImg.style.transition = 'transform 0.3s ease';
currentImg.style.transform = '';
setTimeout(() => {
currentImg.style.transition = '';
currentImg.style.transformOrigin = '';
}, 300);
state.resetZoomState();
state.lightbox.classList.remove('parvus--is-zooming');
};
/**
* Pinch zoom gesture
*
* @param {Object} state - The application state
* @param {HTMLImageElement} currentImg - The image to zoom
* @returns {void}
*/
const pinchZoom = (state, currentImg) => {
// Determine current finger positions
const POINTS = Array.from(state.activePointers.values());
// Calculate current distance between fingers
const CURRENT_DISTANCE = Math.hypot(POINTS[1].clientX - POINTS[0].clientX, POINTS[1].clientY - POINTS[0].clientY);
// Calculate the midpoint between the two points
const MIDPOINT_X = (POINTS[0].clientX + POINTS[1].clientX) / 2;
const MIDPOINT_Y = (POINTS[0].clientY + POINTS[1].clientY) / 2;
// Convert midpoint to relative position within the image
const IMG_RECT = currentImg.getBoundingClientRect();
const RELATIVE_X = (MIDPOINT_X - IMG_RECT.left) / IMG_RECT.width;
const RELATIVE_Y = (MIDPOINT_Y - IMG_RECT.top) / IMG_RECT.height;
// When pinch gesture is about to start or the finger IDs have changed
// Use a unique ID based on the pointer IDs to recognize changes
const CURRENT_POINTERS_ID = POINTS.map(p => p.pointerId).sort().join('-');
const IS_NEW_POINTER_COMBINATION = state.lastPointersId !== CURRENT_POINTERS_ID;
if (!state.isPinching || IS_NEW_POINTER_COMBINATION) {
state.isPinching = true;
state.lastPointersId = CURRENT_POINTERS_ID;
// Save the start distance and current scaling as a basis
state.pinchStartDistance = CURRENT_DISTANCE / state.currentScale;
// Store initial pinch position for this gesture
if (!currentImg.style.transformOrigin && state.currentScale === 1 || state.currentScale === 1 && IS_NEW_POINTER_COMBINATION) {
// Set the transform origin to the pinch midpoint
currentImg.style.transformOrigin = `${RELATIVE_X * 100}% ${RELATIVE_Y * 100}%`;
}
state.lightbox.classList.add('parvus--is-zooming');
}
// Calculate scaling factor based on distance change
const SCALE_FACTOR = CURRENT_DISTANCE / state.pinchStartDistance;
// Limit scaling to 1 - 3
state.currentScale = Math.min(Math.max(1, SCALE_FACTOR), 3);
currentImg.style.willChange = 'transform';
currentImg.style.transform = `scale(${state.currentScale})`;
};
/**
* Determine the swipe direction (horizontal or vertical)
*
* @param {Object} state - The application state
* @returns {void}
*/
const doSwipe = state => {
const MOVEMENT_THRESHOLD = 1.5;
const MAX_OPACITY_DISTANCE = 100;
const DIRECTION_BIAS = 1.15;
const {
startX,
endX,
startY,
endY
} = state.drag;
const MOVEMENT_X = startX - endX;
const MOVEMENT_Y = endY - startY;
const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X);
const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y);
const GROUP = state.GROUPS[state.activeGroup];
const SLIDER = GROUP.slider;
const TOTAL_SLIDES = GROUP.triggerElements.length;
const handleHorizontalSwipe = (movementX, distance) => {
const IS_FIRST_SLIDE = state.currentIndex === 0;
const IS_LAST_SLIDE = state.currentIndex === TOTAL_SLIDES - 1;
const IS_LEFT_SWIPE = movementX > 0;
const IS_RIGHT_SWIPE = movementX < 0;
if (IS_FIRST_SLIDE && IS_RIGHT_SWIPE || IS_LAST_SLIDE && IS_LEFT_SWIPE) {
const DAMPING_FACTOR = 1 / (1 + Math.pow(distance / 100, 0.15));
const REDUCED_MOVEMENT = movementX * DAMPING_FACTOR;
SLIDER.style.transform = `
translate3d(${state.offsetTmp - Math.round(REDUCED_MOVEMENT)}px, 0, 0)
`;
} else {
SLIDER.style.transform = `
translate3d(${state.offsetTmp - Math.round(movementX)}px, 0, 0)
`;
}
};
const handleVerticalSwipe = (movementY, distance) => {
if (!state.isReducedMotion && distance <= 100) {
const NEW_OVERLAY_OPACITY = Math.max(0, state.lightboxOverlayOpacity - distance / MAX_OPACITY_DISTANCE);
state.lightboxOverlay.style.opacity = NEW_OVERLAY_OPACITY;
}
state.lightbox.classList.add('parvus--is-vertical-closing');
SLIDER.style.transform = `
translate3d(${state.offsetTmp}px, ${Math.round(movementY)}px, 0)
`;
};
if (state.isDraggingX || state.isDraggingY) {
if (state.isDraggingX) {
handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE);
} else if (state.isDraggingY) {
handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE);
}
return;
}
// Direction detection based on the relative ratio of movements
if (MOVEMENT_X_DISTANCE > MOVEMENT_THRESHOLD || MOVEMENT_Y_DISTANCE > MOVEMENT_THRESHOLD) {
// Horizontal swipe if X-movement is stronger than Y-movement * DIRECTION_BIAS
if (MOVEMENT_X_DISTANCE > MOVEMENT_Y_DISTANCE * DIRECTION_BIAS && TOTAL_SLIDES > 1) {
state.isDraggingX = true;
state.isDraggingY = false;
handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE);
} else if (MOVEMENT_Y_DISTANCE > MOVEMENT_X_DISTANCE * DIRECTION_BIAS && state.config.swipeClose) {
// Vertical swipe if Y-movement is stronger than X-movement * DIRECTION_BIAS
state.isDraggingX = false;
state.isDraggingY = true;
handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE);
}
}
};
/**
* Recalculate drag/swipe event after pointerup
*
* @param {Object} state - The application state
* @param {Object} actions - Navigation actions
* @returns {void}
*/
const updateAfterDrag = (state, actions) => {
const {
startX,
startY,
endX,
endY
} = state.drag;
const MOVEMENT_X = endX - startX;
const MOVEMENT_Y = endY - startY;
const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X);
const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y);
const {
triggerElements
} = state.GROUPS[state.activeGroup];
const TOTAL_TRIGGER_ELEMENTS = triggerElements.length;
if (state.isDraggingX) {
const IS_RIGHT_SWIPE = MOVEMENT_X > 0;
if (MOVEMENT_X_DISTANCE >= state.config.threshold) {
if (IS_RIGHT_SWIPE && state.currentIndex > 0) {
actions.previous();
} else if (!IS_RIGHT_SWIPE && state.currentIndex < TOTAL_TRIGGER_ELEMENTS - 1) {
actions.next();
}
}
actions.updateOffset();
} else if (state.isDraggingY) {
if (MOVEMENT_Y_DISTANCE >= state.config.threshold && state.config.swipeClose) {
actions.close();
} else {
state.lightbox.classList.remove('parvus--is-vertical-closing');
actions.updateOffset();
}
state.lightboxOverlay.style.opacity = '';
} else {
actions.updateOffset();
}
};
/**
* Image Handler Module
*
* Handles image loading, captions, and dimensions
*/
/**
* Add caption to the container element
*
* @param {Object} config - Configuration object
* @param {HTMLElement} containerEl - The container element to which the caption will be added
* @param {HTMLElement} imageEl - The image the caption is linked to
* @param {HTMLElement} el - The trigger element associated with the caption
* @param {Number} index - The index of the caption
* @returns {void}
*/
const addCaption = (config, containerEl, imageEl, el, index) => {
const getCaptionData = triggerEl => {
const {
captionsAttribute,
captionsSelector,
captionsIdAttribute = 'data-caption-id'
} = config;
// Check for an ID reference on the trigger element
// This allows the caption to be anywhere on the page
const CAPTION_ID = triggerEl.getAttribute(captionsIdAttribute);
if (CAPTION_ID) {
const CAPTION_EL = document.getElementById(CAPTION_ID);
if (CAPTION_EL) {
return CAPTION_EL.innerHTML;
}
}
// Check for a direct caption attribute on the trigger element
const DIRECT_CAPTION = triggerEl.getAttribute(captionsAttribute);
if (DIRECT_CAPTION) {
return DIRECT_CAPTION;
}
// Query for a selector inside the trigger element
if (captionsSelector !== 'self') {
const CAPTION_EL = triggerEl.querySelector(captionsSelector);
if (CAPTION_EL) {
// Prefer a direct attribute on the found element, otherwise use its content
return CAPTION_EL.getAttribute(captionsAttribute) || CAPTION_EL.innerHTML;
}
}
return null;
};
const CAPTION_DATA = getCaptionData(el);
if (CAPTION_DATA) {
const CAPTION_CONTAINER = document.createElement('div');
const CAPTION_ID = `parvus__caption-${index}`;
CAPTION_CONTAINER.className = 'parvus__caption';
CAPTION_CONTAINER.id = CAPTION_ID;
CAPTION_CONTAINER.innerHTML = `<p>${CAPTION_DATA}</p>`;
containerEl.appendChild(CAPTION_CONTAINER);
imageEl.setAttribute('aria-describedby', CAPTION_ID);
}
};
/**
* Add copyright to the image container element
*
* @param {Object} config - Configuration object
* @param {HTMLElement} imageContainer - The image container element (parvus__content) to which the copyright will be added
* @param {HTMLElement} imageEl - The image the copyright is linked to
* @param {HTMLElement} el - The trigger element associated with the copyright
* @param {Number} index - The index of the copyright
* @returns {void}
*/
const addCopyright = (config, imageContainer, imageEl, el, index) => {
const getCopyrightData = triggerEl => {
const {
copyrightAttribute,
copyrightSelector,
copyrightIdAttribute = 'data-copyright-id'
} = config;
// Check for an ID reference on the trigger element
// This allows the copyright to be anywhere on the page
const COPYRIGHT_ID = triggerEl.getAttribute(copyrightIdAttribute);
if (COPYRIGHT_ID) {
const COPYRIGHT_EL = document.getElementById(COPYRIGHT_ID);
if (COPYRIGHT_EL) {
return COPYRIGHT_EL.innerHTML;
}
}
// Check for a direct copyright attribute on the trigger element
const DIRECT_COPYRIGHT = triggerEl.getAttribute(copyrightAttribute);
if (DIRECT_COPYRIGHT) {
return DIRECT_COPYRIGHT;
}
// Query for a selector inside the trigger element
if (copyrightSelector !== 'self') {
const COPYRIGHT_EL = triggerEl.querySelector(copyrightSelector);
if (COPYRIGHT_EL) {
// Prefer a direct attribute on the found element, otherwise use its content
return COPYRIGHT_EL.getAttribute(copyrightAttribute) || COPYRIGHT_EL.innerHTML;
}
}
return null;
};
const COPYRIGHT_DATA = getCopyrightData(el);
if (COPYRIGHT_DATA) {
const COPYRIGHT_CONTAINER = document.createElement('div');
const COPYRIGHT_ID = `parvus__copyright-${index}`;
COPYRIGHT_CONTAINER.className = 'parvus__copyright';
COPYRIGHT_CONTAINER.id = COPYRIGHT_ID;
COPYRIGHT_CONTAINER.innerHTML = `<small>${COPYRIGHT_DATA}</small>`;
imageContainer.appendChild(COPYRIGHT_CONTAINER);
// If image already has aria-describedby (from caption), append copyright ID
const existingAriaDescribedby = imageEl.getAttribute('aria-describedby');
if (existingAriaDescribedby) {
imageEl.setAttribute('aria-describedby', `${existingAriaDescribedby} ${COPYRIGHT_ID}`);
} else {
imageEl.setAttribute('aria-describedby', COPYRIGHT_ID);
}
}
};
/**
* Create image
*
* @param {Object} state - The application state
* @param {HTMLElement} el - The trigger element
* @param {Number} index - The index
* @param {Function} callback - Callback function
* @returns {void}
*/
const createImage = (state, el, index, callback) => {
const {
contentElements,
sliderElements
} = state.GROUPS[state.activeGroup];
if (contentElements[index] !== undefined) {
if (callback && typeof callback === 'function') {
callback();
}
return;
}
const CONTENT_CONTAINER_EL = sliderElements[index].querySelector('div');
const IMAGE = new Image();
const IMAGE_CONTAINER = document.createElement('div');
const THUMBNAIL = el.querySelector('img');
const LOADING_INDICATOR = document.createElement('div');
IMAGE_CONTAINER.className = 'parvus__content';
// Create loading indicator
LOADING_INDICATOR.className = 'parvus__loader';
LOADING_INDICATOR.setAttribute('role', 'progressbar');
LOADING_INDICATOR.setAttribute('aria-label', state.config.l10n.lightboxLoadingIndicatorLabel);
// Add loading indicator to content container
CONTENT_CONTAINER_EL.appendChild(LOADING_INDICATOR);
const checkImagePromise = new Promise((resolve, reject) => {
IMAGE.onload = () => resolve(IMAGE);
IMAGE.onerror = error => reject(error);
});
checkImagePromise.then(loadedImage => {
loadedImage.style.opacity = 0;
IMAGE_CONTAINER.appendChild(loadedImage);
// Add copyright if available (inside IMAGE_CONTAINER)
if (state.config.copyright) {
addCopyright(state.config, IMAGE_CONTAINER, IMAGE, el, index);
}
CONTENT_CONTAINER_EL.appendChild(IMAGE_CONTAINER);
// Add caption if available
if (state.config.captions) {
addCaption(state.config, CONTENT_CONTAINER_EL, IMAGE, el, index);
}
contentElements[index] = loadedImage;
// Set image width and height
loadedImage.setAttribute('width', loadedImage.naturalWidth);
loadedImage.setAttribute('height', loadedImage.naturalHeight);
// Set image dimension
setImageDimension(sliderElements[index], loadedImage);
}).catch(() => {
const ERROR_CONTAINER = document.createElement('div');
ERROR_CONTAINER.classList.add('parvus__content');
ERROR_CONTAINER.classList.add('parvus__content--error');
ERROR_CONTAINER.textContent = state.config.l10n.lightboxLoadingError;
CONTENT_CONTAINER_EL.appendChild(ERROR_CONTAINER);
contentElements[index] = ERROR_CONTAINER;
}).finally(() => {
CONTENT_CONTAINER_EL.removeChild(LOADING_INDICATOR);
if (callback && typeof callback === 'function') {
callback();
}
});
// Add `sizes` attribute
if (el.hasAttribute('data-sizes') && el.getAttribute('data-sizes') !== '') {
IMAGE.setAttribute('sizes', el.getAttribute('data-sizes'));
}
// Add `srcset` attribute
if (el.hasAttribute('data-srcset') && el.getAttribute('data-srcset') !== '') {
IMAGE.setAttribute('srcset', el.getAttribute('data-srcset'));
}
// Add `src` attribute
if (el.tagName === 'A') {
IMAGE.setAttribute('src', el.href);
} else {
IMAGE.setAttribute('src', el.getAttribute('data-target'));
}
// `alt` attribute
if (THUMBNAIL && THUMBNAIL.hasAttribute('alt') && THUMBNAIL.getAttribute('alt') !== '') {
IMAGE.alt = THUMBNAIL.alt;
} else if (el.hasAttribute('data-alt') && el.getAttribute('data-alt') !== '') {
IMAGE.alt = el.getAttribute('data-alt');
} else {
IMAGE.alt = '';
}
};
/**
* Load Image
*
* @param {Object} state - The application state
* @param {Number} index - The index of the image to load
* @param {Boolean} animate - Whether to animate the image
* @returns {void}
*/
const loadImage = (state, index, animate) => {
const IMAGE = state.GROUPS[state.activeGroup].contentElements[index];
if (IMAGE && IMAGE.tagName === 'IMG') {
const THUMBNAIL = state.GROUPS[state.activeGroup].triggerElements[index];
if (animate && document.startViewTransition) {
THUMBNAIL.style.viewTransitionName = 'lightboximage';
const transition = document.startViewTransition(() => {
IMAGE.style.opacity = '';
THUMBNAIL.style.viewTransitionName = null;
IMAGE.style.viewTransitionName = 'lightboximage';
});
transition.finished.finally(() => {
IMAGE.style.viewTransitionName = null;
});
} else {
IMAGE.style.opacity = '';
}
} else {
IMAGE.style.opacity = '';
}
};
/**
* Set image dimension
*
* @param {HTMLElement} slideEl - The slide element
* @param {HTMLElement} contentEl - The content element
* @returns {void}
*/
const setImageDimension = (slideEl, contentEl) => {
if (contentEl.tagName !== 'IMG') {
return;
}
const SRC_HEIGHT = contentEl.getAttribute('height');
const SRC_WIDTH = contentEl.getAttribute('width');
if (!SRC_HEIGHT || !SRC_WIDTH) {
return;
}
const SLIDE_EL_STYLES = getComputedStyle(slideEl);
const HORIZONTAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingLeft) + parseFloat(SLIDE_EL_STYLES.paddingRight);
const VERTICAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingTop) + parseFloat(SLIDE_EL_STYLES.paddingBottom);
const CAPTION_EL = slideEl.querySelector('.parvus__caption');
const CAPTION_HEIGHT = CAPTION_EL ? CAPTION_EL.getBoundingClientRect().height : 0;
const MAX_WIDTH = slideEl.offsetWidth - HORIZONTAL_PADDING;
const MAX_HEIGHT = slideEl.offsetHeight - VERTICAL_PADDING - CAPTION_HEIGHT;
const RATIO = Math.min(MAX_WIDTH / SRC_WIDTH || 0, MAX_HEIGHT / SRC_HEIGHT || 0);
const NEW_WIDTH = SRC_WIDTH * RATIO;
const NEW_HEIGHT = SRC_HEIGHT * RATIO;
const USE_ORIGINAL_SIZE = SRC_WIDTH <= MAX_WIDTH && SRC_HEIGHT <= MAX_HEIGHT;
contentEl.style.width = USE_ORIGINAL_SIZE ? '' : `${NEW_WIDTH}px`;
contentEl.style.height = USE_ORIGINAL_SIZE ? '' : `${NEW_HEIGHT}px`;
};
/**
* Create resize handler
*
* @param {Object} state - The application state
* @param {Function} updateOffset - Update offset function
* @returns {Function} Resize event handler
*/
const createResizeHandler = (state, updateOffset) => {
return () => {
if (!state.resizeTicking) {
state.resizeTicking = true;
window.requestAnimationFrame(() => {
state.GROUPS[state.activeGroup].sliderElements.forEach((slide, index) => {
setImageDimension(slide, state.GROUPS[state.activeGroup].contentElements[index]);
});
updateOffset();
state.resizeTicking = false;
});
}
};
};
// Helper modules
/**
* Parvus Lightbox
*
* @param {Object} userOptions - User configuration options
* @returns {Object} Parvus instance
*/
function Parvus(userOptions) {
const BROWSER_WINDOW = window;
const STATE = new ParvusState();
const MOTIONQUERY = BROWSER_WINDOW.matchMedia('(prefers-reduced-motion)');
const PLUGIN_MANAGER = new PluginManager();
// Event handlers will be created after actions are defined
let keydownHandler, clickHandler, pointerdownHandler, pointermoveHandler, pointerupHandler, resizeHandler;
/**
* Click event handler to trigger Parvus
*
* @param {Event} event - The click event object
*/
const triggerParvus = function triggerParvus(event) {
event.preventDefault();
open(this);
};
/**
* Add an element
*
* @param {HTMLElement} el - The element to be added
*/
const add = el => {
// Check element type and attributes
const IS_VALID_LINK = el.tagName === 'A' && el.hasAttribute('href');
const IS_VALID_BUTTON = el.tagName === 'BUTTON' && el.hasAttribute('data-target');
if (!IS_VALID_LINK && !IS_VALID_BUTTON) {
throw new Error('Use a link with the \'href\' attribute or a button with the \'data-target\' attribute. Both attributes must contain a path to the image file.');
}
// Check if the lightbox already exists
if (!STATE.lightbox) {
createLightbox(STATE);
// Execute afterInit hook when lightbox is first created
PLUGIN_MANAGER.executeHook('afterInit', {
state: STATE
});
}
STATE.newGroup = getGroup(STATE, el);
if (!STATE.GROUPS[STATE.newGroup]) {
STATE.GROUPS[STATE.newGroup] = structuredClone(STATE.GROUP_ATTRIBUTES);
}
if (STATE.GROUPS[STATE.newGroup].triggerElements.includes(el)) {
throw new Error('Ups, element already added.');
}
STATE.GROUPS[STATE.newGroup].triggerElements.push(el);
if (STATE.config.zoomIndicator) {
addZoomIndicator(el, STATE.config);
}
el.classList.add('parvus-trigger');
el.addEventListener('click', triggerParvus);
if (isOpen() && STATE.newGroup === STATE.activeGroup) {
const EL_INDEX = STATE.GROUPS[STATE.newGroup].triggerElements.indexOf(el);
createSlide(STATE, EL_INDEX);
createImage(STATE, el, EL_INDEX, () => {
loadImage(STATE, EL_INDEX);
});
updateAttributes(STATE);
updateSliderNavigationStatus(STATE);
updateCounter(STATE);
}
};
/**
* Remove an element
*
* @param {HTMLElement} el - The element to be removed
*/
const remove = el => {
if (!el || !el.hasAttribute('data-group')) {
return;
}
const EL_GROUP = getGroup(STATE, el);
const GROUP = STATE.GROUPS[EL_GROUP];
// Check if element exists
if (!GROUP) {
return;
}
const EL_INDEX = GROUP.triggerElements.indexOf(el);
if (EL_INDEX === -1) {
return;
}
const IS_CURRENT_EL = isOpen() && EL_GROUP === STATE.activeGroup && EL_INDEX === STATE.currentIndex;
// Remove group data
if (GROUP.contentElements[EL_INDEX]) {
const content = GROUP.contentElements[EL_INDEX];
if (content.tagName === 'IMG') {
content.src = '';
content.srcset = '';
}
}
// Remove DOM element
const sliderElement = GROUP.sliderElements[EL_INDEX];
if (sliderElement && sliderElement.parentNode) {
sliderElement.parentNode.removeChild(sliderElement);
}
// Remove all array elements
GROUP.triggerElements.splice(EL_INDEX, 1);
GROUP.sliderElements.splice(EL_INDEX, 1);
GROUP.contentElements.splice(EL_INDEX, 1);
if (STATE.config.zoomIndicator) {
removeZoomIndicator(el);
}
if (isOpen() && EL_GROUP === STATE.activeGroup) {
if (IS_CURRENT_EL) {
if (GROUP.triggerElements.length === 0) {
close();
} else if (STATE.currentIndex >= GROUP.triggerElements.length) {
select(GROUP.triggerElements.length - 1);
} else {
updateAttributes(STATE);
updateSliderNavigationStatus(STATE);
updateCounter(STATE);
}
} else if (EL_INDEX < STATE.currentIndex) {
STATE.currentIndex--;
updateAttributes(STATE);
updateSliderNavigationStatus(STATE);
updateCounter(STATE);
} else {
updateAttributes(STATE);
updateSliderNavigationStatus(STATE);
updateCounter(STATE);
}
}
// Unbind click event handler
el.removeEventListener('click', triggerParvus);
el.classList.remove('parvus-trigger');
};
/**
* Open Parvus
*
* @param {HTMLElement} el
*/
const open = el => {
if (!STATE.lightbox || !el || !el.classList.contains('parvus-trigger') || isOpen()) {
return;
}
STATE.activeGroup = getGroup(STATE, el);
const GROUP = STATE.GROUPS[STATE.activeGroup];
const EL_INDEX = GROUP.triggerElements.indexOf(el);
if (EL_INDEX === -1) {
throw new Error('Ups, element not found in group.');
}
STATE.currentIndex = EL_INDEX;
history.pushState({
parvus: 'close'
}, 'Image', window.location.href);
bindEvents();
if (STATE.config.hideScrollbar) {
document.body.style.marginInlineEnd = `${getScrollbarWidth()}px`;
document.body.style.overflow = 'hidden';
}
STATE.lightbox.classList.add('parvus--is-opening');
STATE.lightbox.showModal();
createSlider(STATE);
createSlide(STATE, STATE.currentIndex);
updateOffset(STATE);
updateAttributes(STATE);
updateSliderNavigationStatus(STATE);
updateCounter(STATE);
loadSlide(STATE, STATE.currentIndex);
createImage(STATE, el, STATE.currentIndex, () => {
loadImage(STATE, STATE.currentIndex, true);
STATE.lightbox.classList.remove('parvus--is-opening');
GROUP.slider.classList.add('parvus__slider--animate');
});
preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex + 1);
preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex - 1);
// Execute afterOpen hook
PLUGIN_MANAGER.executeHook('afterOpen', {
element: el,
state: STATE
});
// Create and dispatch a new event
dispatchCustomEvent(STATE.lightbox, 'open');
};
/**
* Close Parvus
*/
const close = () => {
if (!isOpen()) {
return;
}
const IMAGE = STATE.GROUPS[STATE.activeGroup].contentElements[STATE.currentIndex];
const THUMBNAIL = STATE.GROUPS[STATE.activeGroup].triggerElements[STATE.currentIndex];
unbindEvents();
STATE.clearDrag();
if (history.state?.parvus === 'close') {
history.back();
}
STATE.lightbox.classList.add('parvus--is-closing');
const transitionendHandler = () => {
// Reset the image zoom (if ESC was pressed or went back in the browser history)
// after the ViewTransition (otherwise it looks bad)
if (STATE.isPinching) {
resetZoom(STATE, IMAGE);
}
leaveSlide(STATE, STATE.currentIndex);
STATE.lightbox.close();
STATE.lightbox.classList.remove('parvus--is-closing');
STATE.lightbox.classList.remove('parvus--is-vertical-closing');
STATE.GROUPS[STATE.activeGroup].slider.remove();
STATE.GROUPS[STATE.activeGroup].slider = null;
STATE.GROUPS[STATE.activeGroup].sliderElements = [];
STATE.GROUPS[STATE.activeGroup].contentElements = [];
STATE.counter.removeAttribute('aria-hidden');
STATE.previousButton.removeAttribute('aria-hidden');
STATE.previousButton.removeAttribute('aria-disabled');
STATE.nextButton.removeAttribute('aria-hidden');
STATE.nextButton.removeAttribute('aria-disabled');
if (STATE.config.hideScrollbar) {
document.body.style.marginInlineEnd = '';
document.body.style.overflow = '';
}
// Execute afterClose hook
PLUGIN_MANAGER.executeHook('afterClose', {
state: STATE
});
};
if (IMAGE && IMAGE.tagName === 'IMG') {
if (document.startViewTransition) {
IMAGE.style.viewTransitionName = 'lightboximage';
const transition = document.startViewTransition(() => {
IMAGE.style.opacity = '0';
IMAGE.style.viewTransitionName = null;
THUMBNAIL.style.viewTransitionName = 'lightboximage';
});
transition.finished.finally(() => {
transitionendHandler();
THUMBNAIL.style.viewTransitionName = null;
});
} else {
IMAGE.style.opacity = '0';
requestAnimationFrame(transitionendHandler);
}
} else {
transitionendHandler();
}
};
/**
* Select a specific slide by index
*
* @param {number} index - Index of the slide to select
*/
const select = index => {
if (!isOpen()) {
throw new Error("Oops, I'm closed.");
}
if (typeof index !== 'number' || isNaN(index)) {
throw new Error('Oops, no slide specified.');
}
const GROUP = STATE.GROUPS[STATE.activeGroup];
const triggerElements = GROUP.triggerElements;
if (index === STATE.currentIndex) {
throw new Error(`Oops, slide ${index} is already selected.`);
}
if (index < 0 || index >= triggerElements.length) {
throw new Error(`Oops, I can't find slide ${index}.`);
}
const OLD_INDEX = STATE.currentIndex;
STATE.currentIndex = index;
if (GROUP.sliderElements[index]) {
loadSlide(STATE, index);
} else {
createSlide(STATE, index);
createImage(STATE, GROUP.triggerElements[index], index, () => {
loadImage(STATE, index);
});
loadSlide(STATE, index);
}
updateOffset(STATE);
updateSliderNavigationStatus(STATE);
updateCounter(STATE);
// Execute slideChange hook
PLUGIN_MANAGER.executeHook('slideChange', {
index,
oldIndex: OLD_INDEX,
state: STATE
});
if (index < OLD_INDEX) {
preload(STATE, createSlide, createImage, loadImage, index - 1);
} else {
preload(STATE, createSlide, createImage, loadImage, index + 1);
}
leaveSlide(STATE, OLD_INDEX);
// Create and dispatch a new event
dispatchCustomEvent(STATE.lightbox, 'select');
};
/**
* Select the previous slide
*/
const previous = () => {
if (STATE.currentIndex > 0) {
select(STATE.currentIndex - 1);
}
};
/**
* Select the next slide
*/
const next = () => {
const {
triggerElements
} = STATE.GROUPS[STATE.activeGroup];
if (STATE.currentIndex < triggerElements.length - 1) {
select(STATE.currentIndex + 1);
}
};
/**
* Bind specified events
*/
const bindEvents = () => {
const actions = {
close,
previous,
next,
updateOffset: () => updateOffset(STATE)
};
// Create handlers with state and actions
keydownHandler = createKeydownHandler(STATE, actions);
clickHandler = createClickHandler(STATE, actions);
resizeHandler = createResizeHandler(STATE, () => updateOffset(STATE));
const updateAfterDragHandler = () => updateAfterDrag(STATE, actions);
const pinchZoomHandler = img => pinchZoom(STATE, img);
const doSwipeHandler = () => doSwipe(STATE);
const resetZoomHandler = img => resetZoom(STATE, img);
pointerdownHandler = createPointerdownHandler(STATE);
pointermoveHandler = createPointermoveHandler(STATE, pinchZoomHandler, doSwipeHandler);
pointerupHandler = createPointerupHandler(STATE, resetZoomHandler, updateAfterDragHandler);
BROWSER_WINDOW.addEventListener('keydown', keydownHandler);
BROWSER_WINDOW.addEventListener('resize', resizeHandler);
// Popstate event
BROWSER_WINDOW.addEventListener('popstate', close);
// Check for any OS level changes to the prefers reduced motion preference
MOTIONQUERY.addEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY));
// Click event
STATE.lightbox.addEventListener('click', clickHandler);
// Pointer events
STATE.lightbox.addEventListener('pointerdown', pointerdownHandler, {
passive: false
});
STATE.lightbox.addEventListener('pointerup', pointerupHandler, {
passive: true
});
STATE.lightbox.addEventListener('pointermove', pointermoveHandler, {
passive: false
});
};
/**
* Unbind specified events
*/
const unbindEvents = () => {
BROWSER_WINDOW.removeEventListener('keydown', keydownHandler);
BROWSER_WINDOW.removeEventListener('resize', resizeHandler);
// Popstate event
BROWSER_WINDOW.removeEventListener('popstate', close);
// Check for any OS level changes to the prefers reduced motion preference
MOTIONQUERY.removeEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY));
// Click event
STATE.lightbox.removeEventListener('click', clickHandler);
// Pointer events
STATE.lightbox.removeEventListener('pointerdown', pointerdownHandler);
STATE.lightbox.removeEventListener('pointerup', pointerupHandler);
STATE.lightbox.removeEventListener('pointermove', pointermoveHandler);
};
/**
* Destroy Parvus
*/
const destroy = () => {
if (!STATE.lightbox) {
return;
}
if (isOpen()) {
close();
}
// Add setTimeout to ensure all possible close transitions are completed
setTimeout(() => {
unbindEvents();
// Remove all registered event listeners for custom events
const eventTypes = ['open', 'close', 'select', 'destroy'];
eventTypes.forEach(eventType => {
const listeners = STATE.lightbox._listeners?.[eventType] || [];
listeners.forEach(listener => {
STATE.lightbox.removeEventListener(eventType, listener);
});
});
// Remove event listeners from trigger elements
const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll('.parvus-trigger');
LIGHTBOX_TRIGGER_ELS.forEach(el => {
el.removeEventListener('click', triggerParvus);
el.classList.remove('parvus-trigger');
if (STATE.config.zoomIndicator) {
removeZoomIndicator(el);
}
if (el.dataset.group) {
delete el.dataset.group;
}
});
// Create and dispatch a new event
dispatchCustomEvent(STATE.lightbox, 'destroy');
STATE.lightbox.remove();
// Remove references
STATE.lightbox = null;
STATE.lightboxOverlay = null;
STATE.toolbar = null;
STATE.toolbarLeft = null;
STATE.toolbarRight = null;
STATE.controls = null;
STATE.previousButton = null;
STATE.nextButton = null;
STATE.closeButton = null;
STATE.counter = null;
// Remove group data
Object.keys(STATE.GROUPS).forEach(groupKey => {
const group = STATE.GROUPS[groupKey];
if (group && group.contentElements) {
group.contentElements.forEach(content => {
if (content && content.tagName === 'IMG') {
content.src = '';
content.srcset = '';
}
});
}
delete STATE.GROUPS[groupKey];
});
// Reset variables
STATE.groupIdCounter = 0;
STATE.newGroup = null;
STATE.activeGroup = null;
STATE.currentIndex = 0;
}, 1000);
};
/**
* Check if Parvus is open
*
* @returns {boolean} - True if Parvus is open, otherwise false
*/
const isOpen = () => {
return STATE.lightbox?.hasAttribute('open');
};
/**
* Get the current index
*
* @returns {number} - The current index
*/
const getCurrentIndex = () => {
return STATE.currentIndex;
};
/**
* Bind a specific event listener
*
* @param {String} eventName - The name of the event to bind
* @param {Function} callback - The callback function
*/
const on$1 = (eventName, callback) => {
on(STATE.lightbox, eventName, callback);
};
/**
* Unbind a specific event listener
*
* @param {String} eventName - The name of the event to unbind
* @param {Function} callback - The callback function
*/
const off$1 = (eventName, callback) => {
off(STATE.lightbox, eventName, callback);
};
/**
* Use a plugin
*
* @param {Object} plugin - Plugin object
* @param {Object} options - Plugin options
*/
const use = (plugin, options = {}) => {
PLUGIN_MANAGER.register(plugin, options);
};
/**
* Add a hook callback
*
* @param {String} hookName - Hook name
* @param {Function} callback - Callback function
*/
const addHook = (hookName, callback) => {
PLUGIN_MANAGER.addHook(hookName, callback);
};
/**
* Get registered plugins
*
* @returns {Array} Array of plugin names
*/
const getPlugins = () => {
return PLUGIN_MANAGER.getPlugins();
};
/**
* Init
*/
const init = () => {
// Merge user options into defaults
STATE.config = mergeOptions(userOptions);
reducedMotionCheck(STATE, MOTIONQUERY);
// Install plugins with context
const pluginContext = {
state: STATE,
on: on,
addHook: PLUGIN_MANAGER.addHook.bind(PLUGIN_MANAGER),
config: STATE.config
};
PLUGIN_MANAGER.install(pluginContext);
if (STATE.config.gallerySelector !== null) {
// Get a list of all `gallerySelector` elements within the document
const GALLERY_ELS = document.querySelectorAll(STATE.config.gallerySelector);
// Execute a few things once per element
GALLERY_ELS.forEach((galleryEl, index) => {
const GALLERY_INDEX = index;
// Get a list of all `selector` elements within the `gallerySelector`
const LIGHTBOX_TRIGGER_GALLERY_ELS = galleryEl.querySelectorAll(STATE.config.selector);
// Execute a few things once per element
LIGHTBOX_TRIGGER_GALLERY_ELS.forEach(lightboxTriggerEl => {
lightboxTriggerEl.setAttribute('data-group', `parvus-gallery-${GALLERY_INDEX}`);
add(lightboxTriggerEl);
});
});
}
// Get a list of all `selector` elements outside or without the `gallerySelector`
const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(`${STATE.config.selector}:not(.parvus-trigger)`);
LIGHTBOX_TRIGGER_ELS.forEach(add);
};
init();
return {
init,
open,
close,
select,
previous,
next,
currentIndex: getCurrentIndex,
add,
remove,
destroy,
isOpen,
on: on$1,
off: off$1,
use,
addHook,
getPlugins
};
}
export { Parvus as default };
================================================
FILE: dist/js/parvus.js
================================================
/**
* Parvus
*
* @author Benjamin de Oostfrees
* @version 3.1.0
* @url https://github.com/deoostfrees/parvus
*
* MIT license
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Parvus = factory());
})(this, (function () { 'use strict';
const BROWSER_WINDOW = window;
/**
* Get scrollbar width
*
* @return {Number} - The scrollbar width
*/
const getScrollbarWidth = () => {
return BROWSER_WINDOW.innerWidth - document.documentElement.clientWidth;
};
const FOCUSABLE_ELEMENTS = ['a:not([inert]):not([tabindex^="-"])', 'button:not([inert]):not([tabindex^="-"]):not(:disabled)', '[tabindex]:not([inert]):not([tabindex^="-"])'];
/**
* Get the focusable children of the given element
*
* @return {Array<Element>} - An array of focusable children
*/
const getFocusableChildren = targetEl => {
return Array.from(targetEl.querySelectorAll(FOCUSABLE_ELEMENTS.join(', '))).filter(child => child.offsetParent !== null);
};
var en = {
lightboxLabel: 'This is a dialog window that overlays the main content of the page. The modal displays the enlarged image. Pressing the Escape key will close the modal and bring you back to where you were on the page.',
lightboxLoadingIndicatorLabel: 'Image loading',
lightboxLoadingError: 'The requested image cannot be loaded.',
controlsLabel: 'Controls',
previousButtonLabel: 'Previous image',
nextButtonLabel: 'Next image',
closeButtonLabel: 'Close dialog window',
sliderLabel: 'Images',
slideLabel: 'Image'
};
/**
* Default configuration options
*/
const DEFAULT_OPTIONS = {
selector: '.lightbox',
gallerySelector: null,
zoomIndicator: true,
captions: true,
captionsSelector: 'self',
captionsAttribute: 'data-caption',
copyright: true,
copyrightSelector: 'self',
copyrightAttribute: 'data-copyright',
docClose: true,
swipeClose: true,
simulateTouch: true,
threshold: 50,
hideScrollbar: true,
lightboxIndicatorIcon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>',
previousButtonIcon: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" stroke="currentColor"><path stroke="none" d="M0 0h24v24H0z"/><polyline points="15 6 9 12 15 18" /></svg>',
nextButtonIcon: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" stroke="currentColor"><path stroke="none" d="M0 0h24v24H0z"/><polyline points="9 6 15 12 9 18" /></svg>',
closeButtonIcon: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" stroke="currentColor"><path d="M18 6L6 18M6 6l12 12"/></svg>',
l10n: en
};
/**
* Merge default options with user-provided options
*
* @param {Object} userOptions - User-provided options
* @returns {Object} - Merged options object
*/
const mergeOptions = userOptions => {
const MERGED_OPTIONS = {
...DEFAULT_OPTIONS,
...userOptions
};
if (userOptions && userOptions.l10n) {
MERGED_OPTIONS.l10n = {
...DEFAULT_OPTIONS.l10n,
...userOptions.l10n
};
}
return MERGED_OPTIONS;
};
/**
* State management for Parvus
*
* Centralizes all mutable state variables
*/
class ParvusState {
constructor() {
// Group management
this.GROUP_ATTRIBUTES = {
triggerElements: [],
slider: null,
sliderElements: [],
contentElements: []
};
this.GROUPS = {};
this.groupIdCounter = 0;
this.newGroup = null;
this.activeGroup = null;
this.currentIndex = 0;
// Configuration
this.config = {};
// DOM elements
this.lightbox = null;
this.lightboxOverlay = null;
this.lightboxOverlayOpacity = 1;
this.toolbar = null;
this.toolbarLeft = null;
this.toolbarRight = null;
this.controls = null;
this.previousButton = null;
this.nextButton = null;
this.closeButton = null;
this.counter = null;
// Drag & interaction state
this.drag = {};
this.isDraggingX = false;
this.isDraggingY = false;
this.pointerDown = false;
this.activePointers = new Map();
// Zoom state
this.currentScale = 1;
this.isPinching = false;
this.isTap = false;
this.pinchStartDistance = 0;
this.lastPointersId = null;
// Offset & animation
this.offset = null;
this.offsetTmp = null;
this.resizeTicking = false;
this.isReducedMotion = true;
}
/**
* Clear drag state
*/
clearDrag() {
this.drag = {
startX: 0,
endX: 0,
startY: 0,
endY: 0
};
}
/**
* Get the active group
*
* @returns {Object} The active group
*/
getActiveGroup() {
return this.GROUPS[this.activeGroup];
}
/**
* Reset zoom state
*/
resetZoomState() {
this.isPinching = false;
this.isTap = false;
this.currentScale = 1;
this.pinchStartDistance = 0;
this.lastPointersId = '';
}
}
/**
* Event System Module
*
* Handles custom event dispatching and listeners
*/
/**
* Dispatch a custom event
*
* @param {HTMLElement} lightbox - The lightbox element
* @param {String} type - The type of the event to dispatch
* @returns {void}
*/
const dispatchCustomEvent = (lightbox, type) => {
const CUSTOM_EVENT = new CustomEvent(type, {
cancelable: true
});
lightbox.dispatchEvent(CUSTOM_EVENT);
};
/**
* Bind a specific event listener
*
* @param {HTMLElement} lightbox - The lightbox element
* @param {String} eventName - The name of the event to bind
* @param {Function} callback - The callback function
* @returns {void}
*/
const on = (lightbox, eventName, callback) => {
if (lightbox) {
lightbox.addEventListener(eventName, callback);
}
};
/**
* Unbind a specific event listener
*
* @param {HTMLElement} lightbox - The lightbox element
* @param {String} eventName - The name of the event to unbind
* @param {Function} callback - The callback function
* @returns {void}
*/
const off = (lightbox, eventName, callback) => {
if (lightbox) {
lightbox.removeEventListener(eventName, callback);
}
};
/**
* Navigation Module
*
* Handles slide navigation and transitions
*/
/**
* Update offset
*
* @param {Object} state - The application state
* @returns {void}
*/
const updateOffset = state => {
state.activeGroup = state.activeGroup !== null ? state.activeGroup : state.newGroup;
state.offset = -state.currentIndex * state.lightbox.offsetWidth;
state.GROUPS[state.activeGroup].slider.style.transform = `translate3d(${state.offset}px, 0, 0)`;
state.offsetTmp = state.offset;
};
/**
* Load slide with the specified index
*
* @param {Object} state - The application state
* @param {Number} index - The index of the slide to be loaded
* @returns {void}
*/
const loadSlide = (state, index) => {
state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'false');
};
/**
* Leave slide
*
* @param {Object} state - The application state
* @param {Number} index - The index of the slide to leave
* @returns {void}
*/
const leaveSlide = (state, index) => {
if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'true');
}
};
/**
* Preload slide with the specified index
*
* @param {Object} state - The application state
* @param {Function} createSlide - Create slide function
* @param {Function} createImage - Create image function
* @param {Function} loadImage - Load image function
* @param {Number} index - The index of the slide to be preloaded
* @returns {void}
*/
const preload = (state, createSlide, createImage, loadImage, index) => {
if (index < 0 || index >= state.GROUPS[state.activeGroup].triggerElements.length || state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
return;
}
createSlide(state, index);
createImage(state, state.GROUPS[state.activeGroup].triggerElements[index], index, () => {
loadImage(state, index);
});
};
/**
* Utils Module
*
* Utility functions
*/
/**
* Check prefers reduced motion
*
* @param {Object} state - The application state
* @param {MediaQueryList} motionQuery - The media query list
* @returns {void}
*/
const reducedMotionCheck = (state, motionQuery) => {
if (motionQuery.matches) {
state.isReducedMotion = true;
} else {
state.isReducedMotion = false;
}
};
/**
* Retrieves or creates a group identifier for the given element
*
* @param {Object} state - The application state
* @param {HTMLElement} el - DOM element to get or assign a group to
* @returns {string} The group identifier associated with the element
*/
const getGroup = (state, el) => {
// Return existing group identifier if already assigned
if (el.dataset.group) {
return el.dataset.group;
}
// Generate new unique group identifier using counter
const EL_GROUP = `default-${state.groupIdCounter++}`;
// Assign the new group identifier to element's dataset
el.dataset.group = EL_GROUP;
return EL_GROUP;
};
/**
* Plugin management for Parvus
*
* Provides a system for registering and managing plugins
*/
class PluginManager {
constructor() {
this.plugins = [];
this.hooks = {};
this.context = null;
this.isInitialized = false;
}
/**
* Register a plugin
*
* @param {Object} plugin - Plugin object with name and install function
* @param {Object} options - Plugin-specific options
*/
register(plugin, options = {}) {
if (!plugin || typeof plugin.install !== 'function') {
throw new Error('Plugin must have an install function');
}
if (!plugin.name) {
throw new Error('Plugin must have a name');
}
// Check if plugin is already registered
const existingPlugin = this.plugins.find(p => p.name === plugin.name);
if (existingPlugin) {
console.warn(`Plugin "${plugin.name}" is already registered`);
return;
}
this.plugins.push({
plugin,
options
});
// If already initialized, install immediately
if (this.isInitialized && this.context) {
this.installPlugin(plugin, options);
}
}
/**
* Install a single plugin
*
* @param {Object} plugin - Plugin object
* @param {Object} options - Plugin options
*/
installPlugin(plugin, options) {
try {
plugin.install(this.context, options);
// If lightbox already exists, execute afterInit hook for this plugin immediately
if (this.context && this.context.state && this.context.state.lightbox) {
this.executeHook('afterInit', {
state: this.context.state
});
}
} catch (error) {
console.error(`Failed to install plugin "${plugin.name}":`, error);
}
}
/**
* Install all registered plugins
*
* @param {Object} context - Parvus instance context
*/
install(context) {
this.context = context;
this.isInitialized = true;
this.plugins.forEach(({
plugin,
options
}) => {
this.installPlugin(plugin, options);
});
}
/**
* Execute a hook
*
* @param {String} hookName - Name of the hook
* @param {*} data - Data to pass to hook callbacks
*/
executeHook(hookName, data) {
const callbacks = this.hooks[hookName] || [];
callbacks.forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`Error in hook "${hookName}":`, error);
}
});
}
/**
* Register a hook callback
*
* @param {String} hookName - Name of the hook
* @param {Function} callback - Callback function
*/
addHook(hookName, callback) {
if (!this.hooks[hookName]) {
this.hooks[hookName] = [];
}
this.hooks[hookName].push(callback);
}
/**
* Remove a hook callback
*
* @param {String} hookName - Name of the hook
* @param {Function} callback - Callback function to remove
*/
removeHook(hookName, callback) {
if (!this.hooks[hookName]) return;
this.hooks[hookName] = this.hooks[hookName].filter(cb => cb !== callback);
}
/**
* Get all registered plugins
*
* @returns {Array} Array of plugin names
*/
getPlugins() {
return this.plugins.map(p => p.plugin.name);
}
}
/**
* UI Components Module
*
* Handles creation of lightbox, toolbar, slider and slides
*/
/**
* Create the lightbox
*
* @param {Object} state - The application state
* @returns {void}
*/
const createLightbox = state => {
const {
config
} = state;
// Use DocumentFragment to batch DOM operations
const fragment = document.createDocumentFragment();
// Create the lightbox container
state.lightbox = document.createElement('dialog');
state.lightbox.setAttribute('role', 'dialog');
state.lightbox.setAttribute('aria-modal', 'true');
state.lightbox.setAttribute('aria-label', config.l10n.lightboxLabel);
state.lightbox.classList.add('parvus');
// Create the lightbox overlay container
state.lightboxOverlay = document.createElement('div');
state.lightboxOverlay.classList.add('parvus__overlay');
// Create the toolbar
state.toolbar = document.createElement('div');
state.toolbar.className = 'parvus__toolbar';
// Create the toolbar items
state.toolbarLeft = document.createElement('div');
state.toolbarRight = document.createElement('div');
// Create the controls
state.controls = document.createElement('div');
state.controls.className = 'parvus__controls';
state.controls.setAttribute('role', 'group');
state.controls.setAttribute('aria-label', config.l10n.controlsLabel);
// Create the close button
state.closeButton = document.createElement('button');
state.closeButton.className = 'parvus__btn parvus__btn--close';
state.closeButton.setAttribute('type', 'button');
state.closeButton.setAttribute('aria-label', config.l10n.closeButtonLabel);
state.closeButton.innerHTML = config.closeButtonIcon;
// Create the previous button
state.previousButton = document.createElement('button');
state.previousButton.className = 'parvus__btn parvus__btn--previous';
state.previousButton.setAttribute('type', 'button');
state.previousButton.setAttribute('aria-label', config.l10n.previousButtonLabel);
state.previousButton.innerHTML = config.previousButtonIcon;
// Create the next button
state.nextButton = document.createElement('button');
state.nextButton.className = 'parvus__btn parvus__btn--next';
state.nextButton.setAttribute('type', 'button');
state.nextButton.setAttribute('aria-label', config.l10n.nextButtonLabel);
state.nextButton.innerHTML = config.nextButtonIcon;
// Create the counter
state.counter = document.createElement('div');
state.counter.className = 'parvus__counter';
// Add the control buttons to the controls
state.controls.append(state.closeButton, state.previousButton, state.nextButton);
// Add the counter to the left toolbar item
state.toolbarLeft.appendChild(state.counter);
// Add the controls to the right toolbar item
state.toolbarRight.appendChild(state.controls);
// Add the toolbar items to the toolbar
state.toolbar.append(state.toolbarLeft, state.toolbarRight);
// Add the overlay and the toolbar to the lightbox
state.lightbox.append(state.lightboxOverlay, state.toolbar);
fragment.appendChild(state.lightbox);
// Add to document body
document.body.appendChild(fragment);
};
/**
* Create a slider
*
* @param {Object} state - The application state
* @returns {void}
*/
const createSlider = state => {
const SLIDER = document.createElement('div');
SLIDER.className = 'parvus__slider';
// Update the slider reference in GROUPS
state.GROUPS[state.activeGroup].slider = SLIDER;
// Add the slider to the lightbox container
state.lightbox.appendChild(SLIDER);
};
/**
* Get next slide index
*
* @param {Object} state - The application state
* @param {Number} currentIndex - Current slide index
* @returns {number} Index of the next available slide or -1 if none found
*/
const getNextSlideIndex = (state, currentIndex) => {
const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;
const TOTAL_SLIDE_ELEMENTS = SLIDE_ELEMENTS.length;
for (let i = currentIndex + 1; i < TOTAL_SLIDE_ELEMENTS; i++) {
if (SLIDE_ELEMENTS[i] !== undefined) {
return i;
}
}
return -1;
};
/**
* Get previous slide index
*
* @param {Object} state - The application state
* @param {number} currentIndex - Current slide index
* @returns {number} Index of the previous available slide or -1 if none found
*/
const getPreviousSlideIndex = (state, currentIndex) => {
const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;
for (let i = currentIndex - 1; i >= 0; i--) {
if (SLIDE_ELEMENTS[i] !== undefined) {
return i;
}
}
return -1;
};
/**
* Create a slide
*
* @param {Object} state - The application state
* @param {Number} index - The index of the slide
* @returns {void}
*/
const createSlide = (state, index) => {
if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
return;
}
const FRAGMENT = document.createDocumentFragment();
const SLIDE_ELEMENT = document.createElement('div');
const SLIDE_ELEMENT_CONTENT = document.createElement('div');
const GROUP = state.GROUPS[state.activeGroup];
const TOTAL_TRIGGER_ELEMENTS = GROUP.triggerElements.length;
SLIDE_ELEMENT.className = 'parvus__slide';
SLIDE_ELEMENT.style.cssText = `
position: absolute;
left: ${index * 100}%;
`;
SLIDE_ELEMENT.setAttribute('aria-hidden', 'true');
// Add accessibility attributes if gallery has multiple slides
if (TOTAL_TRIGGER_ELEMENTS > 1) {
SLIDE_ELEMENT.setAttribute('role', 'group');
SLIDE_ELEMENT.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`);
}
SLIDE_ELEMENT.appendChild(SLIDE_ELEMENT_CONTENT);
FRAGMENT.appendChild(SLIDE_ELEMENT);
GROUP.sliderElements[index] = SLIDE_ELEMENT;
// Insert the slide element based on index position
if (index >= state.currentIndex) {
// Insert the slide element after the current slide
const NEXT_SLIDE_INDEX = getNextSlideIndex(state, index);
if (NEXT_SLIDE_INDEX !== -1) {
GROUP.sliderElements[NEXT_SLIDE_INDEX].before(SLIDE_ELEMENT);
} else {
GROUP.slider.appendChild(SLIDE_ELEMENT);
}
} else {
// Insert the slide element before the current slide
const PREVIOUS_SLIDE_INDEX = getPreviousSlideIndex(state, index);
if (PREVIOUS_SLIDE_INDEX !== -1) {
GROUP.sliderElements[PREVIOUS_SLIDE_INDEX].after(SLIDE_ELEMENT);
} else {
GROUP.slider.prepend(SLIDE_ELEMENT);
}
}
};
/**
* Update counter
*
* @param {Object} state - The application state
* @returns {void}
*/
const updateCounter = state => {
state.counter.textContent = `${state.currentIndex + 1}/${state.GROUPS[state.activeGroup].triggerElements.length}`;
};
/**
* Update Attributes
*
* @param {Object} state - The application state
* @returns {void}
*/
const updateAttributes = state => {
const TRIGGER_ELEMENTS = state.GROUPS[state.activeGroup].triggerElements;
const TOTAL_TRIGGER_ELEMENTS = TRIGGER_ELEMENTS.length;
const SLIDER = state.GROUPS[state.activeGroup].slider;
const SLIDER_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;
const IS_DRAGGABLE = SLIDER.classList.contains('parvus__slider--is-draggable');
// Add draggable class if necessary
if (state.config.simulateTouch && state.config.swipeClose && !IS_DRAGGABLE || state.config.simulateTouch && TOTAL_TRIGGER_ELEMENTS > 1 && !IS_DRAGGABLE) {
SLIDER.classList.add('parvus__slider--is-draggable');
} else {
SLIDER.classList.remove('parvus__slider--is-draggable');
}
// Add extra output for screen reader if there is more than one slide
if (TOTAL_TRIGGER_ELEMENTS > 1) {
SLIDER.setAttribute('role', 'region');
SLIDER.setAttribute('aria-roledescription', 'carousel');
SLIDER.setAttribute('aria-label', state.config.l10n.sliderLabel);
SLIDER_ELEMENTS.forEach((sliderElement, index) => {
sliderElement.setAttribute('role', 'group');
sliderElement.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`);
});
} else {
SLIDER.removeAttribute('role');
SLIDER.removeAttribute('aria-roledescription');
SLIDER.removeAttribute('aria-label');
SLIDER_ELEMENTS.forEach(sliderElement => {
sliderElement.removeAttribute('role');
sliderElement.removeAttribute('aria-label');
});
}
// Show or hide buttons
if (TOTAL_TRIGGER_ELEMENTS === 1) {
state.counter.setAttribute('aria-hidden', 'true');
state.previousButton.setAttribute('aria-hidden', 'true');
state.nextButton.setAttribute('aria-hidden', 'true');
} else {
state.counter.removeAttribute('aria-hidden');
state.previousButton.removeAttribute('aria-hidden');
state.nextButton.removeAttribute('aria-hidden');
}
};
/**
* Update slider navigation status
*
* @param {Object} state - The application state
* @returns {void}
*/
const updateSliderNavigationStatus = state => {
const {
triggerElements
} = state.GROUPS[state.activeGroup];
const TOTAL_TRIGGER_ELEMENTS = triggerElements.length;
if (TOTAL_TRIGGER_ELEMENTS <= 1) {
return;
}
// Determine navigation state
const FIRST_SLIDE = state.currentIndex === 0;
const LAST_SLIDE = state.currentIndex === TOTAL_TRIGGER_ELEMENTS - 1;
// Set previous button state
const PREV_DISABLED = FIRST_SLIDE ? 'true' : null;
if (state.previousButton.getAttribute('aria-disabled') === 'true' !== !!PREV_DISABLED) {
PREV_DISABLED ? state.previousButton.setAttribute('aria-disabled', 'true') : state.previousButton.removeAttribute('aria-disabled');
}
// Set next button state
const NEXT_DISABLED = LAST_SLIDE ? 'true' : null;
if (state.nextButton.getAttribute('aria-disabled') === 'true' !== !!NEXT_DISABLED) {
NEXT_DISABLED ? state.nextButton.setAttribute('aria-disabled', 'true') : state.nextButton.removeAttribute('aria-disabled');
}
};
/**
* Add zoom indicator to element
*
* @param {HTMLElement} el - The element to add the zoom indicator to
* @param {Object} config - Options object
*/
const addZoomIndicator = (el, config) => {
if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') === null) {
const LIGHTBOX_INDICATOR_ICON = document.createElement('div');
LIGHTBOX_INDICATOR_ICON.className = 'parvus-zoom__indicator';
LIGHTBOX_INDICATOR_ICON.innerHTML = config.lightboxIndicatorIcon;
el.appendChild(LIGHTBOX_INDICATOR_ICON);
}
};
/**
* Remove zoom indicator for element
*
* @param {HTMLElement} el - The element to remove the zoom indicator to
*/
const removeZoomIndicator = el => {
if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') !== null) {
const LIGHTBOX_INDICATOR_ICON = el.querySelector('.parvus-zoom__indicator');
el.removeChild(LIGHTBOX_INDICATOR_ICON);
}
};
/**
* Keyboard Event Handler Module
*
* Handles all keyboard interactions
*/
/**
* Create keyboard event handler
*
* @param {Object} state - The application state
* @param {Object} actions - Actions object with navigation functions
* @returns {Function} Keyboard event handler
*/
const createKeydownHandler = (state, actions) => {
return event => {
const FOCUSABLE_CHILDREN = getFocusableChildren(state.lightbox);
const FOCUSED_ITEM_INDEX = FOCUSABLE_CHILDREN.indexOf(document.activeElement);
const lastIndex = FOCUSABLE_CHILDREN.length - 1;
switch (event.code) {
case 'Tab':
{
// Use the TAB key to navigate backwards and forwards
if (event.shiftKey) {
// Navigate backwards
if (FOCUSED_ITEM_INDEX === 0) {
FOCUSABLE_CHILDREN[lastIndex].focus();
event.preventDefault();
}
} else {
// Navigate forwards
if (FOCUSED_ITEM_INDEX === lastIndex) {
FOCUSABLE_CHILDREN[0].focus();
event.preventDefault();
}
}
break;
}
case 'Escape':
{
// Close Parvus when the ESC key is pressed
actions.close();
event.preventDefault();
break;
}
case 'ArrowLeft':
{
// Show the previous slide when the PREV key is pressed
actions.previous();
event.preventDefault();
break;
}
case 'ArrowRight':
{
// Show the next slide when the NEXT key is pressed
actions.next();
event.preventDefault();
break;
}
}
};
};
/**
* Pointer Event Handler Module
*
* Handles all pointer interactions (mouse, touch, pen)
*/
/**
* Create pointerdown event handler
*
* @param {Object} state - The application state
* @returns {Function} Pointerdown event handler
*/
const createPointerdownHandler = state => {
return event => {
event.preventDefault();
event.stopPropagation();
state.isDraggingX = false;
state.isDraggingY = false;
state.pointerDown = true;
state.activePointers.set(event.pointerId, event);
state.drag.startX = event.pageX;
state.drag.startY = event.pageY;
state.drag.endX = event.pageX;
state.drag.endY = event.pageY;
const {
slider
} = state.GROUPS[state.activeGroup];
slider.classList.add('parvus__slider--is-dragging');
slider.style.willChange = 'transform';
state.isTap = state.activePointers.size === 1;
if (state.config.swipeClose) {
state.lightboxOverlayOpacity = getComputedStyle(state.lightboxOverlay).opacity;
}
};
};
/**
* Create pointermove event handler
*
* @param {Object} state - The application state
* @param {Function} pinchZoom - Pinch zoom function
* @param {Function} doSwipe - Swipe function
* @returns {Function} Pointermove event handler
*/
const createPointermoveHandler = (state, pinchZoom, doSwipe) => {
return event => {
event.preventDefault();
if (!state.pointerDown) {
return;
}
const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex];
// Update pointer position
state.activePointers.set(event.pointerId, event);
// Zoom
if (CURRENT_IMAGE && CURRENT_IMAGE.tagName === 'IMG') {
if (state.activePointers.size === 2) {
pinchZoom(CURRENT_IMAGE);
return;
}
if (state.currentScale > 1) {
return;
}
}
state.drag.endX = event.pageX;
state.drag.endY = event.pageY;
doSwipe();
};
};
/**
* Create pointerup event handler
*
* @param {Object} state - The application state
* @param {Function} resetZoom - Reset zoom function
* @param {Function} updateAfterDrag - Update after drag function
* @returns {Function} Pointerup event handler
*/
const createPointerupHandler = (state, resetZoom, updateAfterDrag) => {
return event => {
event.stopPropagation();
const {
slider
} = state.GROUPS[state.activeGroup];
state.activePointers.delete(event.pointerId);
if (state.activePointers.size > 0) {
return;
}
state.pointerDown = false;
const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex];
// Reset zoom state by one tap
const MOVEMENT_X = Math.abs(state.drag.endX - state.drag.startX);
const MOVEMENT_Y = Math.abs(state.drag.endY - state.drag.startY);
const IS_TAP = MOVEMENT_X < 8 && MOVEMENT_Y < 8 && !state.isDraggingX && !state.isDraggingY && state.isTap;
slider.classList.remove('parvus__slider--is-dragging');
slider.style.willChange = '';
if (state.currentScale > 1) {
if (IS_TAP) {
resetZoom(CURRENT_IMAGE);
} else {
CURRENT_IMAGE.style.transform = `
scale(${state.currentScale})
`;
}
} else {
if (state.isPinching) {
resetZoom(CURRENT_IMAGE);
}
if (state.drag.endX || state.drag.endY) {
updateAfterDrag();
}
}
state.clearDrag();
};
};
/**
* Create click event handler
*
* @param {Object} state - The application state
* @param {Object} actions - Actions object with navigation functions
* @returns {Function} Click event handler
*/
const createClickHandler = (state, actions) => {
return event => {
const {
target
} = event;
if (target === state.previousButton) {
actions.previous();
} else if (target === state.nextButton) {
actions.next();
} else if (target === state.closeButton || state.config.docClose && !state.isDraggingY && !state.isDraggingX && target.classList.contains('parvus__slide')) {
actions.close();
}
event.stopPropagation();
};
};
/**
* Gesture Handler Module
*
* Handles gestures like pinch-to-zoom and swipe
*/
/**
* Reset image zoom
*
* @param {Object} state - The application state
* @param {HTMLImageElement} currentImg - The image
* @returns {void}
*/
const resetZoom = (state, currentImg) => {
currentImg.style.transition = 'transform 0.3s ease';
currentImg.style.transform = '';
setTimeout(() => {
currentImg.style.transition = '';
currentImg.style.transformOrigin = '';
}, 300);
state.resetZoomState();
state.lightbox.classList.remove('parvus--is-zooming');
};
/**
* Pinch zoom gesture
*
* @param {Object} state - The application state
* @param {HTMLImageElement} currentImg - The image to zoom
* @returns {void}
*/
const pinchZoom = (state, currentImg) => {
// Determine current finger positions
const POINTS = Array.from(state.activePointers.values());
// Calculate current distance between fingers
const CURRENT_DISTANCE = Math.hypot(POINTS[1].clientX - POINTS[0].clientX, POINTS[1].clientY - POINTS[0].clientY);
// Calculate the midpoint between the two points
const MIDPOINT_X = (POINTS[0].clientX + POINTS[1].clientX) / 2;
const MIDPOINT_Y = (POINTS[0].clientY + POINTS[1].clientY) / 2;
// Convert midpoint to relative position within the image
const IMG_RECT = currentImg.getBoundingClientRect();
const RELATIVE_X = (MIDPOINT_X - IMG_RECT.left) / IMG_RECT.width;
const RELATIVE_Y = (MIDPOINT_Y - IMG_RECT.top) / IMG_RECT.height;
// When pinch gesture is about to start or the finger IDs have changed
// Use a unique ID based on the pointer IDs to recognize changes
const CURRENT_POINTERS_ID = POINTS.map(p => p.pointerId).sort().join('-');
const IS_NEW_POINTER_COMBINATION = state.lastPointersId !== CURRENT_POINTERS_ID;
if (!state.isPinching || IS_NEW_POINTER_COMBINATION) {
state.isPinching = true;
state.lastPointersId = CURRENT_POINTERS_ID;
// Save the start distance and current scaling as a basis
state.pinchStartDistance = CURRENT_DISTANCE / state.currentScale;
// Store initial pinch position for this gesture
if (!currentImg.style.transformOrigin && state.currentScale === 1 || state.currentScale === 1 && IS_NEW_POINTER_COMBINATION) {
// Set the transform origin to the pinch midpoint
currentImg.style.transformOrigin = `${RELATIVE_X * 100}% ${RELATIVE_Y * 100}%`;
}
state.lightbox.classList.add('parvus--is-zooming');
}
// Calculate scaling factor based on distance change
const SCALE_FACTOR = CURRENT_DISTANCE / state.pinchStartDistance;
// Limit scaling to 1 - 3
state.currentScale = Math.min(Math.max(1, SCALE_FACTOR), 3);
currentImg.style.willChange = 'transform';
currentImg.style.transform = `scale(${state.currentScale})`;
};
/**
* Determine the swipe direction (horizontal or vertical)
*
* @param {Object} state - The application state
* @returns {void}
*/
const doSwipe = state => {
const MOVEMENT_THRESHOLD = 1.5;
const MAX_OPACITY_DISTANCE = 100;
const DIRECTION_BIAS = 1.15;
const {
startX,
endX,
startY,
endY
} = state.drag;
const MOVEMENT_X = startX - endX;
const MOVEMENT_Y = endY - startY;
const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X);
const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y);
const GROUP = state.GROUPS[state.activeGroup];
const SLIDER = GROUP.slider;
const TOTAL_SLIDES = GROUP.triggerElements.length;
const handleHorizontalSwipe = (movementX, distance) => {
const IS_FIRST_SLIDE = state.currentIndex === 0;
const IS_LAST_SLIDE = state.currentIndex === TOTAL_SLIDES - 1;
const IS_LEFT_SWIPE = movementX > 0;
const IS_RIGHT_SWIPE = movementX < 0;
if (IS_FIRST_SLIDE && IS_RIGHT_SWIPE || IS_LAST_SLIDE && IS_LEFT_SWIPE) {
const DAMPING_FACTOR = 1 / (1 + Math.pow(distance / 100, 0.15));
const REDUCED_MOVEMENT = movementX * DAMPING_FACTOR;
SLIDER.style.transform = `
translate3d(${state.offsetTmp - Math.round(REDUCED_MOVEMENT)}px, 0, 0)
`;
} else {
SLIDER.style.transform = `
translate3d(${state.offsetTmp - Math.round(movementX)}px, 0, 0)
`;
}
};
const handleVerticalSwipe = (movementY, distance) => {
if (!state.isReducedMotion && distance <= 100) {
const NEW_OVERLAY_OPACITY = Math.max(0, state.lightboxOverlayOpacity - distance / MAX_OPACITY_DISTANCE);
state.lightboxOverlay.style.opacity = NEW_OVERLAY_OPACITY;
}
state.lightbox.classList.add('parvus--is-vertical-closing');
SLIDER.style.transform = `
translate3d(${state.offsetTmp}px, ${Math.round(movementY)}px, 0)
`;
};
if (state.isDraggingX || state.isDraggingY) {
if (state.isDraggingX) {
handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE);
} else if (state.isDraggingY) {
handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE);
}
return;
}
// Direction detection based on the relative ratio of movements
if (MOVEMENT_X_DISTANCE > MOVEMENT_THRESHOLD || MOVEMENT_Y_DISTANCE > MOVEMENT_THRESHOLD) {
// Horizontal swipe if X-movement is stronger than Y-movement * DIRECTION_BIAS
if (MOVEMENT_X_DISTANCE > MOVEMENT_Y_DISTANCE * DIRECTION_BIAS && TOTAL_SLIDES > 1) {
state.isDraggingX = true;
state.isDraggingY = false;
handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE);
} else if (MOVEMENT_Y_DISTANCE > MOVEMENT_X_DISTANCE * DIRECTION_BIAS && state.config.swipeClose) {
// Vertical swipe if Y-movement is stronger than X-movement * DIRECTION_BIAS
state.isDraggingX = false;
state.isDraggingY = true;
handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE);
}
}
};
/**
* Recalculate drag/swipe event after pointerup
*
* @param {Object} state - The application state
* @param {Object} actions - Navigation actions
* @returns {void}
*/
const updateAfterDrag = (state, actions) => {
const {
startX,
startY,
endX,
endY
} = state.drag;
const MOVEMENT_X = endX - startX;
const MOVEMENT_Y = endY - startY;
const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X);
const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y);
const {
triggerElements
} = state.GROUPS[state.activeGroup];
const TOTAL_TRIGGER_ELEMENTS = triggerElements.length;
if (state.isDraggingX) {
const IS_RIGHT_SWIPE = MOVEMENT_X > 0;
if (MOVEMENT_X_DISTANCE >= state.config.threshold) {
if (IS_RIGHT_SWIPE && state.currentIndex > 0) {
actions.previous();
} else if (!IS_RIGHT_SWIPE && state.currentIndex < TOTAL_TRIGGER_ELEMENTS - 1) {
actions.next();
}
}
actions.updateOffset();
} else if (state.isDraggingY) {
if (MOVEMENT_Y_DISTANCE >= state.config.threshold && state.config.swipeClose) {
actions.close();
} else {
state.lightbox.classList.remove('parvus--is-vertical-closing');
actions.updateOffset();
}
state.lightboxOverlay.style.opacity = '';
} else {
actions.updateOffset();
}
};
/**
* Image Handler Module
*
* Handles image loading, captions, and dimensions
*/
/**
* Add caption to the container element
*
* @param {Object} config - Configuration object
* @param {HTMLElement} containerEl - The container element to which the caption will be added
* @param {HTMLElement} imageEl - The image the caption is linked to
* @param {HTMLElement} el - The trigger element associated with the caption
* @param {Number} index - The index of the caption
* @returns {void}
*/
const addCaption = (config, containerEl, imageEl, el, index) => {
const getCaptionData = triggerEl => {
const {
captionsAttribute,
captionsSelector,
captionsIdAttribute = 'data-caption-id'
} = config;
// Check for an ID reference on the trigger element
// This allows the caption to be anywhere on the page
const CAPTION_ID = triggerEl.getAttribute(captionsIdAttribute);
if (CAPTION_ID) {
const CAPTION_EL = document.getElementById(CAPTION_ID);
if (CAPTION_EL) {
return CAPTION_EL.innerHTML;
}
}
// Check for a direct caption attribute on the trigger element
const DIRECT_CAPTION = triggerEl.getAttribute(captionsAttribute);
if (DIRECT_CAPTION) {
return DIRECT_CAPTION;
}
// Query for a selector inside the trigger element
if (captionsSelector !== 'self') {
const CAPTION_EL = triggerEl.querySelector(captionsSelector);
if (CAPTION_EL) {
// Prefer a direct attribute on the found element, otherwise use its content
return CAPTION_EL.getAttribute(captionsAttribute) || CAPTION_EL.innerHTML;
}
}
return null;
};
const CAPTION_DATA = getCaptionData(el);
if (CAPTION_DATA) {
const CAPTION_CONTAINER = document.createElement('div');
const CAPTION_ID = `parvus__caption-${index}`;
CAPTION_CONTAINER.className = 'parvus__caption';
CAPTION_CONTAINER.id = CAPTION_ID;
CAPTION_CONTAINER.innerHTML = `<p>${CAPTION_DATA}</p>`;
containerEl.appendChild(CAPTION_CONTAINER);
imageEl.setAttribute('aria-describedby', CAPTION_ID);
}
};
/**
* Add copyright to the image container element
*
* @param {Object} config - Configuration object
* @param {HTMLElement} imageContainer - The image container element (parvus__content) to which the copyright will be added
* @param {HTMLElement} imageEl - The image the copyright is linked to
* @param {HTMLElement} el - The trigger element associated with the copyright
* @param {Number} index - The index of the copyright
* @returns {void}
*/
const addCopyright = (config, imageContainer, imageEl, el, index) => {
const getCopyrightData = triggerEl => {
const {
copyrightAttribute,
copyrightSelector,
copyrightIdAttribute = 'data-copyright-id'
} = config;
// Check for an ID reference on the trigger element
// This allows the copyright to be anywhere on the page
const COPYRIGHT_ID = triggerEl.getAttribute(copyrightIdAttribute);
if (COPYRIGHT_ID) {
const COPYRIGHT_EL = document.getElementById(COPYRIGHT_ID);
if (COPYRIGHT_EL) {
return COPYRIGHT_EL.innerHTML;
}
}
// Check for a direct copyright attribute on the trigger element
const DIRECT_COPYRIGHT = triggerEl.getAttribute(copyrightAttribute);
if (DIRECT_COPYRIGHT) {
return DIRECT_COPYRIGHT;
}
// Query for a selector inside the trigger element
if (copyrightSelector !== 'self') {
const COPYRIGHT_EL = triggerEl.querySelector(copyrightSelector);
if (COPYRIGHT_EL) {
// Prefer a direct attribute on the found element, otherwise use its content
return COPYRIGHT_EL.getAttribute(copyrightAttribute) || COPYRIGHT_EL.innerHTML;
}
}
return null;
};
const COPYRIGHT_DATA = getCopyrightData(el);
if (COPYRIGHT_DATA) {
const COPYRIGHT_CONTAINER = document.createElement('div');
const COPYRIGHT_ID = `parvus__copyright-${index}`;
COPYRIGHT_CONTAINER.className = 'parvus__copyright';
COPYRIGHT_CONTAINER.id = COPYRIGHT_ID;
COPYRIGHT_CONTAINER.innerHTML = `<small>${COPYRIGHT_DATA}</small>`;
imageContainer.appendChild(COPYRIGHT_CONTAINER);
// If image already has aria-describedby (from caption), append copyright ID
const existingAriaDescribedby = imageEl.getAttribute('aria-describedby');
if (existingAriaDescribedby) {
imageEl.setAttribute('aria-describedby', `${existingAriaDescribedby} ${COPYRIGHT_ID}`);
} else {
imageEl.setAttribute('aria-describedby', COPYRIGHT_ID);
}
}
};
/**
* Create image
*
* @param {Object} state - The application state
* @param {HTMLElement} el - The trigger element
* @param {Number} index - The index
* @param {Function} callback - Callback function
* @returns {void}
*/
const createImage = (state, el, index, callback) => {
const {
contentElements,
sliderElements
} = state.GROUPS[state.activeGroup];
if (contentElements[index] !== undefined) {
if (callback && typeof callback === 'function') {
callback();
}
return;
}
const CONTENT_CONTAINER_EL = sliderElements[index].querySelector('div');
const IMAGE = new Image();
const IMAGE_CONTAINER = document.createElement('div');
const THUMBNAIL = el.querySelector('img');
const LOADING_INDICATOR = document.createElement('div');
IMAGE_CONTAINER.className = 'parvus__content';
// Create loading indicator
LOADING_INDICATOR.className = 'parvus__loader';
LOADING_INDICATOR.setAttribute('role', 'progressbar');
LOADING_INDICATOR.setAttribute('aria-label', state.config.l10n.lightboxLoadingIndicatorLabel);
// Add loading indicator to content container
CONTENT_CONTAINER_EL.appendChild(LOADING_INDICATOR);
const checkImagePromise = new Promise((resolve, reject) => {
IMAGE.onload = () => resolve(IMAGE);
IMAGE.onerror = error => reject(error);
});
checkImagePromise.then(loadedImage => {
loadedImage.style.opacity = 0;
IMAGE_CONTAINER.appendChild(loadedImage);
// Add copyright if available (inside IMAGE_CONTAINER)
if (state.config.copyright) {
addCopyright(state.config, IMAGE_CONTAINER, IMAGE, el, index);
}
CONTENT_CONTAINER_EL.appendChild(IMAGE_CONTAINER);
// Add caption if available
if (state.config.captions) {
addCaption(state.config, CONTENT_CONTAINER_EL, IMAGE, el, index);
}
contentElements[index] = loadedImage;
// Set image width and height
loadedImage.setAttribute('width', loadedImage.naturalWidth);
loadedImage.setAttribute('height', loadedImage.naturalHeight);
// Set image dimension
setImageDimension(sliderElements[index], loadedImage);
}).catch(() => {
const ERROR_CONTAINER = document.createElement('div');
ERROR_CONTAINER.classList.add('parvus__content');
ERROR_CONTAINER.classList.add('parvus__content--error');
ERROR_CONTAINER.textContent = state.config.l10n.lightboxLoadingError;
CONTENT_CONTAINER_EL.appendChild(ERROR_CONTAINER);
contentElements[index] = ERROR_CONTAINER;
}).finally(() => {
CONTENT_CONTAINER_EL.removeChild(LOADING_INDICATOR);
if (callback && typeof callback === 'function') {
callback();
}
});
// Add `sizes` attribute
if (el.hasAttribute('data-sizes') && el.getAttribute('data-sizes') !== '') {
IMAGE.setAttribute('sizes', el.getAttribute('data-sizes'));
}
// Add `srcset` attribute
if (el.hasAttribute('data-srcset') && el.getAttribute('data-srcset') !== '') {
IMAGE.setAttribute('srcset', el.getAttribute('data-srcset'));
}
// Add `src` attribute
if (el.tagName === 'A') {
IMAGE.setAttribute('src', el.href);
} else {
IMAGE.setAttribute('src', el.getAttribute('data-target'));
}
// `alt` attribute
if (THUMBNAIL && THUMBNAIL.hasAttribute('alt') && THUMBNAIL.getAttribute('alt') !== '') {
IMAGE.alt = THUMBNAIL.alt;
} else if (el.hasAttribute('data-alt') && el.getAttribute('data-alt') !== '') {
IMAGE.alt = el.getAttribute('data-alt');
} else {
IMAGE.alt = '';
}
};
/**
* Load Image
*
* @param {Object} state - The application state
* @param {Number} index - The index of the image to load
* @param {Boolean} animate - Whether to animate the image
* @returns {void}
*/
const loadImage = (state, index, animate) => {
const IMAGE = state.GROUPS[state.activeGroup].contentElements[index];
if (IMAGE && IMAGE.tagName === 'IMG') {
const THUMBNAIL = state.GROUPS[state.activeGroup].triggerElements[index];
if (animate && document.startViewTransition) {
THUMBNAIL.style.viewTransitionName = 'lightboximage';
const transition = document.startViewTransition(() => {
IMAGE.style.opacity = '';
THUMBNAIL.style.viewTransitionName = null;
IMAGE.style.viewTransitionName = 'lightboximage';
});
transition.finished.finally(() => {
IMAGE.style.viewTransitionName = null;
});
} else {
IMAGE.style.opacity = '';
}
} else {
IMAGE.style.opacity = '';
}
};
/**
* Set image dimension
*
* @param {HTMLElement} slideEl - The slide element
* @param {HTMLElement} contentEl - The content element
* @returns {void}
*/
const setImageDimension = (slideEl, contentEl) => {
if (contentEl.tagName !== 'IMG') {
return;
}
const SRC_HEIGHT = contentEl.getAttribute('height');
const SRC_WIDTH = contentEl.getAttribute('width');
if (!SRC_HEIGHT || !SRC_WIDTH) {
return;
}
const SLIDE_EL_STYLES = getComputedStyle(slideEl);
const HORIZONTAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingLeft) + parseFloat(SLIDE_EL_STYLES.paddingRight);
const VERTICAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingTop) + parseFloat(SLIDE_EL_STYLES.paddingBottom);
const CAPTION_EL = slideEl.querySelector('.parvus__caption');
const CAPTION_HEIGHT = CAPTION_EL ? CAPTION_EL.getBoundingClientRect().height : 0;
const MAX_WIDTH = slideEl.offsetWidth - HORIZONTAL_PADDING;
const MAX_HEIGHT = slideEl.offsetHeight - VERTICAL_PADDING - CAPTION_HEIGHT;
const RATIO = Math.min(MAX_WIDTH / SRC_WIDTH || 0, MAX_HEIGHT / SRC_HEIGHT || 0);
const NEW_WIDTH = SRC_WIDTH * RATIO;
const NEW_HEIGHT = SRC_HEIGHT * RATIO;
const USE_ORIGINAL_SIZE = SRC_WIDTH <= MAX_WIDTH && SRC_HEIGHT <= MAX_HEIGHT;
contentEl.style.width = USE_ORIGINAL_SIZE ? '' : `${NEW_WIDTH}px`;
contentEl.style.height = USE_ORIGINAL_SIZE ? '' : `${NEW_HEIGHT}px`;
};
/**
* Create resize handler
*
* @param {Object} state - The application state
* @param {Function} updateOffset - Update offset function
* @returns {Function} Resize event handler
*/
const createResizeHandler = (state, updateOffset) => {
return () => {
if (!state.resizeTicking) {
state.resizeTicking = true;
window.requestAnimationFrame(() => {
state.GROUPS[state.activeGroup].sliderElements.forEach((slide, index) => {
setImageDimension(slide, state.GROUPS[state.activeGroup].contentElements[index]);
});
updateOffset();
state.resizeTicking = false;
});
}
};
};
// Helper modules
/**
* Parvus Lightbox
*
* @param {Object} userOptions - User configuration options
* @returns {Object} Parvus instance
*/
function Parvus(userOptions) {
const BROWSER_WINDOW = window;
const STATE = new ParvusState();
const MOTIONQUERY = BROWSER_WINDOW.matchMedia('(prefers-reduced-motion)');
const PLUGIN_MANAGER = new PluginManager();
// Event handlers will be created after actions are defined
let keydownHandler, clickHandler, pointerdownHandler, pointermoveHandler, pointerupHandler, resizeHandler;
/**
* Click event handler to trigger Parvus
*
* @param {Event} event - The click event object
*/
const triggerParvus = function triggerParvus(event) {
event.preventDefault();
open(this);
};
/**
* Add an element
*
* @param {HTMLElement} el - The element to be added
*/
const add = el => {
// Check element type and attributes
const IS_VALID_LINK = el.tagName === 'A' && el.hasAttribute('href');
const IS_VALID_BUTTON = el.tagName === 'BUTTON' && el.hasAttribute('data-target');
if (!IS_VALID_LINK && !IS_VALID_BUTTON) {
throw new Error('Use a link with the \'href\' attribute or a button with the \'data-target\' attribute. Both attributes must contain a path to the image file.');
}
// Check if the lightbox already exists
if (!STATE.lightbox) {
createLightbox(STATE);
// Execute afterInit hook when lightbox is first created
PLUGIN_MANAGER.executeHook('afterInit', {
state: STATE
});
}
STATE.newGroup = getGroup(STATE, el);
if (!STATE.GROUPS[STATE.newGroup]) {
STATE.GROUPS[STATE.newGroup] = structuredClone(STATE.GROUP_ATTRIBUTES);
}
if (STATE.GROUPS[STATE.newGroup].triggerElements.includes(el)) {
throw new Error('Ups, element already added.');
}
STATE.GROUPS[STATE.newGroup].triggerElements.push(el);
if (STATE.config.zoomIndicator) {
addZoomIndicator(el, STATE.config);
}
el.classList.add('parvus-trigger');
el.addEventListener('click', triggerParvus);
if (isOpen() && STATE.newGroup === STATE.activeGroup) {
const EL_INDEX = STATE.GROUPS[STATE.newGroup].triggerElements.indexOf(el);
createSlide(STATE, EL_INDEX);
createImage(STATE, el, EL_INDEX, () => {
loadImage(STATE, EL_INDEX);
});
updateAttributes(STATE);
updateSliderNavigationStatus(STATE);
updateCounter(STATE);
}
};
/**
* Remove an element
*
* @param {HTMLElement} el - The element to be removed
*/
const remove = el => {
if (!el || !el.hasAttribute('data-group')) {
return;
}
const EL_GROUP = getGroup(STATE, el);
const GROUP = STATE.GROUPS[EL_GROUP];
// Check if element exists
if (!GROUP) {
return;
}
const EL_INDEX = GROUP.triggerElements.indexOf(el);
if (EL_INDEX === -1) {
return;
}
const IS_CURRENT_EL = isOpen() && EL_GROUP === STATE.activeGroup && EL_INDEX === STATE.currentIndex;
// Remove group data
if (GROUP.contentElements[EL_INDEX]) {
const content = GROUP.contentElements[EL_INDEX];
if (content.tagName === 'IMG') {
content.src = '';
content.srcset = '';
}
}
// Remove DOM element
const sliderElement = GROUP.sliderElements[EL_INDEX];
if (sliderElement && sliderElement.parentNode) {
sliderElement.parentNode.removeChild(sliderElement);
}
// Remove all array elements
GROUP.triggerElements.splice(EL_INDEX, 1);
GROUP.sliderElements.splice(EL_INDEX, 1);
GROUP.contentElements.splice(EL_INDEX, 1);
if (STATE.config.zoomIndicator) {
removeZoomIndicator(el);
}
if (isOpen() && EL_GROUP === STATE.activeGroup) {
if (IS_CURRENT_EL) {
if (GROUP.triggerElements.length === 0) {
close();
} else if (STATE.currentIndex >= GROUP.triggerElements.length) {
select(GROUP.triggerElements.length - 1);
} else {
updateAttributes(STATE);
updateSliderNavigationStatus(STATE);
updateCounter(STATE);
}
} else if (EL_INDEX < STATE.currentIndex) {
STATE.currentIndex--;
updateAttributes(STATE);
updateSliderNavigationStatus(STATE);
updateCounter(STATE);
} else {
updateAttributes(STATE);
updateSliderNavigationStatus(STATE);
updateCounter(STATE);
}
}
// Unbind click event handler
el.removeEventListener('click', triggerParvus);
el.classList.remove('parvus-trigger');
};
/**
* Open Parvus
*
* @param {HTMLElement} el
*/
const open = el => {
if (!STATE.lightbox || !el || !el.classList.contains('parvus-trigger') || isOpen()) {
return;
}
STATE.activeGroup = getGroup(STATE, el);
const GROUP = STATE.GROUPS[STATE.activeGroup];
const EL_INDEX = GROUP.triggerElements.indexOf(el);
if (EL_INDEX === -1) {
throw new Error('Ups, element not found in group.');
}
STATE.currentIndex = EL_INDEX;
history.pushState({
parvus: 'close'
}, 'Image', window.location.href);
bindEvents();
if (STATE.config.hideScrollbar) {
document.body.style.marginInlineEnd = `${getScrollbarWidth()}px`;
document.body.style.overflow = 'hidden';
}
STATE.lightbox.classList.add('parvus--is-opening');
STATE.lightbox.showModal();
createSlider(STATE);
createSlide(STATE, STATE.currentIndex);
updateOffset(STATE);
updateAttributes(STATE);
updateSliderNavigationStatus(STATE);
updateCounter(STATE);
loadSlide(STATE, STATE.currentIndex);
createImage(STATE, el, STATE.currentIndex, () => {
loadImage(STATE, STATE.currentIndex, true);
STATE.lightbox.classList.remove('parvus--is-opening');
GROUP.slider.classList.add('parvus__slider--animate');
});
preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex + 1);
preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex - 1);
// Execute afterOpen hook
PLUGIN_MANAGER.executeHook('afterOpen', {
element: el,
state: STATE
});
// Create and dispatch a new event
dispatchCustomEvent(STATE.lightbox, 'open');
};
/**
* Close Parvus
*/
const close = () => {
if (!isOpen()) {
return;
}
const IMAGE = STATE.GROUPS[STATE.activeGroup].contentElements[STATE.currentIndex];
const THUMBNAIL = STATE.GROUPS[STATE.activeGroup].triggerElements[STATE.currentIndex];
unbindEvents();
STATE.clearDrag();
if (history.state?.parvus === 'close') {
history.back();
}
STATE.lightbox.classList.add('parvus--is-closing');
const transitionendHandler = () => {
// Reset the image zoom (if ESC was pressed or went back in the browser history)
// after the ViewTransition (otherwise it looks bad)
if (STATE.isPinching) {
resetZoom(STATE, IMAGE);
}
leaveSlide(STATE, STATE.currentIndex);
STATE.lightbox.close();
STATE.lightbox.classList.remove('parvus--is-closing');
STATE.lightbox.classList.remove('parvus--is-vertical-closing');
STATE.GROUPS[STATE.activeGroup].slider.remove();
STATE.GROUPS[STATE.activeGroup].slider = null;
STATE.GROUPS[STATE.activeGroup].sliderElements = [];
STATE.GROUPS[STATE.activeGroup].contentElements = [];
STATE.counter.removeAttribute('aria-hidden');
STATE.previousButton.removeAttribute('aria-hidden');
STATE.previousButton.removeAttribute('aria-disabled');
STATE.nextButton.removeAttribute('aria-hidden');
STATE.nextButton.removeAttribute('aria-disabled');
if (STATE.config.hideScrollbar) {
document.body.style.marginInlineEnd = '';
document.body.style.overflow = '';
}
// Execute afterClose hook
PLUGIN_MANAGER.executeHook('afterClose', {
state: STATE
});
};
if (IMAGE && IMAGE.tagName === 'IMG') {
if (document.startViewTransition) {
IMAGE.style.viewTransitionName = 'lightboximage';
const transition = document.startViewTransition(() => {
IMAGE.style.opacity = '0';
IMAGE.style.viewTransitionName = null;
THUMBNAIL.style.viewTransitionName = 'lightboximage';
});
transition.finished.finally(() => {
transitionendHandler();
THUMBNAIL.style.viewTransitionName = null;
});
} else {
IMAGE.style.opacity = '0';
requestAnimationFrame(transitionendHandler);
}
} else {
transitionendHandler();
}
};
/**
* Select a specific slide by index
*
* @param {number} index - Index of the slide to select
*/
const select = index => {
if (!isOpen()) {
throw new Error("Oops, I'm closed.");
}
if (typeof index !== 'number' || isNaN(index)) {
throw new Error('Oops, no slide specified.');
}
const GROUP = STATE.GROUPS[STATE.activeGroup];
const triggerElements = GROUP.triggerElements;
if (index === STATE.currentIndex) {
throw new Error(`Oops, slide ${index} is already selected.`);
}
if (index < 0 || index >= triggerElements.length) {
throw new Error(`Oops, I can't find slide ${index}.`);
}
const OLD_INDEX = STATE.currentIndex;
STATE.currentIndex = index;
if (GROUP.sliderElements[index]) {
loadSlide(STATE, index);
} else {
createSlide(STATE, index);
createImage(STATE, GROUP.triggerElements[index], index, () => {
loadImage(STATE, index);
});
loadSlide(STATE, index);
}
updateOffset(STATE);
updateSliderNavigationStatus(STATE);
updateCounter(STATE);
// Execute slideChange hook
PLUGIN_MANAGER.executeHook('slideChange', {
index,
oldIndex: OLD_INDEX,
state: STATE
});
if (index < OLD_INDEX) {
preload(STATE, createSlide, createImage, loadImage, index - 1);
} else {
preload(STATE, createSlide, createImage, loadImage, index + 1);
}
leaveSlide(STATE, OLD_INDEX);
// Create and dispatch a new event
dispatchCustomEvent(STATE.lightbox, 'select');
};
/**
* Select the previous slide
*/
const previous = () => {
if (STATE.currentIndex > 0) {
select(STATE.currentIndex - 1);
}
};
/**
* Select the next slide
*/
const next = () => {
const {
triggerElements
} = STATE.GROUPS[STATE.activeGroup];
if (STATE.currentIndex < triggerElements.length - 1) {
select(STATE.currentIndex + 1);
}
};
/**
* Bind specified events
*/
const bindEvents = () => {
const actions = {
close,
previous,
next,
updateOffset: () => updateOffset(STATE)
};
// Create handlers with state and actions
keydownHandler = createKeydownHandler(STATE, actions);
clickHandler = createClickHandler(STATE, actions);
resizeHandler = createResizeHandler(STATE, () => updateOffset(STATE));
const updateAfterDragHandler = () => updateAfterDrag(STATE, actions);
const pinchZoomHandler = img => pinchZoom(STATE, img);
const doSwipeHandler = () => doSwipe(STATE);
const resetZoomHandler = img => resetZoom(STATE, img);
pointerdownHandler = createPointerdownHandler(STATE);
pointermoveHandler = createPointermoveHandler(STATE, pinchZoomHandler, doSwipeHandler);
pointerupHandler = createPointerupHandler(STATE, resetZoomHandler, updateAfterDragHandler);
BROWSER_WINDOW.addEventListener('keydown', keydownHandler);
BROWSER_WINDOW.addEventListener('resize', resizeHandler);
// Popstate event
BROWSER_WINDOW.addEventListener('popstate', close);
// Check for any OS level changes to the prefers reduced motion preference
MOTIONQUERY.addEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY));
// Click event
STATE.lightbox.addEventListener('click', clickHandler);
// Pointer events
STATE.lightbox.addEventListener('pointerdown', pointerdownHandler, {
passive: false
});
STATE.lightbox.addEventListener('pointerup', pointerupHandler, {
passive: true
});
STATE.lightbox.addEventListener('pointermove', pointermoveHandler, {
passive: false
});
};
/**
* Unbind specified events
*/
const unbindEvents = () => {
BROWSER_WINDOW.removeEventListener('keydown', keydownHandler);
BROWSER_WINDOW.removeEventListener('resize', resizeHandler);
// Popstate event
BROWSER_WINDOW.removeEventListener('popstate', close);
// Check for any OS level changes to the prefers reduced motion preference
MOTIONQUERY.removeEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY));
// Click event
STATE.lightbox.removeEventListener('click', clickHandler);
// Pointer events
STATE.lightbox.removeEventListener('pointerdown', pointerdownHandler);
STATE.lightbox.removeEventListener('pointerup', pointerupHandler);
STATE.lightbox.removeEventListener('pointermove', pointermoveHandler);
};
/**
* Destroy Parvus
*/
const destroy = () => {
if (!STATE.lightbox) {
return;
}
if (isOpen()) {
close();
}
// Add setTimeout to ensure all possible close transitions are completed
setTimeout(() => {
unbindEvents();
// Remove all registered event listeners for custom events
const eventTypes = ['open', 'close', 'select', 'destroy'];
eventTypes.forEach(eventType => {
const listeners = STATE.lightbox._listeners?.[eventType] || [];
listeners.forEach(listener => {
STATE.lightbox.removeEventListener(eventType, listener);
});
});
// Remove event listeners from trigger elements
const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll('.parvus-trigger');
LIGHTBOX_TRIGGER_ELS.forEach(el => {
el.removeEventListener('click', triggerParvus);
el.classList.remove('parvus-trigger');
if (STATE.config.zoomIndicator) {
removeZoomIndicator(el);
}
if (el.dataset.group) {
delete el.dataset.group;
}
});
// Create and dispatch a new event
dispatchCustomEvent(STATE.lightbox, 'destroy');
STATE.lightbox.remove();
// Remove references
STATE.lightbox = null;
STATE.lightboxOverlay = null;
STATE.toolbar = null;
STATE.toolbarLeft = null;
STATE.toolbarRight = null;
STATE.controls = null;
STATE.previousButton = null;
STATE.nextButton = null;
STATE.closeButton = null;
STATE.counter = null;
// Remove group data
Object.keys(STATE.GROUPS).forEach(groupKey => {
const group = STATE.GROUPS[groupKey];
if (group && group.contentElements) {
group.contentElements.forEach(content => {
if (content && content.tagName === 'IMG') {
content.src = '';
content.srcset = '';
}
});
}
delete STATE.GROUPS[groupKey];
});
// Reset variables
STATE.groupIdCounter = 0;
STATE.newGroup = null;
STATE.activeGroup = null;
STATE.currentIndex = 0;
}, 1000);
};
/**
* Check if Parvus is open
*
* @returns {boolean} - True if Parvus is open, otherwise false
*/
const isOpen = () => {
return STATE.lightbox?.hasAttribute('open');
};
/**
* Get the current index
*
* @returns {number} - The current index
*/
const getCurrentIndex = () => {
return STATE.currentIndex;
};
/**
* Bind a specific event listener
*
* @param {String} eventName - The name of the event to bind
* @param {Function} callback - The callback function
*/
const on$1 = (eventName, callback) => {
on(STATE.lightbox, eventName, callback);
};
/**
* Unbind a specific event listener
*
* @param {String} eventName - The name of the event to unbind
* @param {Function} callback - The callback function
*/
const off$1 = (eventName, callback) => {
off(STATE.lightbox, eventName, callback);
};
/**
* Use a plugin
*
* @param {Object} plugin - Plugin object
* @param {Object} options - Plugin options
*/
const use = (plugin, options = {}) => {
PLUGIN_MANAGER.register(plugin, options);
};
/**
* Add a hook callback
*
* @param {String} hookName - Hook name
* @param {Function} callback - Callback function
*/
const addHook = (hookName, callback) => {
PLUGIN_MANAGER.addHook(hookName, callback);
};
/**
* Get registered plugins
*
* @returns {Array} Array of plugin names
*/
const getPlugins = () => {
return PLUGIN_MANAGER.getPlugins();
};
/**
* Init
*/
const init = () => {
// Merge user options into defaults
STATE.config = mergeOptions(userOptions);
reducedMotionCheck(STATE, MOTIONQUERY);
// Install plugins with context
const pluginContext = {
state: STATE,
on: on,
addHook: PLUGIN_MANAGER.addHook.bind(PLUGIN_MANAGER),
config: STATE.config
};
PLUGIN_MANAGER.install(pluginContext);
if (STATE.config.gallerySelector !== null) {
// Get a list of all `gallerySelector` elements within the document
const GALLERY_ELS = document.querySelectorAll(STATE.config.gallerySelector);
// Execute a few things once per element
GALLERY_ELS.forEach((galleryEl, index) => {
const GALLERY_INDEX = index;
// Get a list of all `selector` elements within the `gallerySelector`
const LIGHTBOX_TRIGGER_GALLERY_ELS = galleryEl.querySelectorAll(STATE.config.selector);
// Execute a few things once per element
LIGHTBOX_TRIGGER_GALLERY_ELS.forEach(lightboxTriggerEl => {
lightboxTriggerEl.setAttribute('data-group', `parvus-gallery-${GALLERY_INDEX}`);
add(lightboxTriggerEl);
});
});
}
// Get a list of all `selector` elements outside or without the `gallerySelector`
const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(`${STATE.config.selector}:not(.parvus-trigger)`);
LIGHTBOX_TRIGGER_ELS.forEach(add);
};
init();
return {
init,
open,
close,
select,
previous,
next,
currentIndex: getCurrentIndex,
add,
remove,
destroy,
isOpen,
on: on$1,
off: off$1,
use,
addHook,
getPlugins
};
}
return Parvus;
}));
================================================
FILE: package.json
================================================
{
"name": "parvus",
"type": "module",
"version": "3.1.0",
"description": "An open source, dependency free image lightbox with the goal of being accessible.",
"main": "./dist/js/parvus.js",
"module": "./dist/js/parvus.esm.js",
"style": "./dist/css/parvus.css",
"devDependencies": {
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.2",
"@rollup/plugin-babel": "^7.0.0",
"@rollup/plugin-commonjs": "^29.0.2",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-terser": "^1.0.0",
"core-js": "^3.49.0",
"postcss": "^8.5.10",
"rollup": "^4.60.2",
"rollup-plugin-license": "^3.7.1",
"rollup-plugin-postcss": "^4.0.2",
"sass": "^1.99.0",
"standard": "^17.1.2",
"stylelint": "^17.8.0",
"stylelint-config-standard-scss": "^17.0.0",
"stylelint-scss": "^7.0.0",
"stylelint-use-logical": "^2.1.3"
},
"browserslist": [
"last 2 versions and > 1% and not dead"
],
"standard": {
"globals": [
"Image",
"history",
"CustomEvent",
"requestAnimationFrame",
"getComputedStyle"
]
},
"scripts": {
"build": "npm run testCss && npm run buildCss && npm run testJs && npm run buildJs",
"buildCss": "rollup -c --environment BUILDCSS --bundleConfigAsCjs",
"buildJs": "rollup -c --environment BUILDJS --bundleConfigAsCjs",
"buildWatch": "npm run buildWatchJs && npm run buildWatchCss",
"buildWatchCss": "rollup -c -w --environment BUILDCSS --bundleConfigAsCjs",
"buildWatchJs": "rollup -c -w --environment BUILDJS --bundleConfigAsCjs",
"testCss": "stylelint \"src/scss/parvus.scss\"",
"testJs": "standard \"src/js/parvus.js\"",
"test": "npm run testCss && npm run testJs"
},
"exports": {
".": {
"import": "./dist/js/parvus.esm.js",
"require": "./dist/js/parvus.js"
},
"./src/scss/*": "./src/scss/*.scss",
"./src/l10n/*": "./src/l10n/*.js"
},
"repository": {
"type": "git",
"url": "git://github.com/deoostfrees/parvus.git"
},
"keywords": [
"lightbox",
"accessible",
"a11y",
"javascript",
"vanilla",
"scss",
"css"
],
"author": "Benjamin de Oostfrees",
"license": "MIT",
"bugs": {
"url": "https://github.com/deoostfrees/parvus/issues"
},
"homepage": "https://github.com/deoostfrees/parvus"
}
================================================
FILE: rollup.config.js
================================================
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import terser from '@rollup/plugin-terser'
import postcss from 'rollup-plugin-postcss'
import babel from '@rollup/plugin-babel'
import license from 'rollup-plugin-license'
import pkg from './package.json'
const bannerContent = `
Parvus
@author ${pkg.author}
@version ${pkg.version}
@url ${pkg.homepage}
${pkg.license} license`
const rollupBuilds = []
/**
* Build JavaScript
*
*/
if (process.env.BUILDJS) {
rollupBuilds.push({
input: './src/js/parvus.js',
output: [
{
format: 'umd',
file: './dist/js/parvus.js',
name: 'Parvus'
},
{
format: 'es',
file: './dist/js/parvus.esm.js',
name: 'Parvus'
},
{
format: 'umd',
file: './dist/js/parvus.min.js',
name: 'Parvus',
plugins: [
terser(),
license({
banner: {
content: bannerContent
}
})
]
},
{
format: 'es',
file: './dist/js/parvus.esm.min.js',
name: 'Parvus',
plugins: [
terser(),
license({
banner: {
content: bannerContent
}
})
]
}
],
plugins: [
resolve({
browser: true
}),
commonjs(),
babel({
babelHelpers: 'bundled',
exclude: 'node_modules/**',
presets: [
['@babel/preset-env', {
corejs: 3.15,
useBuiltIns: 'entry'
}]
]
}),
license({
banner: {
content: bannerContent
}
})
],
watch: {
clearScreen: false
}
})
}
/**
* Build CSS
*
*/
if (process.env.BUILDCSS) {
rollupBuilds.push(
{
input: './src/scss/parvus.scss',
output: [
{
file: './dist/css/parvus.css'
}
],
plugins: [
resolve({
browser: true
}),
commonjs(),
postcss({
extract: true
}),
license({
banner: {
content: bannerContent
}
})
],
watch: {
clearScreen: false
}
},
{
input: './src/scss/parvus.scss',
output: [
{
file: './dist/css/parvus.min.css'
}
],
plugins: [
resolve({
browser: true
}),
commonjs(),
postcss({
extract: true,
minimize: true
}),
license({
banner: {
content: bannerContent
}
})
],
watch: {
clearScreen: false
}
}
)
}
export default rollupBuilds
================================================
FILE: src/js/core/config.js
================================================
import en from '../../l10n/en.js'
/**
* Default configuration options
*/
export const DEFAULT_OPTIONS = {
selector: '.lightbox',
gallerySelector: null,
zoomIndicator: true,
captions: true,
captionsSelector: 'self',
captionsAttribute: 'data-caption',
copyright: true,
copyrightSelector: 'self',
copyrightAttribute: 'data-copyright',
docClose: true,
swipeClose: true,
simulateTouch: true,
threshold: 50,
hideScrollbar: true,
lightboxIndicatorIcon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>',
previousButtonIcon: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" stroke="currentColor"><path stroke="none" d="M0 0h24v24H0z"/><polyline points="15 6 9 12 15 18" /></svg>',
nextButtonIcon: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" stroke="currentColor"><path stroke="none" d="M0 0h24v24H0z"/><polyline points="9 6 15 12 9 18" /></svg>',
closeButtonIcon: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" stroke="currentColor"><path d="M18 6L6 18M6 6l12 12"/></svg>',
l10n: en
}
/**
* Merge default options with user-provided options
*
* @param {Object} userOptions - User-provided options
* @returns {Object} - Merged options object
*/
export const mergeOptions = (userOptions) => {
const MERGED_OPTIONS = {
...DEFAULT_OPTIONS,
...userOptions
}
if (userOptions && userOptions.l10n) {
MERGED_OPTIONS.l10n = {
...DEFAULT_OPTIONS.l10n,
...userOptions.l10n
}
}
return MERGED_OPTIONS
}
================================================
FILE: src/js/core/events.js
================================================
/**
* Event System Module
*
* Handles custom event dispatching and listeners
*/
/**
* Dispatch a custom event
*
* @param {HTMLElement} lightbox - The lightbox element
* @param {String} type - The type of the event to dispatch
* @returns {void}
*/
export const dispatchCustomEvent = (lightbox, type) => {
const CUSTOM_EVENT = new CustomEvent(type, {
cancelable: true
})
lightbox.dispatchEvent(CUSTOM_EVENT)
}
/**
* Bind a specific event listener
*
* @param {HTMLElement} lightbox - The lightbox element
* @param {String} eventName - The name of the event to bind
* @param {Function} callback - The callback function
* @returns {void}
*/
export const on = (lightbox, eventName, callback) => {
if (lightbox) {
lightbox.addEventListener(eventName, callback)
}
}
/**
* Unbind a specific event listener
*
* @param {HTMLElement} lightbox - The lightbox element
* @param {String} eventName - The name of the event to unbind
* @param {Function} callback - The callback function
* @returns {void}
*/
export const off = (lightbox, eventName, callback) => {
if (lightbox) {
lightbox.removeEventListener(eventName, callback)
}
}
================================================
FILE: src/js/core/navigation.js
================================================
/**
* Navigation Module
*
* Handles slide navigation and transitions
*/
/**
* Update offset
*
* @param {Object} state - The application state
* @returns {void}
*/
export const updateOffset = (state) => {
state.activeGroup = state.activeGroup !== null ? state.activeGroup : state.newGroup
state.offset = -state.currentIndex * state.lightbox.offsetWidth
state.GROUPS[state.activeGroup].slider.style.transform = `translate3d(${state.offset}px, 0, 0)`
state.offsetTmp = state.offset
}
/**
* Load slide with the specified index
*
* @param {Object} state - The application state
* @param {Number} index - The index of the slide to be loaded
* @returns {void}
*/
export const loadSlide = (state, index) => {
state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'false')
}
/**
* Leave slide
*
* @param {Object} state - The application state
* @param {Number} index - The index of the slide to leave
* @returns {void}
*/
export const leaveSlide = (state, index) => {
if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'true')
}
}
/**
* Preload slide with the specified index
*
* @param {Object} state - The application state
* @param {Function} createSlide - Create slide function
* @param {Function} createImage - Create image function
* @param {Function} loadImage - Load image function
* @param {Number} index - The index of the slide to be preloaded
* @returns {void}
*/
export const preload = (state, createSlide, createImage, loadImage, index) => {
if (index < 0 || index >= state.GROUPS[state.activeGroup].triggerElements.length || state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
return
}
createSlide(state, index)
createImage(state, state.GROUPS[state.activeGroup].triggerElements[index], index, () => {
loadImage(state, index)
})
}
================================================
FILE: src/js/core/plugins.js
================================================
/**
* Plugin management for Parvus
*
* Provides a system for registering and managing plugins
*/
export class PluginManager {
constructor () {
this.plugins = []
this.hooks = {}
this.context = null
this.isInitialized = false
}
/**
* Register a plugin
*
* @param {Object} plugin - Plugin object with name and install function
* @param {Object} options - Plugin-specific options
*/
register (plugin, options = {}) {
if (!plugin || typeof plugin.install !== 'function') {
throw new Error('Plugin must have an install function')
}
if (!plugin.name) {
throw new Error('Plugin must have a name')
}
// Check if plugin is already registered
const existingPlugin = this.plugins.find(p => p.name === plugin.name)
if (existingPlugin) {
console.warn(`Plugin "${plugin.name}" is already registered`)
return
}
this.plugins.push({ plugin, options })
// If already initialized, install immediately
if (this.isInitialized && this.context) {
this.installPlugin(plugin, options)
}
}
/**
* Install a single plugin
*
* @param {Object} plugin - Plugin object
* @param {Object} options - Plugin options
*/
installPlugin (plugin, options) {
try {
plugin.install(this.context, options)
// If lightbox already exists, execute afterInit hook for this plugin immediately
if (this.context && this.context.state && this.context.state.lightbox) {
this.executeHook('afterInit', { state: this.context.state })
}
} catch (error) {
console.error(`Failed to install plugin "${plugin.name}":`, error)
}
}
/**
* Install all registered plugins
*
* @param {Object} context - Parvus instance context
*/
install (context) {
this.context = context
this.isInitialized = true
this.plugins.forEach(({ plugin, options }) => {
this.installPlugin(plugin, options)
})
}
/**
* Execute a hook
*
* @param {String} hookName - Name of the hook
* @param {*} data - Data to pass to hook callbacks
*/
executeHook (hookName, data) {
const callbacks = this.hooks[hookName] || []
callbacks.forEach(callback => {
try {
callback(data)
} catch (error) {
console.error(`Error in hook "${hookName}":`, error)
}
})
}
/**
* Register a hook callback
*
* @param {String} hookName - Name of the hook
* @param {Function} callback - Callback function
*/
addHook (hookName, callback) {
if (!this.hooks[hookName]) {
this.hooks[hookName] = []
}
this.hooks[hookName].push(callback)
}
/**
* Remove a hook callback
*
* @param {String} hookName - Name of the hook
* @param {Function} callback - Callback function to remove
*/
removeHook (hookName, callback) {
if (!this.hooks[hookName]) return
this.hooks[hookName] = this.hooks[hookName].filter(cb => cb !== callback)
}
/**
* Get all registered plugins
*
* @returns {Array} Array of plugin names
*/
getPlugins () {
return this.plugins.map(p => p.plugin.name)
}
}
================================================
FILE: src/js/core/state.js
================================================
/**
* State management for Parvus
*
* Centralizes all mutable state variables
*/
export class ParvusState {
constructor () {
// Group management
this.GROUP_ATTRIBUTES = {
triggerElements: [],
slider: null,
sliderElements: [],
contentElements: []
}
this.GROUPS = {}
this.groupIdCounter = 0
this.newGroup = null
this.activeGroup = null
this.currentIndex = 0
// Configuration
this.config = {}
// DOM elements
this.lightbox = null
this.lightboxOverlay = null
this.lightboxOverlayOpacity = 1
this.toolbar = null
this.toolbarLeft = null
this.toolbarRight = null
this.controls = null
this.previousButton = null
this.nextButton = null
this.closeButton = null
this.counter = null
// Drag & interaction state
this.drag = {}
this.isDraggingX = false
this.isDraggingY = false
this.pointerDown = false
this.activePointers = new Map()
// Zoom state
this.currentScale = 1
this.isPinching = false
this.isTap = false
this.pinchStartDistance = 0
this.lastPointersId = null
// Offset & animation
this.offset = null
this.offsetTmp = null
this.resizeTicking = false
this.isReducedMotion = true
}
/**
* Clear drag state
*/
clearDrag () {
this.drag = {
startX: 0,
endX: 0,
startY: 0,
endY: 0
}
}
/**
* Get the active group
*
* @returns {Object} The active group
*/
getActiveGroup () {
return this.GROUPS[this.activeGroup]
}
/**
* Reset zoom state
*/
resetZoomState () {
this.isPinching = false
this.isTap = false
this.currentScale = 1
this.pinchStartDistance = 0
this.lastPointersId = ''
}
}
================================================
FILE: src/js/core/utils.js
================================================
/**
* Utils Module
*
* Utility functions
*/
/**
* Check prefers reduced motion
*
* @param {Object} state - The application state
* @param {MediaQueryList} motionQuery - The media query list
* @returns {void}
*/
export const reducedMotionCheck = (state, motionQuery) => {
if (motionQuery.matches) {
state.isReducedMotion = true
} else {
state.isReducedMotion = false
}
}
/**
* Retrieves or creates a group identifier for the given element
*
* @param {Object} state - The application state
* @param {HTMLElement} el - DOM element to get or assign a group to
* @returns {string} The group identifier associated with the element
*/
export const getGroup = (state, el) => {
// Return existing group identifier if already assigned
if (el.dataset.group) {
return el.dataset.group
}
// Generate new unique group identifier using counter
const EL_GROUP = `default-${state.groupIdCounter++}`
// Assign the new group identifier to element's dataset
el.dataset.group = EL_GROUP
return EL_GROUP
}
================================================
FILE: src/js/handlers/gestures.js
================================================
/**
* Gesture Handler Module
*
* Handles gestures like pinch-to-zoom and swipe
*/
/**
* Reset image zoom
*
* @param {Object} state - The application state
* @param {HTMLImageElement} currentImg - The image
* @returns {void}
*/
export const resetZoom = (state, currentImg) => {
currentImg.style.transition = 'transform 0.3s ease'
currentImg.style.transform = ''
setTimeout(() => {
currentImg.style.transition = ''
currentImg.style.transformOrigin = ''
}, 300)
state.resetZoomState()
state.lightbox.classList.remove('parvus--is-zooming')
}
/**
* Pinch zoom gesture
*
* @param {Object} state - The application state
* @param {HTMLImageElement} currentImg - The image to zoom
* @returns {void}
*/
export const pinchZoom = (state, currentImg) => {
// Determine current finger positions
const POINTS = Array.from(state.activePointers.values())
// Calculate current distance between fingers
const CURRENT_DISTANCE = Math.hypot(
POINTS[1].clientX - POINTS[0].clientX,
POINTS[1].clientY - POINTS[0].clientY
)
// Calculate the midpoint between the two points
const MIDPOINT_X = (POINTS[0].clientX + POINTS[1].clientX) / 2
const MIDPOINT_Y = (POINTS[0].clientY + POINTS[1].clientY) / 2
// Convert midpoint to relative position within the image
const IMG_RECT = currentImg.getBoundingClientRect()
const RELATIVE_X = (MIDPOINT_X - IMG_RECT.left) / IMG_RECT.width
const RELATIVE_Y = (MIDPOINT_Y - IMG_RECT.top) / IMG_RECT.height
// When pinch gesture is about to start or the finger IDs have changed
// Use a unique ID based on the pointer IDs to recognize changes
const CURRENT_POINTERS_ID = POINTS.map(p => p.pointerId).sort().join('-')
const IS_NEW_POINTER_COMBINATION = state.lastPointersId !== CURRENT_POINTERS_ID
if (!state.isPinching || IS_NEW_POINTER_COMBINATION) {
state.isPinching = true
state.lastPointersId = CURRENT_POINTERS_ID
// Save the start distance and current scaling as a basis
state.pinchStartDistance = CURRENT_DISTANCE / state.currentScale
// Store initial pinch position for this gesture
if ((!currentImg.style.transformOrigin && state.currentScale === 1) ||
(state.currentScale === 1 && IS_NEW_POINTER_COMBINATION)) {
// Set the transform origin to the pinch midpoint
currentImg.style.transformOrigin = `${RELATIVE_X * 100}% ${RELATIVE_Y * 100}%`
}
state.lightbox.classList.add('parvus--is-zooming')
}
// Calculate scaling factor based on distance change
const SCALE_FACTOR = CURRENT_DISTANCE / state.pinchStartDistance
// Limit scaling to 1 - 3
state.currentScale = Math.min(Math.max(1, SCALE_FACTOR), 3)
currentImg.style.willChange = 'transform'
currentImg.style.transform = `scale(${state.currentScale})`
}
/**
* Determine the swipe direction (horizontal or vertical)
*
* @param {Object} state - The application state
* @returns {void}
*/
export const doSwipe = (state) => {
const MOVEMENT_THRESHOLD = 1.5
const MAX_OPACITY_DISTANCE = 100
const DIRECTION_BIAS = 1.15
const { startX, endX, startY, endY } = state.drag
const MOVEMENT_X = startX - endX
const MOVEMENT_Y = endY - startY
const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X)
const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y)
const GROUP = state.GROUPS[state.activeGroup]
const SLIDER = GROUP.slider
const TOTAL_SLIDES = GROUP.triggerElements.length
const handleHorizontalSwipe = (movementX, distance) => {
const IS_FIRST_SLIDE = state.currentIndex === 0
const IS_LAST_SLIDE = state.currentIndex === TOTAL_SLIDES - 1
const IS_LEFT_SWIPE = movementX > 0
const IS_RIGHT_SWIPE = movementX < 0
if ((IS_FIRST_SLIDE && IS_RIGHT_SWIPE) || (IS_LAST_SLIDE && IS_LEFT_SWIPE)) {
const DAMPING_FACTOR = 1 / (1 + Math.pow(distance / 100, 0.15))
const REDUCED_MOVEMENT = movementX * DAMPING_FACTOR
SLIDER.style.transform = `
translate3d(${state.offsetTmp - Math.round(REDUCED_MOVEMENT)}px, 0, 0)
`
} else {
SLIDER.style.transform = `
translate3d(${state.offsetTmp - Math.round(movementX)}px, 0, 0)
`
}
}
const handleVerticalSwipe = (movementY, distance) => {
if (!state.isReducedMotion && distance <= 100) {
const NEW_OVERLAY_OPACITY = Math.max(0, state.lightboxOverlayOpacity - (distance / MAX_OPACITY_DISTANCE))
state.lightboxOverlay.style.opacity = NEW_OVERLAY_OPACITY
}
state.lightbox.classList.add('parvus--is-vertical-closing')
SLIDER.style.transform = `
translate3d(${state.offsetTmp}px, ${Math.round(movementY)}px, 0)
`
}
if (state.isDraggingX || state.isDraggingY) {
if (state.isDraggingX) {
handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE)
} else if (state.isDraggingY) {
handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE)
}
return
}
// Direction detection based on the relative ratio of movements
if (MOVEMENT_X_DISTANCE > MOVEMENT_THRESHOLD || MOVEMENT_Y_DISTANCE > MOVEMENT_THRESHOLD) {
// Horizontal swipe if X-movement is stronger than Y-movement * DIRECTION_BIAS
if (MOVEMENT_X_DISTANCE > MOVEMENT_Y_DISTANCE * DIRECTION_BIAS && TOTAL_SLIDES > 1) {
state.isDraggingX = true
state.isDraggingY = false
handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE)
} else if (MOVEMENT_Y_DISTANCE > MOVEMENT_X_DISTANCE * DIRECTION_BIAS && state.config.swipeClose) {
// Vertical swipe if Y-movement is stronger than X-movement * DIRECTION_BIAS
state.isDraggingX = false
state.isDraggingY = true
handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE)
}
}
}
/**
* Recalculate drag/swipe event after pointerup
*
* @param {Object} state - The application state
* @param {Object} actions - Navigation actions
* @returns {void}
*/
export const updateAfterDrag = (state, actions) => {
const { startX, startY, endX, endY } = state.drag
const MOVEMENT_X = endX - startX
const MOVEMENT_Y = endY - startY
const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X)
const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y)
const { triggerElements } = state.GROUPS[state.activeGroup]
const TOTAL_TRIGGER_ELEMENTS = triggerElements.length
if (state.isDraggingX) {
const IS_RIGHT_SWIPE = MOVEMENT_X > 0
if (MOVEMENT_X_DISTANCE >= state.config.threshold) {
if (IS_RIGHT_SWIPE && state.currentIndex > 0) {
actions.previous()
} else if (!IS_RIGHT_SWIPE && state.currentIndex < TOTAL_TRIGGER_ELEMENTS - 1) {
actions.next()
}
}
actions.updateOffset()
} else if (state.isDraggingY) {
if (MOVEMENT_Y_DISTANCE >= state.config.threshold && state.config.swipeClose) {
actions.close()
} else {
state.lightbox.classList.remove('parvus--is-vertical-closing')
actions.updateOffset()
}
state.lightboxOverlay.style.opacity = ''
} else {
actions.updateOffset()
}
}
================================================
FILE: src/js/handlers/images.js
================================================
/**
* Image Handler Module
*
* Handles image loading, captions, and dimensions
*/
/**
* Add caption to the container element
*
* @param {Object} config - Configuration object
* @param {HTMLElement} containerEl - The container element to which the caption will be added
* @param {HTMLElement} imageEl - The image the caption is linked to
* @param {HTMLElement} el - The trigger element associated with the caption
* @param {Number} index - The index of the caption
* @returns {void}
*/
export const addCaption = (config, containerEl, imageEl, el, index) => {
const getCaptionData = (triggerEl) => {
const { captionsAttribute, captionsSelector, captionsIdAttribute = 'data-caption-id' } = config
// Check for an ID reference on the trigger element
// This allows the caption to be anywhere on the page
const CAPTION_ID = triggerEl.getAttribute(captionsIdAttribute)
if (CAPTION_ID) {
const CAPTION_EL = document.getElementById(CAPTION_ID)
if (CAPTION_EL) {
return CAPTION_EL.innerHTML
}
}
// Check for a direct caption attribute on the trigger element
const DIRECT_CAPTION = triggerEl.getAttribute(captionsAttribute)
if (DIRECT_CAPTION) {
return DIRECT_CAPTION
}
// Query for a selector inside the trigger element
if (captionsSelector !== 'self') {
const CAPTION_EL = triggerEl.querySelector(captionsSelector)
if (CAPTION_EL) {
// Prefer a direct attribute on the found element, otherwise use its content
return CAPTION_EL.getAttribute(captionsAttribute) || CAPTION_EL.innerHTML
}
}
return null
}
const CAPTION_DATA = getCaptionData(el)
if (CAPTION_DATA) {
const CAPTION_CONTAINER = document.createElement('div')
const CAPTION_ID = `parvus__caption-${index}`
CAPTION_CONTAINER.className = 'parvus__caption'
CAPTION_CONTAINER.id = CAPTION_ID
CAPTION_CONTAINER.innerHTML = `<p>${CAPTION_DATA}</p>`
containerEl.appendChild(CAPTION_CONTAINER)
imageEl.setAttribute('aria-describedby', CAPTION_ID)
}
}
/**
* Add copyright to the image container element
*
* @param {Object} config - Configuration object
* @param {HTMLElement} imageContainer - The image container element (parvus__content) to which the copyright will be added
* @param {HTMLElement} imageEl - The image the copyright is linked to
* @param {HTMLElement} el - The trigger element associated with the copyright
* @param {Number} index - The index of the copyright
* @returns {void}
*/
export const addCopyright = (config, imageContainer, imageEl, el, index) => {
const getCopyrightData = (triggerEl) => {
const { copyrightAttribute, copyrightSelector, copyrightIdAttribute = 'data-copyright-id' } = config
// Check for an ID reference on the trigger element
// This allows the copyright to be anywhere on the page
const COPYRIGHT_ID = triggerEl.getAttribute(copyrightIdAttribute)
if (COPYRIGHT_ID) {
const COPYRIGHT_EL = document.getElementById(COPYRIGHT_ID)
if (COPYRIGHT_EL) {
return COPYRIGHT_EL.innerHTML
}
}
// Check for a direct copyright attribute on the trigger element
const DIRECT_COPYRIGHT = triggerEl.getAttribute(copyrightAttribute)
if (DIRECT_COPYRIGHT) {
return DIRECT_COPYRIGHT
}
// Query for a selector inside the trigger element
if (copyrightSelector !== 'self') {
const COPYRIGHT_EL = triggerEl.querySelector(copyrightSelector)
if (COPYRIGHT_EL) {
// Prefer a direct attribute on the found element, otherwise use its content
return COPYRIGHT_EL.getAttribute(copyrightAttribute) || COPYRIGHT_EL.innerHTML
}
}
return null
}
const COPYRIGHT_DATA = getCopyrightData(el)
if (COPYRIGHT_DATA) {
const COPYRIGHT_CONTAINER = document.createElement('div')
const COPYRIGHT_ID = `parvus__copyright-${index}`
COPYRIGHT_CONTAINER.className = 'parvus__copyright'
COPYRIGHT_CONTAINER.id = COPYRIGHT_ID
COPYRIGHT_CONTAINER.innerHTML = `<small>${COPYRIGHT_DATA}</small>`
imageContainer.appendChild(COPYRIGHT_CONTAINER)
// If image already has aria-describedby (from caption), append copyright ID
const existingAriaDescribedby = imageEl.getAttribute('aria-describedby')
if (existingAriaDescribedby) {
imageEl.setAttribute('aria-describedby', `${existingAriaDescribedby} ${COPYRIGHT_ID}`)
} else {
imageEl.setAttribute('aria-describedby', COPYRIGHT_ID)
}
}
}
/**
* Create image
*
* @param {Object} state - The application state
* @param {HTMLElement} el - The trigger element
* @param {Number} index - The index
* @param {Function} callback - Callback function
* @returns {void}
*/
export const createImage = (state, el, index, callback) => {
const { contentElements, sliderElements } = state.GROUPS[state.activeGroup]
if (contentElements[index] !== undefined) {
if (callback && typeof callback === 'function') {
callback()
}
return
}
const CONTENT_CONTAINER_EL = sliderElements[index].querySelector('div')
const IMAGE = new Image()
const IMAGE_CONTAINER = document.createElement('div')
const THUMBNAIL = el.querySelector('img')
const LOADING_INDICATOR = document.createElement('div')
IMAGE_CONTAINER.className = 'parvus__content'
// Create loading indicator
LOADING_INDICATOR.className = 'parvus__loader'
LOADING_INDICATOR.setAttribute('role', 'progressbar')
LOADING_INDICATOR.setAttribute('aria-label', state.config.l10n.lightboxLoadingIndicatorLabel)
// Add loading indicator to content container
CONTENT_CONTAINER_EL.appendChild(LOADING_INDICATOR)
const checkImagePromise = new Promise((resolve, reject) => {
IMAGE.onload = () => resolve(IMAGE)
IMAGE.onerror = (error) => reject(error)
})
checkImagePromise
.then((loadedImage) => {
loadedImage.style.opacity = 0
IMAGE_CONTAINER.appendChild(loadedImage)
// Add copyright if available (inside IMAGE_CONTAINER)
if (state.config.copyright) {
addCopyright(state.config, IMAGE_CONTAINER, IMAGE, el, index)
}
CONTENT_CONTAINER_EL.appendChild(IMAGE_CONTAINER)
// Add caption if available
if (state.config.captions) {
addCaption(state.config, CONTENT_CONTAINER_EL, IMAGE, el, index)
}
contentElements[index] = loadedImage
// Set image width and height
loadedImage.setAttribute('width', loadedImage.naturalWidth)
loadedImage.setAttribute('height', loadedImage.naturalHeight)
// Set image dimension
setImageDimension(sliderElements[index], loadedImage)
})
.catch(() => {
const ERROR_CONTAINER = document.createElement('div')
ERROR_CONTAINER.classList.add('parvus__content')
ERROR_CONTAINER.classList.add('parvus__content--error')
ERROR_CONTAINER.textContent = state.config.l10n.lightboxLoadingError
CONTENT_CONTAINER_EL.appendChild(ERROR_CONTAINER)
contentElements[index] = ERROR_CONTAINER
})
.finally(() => {
CONTENT_CONTAINER_EL.removeChild(LOADING_INDICATOR)
if (callback && typeof callback === 'function') {
callback()
}
})
// Add `sizes` attribute
if (el.hasAttribute('data-sizes') && el.getAttribute('data-sizes') !== '') {
IMAGE.setAttribute('sizes', el.getAttribute('data-sizes'))
}
// Add `srcset` attribute
if (el.hasAttribute('data-srcset') && el.getAttribute('data-srcset') !== '') {
IMAGE.setAttribute('srcset', el.getAttribute('data-srcset'))
}
// Add `src` attribute
if (el.tagName === 'A') {
IMAGE.setAttribute('src', el.href)
} else {
IMAGE.setAttribute('src', el.getAttribute('data-target'))
}
// `alt` attribute
if (THUMBNAIL && THUMBNAIL.hasAttribute('alt') && THUMBNAIL.getAttribute('alt') !== '') {
IMAGE.alt = THUMBNAIL.alt
} else if (el.hasAttribute('data-alt') && el.getAttribute('data-alt') !== '') {
IMAGE.alt = el.getAttribute('data-alt')
} else {
IMAGE.alt = ''
}
}
/**
* Load Image
*
* @param {Object} state - The application state
* @param {Number} index - The index of the image to load
* @param {Boolean} animate - Whether to animate the image
* @returns {void}
*/
export const loadImage = (state, index, animate) => {
const IMAGE = state.GROUPS[state.activeGroup].contentElements[index]
if (IMAGE && IMAGE.tagName === 'IMG') {
const THUMBNAIL = state.GROUPS[state.activeGroup].triggerElements[index]
if (animate && document.startViewTransition) {
THUMBNAIL.style.viewTransitionName = 'lightboximage'
const transition = document.startViewTransition(() => {
IMAGE.style.opacity = ''
THUMBNAIL.style.viewTransitionName = null
IMAGE.style.viewTransitionName = 'lightboximage'
})
transition.finished.finally(() => {
IMAGE.style.viewTransitionName = null
})
} else {
IMAGE.style.opacity = ''
}
} else {
IMAGE.style.opacity = ''
}
}
/**
* Set image dimension
*
* @param {HTMLElement} slideEl - The slide element
* @param {HTMLElement} contentEl - The content element
* @returns {void}
*/
export const setImageDimension = (slideEl, contentEl) => {
if (contentEl.tagName !== 'IMG') {
return
}
const SRC_HEIGHT = contentEl.getAttribute('height')
const SRC_WIDTH = contentEl.getAttribute('width')
if (!SRC_HEIGHT || !SRC_WIDTH) {
return
}
const SLIDE_EL_STYLES = getComputedStyle(slideEl)
const HORIZONTAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingLeft) + parseFloat(SLIDE_EL_STYLES.paddingRight)
const VERTICAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingTop) + parseFloat(SLIDE_EL_STYLES.paddingBottom)
const CAPTION_EL = slideEl.querySelector('.parvus__caption')
const CAPTION_HEIGHT = CAPTION_EL ? CAPTION_EL.getBoundingClientRect().height : 0
const MAX_WIDTH = slideEl.offsetWidth - HORIZONTAL_PADDING
const MAX_HEIGHT = slideEl.offsetHeight - VERTICAL_PADDING - CAPTION_HEIGHT
const RATIO = Math.min(MAX_WIDTH / SRC_WIDTH || 0, MAX_HEIGHT / SRC_HEIGHT || 0)
const NEW_WIDTH = SRC_WIDTH * RATIO
const NEW_HEIGHT = SRC_HEIGHT * RATIO
const USE_ORIGINAL_SIZE = (SRC_WIDTH <= MAX_WIDTH && SRC_HEIGHT <= MAX_HEIGHT)
contentEl.style.width = USE_ORIGINAL_SIZE ? '' : `${NEW_WIDTH}px`
contentEl.style.height = USE_ORIGINAL_SIZE ? '' : `${NEW_HEIGHT}px`
}
/**
* Create resize handler
*
* @param {Object} state - The application state
* @param {Function} updateOffset - Update offset function
* @returns {Function} Resize event handler
*/
export const createResizeHandler = (state, updateOffset) => {
return () => {
if (!state.resizeTicking) {
state.resizeTicking = true
window.requestAnimationFrame(() => {
state.GROUPS[state.activeGroup].sliderElements.forEach((slide, index) => {
setImageDimension(slide, state.GROUPS[state.activeGroup].contentElements[index])
})
updateOffset()
state.resizeTicking = false
})
}
}
}
================================================
FILE: src/js/handlers/keyboard.js
================================================
/**
* Keyboard Event Handler Module
*
* Handles all keyboard interactions
*/
import { getFocusableChildren } from '../helpers/dom.js'
/**
* Create keyboard event handler
*
* @param {Object} state - The application state
* @param {Object} actions - Actions object with navigation functions
* @returns {Function} Keyboard event handler
*/
export const createKeydownHandler = (state, actions) => {
return (event) => {
const FOCUSABLE_CHILDREN = getFocusableChildren(state.lightbox)
const FOCUSED_ITEM_INDEX = FOCUSABLE_CHILDREN.indexOf(document.activeElement)
const lastIndex = FOCUSABLE_CHILDREN.length - 1
switch (event.code) {
case 'Tab': {
// Use the TAB key to navigate backwards and forwards
if (event.shiftKey) {
// Navigate backwards
if (FOCUSED_ITEM_INDEX === 0) {
FOCUSABLE_CHILDREN[lastIndex].focus()
event.preventDefault()
}
} else {
// Navigate forwards
if (FOCUSED_ITEM_INDEX === lastIndex) {
FOCUSABLE_CHILDREN[0].focus()
event.preventDefault()
}
}
break
}
case 'Escape': {
// Close Parvus when the ESC key is pressed
actions.close()
event.preventDefault()
break
}
case 'ArrowLeft': {
// Show the previous slide when the PREV key is pressed
actions.previous()
event.preventDefault()
break
}
case 'ArrowRight': {
// Show the next slide when the NEXT key is pressed
actions.next()
event.preventDefault()
break
}
}
}
}
================================================
FILE: src/js/handlers/pointer.js
================================================
/**
* Pointer Event Handler Module
*
* Handles all pointer interactions (mouse, touch, pen)
*/
/**
* Create pointerdown event handler
*
* @param {Object} state - The application state
* @returns {Function} Pointerdown event handler
*/
export const createPointerdownHandler = (state) => {
return (event) => {
event.preventDefault()
event.stopPropagation()
state.isDraggingX = false
state.isDraggingY = false
state.pointerDown = true
gitextract_wwaefxjx/
├── .github/
│ └── FUNDING.yml
├── .gitignore
├── .stylelintrc
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── dist/
│ ├── css/
│ │ └── parvus.css
│ └── js/
│ ├── parvus.esm.js
│ └── parvus.js
├── package.json
├── rollup.config.js
├── src/
│ ├── js/
│ │ ├── core/
│ │ │ ├── config.js
│ │ │ ├── events.js
│ │ │ ├── navigation.js
│ │ │ ├── plugins.js
│ │ │ ├── state.js
│ │ │ └── utils.js
│ │ ├── handlers/
│ │ │ ├── gestures.js
│ │ │ ├── images.js
│ │ │ ├── keyboard.js
│ │ │ └── pointer.js
│ │ ├── helpers/
│ │ │ └── dom.js
│ │ ├── parvus.js
│ │ └── ui/
│ │ ├── lightbox.js
│ │ └── zoom-indicator.js
│ ├── l10n/
│ │ ├── de.js
│ │ ├── en.js
│ │ ├── fr.js
│ │ ├── it.js
│ │ └── nl.js
│ └── scss/
│ └── parvus.scss
└── test/
└── test.html
SYMBOL INDEX (51 symbols across 7 files)
FILE: dist/js/parvus.esm.js
constant BROWSER_WINDOW (line 11) | const BROWSER_WINDOW = window;
constant FOCUSABLE_ELEMENTS (line 21) | const FOCUSABLE_ELEMENTS = ['a:not([inert]):not([tabindex^="-"])', 'butt...
constant DEFAULT_OPTIONS (line 47) | const DEFAULT_OPTIONS = {
class ParvusState (line 94) | class ParvusState {
method constructor (line 95) | constructor() {
method clearDrag (line 149) | clearDrag() {
method getActiveGroup (line 163) | getActiveGroup() {
method resetZoomState (line 170) | resetZoomState() {
class PluginManager (line 338) | class PluginManager {
method constructor (line 339) | constructor() {
method register (line 352) | register(plugin, options = {}) {
method installPlugin (line 383) | installPlugin(plugin, options) {
method install (line 403) | install(context) {
method executeHook (line 420) | executeHook(hookName, data) {
method addHook (line 437) | addHook(hookName, callback) {
method removeHook (line 450) | removeHook(hookName, callback) {
method getPlugins (line 460) | getPlugins() {
function Parvus (line 1495) | function Parvus(userOptions) {
FILE: dist/js/parvus.js
class ParvusState (line 100) | class ParvusState {
method constructor (line 101) | constructor() {
method clearDrag (line 155) | clearDrag() {
method getActiveGroup (line 169) | getActiveGroup() {
method resetZoomState (line 176) | resetZoomState() {
class PluginManager (line 344) | class PluginManager {
method constructor (line 345) | constructor() {
method register (line 358) | register(plugin, options = {}) {
method installPlugin (line 389) | installPlugin(plugin, options) {
method install (line 409) | install(context) {
method executeHook (line 426) | executeHook(hookName, data) {
method addHook (line 443) | addHook(hookName, callback) {
method removeHook (line 456) | removeHook(hookName, callback) {
method getPlugins (line 466) | getPlugins() {
function Parvus (line 1501) | function Parvus(userOptions) {
FILE: src/js/core/config.js
constant DEFAULT_OPTIONS (line 6) | const DEFAULT_OPTIONS = {
FILE: src/js/core/plugins.js
class PluginManager (line 7) | class PluginManager {
method constructor (line 8) | constructor () {
method register (line 21) | register (plugin, options = {}) {
method installPlugin (line 51) | installPlugin (plugin, options) {
method install (line 69) | install (context) {
method executeHook (line 84) | executeHook (hookName, data) {
method addHook (line 101) | addHook (hookName, callback) {
method removeHook (line 114) | removeHook (hookName, callback) {
method getPlugins (line 125) | getPlugins () {
FILE: src/js/core/state.js
class ParvusState (line 6) | class ParvusState {
method constructor (line 7) | constructor () {
method clearDrag (line 61) | clearDrag () {
method getActiveGroup (line 75) | getActiveGroup () {
method resetZoomState (line 82) | resetZoomState () {
FILE: src/js/helpers/dom.js
constant BROWSER_WINDOW (line 1) | const BROWSER_WINDOW = window
constant FOCUSABLE_ELEMENTS (line 12) | const FOCUSABLE_ELEMENTS = [
FILE: src/js/parvus.js
function Parvus (line 28) | function Parvus (userOptions) {
Condensed preview — 32 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (267K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 647,
"preview": "# These are supported funding model platforms\n\ngithub: [deoostfrees]\npatreon: # Replace with a single Patreon username\no"
},
{
"path": ".gitignore",
"chars": 31,
"preview": ".DS_Store\n.vscode\nnode_modules\n"
},
{
"path": ".stylelintrc",
"chars": 963,
"preview": "{\n \"extends\": [\n \"stylelint-config-standard-scss\"\n ],\n \"plugins\": [\n \"stylelint-scss\",\n \"stylelint-use-logic"
},
{
"path": "CHANGELOG.md",
"chars": 3818,
"preview": "# Changelog\n\n## [3.1.0] - 2026-04-18\n\n### Added\n\n- Add copyright information to an image e598627 @deoostfrees\n- Add plug"
},
{
"path": "LICENSE.md",
"chars": 1095,
"preview": "# The MIT License (MIT)\n\nCopyright (c) 2020-2026 Benjamin de Oostfrees\n\nPermission is hereby granted, free of charge, to"
},
{
"path": "README.md",
"chars": 11154,
"preview": "# Parvus\n\nOverlays suck, but if you need one, consider using Parvus. Parvus is an open source, dependency free image lig"
},
{
"path": "dist/css/parvus.css",
"chars": 7791,
"preview": ":root {\n --parvus-transition-duration: 0.3s;\n --parvus-transition-timing-function: cubic-bezier(0.62, 0.16, 0.13, 1.01"
},
{
"path": "dist/js/parvus.esm.js",
"chars": 65646,
"preview": "/**\n * Parvus\n *\n * @author Benjamin de Oostfrees\n * @version 3.1.0\n * @url https://github.com/deoostfrees/parvus\n *\n * "
},
{
"path": "dist/js/parvus.js",
"chars": 69748,
"preview": "/**\n * Parvus\n *\n * @author Benjamin de Oostfrees\n * @version 3.1.0\n * @url https://github.com/deoostfrees/parvus\n *\n * "
},
{
"path": "package.json",
"chars": 2335,
"preview": "{\n \"name\": \"parvus\",\n \"type\": \"module\",\n \"version\": \"3.1.0\",\n \"description\": \"An open source, dependency free image "
},
{
"path": "rollup.config.js",
"chars": 2788,
"preview": "import resolve from '@rollup/plugin-node-resolve'\nimport commonjs from '@rollup/plugin-commonjs'\nimport terser from '@ro"
},
{
"path": "src/js/core/config.js",
"chars": 2196,
"preview": "import en from '../../l10n/en.js'\n\n/**\n * Default configuration options\n */\nexport const DEFAULT_OPTIONS = {\n selector:"
},
{
"path": "src/js/core/events.js",
"chars": 1170,
"preview": "/**\n * Event System Module\n *\n * Handles custom event dispatching and listeners\n */\n\n/**\n * Dispatch a custom event\n *\n "
},
{
"path": "src/js/core/navigation.js",
"chars": 1963,
"preview": "/**\n * Navigation Module\n *\n * Handles slide navigation and transitions\n */\n\n/**\n * Update offset\n *\n * @param {Object} "
},
{
"path": "src/js/core/plugins.js",
"chars": 3138,
"preview": "/**\n * Plugin management for Parvus\n *\n * Provides a system for registering and managing plugins\n */\n\nexport class Plugi"
},
{
"path": "src/js/core/state.js",
"chars": 1763,
"preview": "/**\n * State management for Parvus\n *\n * Centralizes all mutable state variables\n */\nexport class ParvusState {\n constr"
},
{
"path": "src/js/core/utils.js",
"chars": 1039,
"preview": "/**\n * Utils Module\n *\n * Utility functions\n */\n\n/**\n * Check prefers reduced motion\n *\n * @param {Object} state - The a"
},
{
"path": "src/js/handlers/gestures.js",
"chars": 6924,
"preview": "/**\n * Gesture Handler Module\n *\n * Handles gestures like pinch-to-zoom and swipe\n */\n\n/**\n * Reset image zoom\n *\n * @pa"
},
{
"path": "src/js/handlers/images.js",
"chars": 11031,
"preview": "/**\n * Image Handler Module\n *\n * Handles image loading, captions, and dimensions\n */\n\n/**\n * Add caption to the contain"
},
{
"path": "src/js/handlers/keyboard.js",
"chars": 1657,
"preview": "/**\n * Keyboard Event Handler Module\n *\n * Handles all keyboard interactions\n */\n\nimport { getFocusableChildren } from '"
},
{
"path": "src/js/handlers/pointer.js",
"chars": 4068,
"preview": "/**\n * Pointer Event Handler Module\n *\n * Handles all pointer interactions (mouse, touch, pen)\n */\n\n/**\n * Create pointe"
},
{
"path": "src/js/helpers/dom.js",
"chars": 715,
"preview": "const BROWSER_WINDOW = window\n\n/**\n * Get scrollbar width\n *\n * @return {Number} - The scrollbar width\n */\nexport const "
},
{
"path": "src/js/parvus.js",
"chars": 19310,
"preview": "// Helper modules\nimport { getScrollbarWidth } from './helpers/dom.js'\n\n// Core modules\nimport { mergeOptions } from './"
},
{
"path": "src/js/ui/lightbox.js",
"chars": 9849,
"preview": "/**\n * UI Components Module\n *\n * Handles creation of lightbox, toolbar, slider and slides\n */\n\n/**\n * Create the lightb"
},
{
"path": "src/js/ui/zoom-indicator.js",
"chars": 935,
"preview": "/**\n * Add zoom indicator to element\n *\n * @param {HTMLElement} el - The element to add the zoom indicator to\n * @param "
},
{
"path": "src/l10n/de.js",
"chars": 611,
"preview": "export default {\n lightboxLabel: 'Dies ist ein Dialogfenster, das den Hauptinhalt der Seite überlagert. Das Modal zeigt"
},
{
"path": "src/l10n/en.js",
"chars": 549,
"preview": "export default {\n lightboxLabel: 'This is a dialog window that overlays the main content of the page. The modal display"
},
{
"path": "src/l10n/fr.js",
"chars": 621,
"preview": "export default {\r\n lightboxLabel: 'Il s\\'agit d\\'une boîte de dialogue superposée au contenu principal de la page. La f"
},
{
"path": "src/l10n/it.js",
"chars": 598,
"preview": "export default {\n lightboxLabel: 'Questa è una finestra di dialogo che si sovrappone al contenuto principale della pagi"
},
{
"path": "src/l10n/nl.js",
"chars": 657,
"preview": "export default {\n lightboxLabel: 'Dit is een dialoogvenster dat over de hoofdinhoud van de pagina wordt geplaatst. Hier"
},
{
"path": "src/scss/parvus.scss",
"chars": 7657,
"preview": ":root {\n // Transition\n --parvus-transition-duration: 0.3s;\n --parvus-transition-timing-function: cubic-bezier(0.62, "
},
{
"path": "test/test.html",
"chars": 12620,
"preview": "<!DOCTYPE html>\r\n<html lang=\"en\">\r\n\r\n<head>\r\n <meta charset=\"UTF-8\">\r\n <meta name=\"viewport\" content=\"width=device-wid"
}
]
About this extraction
This page contains the full source code of the deoostfreese/Parvus GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 32 files (249.1 KB), approximately 64.7k tokens, and a symbol index with 51 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.