Showing preview only (294K chars total). Download the full file or copy to clipboard to get everything.
Repository: midzer/tobii
Branch: production
Commit: de587ab9be22
Files: 25
Total size: 283.4 KB
Directory structure:
gitextract_tri8bljk/
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ └── npm-publish.yml
├── .gitignore
├── .nvmrc
├── .stylelintrc
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── add-banner.js
├── demo/
│ ├── index.html
│ └── styles.css
├── dist/
│ ├── tobii.js
│ ├── tobii.modern.js
│ ├── tobii.module.js
│ └── tobii.umd.js
├── eslint.config.js
├── package.json
└── src/
├── js/
│ ├── browser.js
│ ├── index.js
│ └── types/
│ ├── html.js
│ ├── iframe.js
│ ├── image.js
│ └── youtube.js
└── scss/
├── _variables.scss
└── tobii.scss
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: midzer
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: .github/workflows/npm-publish.yml
================================================
# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
# For more information see: https://docs.github.com/en/actions/guides/publishing-nodejs-packages#publishing-packages-to-npm-and-github-packages
name: Node.js Package
on:
release:
types: [created]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v4
with:
node-version: 18
registry-url: 'https://registry.npmjs.org'
- run: npm ci
# Publish to npm
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# Setup .npmrc file to publish to GitHub Packages
- uses: actions/setup-node@v4
with:
node-version: 18
registry-url: 'https://npm.pkg.github.com'
scope: '@midzer'
# Publish to GitHub Packages
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .gitignore
================================================
.vscode
.idea
node_modules
test
================================================
FILE: .nvmrc
================================================
lts/hydrogen
================================================
FILE: .stylelintrc
================================================
{
"rules": {
"indentation": 2,
"string-quotes": "single",
"no-duplicate-selectors": true,
"color-hex-case": "lower",
"color-hex-length": "long",
"color-named": "never",
"selector-no-qualifying-type": true,
"selector-max-id": 0,
"selector-combinator-space-after": "always",
"selector-attribute-quotes": "always",
"selector-attribute-operator-space-before": "never",
"selector-attribute-operator-space-after": "never",
"selector-attribute-brackets-space-inside": "never",
"declaration-block-trailing-semicolon": "always",
"declaration-no-important": true,
"declaration-colon-space-before": "never",
"declaration-colon-space-after": "always",
"number-leading-zero": "always",
"function-url-quotes": "always",
"font-weight-notation": "ignore",
"font-family-name-quotes": "always-where-recommended",
"comment-whitespace-inside": "always",
"comment-empty-line-before": "always",
"at-rule-no-vendor-prefix": true,
"rule-empty-line-before": "always",
"selector-pseudo-element-colon-notation": "double",
"selector-pseudo-class-parentheses-space-inside": "never",
"media-feature-range-operator-space-before": "always",
"media-feature-range-operator-space-after": "always",
"media-feature-parentheses-space-inside": "never",
"media-feature-colon-space-before": "never",
"media-feature-colon-space-after": "always"
}
}
================================================
FILE: CHANGELOG.md
================================================
# Changelog
## v3.2.0
### New
- introduce ARIA live region instead of focus approach
+ configurable `announcementLabel`
+ optional data attribute `data-label` as text to be used for screen readers
- replace has() CSS selector for wider browser support
## v3.1.3
### Fixed
- replace legacy allowfullscreen with allow attribute on IFrames
## v3.1.2
### Fixed
- do not show caption initially on mobile devices
## v3.1.1
### Fixed
- remove() for single slide removal
- multiple Tobii instances race conditions
## v3.1.0
### New
- empty or false `selector` option does only init Tobii (to `add()` elements later)
### Fixed
- do not clone html-type target
## v3.0.0
### Breaking Changes
- remove legacy prefixes
- zoom icon default to false
- remove autoplay settings, Media elements like YouTube `<video>` and `<audio>`, just autoplay
- drop IE11 support
- do not compress non-IIFE builds
### Documentation
- add banner to minified IIFE build
### Chore
- cleanup unused settings
## v2.8.5
### Fixed
- zoom only for zoomable elements
## v2.8.4
### Fixed
- increase focus delay for figure due transition bug on low-end Android devices
## v2.8.3
### Fixed
- fix double click zoom and delayed tap on Android by introducing a threshold
## v2.8.2
### Fixed
- fix aria-label of figure
- tweak caption-toggle CSS
## v2.8.1
### Fixed
- Allow multiple lightbox instances
## v2.8.0
### New
- Toggle caption display on click/touch
## v2.7.3
### Fixed
- Fix unclickable top region for docClose
## v2.7.2
### Fixed
- Encapsulate counter text in P element for better accessibility compliance
- Add new dialogTitle setting to allow dialog title customization for better accessibility compliance
- Revert "fix not clickable close() region for slider"
## v2.7.1
### Fixed
- GitHub Action workflow
## v2.7.0
### New
- Accessibility improvements
- Sizes attribute to properly handle responsive images
- Programmatically set focus on figure elements on slide change
### Fixed
- srcset before src to avoid loading images twice
- scroll to top when opening lightbox
## v2.6.6
### Fixed
- Previous release regression
## v2.6.5
### Fixed
- Reset zoom in any case on cleanup from 2.6.0
## v2.6.4
### Fixed
- Next button after 2nd slide regression from 2.6.0
## v2.6.2
### Fixed
- GitHub Action workflow
## v2.6.1
### Fixed
- Single tap cycle on mobile not working in some cases
- Some elements on demo page were broken
- Allow Node 18 again
## v2.6.0
### New
- Pinch zoom feature for touch devices
- Double click and wheel zoom with mouse/touch clamped pan
- Delayed tap on mobile for prev/next navigation
### Fixed
- Not clickable close() region above slider
## v2.5.0
### New
- Apply opacity to buttons on :hover
- Change opacity on close button
- Support for audio element
- Bigger inline content in demo and
- Introduce captionHTML parameter
- Replace em function
### Fixed
- Big local video elements
- Missing close button in rare cases
- Update lightbox on remove element
- Adding/removing elements dynamically
- YouTube link in demo
## v2.4.0
### Changes
- tobii.mjs -> tobii.modern.js
### Fixed
- All CSS custom properties are now prefixed with `--tobii-` to avoid conflicts (e.g. `--tobii-base-font-size` instead of `--base-font-size: 18px`).
### Deprecated
- Unprefixed forms of CSS custom properties are deprecated and will no longer be supported in the next major release. Update now by adding the `--tobii-` prefix to your variables:
- Before: `--base-font-size: 18px;`
- After: `--tobii-base-font-size: 18px;`
================================================
FILE: LICENSE.md
================================================
# The MIT License (MIT)
Copyright (c) 2017-2020 rqrauhvmra, 2021 midzer
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
================================================
# Tobii
[](https://github.com/midzer/tobii/releases)
[](https://github.com/midzer/tobii/blob/master/LICENSE.md)


An accessible, open-source lightbox with no dependencies.
[See it in Action](https://midzer.github.io/tobii/demo/)

## Table of contents
- [Features](#features)
- [Get Tobii](#get-tobii)
- [Download](#download)
- [Package managers](#package-managers)
- [Usage](#usage)
- [Media types](#media-types)
- [Image](#image)
- [Inline HTML](#inline-html)
- [Iframe](#iframe)
- [YouTube](#youtube)
- [Grouping](#grouping)
- [Options](#options)
- [API](#api)
- [Events](#events)
- [Browser support](#browser-support)
- [Contributing](#contributing)
- [License](#license)
## Features
- No dependencies
- Supports multiple content types:
- Images
- Inline HTML
- Iframes
- Videos (YouTube, Vimeo)
- Grouping
- Events
- Customizable with settings and CSS
- Accessible:
- ARIA roles
- Keyboard navigation:
- `Prev`/ `Next` Keys: Navigate through slides
- `ESCAPE` Key: Close Tobii
- `TAB` Key: Focus elements within Tobii, not outside
- User preference media features:
- `prefers-reduced-motion` media query
- When Tobii opens, focus is set to the first focusable element in Tobii
- When Tobii closes, focus returns to what was in focus before Tobii opened
- Touch & mouse drag/swipe support:
- Horizontal to navigate through slides
- Vertical up to close Tobii
- Double click, pinch and wheel zoom:
- Hold pointer to pan
- Responsive
## Get Tobii
### Download
CSS: `dist/tobii.min.css`
JavaScript:
* `dist/tobii.min.js`: minified IIFE build
* `dist/tobii.modern.js`: Build specially designed to work in all modern browsers
* `dist/tobii.module.js`: ESM build
* `dist/tobii.umd.js`: UMD build
* `dist/tobii.js`: CommonJS/Node build
### Package managers
Tobii is also available on npm.
`npm install @midzer/tobii --save`
## Usage
You can install Tobii by linking the `.css` and `.js` files to your HTML file. The HTML code may look like this:
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Your page title</title>
<!-- CSS -->
<link href="tobii.min.css" rel="stylesheet">
</head>
<body>
<!-- Your HTML content -->
<!-- JS -->
<script src="tobii.min.js"></script>
</body>
</html>
```
Initialize the script by running:
```js
const tobii = new Tobii()
```
## Media types
### Image
The standard way of using Tobii is a linked thumbnail image with the class name `lightbox` to a larger image:
```html
<a href="path/to/image.jpg" class="lightbox">
<img src="path/to/thumbnail.jpg" alt="I am a caption">
</a>
```
Instead of a thumbnail, you can also refer to a larger image with a text link:
```html
<a href="path/to/image.jpg" class="lightbox">
Open image
</a>
```
If you use a Markdown parser or CMS and want to make all images in a post
automatically viewable in a lightbox, use the following JavaScript code to add
all images to the same album:
```javascript
document.addEventListener('DOMContentLoaded', () => {
// This assumes your article is wrapped in an element with the class "content-article".
document.querySelectorAll('.content-article img').forEach((articleImg) => {
// Add lightbox elements in blog articles for Tobii.
const lightbox = document.createElement('a');
lightbox.href = articleImg.src;
lightbox.classList.add('lightbox');
lightbox.dataset.group = 'article';
articleImg.parentNode.appendChild(lightbox);
lightbox.appendChild(articleImg);
});
});
```
### Inline-HTML
For inline HTML, create an element with a unique ID:
```html
<div id="selector">
<!-- Your HTML content -->
</div>
```
Then create a link with the class name `lightbox` and a `href` attribute that matches the ID of the element:
```html
<a href="#selector" data-type="html" class="lightbox">
Open HTML content
</a>
```
or a button with the class name `lightbox` and a `data-target` attribute that matches the ID of the element:
```html
<button type="button" data-type="html" data-target="#selector" class="lightbox">
Open HTML content
</button>
```
In both ways, the attribute `data-type` with the value `html` is required.
### Iframe
For an iframe, create a link with the class name `lightbox`:
```html
<a href="https://www.wikipedia.org" data-type="iframe" class="lightbox">
Open Wikipedia
</a>
```
or a button with the class name `lightbox` and a `data-target` attribute:
```html
<button type="button" data-type="iframe" data-target="https://www.wikipedia.org" class="lightbox">
Open Wikipedia
</button>
```
In both ways, the attribute `data-type` with the value `iframe` is required.
#### Optional attributes
- `data-height` set the height and `data-width` the width of the iframe.
### YouTube
For a YouTube video, create a link with the class name `lightbox` and a `data-id` attribute with the YouTube video ID:
```html
<a href="#" data-type="youtube" data-id="KU2sSZ_90PY" class="lightbox">
Open YouTube video
</a>
```
or a button with the class name `lightbox` and a `data-id` attribute with the YouTube video ID:
```html
<button type="button" data-type="youtube" data-id="KU2sSZ_90PY" class="lightbox">
Open YouTube video
</button>
```
In both ways, the attribute `data-type` with the value `youtube` is required.
#### Optional attributes
- `data-controls` indicates whether the video player controls are displayed: `0` do not display and `1` display controls in the player.
- `data-height` set the height and `data-width` the width of the player. I recommend using an external library for responsive iframes.
## Grouping
If you have a group of related types that you would like to combine into a set, add the `data-group` attribute:
```html
<a href="path/to/image_1.jpg" class="lightbox" data-group="vacation">
<img src="path/to/thumbnail_1.jpg" alt="I am a caption">
</a>
<a href="path/to/image_2.jpg" class="lightbox" data-group="vacation">
<img src="path/to/thumbnail_2.jpg" alt="I am a caption">
</a>
// ...
<a href="path/to/image_4.jpg" class="lightbox" data-group="birthday">
<img src="path/to/thumbnail_4.jpg" alt="I am a caption">
</a>
// ...
```
## Options
You can pass an object with custom options as an argument.
```js
const tobii = new Tobii({
captions: false
})
```
The following options are available:
| Property | Type | Default | Description |
| --- | --- | --- | --- |
| selector | string | ".lightbox" | All elements with this class trigger Tobii. Pass `""` or `false` to init Tobii only (and `add()` later) |
| captions | bool | true | Display captions, if available. |
| captionsSelector | "self", "img" | "img" | Set the element where the caption is. Set it to "self" for the `a` tag itself. |
| captionAttribute | string | "alt" | Get the caption from given attribute. |
| captionText | function | null | Custom callback which returns the caption text for the current element. The first argument of the callback is the element. If set, `captionsSelector` and `captionAttribute` are ignored. |
| captionHTML | bool | false | Allow HTML captions. |
| captionToggle | bool | true | Allows users to hide or show the caption by clicking or tapping on it. |
| captionToggleLabel | string | ["Hide caption", "Show caption"] | Labels for the caption display toggle button. |
| nav | bool, "auto" | "auto" | Display navigation buttons. "auto" hides buttons on touch-enabled devices. |
| navText | string | ["inline svg", "inline svg"] | Text or HTML for the navigation buttons. |
| navLabel | string | ["Previous", "Next"] | ARIA label for screen readers. |
| announcementLabel | string | ["Slide", "of"] | ARIA label for screen readers. |
| close | bool | true | Display close button. |
| closeText | string | "inline svg" | Text or HTML for the close button. |
| closeLabel | string | "Close" | ARIA label for screen readers. |
| dialogTitle | string | "Lightbox" | ARIA label for screen readers. |
| loadingIndicatorLabel | string | "Image loading" | ARIA label for screen readers. |
| counter | bool | true | Display current image index. |
| keyboard | bool | true | Allow keyboard navigation. |
| zoom | bool | false | Display zoom icon. |
| zoomText | string | "inline svg" | Text or HTML for the zoom icon. |
| docClose | bool | true | Click outside to close Tobii. |
| swipeClose | bool | true | Swipe up to close Tobii. |
| draggable | bool | true | Use dragging and touch swiping. |
| threshold | number | 100 | Touch and mouse dragging threshold (in px). |
### Data attributes
You can also use data attributes to customize HTML elements.
```js
<a href="path/to/image.jpg" class="lightbox" data-group="custom-group">
Open image.
</a>
```
The following options are available:
| Property | Description |
| --- | --- |
| data-type | Sets media type. Possible values: `html`,`iframe`,`youtube`. |
| data-id | Required for YouTube media type. |
| data-target | Can be used to set target for "iframe" and "html" types. |
| data-group | Set custom group |
| data-width | Set container width for iframe or YouTube types. |
| data-height | Set container height for iframe or YouTube types. |
| data-controls | Indicates whether the video player controls are displayed: 0 do not display and 1 display controls in the player. |
| data-allow | Allows to set allow attribute on iframes. |
| data-srcset | Allows to have Responsive image or retina images |
| data-zoom | Allows to enable or disable zoom icon. Values: "true" or "false" |
| data-label | Text to be used as an extra announcement for screen readers when this slide is shown. If present, Tobii’s aria‑live region will say “Slide X of Y. [data‑label]”. If data-label is missing, it will fall back to the alt attribute of the img, if available. |
## API
| Function | Description |
| --- | --- |
| `open(index)` | Open Tobii. Optional `index` (Integer), zero-based index of the slide to open. |
| `select(index)` | Select a slide with `index` (Integer), zero-based index of the slide to select. |
| `previous()` | Select the previous slide. |
| `next()` | Select the next slide. |
| `selectGroup(value)` | Select a group with `value` (string), name of the group to select. |
| `close()` | Close Tobii. |
| `add(element)` | Add `element` (DOM element). |
| `remove(element)` | Remove `element` (DOM element). |
| `isOpen()` | Check if Tobii is open. |
| `slidesIndex()` | Return the current slide index. |
| `slidesCount()` | Return the current number of slides. |
| `currentGroup()` | Return the current group name. |
| `reset()` | Reset Tobii. |
| `destroy()` | Destroy Tobii. |
## Events
Bind events with the `.on()` and `.off()` methods.
```js
const tobii = new Tobii()
const listener = function listener () {
console.log('eventName happened')
}
// bind event listener
tobii.on(eventName, listener)
// unbind event listener
tobii.off(eventName, listener)
```
| eventName | Description |
| --- | --- |
| `open` | Triggered after Tobii has been opened. |
| `close` | Triggered after Tobii has been closed. |
| `previous` | Triggered after the previous slide is selected. |
| `next` | Triggered after the next slide is selected. |
## Browser support
Tobii supports the following browser (all the latest versions):
- Chrome
- Firefox
- Edge
- Safari
## Build instructions
See [Wiki > Build instructions](https://github.com/midzer/tobii/wiki/Build-instructions)
## Contributing
- Open an issue or a pull request to suggest changes or additions
- Spread the word
## License
Tobii is available under the MIT license. See the [LICENSE](https://github.com/midzer/Tobii/blob/master/LICENSE.md) file for more info.
================================================
FILE: add-banner.js
================================================
import fs from 'fs';
import path from 'path';
import pkg from './package.json' with { type: 'json' };
const banner = `/*!
* ${pkg.name} ${pkg.version}
* Licensed under the ${pkg.license} license.
* ${pkg.homepage}
*/
`;
const filePath = path.join(process.env.PWD, 'dist/tobii.min.js');
const content = fs.readFileSync(filePath, 'utf8');
const output = banner + '\n' + content;
fs.writeFileSync(filePath, output);
console.log('Banner prepended to ' + filePath);
================================================
FILE: demo/index.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tobii LightBox Demo</title>
<meta name="description" content="Demo page for tobii - an accessible, open-source lightbox with no dependencies">
<link rel="stylesheet" href="../dist/tobii.min.css">
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<h1>Tobii LightBox Demo</h1>
<table>
<tr>
<th style="width: 200px;">
Image (single)
</th>
<td>
<a href="https://img.youtube.com/vi/HHi8qOtHnhE/maxresdefault.jpg" class="lightbox" data-group="single">
<img alt="Aquarium" title="Aquarium view" src="https://img.youtube.com/vi/HHi8qOtHnhE/mqdefault.jpg" />
</a>
</td>
</tr>
<tr>
<th>
Images (grouped)
</th>
<td>
<a href="https://img.youtube.com/vi/spxtEt6RaS4/maxresdefault.jpg" class="lightbox" data-group="gallery">
<img alt="" src="https://img.youtube.com/vi/spxtEt6RaS4/mqdefault.jpg"/>
</a>
<a href="https://img.youtube.com/vi/DGIXT7ce3vQ/maxresdefault.jpg" class="lightbox" data-group="gallery">
<img alt="" src="https://img.youtube.com/vi/DGIXT7ce3vQ/mqdefault.jpg"/>
</a>
<a href="https://img.youtube.com/vi/Q8eh58Z249o/maxresdefault.jpg?param=test" class="lightbox" data-group="gallery">
<img alt="" src="https://img.youtube.com/vi/Q8eh58Z249o/mqdefault.jpg?param=test"/>
</a>
</td>
</tr>
<tr>
<th style="width: 200px;">
Image (retina)
</th>
<td>
<a href="https://i.postimg.cc/7D1zM4PH/Wind7-Ult64-img0-Scale2304x1440.png" class="lightbox"
data-srcset="https://s20.postimg.cc/svc6skku5/Wind7_Ult64_img0_SCALE3456x2160.png 2x"
data-group="retina">
Open
</a>
</td>
</tr>
<tr>
<th>
Inline HTML
</th>
<td>
<a href="?fallback_url" class="lightbox" data-type="html" data-target="#selector" data-group="inline">
Text
</a>
<div style="display: none;">
<div id="selector" data-group="iframe">
Instrument cultivated alteration any favourable expression law far nor. Both new like tore but year.
An from mean on with when sing pain. Oh to as principles devonshire companions unsatiable an
delightful. The ourselves suffering the sincerity. Inhabit her manners adapted age certain. Debating
offended at branched <a href="#">striking be subjects</a>.
Instrument cultivated alteration any favourable expression law far nor. Both new like tore but year.
An from mean on with when sing pain. Oh to as principles devonshire companions unsatiable an
delightful. The ourselves suffering the sincerity. Inhabit her manners adapted age certain.
Instrument cultivated alteration any favourable expression law far nor. Both new like tore but year.
An from mean on with when sing pain. Oh to as principles devonshire companions unsatiable an
delightful. The ourselves suffering the sincerity. Inhabit her manners adapted age certain.
Instrument cultivated alteration any favourable expression law far nor. Both new like tore but year.
An from mean on with when sing pain. Oh to as principles devonshire companions unsatiable an
delightful. The ourselves suffering the sincerity. Inhabit her manners adapted age certain.
Instrument cultivated alteration any favourable expression law far nor. Both new like tore but year.
An from mean on with when sing pain. Oh to as principles devonshire companions unsatiable an
delightful. The ourselves suffering the sincerity. Inhabit her manners adapted age certain.
Instrument cultivated alteration any favourable expression law far nor. Both new like tore but year.
An from mean on with when sing pain. Oh to as principles devonshire companions unsatiable an
delightful. The ourselves suffering the sincerity. Inhabit her manners adapted age certain.
Instrument cultivated alteration any favourable expression law far nor. Both new like tore but year.
An from mean on with when sing pain. Oh to as principles devonshire companions unsatiable an
delightful. The ourselves suffering the sincerity. Inhabit her manners adapted age certain.
</div>
</div>
<br />
<a href="?fallback_url" class="lightbox" data-type="html" data-target="#selector_audio" data-group="inline_audio">
Audio
</a>
<div style="display: none;">
<div id="selector_audio" data-group="iframe">
<audio controls="" preload="none">
<source src="https://upload.wikimedia.org/wikipedia/commons/transcoded/b/bb/Test_ogg_mp3_48kbps.wav/Test_ogg_mp3_48kbps.wav.mp3"
type="audio/mpeg" />
</audio>
</div>
</div>
<br />
<a href="?fallback_url" class="lightbox" data-type="html" data-target="#selector_video" data-group="inline_video">
Video
</a>
<style>
.tobii__slide .tobii-group-inline_video{
/* set container to fit all space. Class above contains data group */
max-width:none;
padding: 0;
}
</style>
<div style="display: none;">
<div style="background-color:black;" id="selector_video" data-group="iframe">
<video width="1280" height="704" controls>
<source src="https://www.w3schools.com/html/mov_bbb.mp4" type="video/mp4">
</video>
</div>
</div>
</td>
</tr>
<tr>
<th>
Iframe
</th>
<td>
<a href="https://www.wikipedia.org" class="lightbox"
data-type="iframe"
data-group="iframe"
data-width="1024px"
data-height="576px">
Open
</a>
</td>
</tr>
<tr>
<th>
YouTube: (mode: iframe)
</th>
<td>
<a href="https://www.youtube.com/embed/RK1K2bCg4J8?autoplay=1" class="lightbox"
data-type="iframe"
data-group="iframe-youtube"
data-width="1120px"
data-height="630px">
<img alt="" src="https://img.youtube.com/vi/RK1K2bCg4J8/mqdefault.jpg" />
</a>
</td>
</tr>
<tr>
<th>
YouTube: (mode: API)
</th>
<td>
<a href="https://www.youtube.com/watch?v=RK1K2bCg4J8" class="lightbox"
data-type="youtube" data-id="RK1K2bCg4J8" data-group="youtube">
Open
</a>
</td>
</tr>
<tr>
<th>
Vimeo:
</th>
<td>
<a href="https://player.vimeo.com/video/15209448?autoplay=1" class="lightbox"
data-type="iframe"
data-group="iframe-vimeo"
data-width="1280px"
data-height="720px">
<img alt="" src="https://i.vimeocdn.com/video/91219950_1280x720" />
</a>
</td>
</tr>
<tr>
<th style="width: 200px;">
Events
</th>
<td>
<a href="https://img.youtube.com/vi/HHi8qOtHnhE/maxresdefault.jpg" class="lightbox" data-group="events">
Open
</a>
</td>
</tr>
<tr>
<th>
Manual call
</th>
<td>
<a href="#" id="manual">
Open
</a>
</td>
</tr>
<tr>
<th>
Image error
</th>
<td>
<a href="https://example.org/404.jpg" class="lightbox" data-group="single-error">
Open
</a>
</td>
</tr>
</table>
<!-- JS -->
<!-- use this in production: <script src="tobii.min.js"></script> -->
<script type="module">
//using modern mode as module (optional)
import Tobii from '../dist/tobii.module.js'
//prepare manual executions
let manual = document.getElementById('manual')
manual.classList.add('lightbox')
manual.href = 'https://via.placeholder.com/600.jpg'
manual.attributes.group = 'manual'
//init
const tobii = new Tobii()
//set events
tobii.on('open', function (e) {
if(e.detail.group === 'events')
console.log('event: ' + 'open', e.detail)
})
tobii.on('previous', function (e) {
if(e.detail.group === 'events')
console.log('event: ' + 'previous', e.detail)
})
tobii.on('next', function (e) {
if(e.detail.group === 'events')
console.log('event: ' + 'next', e.detail)
})
tobii.on('close', function (e) {
if(e.detail.group === 'events')
console.log('event: ' + 'close', e.detail)
})
</script>
</body>
</html>
================================================
FILE: demo/styles.css
================================================
html {
background-color: #f5f5f7;
}
h1 {
margin-top: 0;
text-decoration: underline;
text-align: center;
}
body {
max-width: 1024px;
margin: 32px auto;
padding: 1em 1em;
border-radius: 8px;
background-color: #bfbfbf;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
font-size: 16px;
line-height: 1.5;
box-shadow: #555 0 1px 3px 0, #555 0 3px 8px 3px;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
th {
text-align: left;
}
th, td {
padding: 5px 1em;
}
img {
max-width: 200px;
height: auto;
border: 1px solid #555;
box-shadow: 1px 2px 2px 0 #555;
}
================================================
FILE: dist/tobii.js
================================================
class ImageType {
constructor() {
this.figcaptionId = 0;
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const FIGURE = document.createElement('figure');
const IMAGE = document.createElement('img');
const THUMBNAIL = el.querySelector('img');
const LOADING_INDICATOR = document.createElement('div');
// Accessibility: allow setting focus programmatically on figure elements.
FIGURE.tabIndex = -1;
// Add role="group" to figure
FIGURE.setAttribute('role', 'group');
// Hide figure until the image is loaded
FIGURE.style.opacity = '0';
if (THUMBNAIL) {
IMAGE.alt = THUMBNAIL.alt || '';
}
IMAGE.setAttribute('data-src', el.href);
if (el.hasAttribute('data-srcset')) {
IMAGE.setAttribute('data-srcset', el.getAttribute('data-srcset'));
}
if (el.hasAttribute('data-sizes')) {
IMAGE.setAttribute('data-sizes', el.getAttribute('data-sizes'));
}
// Add image to figure
FIGURE.appendChild(IMAGE);
// Create figcaption
let captionContent;
if (typeof this.userSettings.captionText === 'function') {
captionContent = this.userSettings.captionText(el);
} else if (this.userSettings.captionsSelector === 'self' && el.hasAttribute(this.userSettings.captionAttribute)) {
captionContent = el.getAttribute(this.userSettings.captionAttribute);
} else if (this.userSettings.captionsSelector === 'img' && THUMBNAIL && THUMBNAIL.hasAttribute(this.userSettings.captionAttribute)) {
captionContent = THUMBNAIL.getAttribute(this.userSettings.captionAttribute);
}
if (this.userSettings.captions && captionContent) {
const FIGCAPTION = document.createElement('figcaption');
FIGCAPTION.id = `tobii-figcaption-${this.figcaptionId}`;
const SPAN = document.createElement('span');
if (this.userSettings.captionHTML) {
SPAN.innerHTML = captionContent;
} else {
SPAN.textContent = captionContent;
}
FIGCAPTION.appendChild(SPAN);
if (this.userSettings.captionToggle) {
const isMobile = window.innerWidth < 768;
const BUTTON = document.createElement('button');
BUTTON.className = 'caption-toggle';
BUTTON.textContent = BUTTON.title = this.userSettings.captionToggleLabel[isMobile ? 1 : 0];
BUTTON.setAttribute('aria-controls', FIGCAPTION.id);
BUTTON.setAttribute('aria-expanded', !isMobile);
if (isMobile) {
FIGCAPTION.classList.add('caption-hidden');
}
SPAN.setAttribute('aria-hidden', isMobile);
const preventAndStopEvent = event => {
event.preventDefault();
event.stopPropagation();
};
BUTTON.addEventListener('pointerdown', event => preventAndStopEvent(event));
BUTTON.addEventListener('pointerup', event => preventAndStopEvent(event));
BUTTON.addEventListener('click', event => {
preventAndStopEvent(event);
const isExpanded = BUTTON.getAttribute('aria-expanded') === 'true';
const buttonLabel = isExpanded ? this.userSettings.captionToggleLabel[1] : this.userSettings.captionToggleLabel[0];
BUTTON.textContent = BUTTON.title = buttonLabel;
BUTTON.setAttribute('aria-expanded', !isExpanded);
FIGCAPTION.classList.toggle('caption-hidden');
SPAN.setAttribute('aria-hidden', isExpanded);
});
FIGCAPTION.appendChild(BUTTON);
}
FIGURE.appendChild(FIGCAPTION);
IMAGE.setAttribute('aria-labelledby', FIGCAPTION.id);
// Add aria-label to the figure containing the caption content
FIGURE.setAttribute('aria-label', SPAN.textContent);
++this.figcaptionId;
}
// Add figure to container
container.appendChild(FIGURE);
// Create loading indicator
LOADING_INDICATOR.className = 'tobii__loader';
LOADING_INDICATOR.setAttribute('role', 'progressbar');
LOADING_INDICATOR.setAttribute('aria-label', this.userSettings.loadingIndicatorLabel);
// Add loading indicator to container
container.appendChild(LOADING_INDICATOR);
// Register type
container.setAttribute('data-type', 'image');
container.classList.add('tobii-image');
}
onPreload(container) {
// Same as preload
this.onLoad(container);
}
onLoad(container) {
const IMAGE = container.querySelector('img');
if (!IMAGE.hasAttribute('data-src')) {
return;
}
const FIGURE = container.querySelector('figure');
const LOADING_INDICATOR = container.querySelector('.tobii__loader');
const handleImageEvent = () => {
container.removeChild(LOADING_INDICATOR);
FIGURE.style.opacity = '1';
};
IMAGE.addEventListener('load', handleImageEvent);
IMAGE.addEventListener('error', handleImageEvent);
if (IMAGE.hasAttribute('data-srcset')) {
IMAGE.setAttribute('srcset', IMAGE.getAttribute('data-srcset'));
IMAGE.removeAttribute('data-srcset');
}
if (IMAGE.hasAttribute('data-sizes')) {
IMAGE.setAttribute('sizes', IMAGE.getAttribute('data-sizes'));
IMAGE.removeAttribute('data-sizes');
}
IMAGE.setAttribute('src', IMAGE.getAttribute('data-src'));
IMAGE.removeAttribute('data-src');
}
onLeave(container) {
// Nothing
}
onCleanup(container) {
// Nothing
}
onReset() {
this.figcaptionId = 0;
}
}
class IframeType {
constructor() {
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const HREF = el.hasAttribute('data-target') ? el.getAttribute('data-target') : el.getAttribute('href');
container.setAttribute('data-HREF', HREF);
if (el.hasAttribute('data-allow')) {
container.setAttribute('data-allow', el.getAttribute('data-allow'));
}
if (el.hasAttribute('data-width')) {
container.setAttribute('data-width', `${el.getAttribute('data-width')}`);
}
if (el.hasAttribute('data-height')) {
container.setAttribute('data-height', `${el.getAttribute('data-height')}`);
}
// dont create empty iframes here - very slow
// Register type
container.setAttribute('data-type', 'iframe');
container.classList.add('tobii-iframe');
}
onPreload(container) {
// Nothing
}
onLoad(container) {
let IFRAME = container.querySelector('iframe');
// Create loading indicator
const LOADING_INDICATOR = document.createElement('div');
LOADING_INDICATOR.className = 'tobii__loader';
LOADING_INDICATOR.setAttribute('role', 'progressbar');
LOADING_INDICATOR.setAttribute('aria-label', this.userSettings.loadingIndicatorLabel);
container.appendChild(LOADING_INDICATOR);
if (IFRAME == null) {
// create iframe
IFRAME = document.createElement('iframe');
const HREF = container.getAttribute('data-href');
IFRAME.setAttribute('frameborder', '0');
IFRAME.setAttribute('src', HREF);
// Set allow parameters
let allowValue = 'fullscreen';
if (HREF.includes('youtube.com')) {
allowValue += '; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
} else if (HREF.includes('vimeo.com')) {
allowValue += '; autoplay; picture-in-picture';
} else if (container.hasAttribute('data-allow')) {
allowValue = container.getAttribute('data-allow');
}
IFRAME.setAttribute('allow', allowValue);
if (container.hasAttribute('data-width')) {
IFRAME.style.maxWidth = `${container.getAttribute('data-width')}`;
}
if (container.hasAttribute('data-height')) {
IFRAME.style.maxHeight = `${container.getAttribute('data-height')}`;
}
// Hide until loaded
IFRAME.style.opacity = '0';
// Add iframe to container
container.appendChild(IFRAME);
// Handle load and error
const removeLoader = () => {
IFRAME.style.opacity = '1';
const LOADING_INDICATOR = container.querySelector('.tobii__loader');
if (LOADING_INDICATOR) container.removeChild(LOADING_INDICATOR);
};
IFRAME.addEventListener('load', removeLoader);
IFRAME.addEventListener('error', removeLoader);
} else {
// was already created
IFRAME.setAttribute('src', container.getAttribute('data-href'));
}
}
onLeave(container) {
// Nothing
}
onCleanup(container) {
const IFRAME = container.querySelector('iframe');
IFRAME.removeAttribute('src');
IFRAME.style.opacity = '0';
}
onReset() {
// Nothing
}
}
class HtmlType {
constructor() {
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const TARGET_SELECTOR = el.hasAttribute('data-target') ? el.getAttribute('data-target') : el.getAttribute('href');
const TARGET = document.querySelector(TARGET_SELECTOR);
if (!TARGET) {
throw new Error(`Ups, I can't find the target ${TARGET_SELECTOR}.`);
}
// Add content to container
container.appendChild(TARGET);
// Register type
container.setAttribute('data-type', 'html');
container.classList.add('tobii-html');
}
onPreload(container) {
// Nothing
}
onLoad(container, group) {
const VIDEO = container.querySelector('video');
if (VIDEO) {
if (VIDEO.hasAttribute('data-time') && VIDEO.readyState > 0) {
// Continue where video was stopped
VIDEO.currentTime = VIDEO.getAttribute('data-time');
}
// Start playback (and loading if necessary)
VIDEO.play();
}
const audio = container.querySelector('audio');
if (audio) {
// Start playback (and loading if necessary)
audio.play();
}
container.classList.add('tobii-group-' + group);
}
onLeave(container) {
const VIDEO = container.querySelector('video');
if (VIDEO) {
if (!VIDEO.paused) {
// Stop if video is playing
VIDEO.pause();
}
// Backup currentTime (needed for revisit)
if (VIDEO.readyState > 0) {
VIDEO.setAttribute('data-time', VIDEO.currentTime);
}
}
const audio = container.querySelector('audio');
if (audio) {
if (!audio.paused) {
// Stop if is playing
audio.pause();
}
}
}
onCleanup(container) {
const VIDEO = container.querySelector('video');
if (VIDEO) {
if (VIDEO.readyState > 0 && VIDEO.readyState < 3 && VIDEO.duration !== VIDEO.currentTime) {
// Some data has been loaded but not the whole package.
// In order to save bandwidth, stop downloading as soon as possible.
const VIDEO_CLONE = VIDEO.cloneNode(true);
this._removeSources(VIDEO);
VIDEO.load();
VIDEO.parentNode.removeChild(VIDEO);
container.appendChild(VIDEO_CLONE);
}
}
}
onReset() {
// Nothing
}
/**
* Remove all `src` attributes
*
* @param {HTMLElement} el - Element to remove all `src` attributes
*/
_removeSources(el) {
const SOURCES = el.querySelectorAll('src');
if (SOURCES) {
SOURCES.forEach(source => {
source.removeAttribute('src');
});
}
}
}
class YoutubeType {
constructor() {
this.playerId = 0;
this.PLAYER = [];
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const IFRAME_PLACEHOLDER = document.createElement('div');
// Add iframePlaceholder to container
container.appendChild(IFRAME_PLACEHOLDER);
this.PLAYER[this.playerId] = new window.YT.Player(IFRAME_PLACEHOLDER, {
host: 'https://www.youtube-nocookie.com',
height: el.getAttribute('data-height') || '360',
width: el.getAttribute('data-width') || '640',
videoId: el.getAttribute('data-id'),
playerVars: {
controls: el.getAttribute('data-controls') || 1,
rel: 0,
playsinline: 1
}
});
// Set player ID
container.setAttribute('data-player', this.playerId);
// Register type
container.setAttribute('data-type', 'youtube');
container.classList.add('tobii-youtube');
this.playerId++;
}
onPreload(container) {
// Nothing
}
onLoad(container) {
this.PLAYER[container.getAttribute('data-player')].playVideo();
}
onLeave(container) {
if (this.PLAYER[container.getAttribute('data-player')].getPlayerState() === 1) {
this.PLAYER[container.getAttribute('data-player')].pauseVideo();
}
}
onCleanup(container) {
if (this.PLAYER[container.getAttribute('data-player')].getPlayerState() === 1) {
this.PLAYER[container.getAttribute('data-player')].pauseVideo();
}
}
onReset() {
// Nothing
}
}
/**
* Tobii
*
* @author midzer
* @version 3.2.0
* @url https://github.com/midzer/tobii
*
* MIT License
*/
function Tobii(userOptions) {
/**
* Global variables
*
*/
const SUPPORTED_ELEMENTS = {
image: new ImageType(),
// default
html: new HtmlType(),
iframe: new IframeType(),
youtube: new YoutubeType()
};
const FOCUSABLE_ELEMENTS = ['a[href]:not([tabindex^="-"]):not([inert])', 'area[href]:not([tabindex^="-"]):not([inert])', 'input:not([disabled]):not([inert])', 'select:not([disabled]):not([inert])', 'textarea:not([disabled]):not([inert])', 'button:not([disabled]):not([inert])', 'iframe:not([tabindex^="-"]):not([inert])', 'audio:not([tabindex^="-"]):not([inert])', 'video:not([tabindex^="-"]):not([inert])', '[contenteditable]:not([tabindex^="-"]):not([inert])', '[tabindex]:not([tabindex^="-"]):not([inert])'];
let userSettings = {};
const WAITING_ELS = [];
const GROUP_ATTS = {
gallery: [],
slider: null,
sliderElements: [],
elementsLength: 0,
currentIndex: 0,
x: 0
};
let lightbox = null;
let prevButton = null;
let nextButton = null;
let closeButton = null;
let counter = null;
let lastFocus = null;
let offset = null;
let isYouTubeDependencyLoaded = false;
let groups = {};
let activeGroup = null;
let pointerDownCache = [];
let lastTapTime = 0;
let liveRegion = null;
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const DOUBLE_TAP_TIME = 500; // milliseconds
const SCALE_SENSITIVITY = 10;
const TRANSFORM = {
element: null,
originX: 0,
originY: 0,
translateX: 0,
translateY: 0,
scale: MIN_SCALE
};
const DRAG = {
startX: 0,
startY: 0,
x: 0,
y: 0,
distance: 0
};
/**
* Merge default options with user options
*
* @param {Object} userOptions - Optional user options
* @returns {Object} - Custom options
*/
const mergeOptions = userOptions => {
// Default options
const OPTIONS = {
selector: '.lightbox',
captions: true,
captionsSelector: 'img',
captionAttribute: 'alt',
captionText: null,
captionHTML: false,
captionToggle: true,
captionToggleLabel: ['Hide caption', 'Show caption'],
nav: 'auto',
navText: ['<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>', '<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>'],
navLabel: ['Previous image', 'Next image'],
announcementLabel: ['Slide', 'of'],
close: true,
closeText: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path stroke="none" d="M0 0h24v24H0z"/><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>',
closeLabel: 'Close lightbox',
dialogTitle: 'Lightbox',
loadingIndicatorLabel: 'Image loading',
counter: true,
keyboard: true,
zoom: false,
zoomText: '<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="16 4 20 4 20 8" /><line x1="14" y1="10" x2="20" y2="4" /><polyline points="8 20 4 20 4 16" /><line x1="4" y1="20" x2="10" y2="14" /><polyline points="16 20 20 20 20 16" /><line x1="14" y1="14" x2="20" y2="20" /><polyline points="8 4 4 4 4 8" /><line x1="4" y1="4" x2="10" y2="10" /></svg>',
docClose: true,
swipeClose: true,
hideScrollbar: true,
draggable: true,
threshold: 100,
theme: 'tobii--theme-default'
};
return {
...OPTIONS,
...userOptions
};
};
/**
* Init
*
*/
const init = userOptions => {
// Merge user options into defaults
userSettings = mergeOptions(userOptions);
// Create the lightbox container
lightbox = document.createElement('div');
lightbox.setAttribute('role', 'dialog');
lightbox.setAttribute('aria-hidden', 'true');
lightbox.setAttribute('aria-modal', 'true');
lightbox.setAttribute('aria-label', userSettings.dialogTitle);
lightbox.classList.add('tobii');
// Add theme class
lightbox.classList.add(userSettings.theme);
// Create the previous button
prevButton = document.createElement('button');
prevButton.className = 'tobii__btn tobii__btn--previous';
prevButton.setAttribute('type', 'button');
prevButton.setAttribute('aria-label', userSettings.navLabel[0]);
prevButton.innerHTML = userSettings.navText[0];
lightbox.appendChild(prevButton);
// Create the next button
nextButton = document.createElement('button');
nextButton.className = 'tobii__btn tobii__btn--next';
nextButton.setAttribute('type', 'button');
nextButton.setAttribute('aria-label', userSettings.navLabel[1]);
nextButton.innerHTML = userSettings.navText[1];
lightbox.appendChild(nextButton);
// Create the close button
closeButton = document.createElement('button');
closeButton.className = 'tobii__btn tobii__btn--close';
closeButton.setAttribute('type', 'button');
closeButton.setAttribute('aria-label', userSettings.closeLabel);
closeButton.innerHTML = userSettings.closeText;
lightbox.appendChild(closeButton);
// Create the counter
counter = document.createElement('div');
counter.className = 'tobii__counter';
lightbox.appendChild(counter);
// Create the live region
liveRegion = document.createElement('div');
liveRegion.className = 'tobii__sr';
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
lightbox.appendChild(liveRegion);
// Append to body
document.body.appendChild(lightbox);
// Init only
if (!userSettings.selector) return;
// Get a list of all elements within the document
const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(userSettings.selector);
if (!LIGHTBOX_TRIGGER_ELS) {
throw new Error(`Ups, I can't find the selector ${userSettings.selector} on this website.`);
}
LIGHTBOX_TRIGGER_ELS.forEach(el => checkDependencies(el));
};
/**
* Check dependencies
*
* @param {HTMLElement} el - Element to add
*/
const checkDependencies = el => {
// Check if there is a YouTube video and if the YouTube iframe-API is ready
if (document.querySelector('[data-type="youtube"]') !== null && !isYouTubeDependencyLoaded) {
if (document.getElementById('iframe_api') === null) {
const TAG = document.createElement('script');
const FIRST_SCRIPT_TAG = document.getElementsByTagName('script')[0];
TAG.id = 'iframe_api';
TAG.src = 'https://www.youtube.com/iframe_api';
FIRST_SCRIPT_TAG.parentNode.insertBefore(TAG, FIRST_SCRIPT_TAG);
}
if (WAITING_ELS.indexOf(el) === -1) {
WAITING_ELS.push(el);
}
window.onYouTubePlayerAPIReady = () => {
WAITING_ELS.forEach(waitingEl => {
add(waitingEl);
});
isYouTubeDependencyLoaded = true;
};
} else {
add(el);
}
};
/**
* Get group name from element
*
* @param {HTMLElement} el
* @return {string}
*/
const getGroupName = el => {
return el.hasAttribute('data-group') ? el.getAttribute('data-group') : 'default';
};
/**
* Copy an object. (The secure way)
*
* @param {object} object
* @return {object}
*/
const copyObject = object => {
return JSON.parse(JSON.stringify(object));
};
/**
* Add element
*
* @param {HTMLElement} el - Element to add
*/
const add = el => {
const newGroup = getGroupName(el);
if (!Object.prototype.hasOwnProperty.call(groups, newGroup)) {
groups[newGroup] = copyObject(GROUP_ATTS);
// Create slider
groups[newGroup].slider = document.createElement('div');
groups[newGroup].slider.className = 'tobii__slider';
// Hide slider
groups[newGroup].slider.setAttribute('aria-hidden', 'true');
lightbox.appendChild(groups[newGroup].slider);
}
// Check if element already exists
if (groups[newGroup].gallery.indexOf(el) === -1) {
groups[newGroup].gallery.push(el);
groups[newGroup].elementsLength++;
// Set zoom icon if necessary
if (userSettings.zoom && el.querySelector('img') && el.getAttribute('data-zoom') !== 'false' || el.getAttribute('data-zoom') === 'true') {
const TOBII_ZOOM = document.createElement('div');
TOBII_ZOOM.className = 'tobii-zoom__icon';
TOBII_ZOOM.innerHTML = userSettings.zoomText;
el.classList.add('tobii-zoom');
el.appendChild(TOBII_ZOOM);
}
// Bind click event handler
el.addEventListener('click', triggerTobii);
// Create slide
const SLIDE_ELEMENT = document.createElement('div');
const SLIDE_ELEMENT_CONTENT = document.createElement('div');
SLIDE_ELEMENT.className = 'tobii__slide';
SLIDE_ELEMENT.style.position = 'absolute';
SLIDE_ELEMENT.style.left = `${groups[newGroup].x * 100}%`;
// Hide slide
SLIDE_ELEMENT.setAttribute('aria-hidden', 'true');
// Create type elements
const model = getModel(el);
model.init(el, SLIDE_ELEMENT_CONTENT, userSettings);
// Add slide content container to slide element
SLIDE_ELEMENT.appendChild(SLIDE_ELEMENT_CONTENT);
// Add slide element to slider
groups[newGroup].slider.appendChild(SLIDE_ELEMENT);
groups[newGroup].sliderElements.push(SLIDE_ELEMENT);
++groups[newGroup].x;
if (isOpen() && newGroup === activeGroup) {
updateConfig();
updateLightbox();
}
} else {
throw new Error('Ups, element already added.');
}
};
/**
* Remove element
*
* @param {HTMLElement} el - Element to remove
*/
const remove = el => {
const GROUP_NAME = getGroupName(el);
// Check if element exists
const galleryIndex = groups[GROUP_NAME].gallery.indexOf(el);
if (galleryIndex === -1) {
throw new Error(`Ups, I can't find a slide for the element ${el}.`);
}
const SLIDE_ELEMENT = groups[GROUP_NAME].sliderElements[galleryIndex];
// If the element to be removed is the currently visible slide
if (isOpen() && GROUP_NAME === activeGroup && galleryIndex === groups[GROUP_NAME].currentIndex) {
if (groups[GROUP_NAME].elementsLength === 1) {
close();
throw new Error('Ups, I\'ve closed. There are no slides more to show.');
} else {
// Navigate away before removal
if (groups[GROUP_NAME].currentIndex === 0) {
next();
} else {
previous();
}
updateConfig();
updateLightbox();
}
}
groups[GROUP_NAME].gallery.splice(galleryIndex, 1);
groups[GROUP_NAME].sliderElements.splice(galleryIndex, 1);
groups[GROUP_NAME].elementsLength--;
--groups[GROUP_NAME].x;
// Remove zoom icon if necessary
if (userSettings.zoom && el.querySelector('.tobii-zoom__icon')) {
const ZOOM_ICON = el.querySelector('.tobii-zoom__icon');
ZOOM_ICON.parentNode.classList.remove('tobii-zoom');
ZOOM_ICON.parentNode.removeChild(ZOOM_ICON);
}
// Unbind click event handler
el.removeEventListener('click', triggerTobii);
// Remove slide
SLIDE_ELEMENT.parentNode.removeChild(SLIDE_ELEMENT);
};
const getModel = el => {
const type = el.getAttribute('data-type');
if (SUPPORTED_ELEMENTS[type] !== undefined) {
return SUPPORTED_ELEMENTS[type];
} else {
// unknown - use default
if (el.hasAttribute('data-type')) {
console.log('Unknown lightbox element type: ' + type);
}
return SUPPORTED_ELEMENTS.image;
}
};
/**
* Open Tobii
*
* @param {number} index - Index to load
*/
const open = (index = 0) => {
if (isOpen()) {
throw new Error('Ups, I\'m aleady open.');
}
if (index === -1 || index >= groups[activeGroup].elementsLength) {
throw new Error(`Ups, I can't find slide ${index}.`);
}
document.documentElement.classList.add('tobii-is-open');
document.body.classList.add('tobii-is-open');
document.body.classList.add('tobii-is-open-' + activeGroup);
updateConfig();
// Hide close if necessary
if (!userSettings.close) {
closeButton.disabled = false;
closeButton.setAttribute('aria-hidden', 'true');
}
// Save user’s focus
lastFocus = document.activeElement;
// Use `history.pushState()` to make sure the "Back" button behavior
// that aligns with the user's expectations
const stateObj = {
tobii: 'close'
};
const url = window.location.href;
window.history.pushState(stateObj, 'Image', url);
// Set current index
groups[activeGroup].currentIndex = index;
bindEvents();
// Load slide
load(groups[activeGroup].currentIndex);
// Show slider
groups[activeGroup].slider.setAttribute('aria-hidden', 'false');
// Show lightbox
lightbox.setAttribute('aria-hidden', 'false');
updateLightbox();
// Preload previous and next slide
preload(groups[activeGroup].currentIndex + 1);
preload(groups[activeGroup].currentIndex - 1);
groups[activeGroup].slider.classList.add('tobii__slider--animate');
// Create and dispatch a new event
const openEvent = new window.CustomEvent('open', {
detail: {
group: activeGroup
}
});
lightbox.dispatchEvent(openEvent);
};
/**
* Close Tobii
*
*/
const close = () => {
if (!isOpen()) {
throw new Error('Ups, I\'m already closed.');
}
document.documentElement.classList.remove('tobii-is-open');
document.body.classList.remove('tobii-is-open');
document.body.classList.remove('tobii-is-open-' + activeGroup);
unbindEvents();
// Remove entry in browser history
if (window.history.state !== null) {
if (window.history.state.tobii === 'close') {
window.history.back();
}
}
// Reenable the user’s focus
lastFocus.focus();
// Don't forget to cleanup our current element
leave(groups[activeGroup].currentIndex);
cleanup(groups[activeGroup].currentIndex);
// Hide lightbox
lightbox.setAttribute('aria-hidden', 'true');
// Hide slider
groups[activeGroup].slider.setAttribute('aria-hidden', 'true');
// Reset current index
groups[activeGroup].currentIndex = 0;
// Remove the hack to prevent animation during opening
groups[activeGroup].slider.classList.remove('tobii__slider--animate');
// Create and dispatch a new event
const closeEvent = new window.CustomEvent('close', {
detail: {
group: activeGroup
}
});
lightbox.dispatchEvent(closeEvent);
};
/**
* Preload slide
*
* @param {number} index - Index to preload
*/
const preload = index => {
if (groups[activeGroup].sliderElements[index] === undefined) {
return;
}
const CONTAINER = groups[activeGroup].sliderElements[index].querySelector('[data-type]');
const model = getModel(CONTAINER);
model.onPreload(CONTAINER);
};
/**
* Load slide
* Will be called when opening the lightbox or moving index
*
* @param {number} index - Index to load
*/
const load = index => {
if (groups[activeGroup].sliderElements[index] === undefined) {
return;
}
const CONTAINER = groups[activeGroup].sliderElements[index].querySelector('[data-type]');
const model = getModel(CONTAINER);
model.onLoad(CONTAINER, activeGroup);
};
/**
* Select a slide
*
* @param {number} index - Index to select
*/
const select = index => {
const currIndex = groups[activeGroup].currentIndex;
if (!isOpen()) {
throw new Error('Ups, I\'m closed.');
}
if (isOpen()) {
if (!index && index !== 0) {
throw new Error('Ups, no slide specified.');
}
if (index === groups[activeGroup].currentIndex) {
throw new Error(`Ups, slide ${index} is already selected.`);
}
if (index === -1 || index >= groups[activeGroup].elementsLength) {
throw new Error(`Ups, I can't find slide ${index}.`);
}
}
// Set current index
groups[activeGroup].currentIndex = index;
leave(currIndex);
load(index);
if (index < currIndex) {
updateLightbox('left');
cleanup(currIndex);
preload(index - 1);
}
if (index > currIndex) {
updateLightbox('right');
cleanup(currIndex);
preload(index + 1);
}
};
/**
* Select the previous slide
*
*/
const previous = () => {
if (!isOpen()) {
throw new Error('Ups, I\'m closed.');
}
if (groups[activeGroup].currentIndex > 0) {
leave(groups[activeGroup].currentIndex);
load(--groups[activeGroup].currentIndex);
updateLightbox('left');
cleanup(groups[activeGroup].currentIndex + 1);
preload(groups[activeGroup].currentIndex - 1);
}
// Create and dispatch a new event
const previousEvent = new window.CustomEvent('previous', {
detail: {
group: activeGroup
}
});
lightbox.dispatchEvent(previousEvent);
};
/**
* Select the next slide
*
*/
const next = () => {
if (!isOpen()) {
throw new Error('Ups, I\'m closed.');
}
if (groups[activeGroup].currentIndex < groups[activeGroup].elementsLength - 1) {
leave(groups[activeGroup].currentIndex);
load(++groups[activeGroup].currentIndex);
updateLightbox('right');
cleanup(groups[activeGroup].currentIndex - 1);
preload(groups[activeGroup].currentIndex + 1);
}
// Create and dispatch a new event
const nextEvent = new window.CustomEvent('next', {
detail: {
group: activeGroup
}
});
lightbox.dispatchEvent(nextEvent);
};
/**
* Select a group
*
* @param {string} name - Name of the group to select
*/
const selectGroup = name => {
if (isOpen()) {
throw new Error('Ups, I\'m open.');
}
if (!name) {
throw new Error('Ups, no group specified.');
}
if (name && !Object.prototype.hasOwnProperty.call(groups, name)) {
throw new Error(`Ups, I don't have a group called "${name}".`);
}
activeGroup = name;
};
/**
* Leave slide
* Will be called before moving index
*
* @param {number} index - Index to leave
*/
const leave = index => {
if (groups[activeGroup].sliderElements[index] === undefined) {
return;
}
const CONTAINER = groups[activeGroup].sliderElements[index].querySelector('[data-type]');
const model = getModel(CONTAINER);
model.onLeave(CONTAINER);
};
/**
* Cleanup slide
* Will be called after moving index
*
* @param {number} index - Index to cleanup
*/
const cleanup = index => {
if (groups[activeGroup].sliderElements[index] === undefined) {
return;
}
const CONTAINER = groups[activeGroup].sliderElements[index].querySelector('[data-type]');
const model = getModel(CONTAINER);
model.onCleanup(CONTAINER);
DRAG.startX = 0;
DRAG.startY = 0;
DRAG.x = 0;
DRAG.y = 0;
DRAG.distance = 0;
lastTapTime = 0;
if (isZoomed()) resetZoom();
TRANSFORM.element = null;
};
/**
* Update offset
*
*/
const updateOffset = () => {
offset = -groups[activeGroup].currentIndex * lightbox.offsetWidth;
groups[activeGroup].slider.style.transform = `translate(${offset}px, 0)`;
};
/**
* Update counter
*
*/
const updateCounter = () => {
counter.innerHTML = `<p>${groups[activeGroup].currentIndex + 1}/${groups[activeGroup].elementsLength}</p>`;
};
/**
* Update focus
*
* @param {string|null} dir - Current slide direction
*/
const updateFocus = dir => {
const group = groups[activeGroup];
const isNavEnabled = userSettings.nav === true || userSettings.nav === 'auto';
const hasMultipleSlides = group.elementsLength > 1;
if (isNavEnabled && !isTouchDevice() && hasMultipleSlides) {
setButtonState(prevButton, true, true);
setButtonState(nextButton, true, true);
if (group.currentIndex === 0) {
setButtonState(nextButton, false, false);
nextButton.focus();
} else if (group.currentIndex === group.elementsLength - 1) {
setButtonState(prevButton, false, false);
prevButton.focus();
} else {
setButtonState(prevButton, false, false);
setButtonState(nextButton, false, false);
if (dir === 'left') {
prevButton.focus();
} else {
nextButton.focus();
}
}
} else if (userSettings.close) {
closeButton.focus();
}
};
/**
* Resize event
*
*/
const resizeHandler = () => {
updateOffset();
};
/**
* Click event handler to trigger Tobii
*
*/
const triggerTobii = event => {
event.preventDefault();
activeGroup = getGroupName(event.currentTarget);
open(groups[activeGroup].gallery.indexOf(event.currentTarget));
};
/**
* Click event handler
*
*/
const clickHandler = event => {
if (event.target === prevButton) {
previous();
} else if (event.target === nextButton) {
next();
} else if (event.target === closeButton || event.target.classList.contains('tobii__slide') || event.target.classList.contains('tobii') && userSettings.docClose) {
close();
}
event.stopPropagation();
};
/**
* Set the hidden/disabled state of a button
*
*/
const setButtonState = (button, hidden, disabled) => {
button.setAttribute('aria-hidden', hidden ? 'true' : 'false');
button.disabled = disabled;
};
/**
* Keydown event handler
*
*/
const keydownHandler = event => {
if (event.code === 'Tab') {
const FOCUSABLE = Array.from(lightbox.querySelectorAll(FOCUSABLE_ELEMENTS.join(', ')));
if (FOCUSABLE.length === 0) return;
const FOCUSED_INDEX = FOCUSABLE.findIndex(el => el === document.activeElement);
if (event.shiftKey && FOCUSED_INDEX === 0) {
// SHIFT+Tab on first → jump to last
FOCUSABLE[FOCUSABLE.length - 1].focus();
event.preventDefault();
} else if (!event.shiftKey && FOCUSED_INDEX === FOCUSABLE.length - 1) {
// Tab on last → jump to first
FOCUSABLE[0].focus();
event.preventDefault();
}
} else if (event.code === 'Escape') {
// `ESC` Key: Close Tobii
event.preventDefault();
close();
} else if (event.code === 'ArrowLeft') {
// `PREV` Key: Show the previous slide
event.preventDefault();
previous();
} else if (event.code === 'ArrowRight') {
// `NEXT` Key: Show the next slide
event.preventDefault();
next();
}
};
/**
* Contextmenu event handler
* This is a fix for chromium based browser on mac.
* The 'contextmenu' terminates a mouse event sequence.
* https://bugs.chromium.org/p/chromium/issues/detail?id=506801
*
*/
const contextmenuHandler = () => {
pointerDownCache = [];
updateOffset();
groups[activeGroup].slider.classList.remove('tobii__slider--is-' + (isZoomed() ? 'moving' : 'dragging'));
};
/**
* Pointerdown event handler
*
*/
const pointerdownHandler = event => {
// Prevent dragging / swiping on textareas, inputs and selects
if (isIgnoreElement(event.target)) {
return;
}
event.preventDefault();
event.stopPropagation();
DRAG.startX = DRAG.x = event.clientX;
DRAG.startY = DRAG.y = event.clientY;
DRAG.distance = 0;
// This event is cached to support 2-finger gestures
pointerDownCache.push(event);
if (pointerDownCache.length === 2) {
const {
x,
y
} = midPoint(pointerDownCache[0].clientX, pointerDownCache[0].clientY, pointerDownCache[1].clientX, pointerDownCache[1].clientY);
DRAG.startX = DRAG.x = x;
DRAG.startY = DRAG.y = y;
DRAG.distance = distance(pointerDownCache[0].clientX - pointerDownCache[1].clientX, pointerDownCache[0].clientY - pointerDownCache[1].clientY) / TRANSFORM.scale;
}
};
/**
* Pointermove event handler
*
*/
const pointermoveHandler = event => {
if (!pointerDownCache.length) return;
groups[activeGroup].slider.classList.add('tobii__slider--is-' + (isZoomed() ? 'moving' : 'dragging'));
// Find this event in the cache and update its record with this event
const index = pointerDownCache.findIndex(cachedEv => cachedEv.pointerId === event.pointerId);
pointerDownCache[index] = event;
if (pointerDownCache.length === 2) {
// 2-pointer horizontal pinch/zoom gesture
const {
x,
y
} = midPoint(pointerDownCache[0].clientX, pointerDownCache[0].clientY, pointerDownCache[1].clientX, pointerDownCache[1].clientY);
const scale = distance(pointerDownCache[0].clientX - pointerDownCache[1].clientX, pointerDownCache[0].clientY - pointerDownCache[1].clientY) / DRAG.distance;
zoomPan(event.target, clamp(scale, MIN_SCALE, MAX_SCALE), x, y, x - DRAG.x, y - DRAG.y);
DRAG.x = x;
DRAG.y = y;
return;
}
if (isZoomed()) {
const deltaX = event.clientX - DRAG.x;
const deltaY = event.clientY - DRAG.y;
pan(deltaX, deltaY);
}
DRAG.x = event.clientX;
DRAG.y = event.clientY;
if (!isZoomed()) {
// Drag animation
const deltaX = DRAG.startX - DRAG.x;
const deltaY = DRAG.startY - DRAG.y;
// Skip animation if drag distance is too low
if (distance(deltaX, deltaY) < 10) return;
if (Math.abs(deltaX) > Math.abs(deltaY) && groups[activeGroup].elementsLength > 1) {
// Horizontal swipe
groups[activeGroup].slider.style.transform = `translate(${offset - Math.round(deltaX)}px, 0)`;
} else if (userSettings.swipeClose) {
// Vertical swipe
groups[activeGroup].slider.style.transform = `translate(${offset}px, -${Math.round(deltaY)}px)`;
}
}
};
/**
* Pointerup event handler
*
*/
const pointerupHandler = event => {
// Intercept regular click handler
if (!pointerDownCache.length) return;
groups[activeGroup].slider.classList.remove('tobii__slider--is-' + (isZoomed() ? 'moving' : 'dragging'));
// Remove this event from the target's cache
const index = pointerDownCache.findIndex(cachedEv => cachedEv.pointerId === event.pointerId);
pointerDownCache.splice(index, 1);
const x = event.clientX;
const y = event.clientY;
const deltaX = DRAG.startX - x;
const deltaY = DRAG.startY - y;
const distanceX = Math.abs(deltaX);
const distanceY = Math.abs(deltaY);
if (distanceX > 8 || distanceY > 8) {
if (!isZoomed()) {
// Evaluate drag
if (deltaX < 0 && distanceX > userSettings.threshold && groups[activeGroup].currentIndex > 0) {
previous();
} else if (deltaX > 0 && distanceX > userSettings.threshold && groups[activeGroup].currentIndex !== groups[activeGroup].elementsLength - 1) {
next();
} else if (deltaY > 0 && distanceY > userSettings.threshold && userSettings.swipeClose) {
close();
} else {
updateOffset();
}
}
} else {
// Evaluate tap
const now = Date.now();
const tapLength = now - lastTapTime;
if (tapLength < DOUBLE_TAP_TIME && tapLength > 100) {
// Double click
event.preventDefault();
lastTapTime = 0;
if (isZoomed()) {
resetZoom();
} else {
zoomPan(event.target, MAX_SCALE / 2, x, y, 0, 0);
}
} else {
lastTapTime = now;
if (isTouchDevice()) {
// Delayed tap on mobile
window.setTimeout(() => {
const {
left,
top,
bottom,
right,
width
} = event.target.getBoundingClientRect();
if (y < top || y > bottom || !lastTapTime) return;
if (x > left && x < left + width / 2) {
previous();
} else if (x < right && x > right - width / 2) {
next();
}
}, DOUBLE_TAP_TIME);
}
}
}
};
/**
* Wheel event handler
*
*/
const wheelHandler = event => {
const deltaScale = Math.sign(event.deltaY) > 0 ? -1 : 1;
if (!isZoomed() && !deltaScale) return;
event.preventDefault();
const newScale = TRANSFORM.scale + deltaScale / (SCALE_SENSITIVITY / TRANSFORM.scale);
zoomPan(event.target, clamp(newScale, MIN_SCALE, MAX_SCALE), event.clientX, event.clientY, 0, 0);
};
const clampedTranslate = (axis, translate) => {
// Whole clamping functionality heavily inspired
// by https://github.com/Neophen/pinch-zoom-pan
const {
element,
scale,
originX,
originY
} = TRANSFORM;
const axisIsX = axis === 'x';
const origin = axisIsX ? originX : originY;
const axisKey = axisIsX ? 'offsetWidth' : 'offsetHeight';
const containerSize = element.parentNode[axisKey];
const imageSize = element[axisKey];
const bounds = element.getBoundingClientRect();
const imageScaledSize = axisIsX ? bounds.width : bounds.height;
const defaultOrigin = imageSize / 2;
const originOffset = (origin - defaultOrigin) * (scale - 1);
const range = Math.max(0, Math.round(imageScaledSize) - containerSize);
const max = Math.round(range / 2);
const min = 0 - max;
return clamp(translate, min + originOffset, max + originOffset);
};
const clamp = (value, min, max) => Math.max(Math.min(value, max), min);
const isZoomed = () => TRANSFORM.scale !== MIN_SCALE;
const pan = (deltaX, deltaY) => {
if (deltaX !== 0) {
TRANSFORM.translateX = clampedTranslate('x', TRANSFORM.translateX + deltaX);
}
if (deltaY !== 0) {
TRANSFORM.translateY = clampedTranslate('y', TRANSFORM.translateY + deltaY);
}
const {
element,
originX,
originY,
translateX,
translateY,
scale
} = TRANSFORM;
element.style.transformOrigin = `${originX}px ${originY}px`;
element.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
};
const zoomPan = (el, newScale, x, y, deltaX, deltaY) => {
if (el.tagName !== 'IMG') return;
const {
left,
top
} = el.getBoundingClientRect();
const originX = x - left;
const originY = y - top;
const newOriginX = originX / TRANSFORM.scale;
const newOriginY = originY / TRANSFORM.scale;
TRANSFORM.element = el;
TRANSFORM.originX = newOriginX;
TRANSFORM.originY = newOriginY;
TRANSFORM.scale = newScale;
pan(deltaX, deltaY);
};
const distance = (dx, dy) => Math.hypot(dx, dy);
const midPoint = (x1, y1, x2, y2) => ({
x: (x1 + x2) / 2,
y: (y1 + y2) / 2
});
const resetZoom = () => {
TRANSFORM.scale = MIN_SCALE;
TRANSFORM.originX = 0;
TRANSFORM.originY = 0;
TRANSFORM.translateX = 0;
TRANSFORM.translateY = 0;
pan(0, 0);
};
/**
* Bind events
*
*/
const bindEvents = () => {
if (userSettings.keyboard) {
window.addEventListener('keydown', keydownHandler);
}
// Resize event
window.addEventListener('resize', resizeHandler);
// Popstate event
window.addEventListener('popstate', close);
// Click event
on('click', clickHandler);
if (userSettings.draggable) {
// Pointer events
on('pointerdown', pointerdownHandler);
on('pointermove', pointermoveHandler);
on('pointerup', pointerupHandler);
on('pointercancel', contextmenuHandler);
on('pointerout', contextmenuHandler);
on('pointerleave', contextmenuHandler);
on('contextmenu', contextmenuHandler);
}
// Wheel event
on('wheel', wheelHandler);
};
/**
* Unbind events
*
*/
const unbindEvents = () => {
if (userSettings.keyboard) {
window.removeEventListener('keydown', keydownHandler);
}
// Resize event
window.removeEventListener('resize', resizeHandler);
// Popstate event
window.removeEventListener('popstate', close);
// Click event
off('click', clickHandler);
if (userSettings.draggable) {
// Pointer events
off('pointerdown', pointerdownHandler);
off('pointermove', pointermoveHandler);
off('pointerup', pointerupHandler);
off('pointercancel', contextmenuHandler);
off('pointerout', contextmenuHandler);
off('pointerleave', contextmenuHandler);
off('contextmenu', contextmenuHandler);
}
// Wheel event
off('wheel', wheelHandler);
};
/**
* Update userSettings
*
*/
const updateConfig = () => {
const group = groups[activeGroup];
const slider = group.slider;
if (userSettings.draggable && !slider.classList.contains('tobii__slider--is-draggable')) {
slider.classList.add('tobii__slider--is-draggable');
}
const hideButtons = !userSettings.nav || group.elementsLength === 1 || userSettings.nav === 'auto' && isTouchDevice();
setButtonState(prevButton, hideButtons, hideButtons);
setButtonState(nextButton, hideButtons, hideButtons);
const hideCounter = !userSettings.counter || group.elementsLength === 1;
counter.setAttribute('aria-hidden', hideCounter ? 'true' : 'false');
};
/**
* Update live region
*
*/
const updateAnnouncement = () => {
const group = groups[activeGroup];
const currIndex = group.currentIndex;
const total = group.elementsLength;
const trigger = group.gallery[currIndex];
const [slide, of] = userSettings.announcementLabel;
let extra;
if (trigger.hasAttribute('data-label')) {
extra = trigger.getAttribute('data-label');
} else {
const img = trigger.querySelector('img');
extra = img?.alt || '';
}
const base = `${slide} ${currIndex + 1} ${of} ${total}`;
// Announce reliably
liveRegion.textContent = '';
window.setTimeout(() => {
liveRegion.textContent = extra ? `${base}. ${extra}` : base;
}, 10);
};
/**
* Update lightbox
*
* @param {string|null} dir - Current slide direction
*/
const updateLightbox = (dir = null) => {
updateOffset();
updateCounter();
updateAnnouncement();
updateFocus(dir);
};
/**
* Reset Tobii
*
*/
const reset = () => {
if (isOpen()) close();
Object.values(groups).forEach(group => group.gallery.forEach(remove));
groups = {};
activeGroup = null;
Object.values(SUPPORTED_ELEMENTS).forEach(type => type.onReset());
};
/**
* Destroy Tobii
*
*/
const destroy = () => {
reset();
lightbox.parentNode.removeChild(lightbox);
};
/**
* Check if Tobii is open
*
*/
const isOpen = () => {
return lightbox.getAttribute('aria-hidden') === 'false';
};
/**
* Detect whether device is touch capable
*
*/
const isTouchDevice = () => {
return 'ontouchstart' in window;
};
/**
* Checks whether element's tagName is part of array
*
*/
const isIgnoreElement = el => {
return ['TEXTAREA', 'OPTION', 'INPUT', 'SELECT'].indexOf(el.tagName) !== -1 || el === prevButton || el === nextButton || el === closeButton;
};
/**
* Return current index
*
*/
const slidesIndex = () => {
return groups[activeGroup].currentIndex;
};
/**
* Return elements length
*
*/
const slidesCount = () => {
return groups[activeGroup].elementsLength;
};
/**
* Return current group
*
*/
const currentGroup = () => {
return activeGroup;
};
/**
* Bind events
* @param {String} eventName
* @param {function} callback - callback to call
*
*/
const on = (eventName, callback) => {
lightbox.addEventListener(eventName, callback);
};
/**
* Unbind events
* @param {String} eventName
* @param {function} callback - callback to call
*
*/
const off = (eventName, callback) => {
lightbox.removeEventListener(eventName, callback);
};
init(userOptions);
return {
open,
previous,
next,
close,
add: checkDependencies,
remove,
reset,
destroy,
isOpen,
slidesIndex,
select,
slidesCount,
selectGroup,
currentGroup,
on,
off
};
}
module.exports = Tobii;
================================================
FILE: dist/tobii.modern.js
================================================
function _extends() {
return _extends = Object.assign ? Object.assign.bind() : function (n) {
for (var e = 1; e < arguments.length; e++) {
var t = arguments[e];
for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]);
}
return n;
}, _extends.apply(null, arguments);
}
class ImageType {
constructor() {
this.figcaptionId = 0;
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const FIGURE = document.createElement('figure');
const IMAGE = document.createElement('img');
const THUMBNAIL = el.querySelector('img');
const LOADING_INDICATOR = document.createElement('div');
// Accessibility: allow setting focus programmatically on figure elements.
FIGURE.tabIndex = -1;
// Add role="group" to figure
FIGURE.setAttribute('role', 'group');
// Hide figure until the image is loaded
FIGURE.style.opacity = '0';
if (THUMBNAIL) {
IMAGE.alt = THUMBNAIL.alt || '';
}
IMAGE.setAttribute('data-src', el.href);
if (el.hasAttribute('data-srcset')) {
IMAGE.setAttribute('data-srcset', el.getAttribute('data-srcset'));
}
if (el.hasAttribute('data-sizes')) {
IMAGE.setAttribute('data-sizes', el.getAttribute('data-sizes'));
}
// Add image to figure
FIGURE.appendChild(IMAGE);
// Create figcaption
let captionContent;
if (typeof this.userSettings.captionText === 'function') {
captionContent = this.userSettings.captionText(el);
} else if (this.userSettings.captionsSelector === 'self' && el.hasAttribute(this.userSettings.captionAttribute)) {
captionContent = el.getAttribute(this.userSettings.captionAttribute);
} else if (this.userSettings.captionsSelector === 'img' && THUMBNAIL && THUMBNAIL.hasAttribute(this.userSettings.captionAttribute)) {
captionContent = THUMBNAIL.getAttribute(this.userSettings.captionAttribute);
}
if (this.userSettings.captions && captionContent) {
const FIGCAPTION = document.createElement('figcaption');
FIGCAPTION.id = `tobii-figcaption-${this.figcaptionId}`;
const SPAN = document.createElement('span');
if (this.userSettings.captionHTML) {
SPAN.innerHTML = captionContent;
} else {
SPAN.textContent = captionContent;
}
FIGCAPTION.appendChild(SPAN);
if (this.userSettings.captionToggle) {
const isMobile = window.innerWidth < 768;
const BUTTON = document.createElement('button');
BUTTON.className = 'caption-toggle';
BUTTON.textContent = BUTTON.title = this.userSettings.captionToggleLabel[isMobile ? 1 : 0];
BUTTON.setAttribute('aria-controls', FIGCAPTION.id);
BUTTON.setAttribute('aria-expanded', !isMobile);
if (isMobile) {
FIGCAPTION.classList.add('caption-hidden');
}
SPAN.setAttribute('aria-hidden', isMobile);
const preventAndStopEvent = event => {
event.preventDefault();
event.stopPropagation();
};
BUTTON.addEventListener('pointerdown', event => preventAndStopEvent(event));
BUTTON.addEventListener('pointerup', event => preventAndStopEvent(event));
BUTTON.addEventListener('click', event => {
preventAndStopEvent(event);
const isExpanded = BUTTON.getAttribute('aria-expanded') === 'true';
const buttonLabel = isExpanded ? this.userSettings.captionToggleLabel[1] : this.userSettings.captionToggleLabel[0];
BUTTON.textContent = BUTTON.title = buttonLabel;
BUTTON.setAttribute('aria-expanded', !isExpanded);
FIGCAPTION.classList.toggle('caption-hidden');
SPAN.setAttribute('aria-hidden', isExpanded);
});
FIGCAPTION.appendChild(BUTTON);
}
FIGURE.appendChild(FIGCAPTION);
IMAGE.setAttribute('aria-labelledby', FIGCAPTION.id);
// Add aria-label to the figure containing the caption content
FIGURE.setAttribute('aria-label', SPAN.textContent);
++this.figcaptionId;
}
// Add figure to container
container.appendChild(FIGURE);
// Create loading indicator
LOADING_INDICATOR.className = 'tobii__loader';
LOADING_INDICATOR.setAttribute('role', 'progressbar');
LOADING_INDICATOR.setAttribute('aria-label', this.userSettings.loadingIndicatorLabel);
// Add loading indicator to container
container.appendChild(LOADING_INDICATOR);
// Register type
container.setAttribute('data-type', 'image');
container.classList.add('tobii-image');
}
onPreload(container) {
// Same as preload
this.onLoad(container);
}
onLoad(container) {
const IMAGE = container.querySelector('img');
if (!IMAGE.hasAttribute('data-src')) {
return;
}
const FIGURE = container.querySelector('figure');
const LOADING_INDICATOR = container.querySelector('.tobii__loader');
const handleImageEvent = () => {
container.removeChild(LOADING_INDICATOR);
FIGURE.style.opacity = '1';
};
IMAGE.addEventListener('load', handleImageEvent);
IMAGE.addEventListener('error', handleImageEvent);
if (IMAGE.hasAttribute('data-srcset')) {
IMAGE.setAttribute('srcset', IMAGE.getAttribute('data-srcset'));
IMAGE.removeAttribute('data-srcset');
}
if (IMAGE.hasAttribute('data-sizes')) {
IMAGE.setAttribute('sizes', IMAGE.getAttribute('data-sizes'));
IMAGE.removeAttribute('data-sizes');
}
IMAGE.setAttribute('src', IMAGE.getAttribute('data-src'));
IMAGE.removeAttribute('data-src');
}
onLeave(container) {
// Nothing
}
onCleanup(container) {
// Nothing
}
onReset() {
this.figcaptionId = 0;
}
}
class IframeType {
constructor() {
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const HREF = el.hasAttribute('data-target') ? el.getAttribute('data-target') : el.getAttribute('href');
container.setAttribute('data-HREF', HREF);
if (el.hasAttribute('data-allow')) {
container.setAttribute('data-allow', el.getAttribute('data-allow'));
}
if (el.hasAttribute('data-width')) {
container.setAttribute('data-width', `${el.getAttribute('data-width')}`);
}
if (el.hasAttribute('data-height')) {
container.setAttribute('data-height', `${el.getAttribute('data-height')}`);
}
// dont create empty iframes here - very slow
// Register type
container.setAttribute('data-type', 'iframe');
container.classList.add('tobii-iframe');
}
onPreload(container) {
// Nothing
}
onLoad(container) {
let IFRAME = container.querySelector('iframe');
// Create loading indicator
const LOADING_INDICATOR = document.createElement('div');
LOADING_INDICATOR.className = 'tobii__loader';
LOADING_INDICATOR.setAttribute('role', 'progressbar');
LOADING_INDICATOR.setAttribute('aria-label', this.userSettings.loadingIndicatorLabel);
container.appendChild(LOADING_INDICATOR);
if (IFRAME == null) {
// create iframe
IFRAME = document.createElement('iframe');
const HREF = container.getAttribute('data-href');
IFRAME.setAttribute('frameborder', '0');
IFRAME.setAttribute('src', HREF);
// Set allow parameters
let allowValue = 'fullscreen';
if (HREF.includes('youtube.com')) {
allowValue += '; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
} else if (HREF.includes('vimeo.com')) {
allowValue += '; autoplay; picture-in-picture';
} else if (container.hasAttribute('data-allow')) {
allowValue = container.getAttribute('data-allow');
}
IFRAME.setAttribute('allow', allowValue);
if (container.hasAttribute('data-width')) {
IFRAME.style.maxWidth = `${container.getAttribute('data-width')}`;
}
if (container.hasAttribute('data-height')) {
IFRAME.style.maxHeight = `${container.getAttribute('data-height')}`;
}
// Hide until loaded
IFRAME.style.opacity = '0';
// Add iframe to container
container.appendChild(IFRAME);
// Handle load and error
const removeLoader = () => {
IFRAME.style.opacity = '1';
const LOADING_INDICATOR = container.querySelector('.tobii__loader');
if (LOADING_INDICATOR) container.removeChild(LOADING_INDICATOR);
};
IFRAME.addEventListener('load', removeLoader);
IFRAME.addEventListener('error', removeLoader);
} else {
// was already created
IFRAME.setAttribute('src', container.getAttribute('data-href'));
}
}
onLeave(container) {
// Nothing
}
onCleanup(container) {
const IFRAME = container.querySelector('iframe');
IFRAME.removeAttribute('src');
IFRAME.style.opacity = '0';
}
onReset() {
// Nothing
}
}
class HtmlType {
constructor() {
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const TARGET_SELECTOR = el.hasAttribute('data-target') ? el.getAttribute('data-target') : el.getAttribute('href');
const TARGET = document.querySelector(TARGET_SELECTOR);
if (!TARGET) {
throw new Error(`Ups, I can't find the target ${TARGET_SELECTOR}.`);
}
// Add content to container
container.appendChild(TARGET);
// Register type
container.setAttribute('data-type', 'html');
container.classList.add('tobii-html');
}
onPreload(container) {
// Nothing
}
onLoad(container, group) {
const VIDEO = container.querySelector('video');
if (VIDEO) {
if (VIDEO.hasAttribute('data-time') && VIDEO.readyState > 0) {
// Continue where video was stopped
VIDEO.currentTime = VIDEO.getAttribute('data-time');
}
// Start playback (and loading if necessary)
VIDEO.play();
}
const audio = container.querySelector('audio');
if (audio) {
// Start playback (and loading if necessary)
audio.play();
}
container.classList.add('tobii-group-' + group);
}
onLeave(container) {
const VIDEO = container.querySelector('video');
if (VIDEO) {
if (!VIDEO.paused) {
// Stop if video is playing
VIDEO.pause();
}
// Backup currentTime (needed for revisit)
if (VIDEO.readyState > 0) {
VIDEO.setAttribute('data-time', VIDEO.currentTime);
}
}
const audio = container.querySelector('audio');
if (audio) {
if (!audio.paused) {
// Stop if is playing
audio.pause();
}
}
}
onCleanup(container) {
const VIDEO = container.querySelector('video');
if (VIDEO) {
if (VIDEO.readyState > 0 && VIDEO.readyState < 3 && VIDEO.duration !== VIDEO.currentTime) {
// Some data has been loaded but not the whole package.
// In order to save bandwidth, stop downloading as soon as possible.
const VIDEO_CLONE = VIDEO.cloneNode(true);
this._removeSources(VIDEO);
VIDEO.load();
VIDEO.parentNode.removeChild(VIDEO);
container.appendChild(VIDEO_CLONE);
}
}
}
onReset() {
// Nothing
}
/**
* Remove all `src` attributes
*
* @param {HTMLElement} el - Element to remove all `src` attributes
*/
_removeSources(el) {
const SOURCES = el.querySelectorAll('src');
if (SOURCES) {
SOURCES.forEach(source => {
source.removeAttribute('src');
});
}
}
}
class YoutubeType {
constructor() {
this.playerId = 0;
this.PLAYER = [];
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const IFRAME_PLACEHOLDER = document.createElement('div');
// Add iframePlaceholder to container
container.appendChild(IFRAME_PLACEHOLDER);
this.PLAYER[this.playerId] = new window.YT.Player(IFRAME_PLACEHOLDER, {
host: 'https://www.youtube-nocookie.com',
height: el.getAttribute('data-height') || '360',
width: el.getAttribute('data-width') || '640',
videoId: el.getAttribute('data-id'),
playerVars: {
controls: el.getAttribute('data-controls') || 1,
rel: 0,
playsinline: 1
}
});
// Set player ID
container.setAttribute('data-player', this.playerId);
// Register type
container.setAttribute('data-type', 'youtube');
container.classList.add('tobii-youtube');
this.playerId++;
}
onPreload(container) {
// Nothing
}
onLoad(container) {
this.PLAYER[container.getAttribute('data-player')].playVideo();
}
onLeave(container) {
if (this.PLAYER[container.getAttribute('data-player')].getPlayerState() === 1) {
this.PLAYER[container.getAttribute('data-player')].pauseVideo();
}
}
onCleanup(container) {
if (this.PLAYER[container.getAttribute('data-player')].getPlayerState() === 1) {
this.PLAYER[container.getAttribute('data-player')].pauseVideo();
}
}
onReset() {
// Nothing
}
}
function Tobii(userOptions) {
/**
* Global variables
*
*/
const SUPPORTED_ELEMENTS = {
image: new ImageType(),
// default
html: new HtmlType(),
iframe: new IframeType(),
youtube: new YoutubeType()
};
const FOCUSABLE_ELEMENTS = ['a[href]:not([tabindex^="-"]):not([inert])', 'area[href]:not([tabindex^="-"]):not([inert])', 'input:not([disabled]):not([inert])', 'select:not([disabled]):not([inert])', 'textarea:not([disabled]):not([inert])', 'button:not([disabled]):not([inert])', 'iframe:not([tabindex^="-"]):not([inert])', 'audio:not([tabindex^="-"]):not([inert])', 'video:not([tabindex^="-"]):not([inert])', '[contenteditable]:not([tabindex^="-"]):not([inert])', '[tabindex]:not([tabindex^="-"]):not([inert])'];
let userSettings = {};
const WAITING_ELS = [];
const GROUP_ATTS = {
gallery: [],
slider: null,
sliderElements: [],
elementsLength: 0,
currentIndex: 0,
x: 0
};
let lightbox = null;
let prevButton = null;
let nextButton = null;
let closeButton = null;
let counter = null;
let lastFocus = null;
let offset = null;
let isYouTubeDependencyLoaded = false;
let groups = {};
let activeGroup = null;
let pointerDownCache = [];
let lastTapTime = 0;
let liveRegion = null;
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const DOUBLE_TAP_TIME = 500; // milliseconds
const SCALE_SENSITIVITY = 10;
const TRANSFORM = {
element: null,
originX: 0,
originY: 0,
translateX: 0,
translateY: 0,
scale: MIN_SCALE
};
const DRAG = {
startX: 0,
startY: 0,
x: 0,
y: 0,
distance: 0
};
/**
* Merge default options with user options
*
* @param {Object} userOptions - Optional user options
* @returns {Object} - Custom options
*/
const mergeOptions = userOptions => {
// Default options
const OPTIONS = {
selector: '.lightbox',
captions: true,
captionsSelector: 'img',
captionAttribute: 'alt',
captionText: null,
captionHTML: false,
captionToggle: true,
captionToggleLabel: ['Hide caption', 'Show caption'],
nav: 'auto',
navText: ['<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>', '<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>'],
navLabel: ['Previous image', 'Next image'],
announcementLabel: ['Slide', 'of'],
close: true,
closeText: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path stroke="none" d="M0 0h24v24H0z"/><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>',
closeLabel: 'Close lightbox',
dialogTitle: 'Lightbox',
loadingIndicatorLabel: 'Image loading',
counter: true,
keyboard: true,
zoom: false,
zoomText: '<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="16 4 20 4 20 8" /><line x1="14" y1="10" x2="20" y2="4" /><polyline points="8 20 4 20 4 16" /><line x1="4" y1="20" x2="10" y2="14" /><polyline points="16 20 20 20 20 16" /><line x1="14" y1="14" x2="20" y2="20" /><polyline points="8 4 4 4 4 8" /><line x1="4" y1="4" x2="10" y2="10" /></svg>',
docClose: true,
swipeClose: true,
hideScrollbar: true,
draggable: true,
threshold: 100,
theme: 'tobii--theme-default'
};
return _extends({}, OPTIONS, userOptions);
};
/**
* Init
*
*/
const init = userOptions => {
// Merge user options into defaults
userSettings = mergeOptions(userOptions);
// Create the lightbox container
lightbox = document.createElement('div');
lightbox.setAttribute('role', 'dialog');
lightbox.setAttribute('aria-hidden', 'true');
lightbox.setAttribute('aria-modal', 'true');
lightbox.setAttribute('aria-label', userSettings.dialogTitle);
lightbox.classList.add('tobii');
// Add theme class
lightbox.classList.add(userSettings.theme);
// Create the previous button
prevButton = document.createElement('button');
prevButton.className = 'tobii__btn tobii__btn--previous';
prevButton.setAttribute('type', 'button');
prevButton.setAttribute('aria-label', userSettings.navLabel[0]);
prevButton.innerHTML = userSettings.navText[0];
lightbox.appendChild(prevButton);
// Create the next button
nextButton = document.createElement('button');
nextButton.className = 'tobii__btn tobii__btn--next';
nextButton.setAttribute('type', 'button');
nextButton.setAttribute('aria-label', userSettings.navLabel[1]);
nextButton.innerHTML = userSettings.navText[1];
lightbox.appendChild(nextButton);
// Create the close button
closeButton = document.createElement('button');
closeButton.className = 'tobii__btn tobii__btn--close';
closeButton.setAttribute('type', 'button');
closeButton.setAttribute('aria-label', userSettings.closeLabel);
closeButton.innerHTML = userSettings.closeText;
lightbox.appendChild(closeButton);
// Create the counter
counter = document.createElement('div');
counter.className = 'tobii__counter';
lightbox.appendChild(counter);
// Create the live region
liveRegion = document.createElement('div');
liveRegion.className = 'tobii__sr';
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
lightbox.appendChild(liveRegion);
// Append to body
document.body.appendChild(lightbox);
// Init only
if (!userSettings.selector) return;
// Get a list of all elements within the document
const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(userSettings.selector);
if (!LIGHTBOX_TRIGGER_ELS) {
throw new Error(`Ups, I can't find the selector ${userSettings.selector} on this website.`);
}
LIGHTBOX_TRIGGER_ELS.forEach(el => checkDependencies(el));
};
/**
* Check dependencies
*
* @param {HTMLElement} el - Element to add
*/
const checkDependencies = el => {
// Check if there is a YouTube video and if the YouTube iframe-API is ready
if (document.querySelector('[data-type="youtube"]') !== null && !isYouTubeDependencyLoaded) {
if (document.getElementById('iframe_api') === null) {
const TAG = document.createElement('script');
const FIRST_SCRIPT_TAG = document.getElementsByTagName('script')[0];
TAG.id = 'iframe_api';
TAG.src = 'https://www.youtube.com/iframe_api';
FIRST_SCRIPT_TAG.parentNode.insertBefore(TAG, FIRST_SCRIPT_TAG);
}
if (WAITING_ELS.indexOf(el) === -1) {
WAITING_ELS.push(el);
}
window.onYouTubePlayerAPIReady = () => {
WAITING_ELS.forEach(waitingEl => {
add(waitingEl);
});
isYouTubeDependencyLoaded = true;
};
} else {
add(el);
}
};
/**
* Get group name from element
*
* @param {HTMLElement} el
* @return {string}
*/
const getGroupName = el => {
return el.hasAttribute('data-group') ? el.getAttribute('data-group') : 'default';
};
/**
* Copy an object. (The secure way)
*
* @param {object} object
* @return {object}
*/
const copyObject = object => {
return JSON.parse(JSON.stringify(object));
};
/**
* Add element
*
* @param {HTMLElement} el - Element to add
*/
const add = el => {
const newGroup = getGroupName(el);
if (!Object.prototype.hasOwnProperty.call(groups, newGroup)) {
groups[newGroup] = copyObject(GROUP_ATTS);
// Create slider
groups[newGroup].slider = document.createElement('div');
groups[newGroup].slider.className = 'tobii__slider';
// Hide slider
groups[newGroup].slider.setAttribute('aria-hidden', 'true');
lightbox.appendChild(groups[newGroup].slider);
}
// Check if element already exists
if (groups[newGroup].gallery.indexOf(el) === -1) {
groups[newGroup].gallery.push(el);
groups[newGroup].elementsLength++;
// Set zoom icon if necessary
if (userSettings.zoom && el.querySelector('img') && el.getAttribute('data-zoom') !== 'false' || el.getAttribute('data-zoom') === 'true') {
const TOBII_ZOOM = document.createElement('div');
TOBII_ZOOM.className = 'tobii-zoom__icon';
TOBII_ZOOM.innerHTML = userSettings.zoomText;
el.classList.add('tobii-zoom');
el.appendChild(TOBII_ZOOM);
}
// Bind click event handler
el.addEventListener('click', triggerTobii);
// Create slide
const SLIDE_ELEMENT = document.createElement('div');
const SLIDE_ELEMENT_CONTENT = document.createElement('div');
SLIDE_ELEMENT.className = 'tobii__slide';
SLIDE_ELEMENT.style.position = 'absolute';
SLIDE_ELEMENT.style.left = `${groups[newGroup].x * 100}%`;
// Hide slide
SLIDE_ELEMENT.setAttribute('aria-hidden', 'true');
// Create type elements
const model = getModel(el);
model.init(el, SLIDE_ELEMENT_CONTENT, userSettings);
// Add slide content container to slide element
SLIDE_ELEMENT.appendChild(SLIDE_ELEMENT_CONTENT);
// Add slide element to slider
groups[newGroup].slider.appendChild(SLIDE_ELEMENT);
groups[newGroup].sliderElements.push(SLIDE_ELEMENT);
++groups[newGroup].x;
if (isOpen() && newGroup === activeGroup) {
updateConfig();
updateLightbox();
}
} else {
throw new Error('Ups, element already added.');
}
};
/**
* Remove element
*
* @param {HTMLElement} el - Element to remove
*/
const remove = el => {
const GROUP_NAME = getGroupName(el);
// Check if element exists
const galleryIndex = groups[GROUP_NAME].gallery.indexOf(el);
if (galleryIndex === -1) {
throw new Error(`Ups, I can't find a slide for the element ${el}.`);
}
const SLIDE_ELEMENT = groups[GROUP_NAME].sliderElements[galleryIndex];
// If the element to be removed is the currently visible slide
if (isOpen() && GROUP_NAME === activeGroup && galleryIndex === groups[GROUP_NAME].currentIndex) {
if (groups[GROUP_NAME].elementsLength === 1) {
close();
throw new Error('Ups, I\'ve closed. There are no slides more to show.');
} else {
// Navigate away before removal
if (groups[GROUP_NAME].currentIndex === 0) {
next();
} else {
previous();
}
updateConfig();
updateLightbox();
}
}
groups[GROUP_NAME].gallery.splice(galleryIndex, 1);
groups[GROUP_NAME].sliderElements.splice(galleryIndex, 1);
groups[GROUP_NAME].elementsLength--;
--groups[GROUP_NAME].x;
// Remove zoom icon if necessary
if (userSettings.zoom && el.querySelector('.tobii-zoom__icon')) {
const ZOOM_ICON = el.querySelector('.tobii-zoom__icon');
ZOOM_ICON.parentNode.classList.remove('tobii-zoom');
ZOOM_ICON.parentNode.removeChild(ZOOM_ICON);
}
// Unbind click event handler
el.removeEventListener('click', triggerTobii);
// Remove slide
SLIDE_ELEMENT.parentNode.removeChild(SLIDE_ELEMENT);
};
const getModel = el => {
const type = el.getAttribute('data-type');
if (SUPPORTED_ELEMENTS[type] !== undefined) {
return SUPPORTED_ELEMENTS[type];
} else {
// unknown - use default
if (el.hasAttribute('data-type')) {
console.log('Unknown lightbox element type: ' + type);
}
return SUPPORTED_ELEMENTS.image;
}
};
/**
* Open Tobii
*
* @param {number} index - Index to load
*/
const open = (index = 0) => {
if (isOpen()) {
throw new Error('Ups, I\'m aleady open.');
}
if (index === -1 || index >= groups[activeGroup].elementsLength) {
throw new Error(`Ups, I can't find slide ${index}.`);
}
document.documentElement.classList.add('tobii-is-open');
document.body.classList.add('tobii-is-open');
document.body.classList.add('tobii-is-open-' + activeGroup);
updateConfig();
// Hide close if necessary
if (!userSettings.close) {
closeButton.disabled = false;
closeButton.setAttribute('aria-hidden', 'true');
}
// Save user’s focus
lastFocus = document.activeElement;
// Use `history.pushState()` to make sure the "Back" button behavior
// that aligns with the user's expectations
const stateObj = {
tobii: 'close'
};
const url = window.location.href;
window.history.pushState(stateObj, 'Image', url);
// Set current index
groups[activeGroup].currentIndex = index;
bindEvents();
// Load slide
load(groups[activeGroup].currentIndex);
// Show slider
groups[activeGroup].slider.setAttribute('aria-hidden', 'false');
// Show lightbox
lightbox.setAttribute('aria-hidden', 'false');
updateLightbox();
// Preload previous and next slide
preload(groups[activeGroup].currentIndex + 1);
preload(groups[activeGroup].currentIndex - 1);
groups[activeGroup].slider.classList.add('tobii__slider--animate');
// Create and dispatch a new event
const openEvent = new window.CustomEvent('open', {
detail: {
group: activeGroup
}
});
lightbox.dispatchEvent(openEvent);
};
/**
* Close Tobii
*
*/
const close = () => {
if (!isOpen()) {
throw new Error('Ups, I\'m already closed.');
}
document.documentElement.classList.remove('tobii-is-open');
document.body.classList.remove('tobii-is-open');
document.body.classList.remove('tobii-is-open-' + activeGroup);
unbindEvents();
// Remove entry in browser history
if (window.history.state !== null) {
if (window.history.state.tobii === 'close') {
window.history.back();
}
}
// Reenable the user’s focus
lastFocus.focus();
// Don't forget to cleanup our current element
leave(groups[activeGroup].currentIndex);
cleanup(groups[activeGroup].currentIndex);
// Hide lightbox
lightbox.setAttribute('aria-hidden', 'true');
// Hide slider
groups[activeGroup].slider.setAttribute('aria-hidden', 'true');
// Reset current index
groups[activeGroup].currentIndex = 0;
// Remove the hack to prevent animation during opening
groups[activeGroup].slider.classList.remove('tobii__slider--animate');
// Create and dispatch a new event
const closeEvent = new window.CustomEvent('close', {
detail: {
group: activeGroup
}
});
lightbox.dispatchEvent(closeEvent);
};
/**
* Preload slide
*
* @param {number} index - Index to preload
*/
const preload = index => {
if (groups[activeGroup].sliderElements[index] === undefined) {
return;
}
const CONTAINER = groups[activeGroup].sliderElements[index].querySelector('[data-type]');
const model = getModel(CONTAINER);
model.onPreload(CONTAINER);
};
/**
* Load slide
* Will be called when opening the lightbox or moving index
*
* @param {number} index - Index to load
*/
const load = index => {
if (groups[activeGroup].sliderElements[index] === undefined) {
return;
}
const CONTAINER = groups[activeGroup].sliderElements[index].querySelector('[data-type]');
const model = getModel(CONTAINER);
model.onLoad(CONTAINER, activeGroup);
};
/**
* Select a slide
*
* @param {number} index - Index to select
*/
const select = index => {
const currIndex = groups[activeGroup].currentIndex;
if (!isOpen()) {
throw new Error('Ups, I\'m closed.');
}
if (isOpen()) {
if (!index && index !== 0) {
throw new Error('Ups, no slide specified.');
}
if (index === groups[activeGroup].currentIndex) {
throw new Error(`Ups, slide ${index} is already selected.`);
}
if (index === -1 || index >= groups[activeGroup].elementsLength) {
throw new Error(`Ups, I can't find slide ${index}.`);
}
}
// Set current index
groups[activeGroup].currentIndex = index;
leave(currIndex);
load(index);
if (index < currIndex) {
updateLightbox('left');
cleanup(currIndex);
preload(index - 1);
}
if (index > currIndex) {
updateLightbox('right');
cleanup(currIndex);
preload(index + 1);
}
};
/**
* Select the previous slide
*
*/
const previous = () => {
if (!isOpen()) {
throw new Error('Ups, I\'m closed.');
}
if (groups[activeGroup].currentIndex > 0) {
leave(groups[activeGroup].currentIndex);
load(--groups[activeGroup].currentIndex);
updateLightbox('left');
cleanup(groups[activeGroup].currentIndex + 1);
preload(groups[activeGroup].currentIndex - 1);
}
// Create and dispatch a new event
const previousEvent = new window.CustomEvent('previous', {
detail: {
group: activeGroup
}
});
lightbox.dispatchEvent(previousEvent);
};
/**
* Select the next slide
*
*/
const next = () => {
if (!isOpen()) {
throw new Error('Ups, I\'m closed.');
}
if (groups[activeGroup].currentIndex < groups[activeGroup].elementsLength - 1) {
leave(groups[activeGroup].currentIndex);
load(++groups[activeGroup].currentIndex);
updateLightbox('right');
cleanup(groups[activeGroup].currentIndex - 1);
preload(groups[activeGroup].currentIndex + 1);
}
// Create and dispatch a new event
const nextEvent = new window.CustomEvent('next', {
detail: {
group: activeGroup
}
});
lightbox.dispatchEvent(nextEvent);
};
/**
* Select a group
*
* @param {string} name - Name of the group to select
*/
const selectGroup = name => {
if (isOpen()) {
throw new Error('Ups, I\'m open.');
}
if (!name) {
throw new Error('Ups, no group specified.');
}
if (name && !Object.prototype.hasOwnProperty.call(groups, name)) {
throw new Error(`Ups, I don't have a group called "${name}".`);
}
activeGroup = name;
};
/**
* Leave slide
* Will be called before moving index
*
* @param {number} index - Index to leave
*/
const leave = index => {
if (groups[activeGroup].sliderElements[index] === undefined) {
return;
}
const CONTAINER = groups[activeGroup].sliderElements[index].querySelector('[data-type]');
const model = getModel(CONTAINER);
model.onLeave(CONTAINER);
};
/**
* Cleanup slide
* Will be called after moving index
*
* @param {number} index - Index to cleanup
*/
const cleanup = index => {
if (groups[activeGroup].sliderElements[index] === undefined) {
return;
}
const CONTAINER = groups[activeGroup].sliderElements[index].querySelector('[data-type]');
const model = getModel(CONTAINER);
model.onCleanup(CONTAINER);
DRAG.startX = 0;
DRAG.startY = 0;
DRAG.x = 0;
DRAG.y = 0;
DRAG.distance = 0;
lastTapTime = 0;
if (isZoomed()) resetZoom();
TRANSFORM.element = null;
};
/**
* Update offset
*
*/
const updateOffset = () => {
offset = -groups[activeGroup].currentIndex * lightbox.offsetWidth;
groups[activeGroup].slider.style.transform = `translate(${offset}px, 0)`;
};
/**
* Update counter
*
*/
const updateCounter = () => {
counter.innerHTML = `<p>${groups[activeGroup].currentIndex + 1}/${groups[activeGroup].elementsLength}</p>`;
};
/**
* Update focus
*
* @param {string|null} dir - Current slide direction
*/
const updateFocus = dir => {
const group = groups[activeGroup];
const isNavEnabled = userSettings.nav === true || userSettings.nav === 'auto';
const hasMultipleSlides = group.elementsLength > 1;
if (isNavEnabled && !isTouchDevice() && hasMultipleSlides) {
setButtonState(prevButton, true, true);
setButtonState(nextButton, true, true);
if (group.currentIndex === 0) {
setButtonState(nextButton, false, false);
nextButton.focus();
} else if (group.currentIndex === group.elementsLength - 1) {
setButtonState(prevButton, false, false);
prevButton.focus();
} else {
setButtonState(prevButton, false, false);
setButtonState(nextButton, false, false);
if (dir === 'left') {
prevButton.focus();
} else {
nextButton.focus();
}
}
} else if (userSettings.close) {
closeButton.focus();
}
};
/**
* Resize event
*
*/
const resizeHandler = () => {
updateOffset();
};
/**
* Click event handler to trigger Tobii
*
*/
const triggerTobii = event => {
event.preventDefault();
activeGroup = getGroupName(event.currentTarget);
open(groups[activeGroup].gallery.indexOf(event.currentTarget));
};
/**
* Click event handler
*
*/
const clickHandler = event => {
if (event.target === prevButton) {
previous();
} else if (event.target === nextButton) {
next();
} else if (event.target === closeButton || event.target.classList.contains('tobii__slide') || event.target.classList.contains('tobii') && userSettings.docClose) {
close();
}
event.stopPropagation();
};
/**
* Set the hidden/disabled state of a button
*
*/
const setButtonState = (button, hidden, disabled) => {
button.setAttribute('aria-hidden', hidden ? 'true' : 'false');
button.disabled = disabled;
};
/**
* Keydown event handler
*
*/
const keydownHandler = event => {
if (event.code === 'Tab') {
const FOCUSABLE = Array.from(lightbox.querySelectorAll(FOCUSABLE_ELEMENTS.join(', ')));
if (FOCUSABLE.length === 0) return;
const FOCUSED_INDEX = FOCUSABLE.findIndex(el => el === document.activeElement);
if (event.shiftKey && FOCUSED_INDEX === 0) {
// SHIFT+Tab on first → jump to last
FOCUSABLE[FOCUSABLE.length - 1].focus();
event.preventDefault();
} else if (!event.shiftKey && FOCUSED_INDEX === FOCUSABLE.length - 1) {
// Tab on last → jump to first
FOCUSABLE[0].focus();
event.preventDefault();
}
} else if (event.code === 'Escape') {
// `ESC` Key: Close Tobii
event.preventDefault();
close();
} else if (event.code === 'ArrowLeft') {
// `PREV` Key: Show the previous slide
event.preventDefault();
previous();
} else if (event.code === 'ArrowRight') {
// `NEXT` Key: Show the next slide
event.preventDefault();
next();
}
};
/**
* Contextmenu event handler
* This is a fix for chromium based browser on mac.
* The 'contextmenu' terminates a mouse event sequence.
* https://bugs.chromium.org/p/chromium/issues/detail?id=506801
*
*/
const contextmenuHandler = () => {
pointerDownCache = [];
updateOffset();
groups[activeGroup].slider.classList.remove('tobii__slider--is-' + (isZoomed() ? 'moving' : 'dragging'));
};
/**
* Pointerdown event handler
*
*/
const pointerdownHandler = event => {
// Prevent dragging / swiping on textareas, inputs and selects
if (isIgnoreElement(event.target)) {
return;
}
event.preventDefault();
event.stopPropagation();
DRAG.startX = DRAG.x = event.clientX;
DRAG.startY = DRAG.y = event.clientY;
DRAG.distance = 0;
// This event is cached to support 2-finger gestures
pointerDownCache.push(event);
if (pointerDownCache.length === 2) {
const {
x,
y
} = midPoint(pointerDownCache[0].clientX, pointerDownCache[0].clientY, pointerDownCache[1].clientX, pointerDownCache[1].clientY);
DRAG.startX = DRAG.x = x;
DRAG.startY = DRAG.y = y;
DRAG.distance = distance(pointerDownCache[0].clientX - pointerDownCache[1].clientX, pointerDownCache[0].clientY - pointerDownCache[1].clientY) / TRANSFORM.scale;
}
};
/**
* Pointermove event handler
*
*/
const pointermoveHandler = event => {
if (!pointerDownCache.length) return;
groups[activeGroup].slider.classList.add('tobii__slider--is-' + (isZoomed() ? 'moving' : 'dragging'));
// Find this event in the cache and update its record with this event
const index = pointerDownCache.findIndex(cachedEv => cachedEv.pointerId === event.pointerId);
pointerDownCache[index] = event;
if (pointerDownCache.length === 2) {
// 2-pointer horizontal pinch/zoom gesture
const {
x,
y
} = midPoint(pointerDownCache[0].clientX, pointerDownCache[0].clientY, pointerDownCache[1].clientX, pointerDownCache[1].clientY);
const scale = distance(pointerDownCache[0].clientX - pointerDownCache[1].clientX, pointerDownCache[0].clientY - pointerDownCache[1].clientY) / DRAG.distance;
zoomPan(event.target, clamp(scale, MIN_SCALE, MAX_SCALE), x, y, x - DRAG.x, y - DRAG.y);
DRAG.x = x;
DRAG.y = y;
return;
}
if (isZoomed()) {
const deltaX = event.clientX - DRAG.x;
const deltaY = event.clientY - DRAG.y;
pan(deltaX, deltaY);
}
DRAG.x = event.clientX;
DRAG.y = event.clientY;
if (!isZoomed()) {
// Drag animation
const deltaX = DRAG.startX - DRAG.x;
const deltaY = DRAG.startY - DRAG.y;
// Skip animation if drag distance is too low
if (distance(deltaX, deltaY) < 10) return;
if (Math.abs(deltaX) > Math.abs(deltaY) && groups[activeGroup].elementsLength > 1) {
// Horizontal swipe
groups[activeGroup].slider.style.transform = `translate(${offset - Math.round(deltaX)}px, 0)`;
} else if (userSettings.swipeClose) {
// Vertical swipe
groups[activeGroup].slider.style.transform = `translate(${offset}px, -${Math.round(deltaY)}px)`;
}
}
};
/**
* Pointerup event handler
*
*/
const pointerupHandler = event => {
// Intercept regular click handler
if (!pointerDownCache.length) return;
groups[activeGroup].slider.classList.remove('tobii__slider--is-' + (isZoomed() ? 'moving' : 'dragging'));
// Remove this event from the target's cache
const index = pointerDownCache.findIndex(cachedEv => cachedEv.pointerId === event.pointerId);
pointerDownCache.splice(index, 1);
const x = event.clientX;
const y = event.clientY;
const deltaX = DRAG.startX - x;
const deltaY = DRAG.startY - y;
const distanceX = Math.abs(deltaX);
const distanceY = Math.abs(deltaY);
if (distanceX > 8 || distanceY > 8) {
if (!isZoomed()) {
// Evaluate drag
if (deltaX < 0 && distanceX > userSettings.threshold && groups[activeGroup].currentIndex > 0) {
previous();
} else if (deltaX > 0 && distanceX > userSettings.threshold && groups[activeGroup].currentIndex !== groups[activeGroup].elementsLength - 1) {
next();
} else if (deltaY > 0 && distanceY > userSettings.threshold && userSettings.swipeClose) {
close();
} else {
updateOffset();
}
}
} else {
// Evaluate tap
const now = Date.now();
const tapLength = now - lastTapTime;
if (tapLength < DOUBLE_TAP_TIME && tapLength > 100) {
// Double click
event.preventDefault();
lastTapTime = 0;
if (isZoomed()) {
resetZoom();
} else {
zoomPan(event.target, MAX_SCALE / 2, x, y, 0, 0);
}
} else {
lastTapTime = now;
if (isTouchDevice()) {
// Delayed tap on mobile
window.setTimeout(() => {
const {
left,
top,
bottom,
right,
width
} = event.target.getBoundingClientRect();
if (y < top || y > bottom || !lastTapTime) return;
if (x > left && x < left + width / 2) {
previous();
} else if (x < right && x > right - width / 2) {
next();
}
}, DOUBLE_TAP_TIME);
}
}
}
};
/**
* Wheel event handler
*
*/
const wheelHandler = event => {
const deltaScale = Math.sign(event.deltaY) > 0 ? -1 : 1;
if (!isZoomed() && !deltaScale) return;
event.preventDefault();
const newScale = TRANSFORM.scale + deltaScale / (SCALE_SENSITIVITY / TRANSFORM.scale);
zoomPan(event.target, clamp(newScale, MIN_SCALE, MAX_SCALE), event.clientX, event.clientY, 0, 0);
};
const clampedTranslate = (axis, translate) => {
// Whole clamping functionality heavily inspired
// by https://github.com/Neophen/pinch-zoom-pan
const {
element,
scale,
originX,
originY
} = TRANSFORM;
const axisIsX = axis === 'x';
const origin = axisIsX ? originX : originY;
const axisKey = axisIsX ? 'offsetWidth' : 'offsetHeight';
const containerSize = element.parentNode[axisKey];
const imageSize = element[axisKey];
const bounds = element.getBoundingClientRect();
const imageScaledSize = axisIsX ? bounds.width : bounds.height;
const defaultOrigin = imageSize / 2;
const originOffset = (origin - defaultOrigin) * (scale - 1);
const range = Math.max(0, Math.round(imageScaledSize) - containerSize);
const max = Math.round(range / 2);
const min = 0 - max;
return clamp(translate, min + originOffset, max + originOffset);
};
const clamp = (value, min, max) => Math.max(Math.min(value, max), min);
const isZoomed = () => TRANSFORM.scale !== MIN_SCALE;
const pan = (deltaX, deltaY) => {
if (deltaX !== 0) {
TRANSFORM.translateX = clampedTranslate('x', TRANSFORM.translateX + deltaX);
}
if (deltaY !== 0) {
TRANSFORM.translateY = clampedTranslate('y', TRANSFORM.translateY + deltaY);
}
const {
element,
originX,
originY,
translateX,
translateY,
scale
} = TRANSFORM;
element.style.transformOrigin = `${originX}px ${originY}px`;
element.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
};
const zoomPan = (el, newScale, x, y, deltaX, deltaY) => {
if (el.tagName !== 'IMG') return;
const {
left,
top
} = el.getBoundingClientRect();
const originX = x - left;
const originY = y - top;
const newOriginX = originX / TRANSFORM.scale;
const newOriginY = originY / TRANSFORM.scale;
TRANSFORM.element = el;
TRANSFORM.originX = newOriginX;
TRANSFORM.originY = newOriginY;
TRANSFORM.scale = newScale;
pan(deltaX, deltaY);
};
const distance = (dx, dy) => Math.hypot(dx, dy);
const midPoint = (x1, y1, x2, y2) => ({
x: (x1 + x2) / 2,
y: (y1 + y2) / 2
});
const resetZoom = () => {
TRANSFORM.scale = MIN_SCALE;
TRANSFORM.originX = 0;
TRANSFORM.originY = 0;
TRANSFORM.translateX = 0;
TRANSFORM.translateY = 0;
pan(0, 0);
};
/**
* Bind events
*
*/
const bindEvents = () => {
if (userSettings.keyboard) {
window.addEventListener('keydown', keydownHandler);
}
// Resize event
window.addEventListener('resize', resizeHandler);
// Popstate event
window.addEventListener('popstate', close);
// Click event
on('click', clickHandler);
if (userSettings.draggable) {
// Pointer events
on('pointerdown', pointerdownHandler);
on('pointermove', pointermoveHandler);
on('pointerup', pointerupHandler);
on('pointercancel', contextmenuHandler);
on('pointerout', contextmenuHandler);
on('pointerleave', contextmenuHandler);
on('contextmenu', contextmenuHandler);
}
// Wheel event
on('wheel', wheelHandler);
};
/**
* Unbind events
*
*/
const unbindEvents = () => {
if (userSettings.keyboard) {
window.removeEventListener('keydown', keydownHandler);
}
// Resize event
window.removeEventListener('resize', resizeHandler);
// Popstate event
window.removeEventListener('popstate', close);
// Click event
off('click', clickHandler);
if (userSettings.draggable) {
// Pointer events
off('pointerdown', pointerdownHandler);
off('pointermove', pointermoveHandler);
off('pointerup', pointerupHandler);
off('pointercancel', contextmenuHandler);
off('pointerout', contextmenuHandler);
off('pointerleave', contextmenuHandler);
off('contextmenu', contextmenuHandler);
}
// Wheel event
off('wheel', wheelHandler);
};
/**
* Update userSettings
*
*/
const updateConfig = () => {
const group = groups[activeGroup];
const slider = group.slider;
if (userSettings.draggable && !slider.classList.contains('tobii__slider--is-draggable')) {
slider.classList.add('tobii__slider--is-draggable');
}
const hideButtons = !userSettings.nav || group.elementsLength === 1 || userSettings.nav === 'auto' && isTouchDevice();
setButtonState(prevButton, hideButtons, hideButtons);
setButtonState(nextButton, hideButtons, hideButtons);
const hideCounter = !userSettings.counter || group.elementsLength === 1;
counter.setAttribute('aria-hidden', hideCounter ? 'true' : 'false');
};
/**
* Update live region
*
*/
const updateAnnouncement = () => {
const group = groups[activeGroup];
const currIndex = group.currentIndex;
const total = group.elementsLength;
const trigger = group.gallery[currIndex];
const [slide, of] = userSettings.announcementLabel;
let extra;
if (trigger.hasAttribute('data-label')) {
extra = trigger.getAttribute('data-label');
} else {
const img = trigger.querySelector('img');
extra = (img == null ? void 0 : img.alt) || '';
}
const base = `${slide} ${currIndex + 1} ${of} ${total}`;
// Announce reliably
liveRegion.textContent = '';
window.setTimeout(() => {
liveRegion.textContent = extra ? `${base}. ${extra}` : base;
}, 10);
};
/**
* Update lightbox
*
* @param {string|null} dir - Current slide direction
*/
const updateLightbox = (dir = null) => {
updateOffset();
updateCounter();
updateAnnouncement();
updateFocus(dir);
};
/**
* Reset Tobii
*
*/
const reset = () => {
if (isOpen()) close();
Object.values(groups).forEach(group => group.gallery.forEach(remove));
groups = {};
activeGroup = null;
Object.values(SUPPORTED_ELEMENTS).forEach(type => type.onReset());
};
/**
* Destroy Tobii
*
*/
const destroy = () => {
reset();
lightbox.parentNode.removeChild(lightbox);
};
/**
* Check if Tobii is open
*
*/
const isOpen = () => {
return lightbox.getAttribute('aria-hidden') === 'false';
};
/**
* Detect whether device is touch capable
*
*/
const isTouchDevice = () => {
return 'ontouchstart' in window;
};
/**
* Checks whether element's tagName is part of array
*
*/
const isIgnoreElement = el => {
return ['TEXTAREA', 'OPTION', 'INPUT', 'SELECT'].indexOf(el.tagName) !== -1 || el === prevButton || el === nextButton || el === closeButton;
};
/**
* Return current index
*
*/
const slidesIndex = () => {
return groups[activeGroup].currentIndex;
};
/**
* Return elements length
*
*/
const slidesCount = () => {
return groups[activeGroup].elementsLength;
};
/**
* Return current group
*
*/
const currentGroup = () => {
return activeGroup;
};
/**
* Bind events
* @param {String} eventName
* @param {function} callback - callback to call
*
*/
const on = (eventName, callback) => {
lightbox.addEventListener(eventName, callback);
};
/**
* Unbind events
* @param {String} eventName
* @param {function} callback - callback to call
*
*/
const off = (eventName, callback) => {
lightbox.removeEventListener(eventName, callback);
};
init(userOptions);
return {
open,
previous,
next,
close,
add: checkDependencies,
remove,
reset,
destroy,
isOpen,
slidesIndex,
select,
slidesCount,
selectGroup,
currentGroup,
on,
off
};
}
export { Tobii as default };
================================================
FILE: dist/tobii.module.js
================================================
class ImageType {
constructor() {
this.figcaptionId = 0;
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const FIGURE = document.createElement('figure');
const IMAGE = document.createElement('img');
const THUMBNAIL = el.querySelector('img');
const LOADING_INDICATOR = document.createElement('div');
// Accessibility: allow setting focus programmatically on figure elements.
FIGURE.tabIndex = -1;
// Add role="group" to figure
FIGURE.setAttribute('role', 'group');
// Hide figure until the image is loaded
FIGURE.style.opacity = '0';
if (THUMBNAIL) {
IMAGE.alt = THUMBNAIL.alt || '';
}
IMAGE.setAttribute('data-src', el.href);
if (el.hasAttribute('data-srcset')) {
IMAGE.setAttribute('data-srcset', el.getAttribute('data-srcset'));
}
if (el.hasAttribute('data-sizes')) {
IMAGE.setAttribute('data-sizes', el.getAttribute('data-sizes'));
}
// Add image to figure
FIGURE.appendChild(IMAGE);
// Create figcaption
let captionContent;
if (typeof this.userSettings.captionText === 'function') {
captionContent = this.userSettings.captionText(el);
} else if (this.userSettings.captionsSelector === 'self' && el.hasAttribute(this.userSettings.captionAttribute)) {
captionContent = el.getAttribute(this.userSettings.captionAttribute);
} else if (this.userSettings.captionsSelector === 'img' && THUMBNAIL && THUMBNAIL.hasAttribute(this.userSettings.captionAttribute)) {
captionContent = THUMBNAIL.getAttribute(this.userSettings.captionAttribute);
}
if (this.userSettings.captions && captionContent) {
const FIGCAPTION = document.createElement('figcaption');
FIGCAPTION.id = `tobii-figcaption-${this.figcaptionId}`;
const SPAN = document.createElement('span');
if (this.userSettings.captionHTML) {
SPAN.innerHTML = captionContent;
} else {
SPAN.textContent = captionContent;
}
FIGCAPTION.appendChild(SPAN);
if (this.userSettings.captionToggle) {
const isMobile = window.innerWidth < 768;
const BUTTON = document.createElement('button');
BUTTON.className = 'caption-toggle';
BUTTON.textContent = BUTTON.title = this.userSettings.captionToggleLabel[isMobile ? 1 : 0];
BUTTON.setAttribute('aria-controls', FIGCAPTION.id);
BUTTON.setAttribute('aria-expanded', !isMobile);
if (isMobile) {
FIGCAPTION.classList.add('caption-hidden');
}
SPAN.setAttribute('aria-hidden', isMobile);
const preventAndStopEvent = event => {
event.preventDefault();
event.stopPropagation();
};
BUTTON.addEventListener('pointerdown', event => preventAndStopEvent(event));
BUTTON.addEventListener('pointerup', event => preventAndStopEvent(event));
BUTTON.addEventListener('click', event => {
preventAndStopEvent(event);
const isExpanded = BUTTON.getAttribute('aria-expanded') === 'true';
const buttonLabel = isExpanded ? this.userSettings.captionToggleLabel[1] : this.userSettings.captionToggleLabel[0];
BUTTON.textContent = BUTTON.title = buttonLabel;
BUTTON.setAttribute('aria-expanded', !isExpanded);
FIGCAPTION.classList.toggle('caption-hidden');
SPAN.setAttribute('aria-hidden', isExpanded);
});
FIGCAPTION.appendChild(BUTTON);
}
FIGURE.appendChild(FIGCAPTION);
IMAGE.setAttribute('aria-labelledby', FIGCAPTION.id);
// Add aria-label to the figure containing the caption content
FIGURE.setAttribute('aria-label', SPAN.textContent);
++this.figcaptionId;
}
// Add figure to container
container.appendChild(FIGURE);
// Create loading indicator
LOADING_INDICATOR.className = 'tobii__loader';
LOADING_INDICATOR.setAttribute('role', 'progressbar');
LOADING_INDICATOR.setAttribute('aria-label', this.userSettings.loadingIndicatorLabel);
// Add loading indicator to container
container.appendChild(LOADING_INDICATOR);
// Register type
container.setAttribute('data-type', 'image');
container.classList.add('tobii-image');
}
onPreload(container) {
// Same as preload
this.onLoad(container);
}
onLoad(container) {
const IMAGE = container.querySelector('img');
if (!IMAGE.hasAttribute('data-src')) {
return;
}
const FIGURE = container.querySelector('figure');
const LOADING_INDICATOR = container.querySelector('.tobii__loader');
const handleImageEvent = () => {
container.removeChild(LOADING_INDICATOR);
FIGURE.style.opacity = '1';
};
IMAGE.addEventListener('load', handleImageEvent);
IMAGE.addEventListener('error', handleImageEvent);
if (IMAGE.hasAttribute('data-srcset')) {
IMAGE.setAttribute('srcset', IMAGE.getAttribute('data-srcset'));
IMAGE.removeAttribute('data-srcset');
}
if (IMAGE.hasAttribute('data-sizes')) {
IMAGE.setAttribute('sizes', IMAGE.getAttribute('data-sizes'));
IMAGE.removeAttribute('data-sizes');
}
IMAGE.setAttribute('src', IMAGE.getAttribute('data-src'));
IMAGE.removeAttribute('data-src');
}
onLeave(container) {
// Nothing
}
onCleanup(container) {
// Nothing
}
onReset() {
this.figcaptionId = 0;
}
}
class IframeType {
constructor() {
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const HREF = el.hasAttribute('data-target') ? el.getAttribute('data-target') : el.getAttribute('href');
container.setAttribute('data-HREF', HREF);
if (el.hasAttribute('data-allow')) {
container.setAttribute('data-allow', el.getAttribute('data-allow'));
}
if (el.hasAttribute('data-width')) {
container.setAttribute('data-width', `${el.getAttribute('data-width')}`);
}
if (el.hasAttribute('data-height')) {
container.setAttribute('data-height', `${el.getAttribute('data-height')}`);
}
// dont create empty iframes here - very slow
// Register type
container.setAttribute('data-type', 'iframe');
container.classList.add('tobii-iframe');
}
onPreload(container) {
// Nothing
}
onLoad(container) {
let IFRAME = container.querySelector('iframe');
// Create loading indicator
const LOADING_INDICATOR = document.createElement('div');
LOADING_INDICATOR.className = 'tobii__loader';
LOADING_INDICATOR.setAttribute('role', 'progressbar');
LOADING_INDICATOR.setAttribute('aria-label', this.userSettings.loadingIndicatorLabel);
container.appendChild(LOADING_INDICATOR);
if (IFRAME == null) {
// create iframe
IFRAME = document.createElement('iframe');
const HREF = container.getAttribute('data-href');
IFRAME.setAttribute('frameborder', '0');
IFRAME.setAttribute('src', HREF);
// Set allow parameters
let allowValue = 'fullscreen';
if (HREF.includes('youtube.com')) {
allowValue += '; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
} else if (HREF.includes('vimeo.com')) {
allowValue += '; autoplay; picture-in-picture';
} else if (container.hasAttribute('data-allow')) {
allowValue = container.getAttribute('data-allow');
}
IFRAME.setAttribute('allow', allowValue);
if (container.hasAttribute('data-width')) {
IFRAME.style.maxWidth = `${container.getAttribute('data-width')}`;
}
if (container.hasAttribute('data-height')) {
IFRAME.style.maxHeight = `${container.getAttribute('data-height')}`;
}
// Hide until loaded
IFRAME.style.opacity = '0';
// Add iframe to container
container.appendChild(IFRAME);
// Handle load and error
const removeLoader = () => {
IFRAME.style.opacity = '1';
const LOADING_INDICATOR = container.querySelector('.tobii__loader');
if (LOADING_INDICATOR) container.removeChild(LOADING_INDICATOR);
};
IFRAME.addEventListener('load', removeLoader);
IFRAME.addEventListener('error', removeLoader);
} else {
// was already created
IFRAME.setAttribute('src', container.getAttribute('data-href'));
}
}
onLeave(container) {
// Nothing
}
onCleanup(container) {
const IFRAME = container.querySelector('iframe');
IFRAME.removeAttribute('src');
IFRAME.style.opacity = '0';
}
onReset() {
// Nothing
}
}
class HtmlType {
constructor() {
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const TARGET_SELECTOR = el.hasAttribute('data-target') ? el.getAttribute('data-target') : el.getAttribute('href');
const TARGET = document.querySelector(TARGET_SELECTOR);
if (!TARGET) {
throw new Error(`Ups, I can't find the target ${TARGET_SELECTOR}.`);
}
// Add content to container
container.appendChild(TARGET);
// Register type
container.setAttribute('data-type', 'html');
container.classList.add('tobii-html');
}
onPreload(container) {
// Nothing
}
onLoad(container, group) {
const VIDEO = container.querySelector('video');
if (VIDEO) {
if (VIDEO.hasAttribute('data-time') && VIDEO.readyState > 0) {
// Continue where video was stopped
VIDEO.currentTime = VIDEO.getAttribute('data-time');
}
// Start playback (and loading if necessary)
VIDEO.play();
}
const audio = container.querySelector('audio');
if (audio) {
// Start playback (and loading if necessary)
audio.play();
}
container.classList.add('tobii-group-' + group);
}
onLeave(container) {
const VIDEO = container.querySelector('video');
if (VIDEO) {
if (!VIDEO.paused) {
// Stop if video is playing
VIDEO.pause();
}
// Backup currentTime (needed for revisit)
if (VIDEO.readyState > 0) {
VIDEO.setAttribute('data-time', VIDEO.currentTime);
}
}
const audio = container.querySelector('audio');
if (audio) {
if (!audio.paused) {
// Stop if is playing
audio.pause();
}
}
}
onCleanup(container) {
const VIDEO = container.querySelector('video');
if (VIDEO) {
if (VIDEO.readyState > 0 && VIDEO.readyState < 3 && VIDEO.duration !== VIDEO.currentTime) {
// Some data has been loaded but not the whole package.
// In order to save bandwidth, stop downloading as soon as possible.
const VIDEO_CLONE = VIDEO.cloneNode(true);
this._removeSources(VIDEO);
VIDEO.load();
VIDEO.parentNode.removeChild(VIDEO);
container.appendChild(VIDEO_CLONE);
}
}
}
onReset() {
// Nothing
}
/**
* Remove all `src` attributes
*
* @param {HTMLElement} el - Element to remove all `src` attributes
*/
_removeSources(el) {
const SOURCES = el.querySelectorAll('src');
if (SOURCES) {
SOURCES.forEach(source => {
source.removeAttribute('src');
});
}
}
}
class YoutubeType {
constructor() {
this.playerId = 0;
this.PLAYER = [];
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const IFRAME_PLACEHOLDER = document.createElement('div');
// Add iframePlaceholder to container
container.appendChild(IFRAME_PLACEHOLDER);
this.PLAYER[this.playerId] = new window.YT.Player(IFRAME_PLACEHOLDER, {
host: 'https://www.youtube-nocookie.com',
height: el.getAttribute('data-height') || '360',
width: el.getAttribute('data-width') || '640',
videoId: el.getAttribute('data-id'),
playerVars: {
controls: el.getAttribute('data-controls') || 1,
rel: 0,
playsinline: 1
}
});
// Set player ID
container.setAttribute('data-player', this.playerId);
// Register type
container.setAttribute('data-type', 'youtube');
container.classList.add('tobii-youtube');
this.playerId++;
}
onPreload(container) {
// Nothing
}
onLoad(container) {
this.PLAYER[container.getAttribute('data-player')].playVideo();
}
onLeave(container) {
if (this.PLAYER[container.getAttribute('data-player')].getPlayerState() === 1) {
this.PLAYER[container.getAttribute('data-player')].pauseVideo();
}
}
onCleanup(container) {
if (this.PLAYER[container.getAttribute('data-player')].getPlayerState() === 1) {
this.PLAYER[container.getAttribute('data-player')].pauseVideo();
}
}
onReset() {
// Nothing
}
}
/**
* Tobii
*
* @author midzer
* @version 3.2.0
* @url https://github.com/midzer/tobii
*
* MIT License
*/
function Tobii(userOptions) {
/**
* Global variables
*
*/
const SUPPORTED_ELEMENTS = {
image: new ImageType(),
// default
html: new HtmlType(),
iframe: new IframeType(),
youtube: new YoutubeType()
};
const FOCUSABLE_ELEMENTS = ['a[href]:not([tabindex^="-"]):not([inert])', 'area[href]:not([tabindex^="-"]):not([inert])', 'input:not([disabled]):not([inert])', 'select:not([disabled]):not([inert])', 'textarea:not([disabled]):not([inert])', 'button:not([disabled]):not([inert])', 'iframe:not([tabindex^="-"]):not([inert])', 'audio:not([tabindex^="-"]):not([inert])', 'video:not([tabindex^="-"]):not([inert])', '[contenteditable]:not([tabindex^="-"]):not([inert])', '[tabindex]:not([tabindex^="-"]):not([inert])'];
let userSettings = {};
const WAITING_ELS = [];
const GROUP_ATTS = {
gallery: [],
slider: null,
sliderElements: [],
elementsLength: 0,
currentIndex: 0,
x: 0
};
let lightbox = null;
let prevButton = null;
let nextButton = null;
let closeButton = null;
let counter = null;
let lastFocus = null;
let offset = null;
let isYouTubeDependencyLoaded = false;
let groups = {};
let activeGroup = null;
let pointerDownCache = [];
let lastTapTime = 0;
let liveRegion = null;
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const DOUBLE_TAP_TIME = 500; // milliseconds
const SCALE_SENSITIVITY = 10;
const TRANSFORM = {
element: null,
originX: 0,
originY: 0,
translateX: 0,
translateY: 0,
scale: MIN_SCALE
};
const DRAG = {
startX: 0,
startY: 0,
x: 0,
y: 0,
distance: 0
};
/**
* Merge default options with user options
*
* @param {Object} userOptions - Optional user options
* @returns {Object} - Custom options
*/
const mergeOptions = userOptions => {
// Default options
const OPTIONS = {
selector: '.lightbox',
captions: true,
captionsSelector: 'img',
captionAttribute: 'alt',
captionText: null,
captionHTML: false,
captionToggle: true,
captionToggleLabel: ['Hide caption', 'Show caption'],
nav: 'auto',
navText: ['<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>', '<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>'],
navLabel: ['Previous image', 'Next image'],
announcementLabel: ['Slide', 'of'],
close: true,
closeText: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path stroke="none" d="M0 0h24v24H0z"/><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>',
closeLabel: 'Close lightbox',
dialogTitle: 'Lightbox',
loadingIndicatorLabel: 'Image loading',
counter: true,
keyboard: true,
zoom: false,
zoomText: '<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="16 4 20 4 20 8" /><line x1="14" y1="10" x2="20" y2="4" /><polyline points="8 20 4 20 4 16" /><line x1="4" y1="20" x2="10" y2="14" /><polyline points="16 20 20 20 20 16" /><line x1="14" y1="14" x2="20" y2="20" /><polyline points="8 4 4 4 4 8" /><line x1="4" y1="4" x2="10" y2="10" /></svg>',
docClose: true,
swipeClose: true,
hideScrollbar: true,
draggable: true,
threshold: 100,
theme: 'tobii--theme-default'
};
return {
...OPTIONS,
...userOptions
};
};
/**
* Init
*
*/
const init = userOptions => {
// Merge user options into defaults
userSettings = mergeOptions(userOptions);
// Create the lightbox container
lightbox = document.createElement('div');
lightbox.setAttribute('role', 'dialog');
lightbox.setAttribute('aria-hidden', 'true');
lightbox.setAttribute('aria-modal', 'true');
lightbox.setAttribute('aria-label', userSettings.dialogTitle);
lightbox.classList.add('tobii');
// Add theme class
lightbox.classList.add(userSettings.theme);
// Create the previous button
prevButton = document.createElement('button');
prevButton.className = 'tobii__btn tobii__btn--previous';
prevButton.setAttribute('type', 'button');
prevButton.setAttribute('aria-label', userSettings.navLabel[0]);
prevButton.innerHTML = userSettings.navText[0];
lightbox.appendChild(prevButton);
// Create the next button
nextButton = document.createElement('button');
nextButton.className = 'tobii__btn tobii__btn--next';
nextButton.setAttribute('type', 'button');
nextButton.setAttribute('aria-label', userSettings.navLabel[1]);
nextButton.innerHTML = userSettings.navText[1];
lightbox.appendChild(nextButton);
// Create the close button
closeButton = document.createElement('button');
closeButton.className = 'tobii__btn tobii__btn--close';
closeButton.setAttribute('type', 'button');
closeButton.setAttribute('aria-label', userSettings.closeLabel);
closeButton.innerHTML = userSettings.closeText;
lightbox.appendChild(closeButton);
// Create the counter
counter = document.createElement('div');
counter.className = 'tobii__counter';
lightbox.appendChild(counter);
// Create the live region
liveRegion = document.createElement('div');
liveRegion.className = 'tobii__sr';
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
lightbox.appendChild(liveRegion);
// Append to body
document.body.appendChild(lightbox);
// Init only
if (!userSettings.selector) return;
// Get a list of all elements within the document
const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(userSettings.selector);
if (!LIGHTBOX_TRIGGER_ELS) {
throw new Error(`Ups, I can't find the selector ${userSettings.selector} on this website.`);
}
LIGHTBOX_TRIGGER_ELS.forEach(el => checkDependencies(el));
};
/**
* Check dependencies
*
* @param {HTMLElement} el - Element to add
*/
const checkDependencies = el => {
// Check if there is a YouTube video and if the YouTube iframe-API is ready
if (document.querySelector('[data-type="youtube"]') !== null && !isYouTubeDependencyLoaded) {
if (document.getElementById('iframe_api') === null) {
const TAG = document.createElement('script');
const FIRST_SCRIPT_TAG = document.getElementsByTagName('script')[0];
TAG.id = 'iframe_api';
TAG.src = 'https://www.youtube.com/iframe_api';
FIRST_SCRIPT_TAG.parentNode.insertBefore(TAG, FIRST_SCRIPT_TAG);
}
if (WAITING_ELS.indexOf(el) === -1) {
WAITING_ELS.push(el);
}
window.onYouTubePlayerAPIReady = () => {
WAITING_ELS.forEach(waitingEl => {
add(waitingEl);
});
isYouTubeDependencyLoaded = true;
};
} else {
add(el);
}
};
/**
* Get group name from element
*
* @param {HTMLElement} el
* @return {string}
*/
const getGroupName = el => {
return el.hasAttribute('data-group') ? el.getAttribute('data-group') : 'default';
};
/**
* Copy an object. (The secure way)
*
* @param {object} object
* @return {object}
*/
const copyObject = object => {
return JSON.parse(JSON.stringify(object));
};
/**
* Add element
*
* @param {HTMLElement} el - Element to add
*/
const add = el => {
const newGroup = getGroupName(el);
if (!Object.prototype.hasOwnProperty.call(groups, newGroup)) {
groups[newGroup] = copyObject(GROUP_ATTS);
// Create slider
groups[newGroup].slider = document.createElement('div');
groups[newGroup].slider.className = 'tobii__slider';
// Hide slider
groups[newGroup].slider.setAttribute('aria-hidden', 'true');
lightbox.appendChild(groups[newGroup].slider);
}
// Check if element already exists
if (groups[newGroup].gallery.indexOf(el) === -1) {
groups[newGroup].gallery.push(el);
groups[newGroup].elementsLength++;
// Set zoom icon if necessary
if (userSettings.zoom && el.querySelector('img') && el.getAttribute('data-zoom') !== 'false' || el.getAttribute('data-zoom') === 'true') {
const TOBII_ZOOM = document.createElement('div');
TOBII_ZOOM.className = 'tobii-zoom__icon';
TOBII_ZOOM.innerHTML = userSettings.zoomText;
el.classList.add('tobii-zoom');
el.appendChild(TOBII_ZOOM);
}
// Bind click event handler
el.addEventListener('click', triggerTobii);
// Create slide
const SLIDE_ELEMENT = document.createElement('div');
const SLIDE_ELEMENT_CONTENT = document.createElement('div');
SLIDE_ELEMENT.className = 'tobii__slide';
SLIDE_ELEMENT.style.position = 'absolute';
SLIDE_ELEMENT.style.left = `${groups[newGroup].x * 100}%`;
// Hide slide
SLIDE_ELEMENT.setAttribute('aria-hidden', 'true');
// Create type elements
const model = getModel(el);
model.init(el, SLIDE_ELEMENT_CONTENT, userSettings);
// Add slide content container to slide element
SLIDE_ELEMENT.appendChild(SLIDE_ELEMENT_CONTENT);
// Add slide element to slider
groups[newGroup].slider.appendChild(SLIDE_ELEMENT);
groups[newGroup].sliderElements.push(SLIDE_ELEMENT);
++groups[newGroup].x;
if (isOpen() && newGroup === activeGroup) {
updateConfig();
updateLightbox();
}
} else {
throw new Error('Ups, element already added.');
}
};
/**
* Remove element
*
* @param {HTMLElement} el - Element to remove
*/
const remove = el => {
const GROUP_NAME = getGroupName(el);
// Check if element exists
const galleryIndex = groups[GROUP_NAME].gallery.indexOf(el);
if (galleryIndex === -1) {
throw new Error(`Ups, I can't find a slide for the element ${el}.`);
}
const SLIDE_ELEMENT = groups[GROUP_NAME].sliderElements[galleryIndex];
// If the element to be removed is the currently visible slide
if (isOpen() && GROUP_NAME === activeGroup && galleryIndex === groups[GROUP_NAME].currentIndex) {
if (groups[GROUP_NAME].elementsLength === 1) {
close();
throw new Error('Ups, I\'ve closed. There are no slides more to show.');
} else {
// Navigate away before removal
if (groups[GROUP_NAME].currentIndex === 0) {
next();
} else {
previous();
}
updateConfig();
updateLightbox();
}
}
groups[GROUP_NAME].gallery.splice(galleryIndex, 1);
groups[GROUP_NAME].sliderElements.splice(galleryIndex, 1);
groups[GROUP_NAME].elementsLength--;
--groups[GROUP_NAME].x;
// Remove zoom icon if necessary
if (userSettings.zoom && el.querySelector('.tobii-zoom__icon')) {
const ZOOM_ICON = el.querySelector('.tobii-zoom__icon');
ZOOM_ICON.parentNode.classList.remove('tobii-zoom');
ZOOM_ICON.parentNode.removeChild(ZOOM_ICON);
}
// Unbind click event handler
el.removeEventListener('click', triggerTobii);
// Remove slide
SLIDE_ELEMENT.parentNode.removeChild(SLIDE_ELEMENT);
};
const getModel = el => {
const type = el.getAttribute('data-type');
if (SUPPORTED_ELEMENTS[type] !== undefined) {
return SUPPORTED_ELEMENTS[type];
} else {
// unknown - use default
if (el.hasAttribute('data-type')) {
console.log('Unknown lightbox element type: ' + type);
}
return SUPPORTED_ELEMENTS.image;
}
};
/**
* Open Tobii
*
* @param {number} index - Index to load
*/
const open = (index = 0) => {
if (isOpen()) {
throw new Error('Ups, I\'m aleady open.');
}
if (index === -1 || index >= groups[activeGroup].elementsLength) {
throw new Error(`Ups, I can't find slide ${index}.`);
}
document.documentElement.classList.add('tobii-is-open');
document.body.classList.add('tobii-is-open');
document.body.classList.add('tobii-is-open-' + activeGroup);
updateConfig();
// Hide close if necessary
if (!userSettings.close) {
closeButton.disabled = false;
closeButton.setAttribute('aria-hidden', 'true');
}
// Save user’s focus
lastFocus = document.activeElement;
// Use `history.pushState()` to make sure the "Back" button behavior
// that aligns with the user's expectations
const stateObj = {
tobii: 'close'
};
const url = window.location.href;
window.history.pushState(stateObj, 'Image', url);
// Set current index
groups[activeGroup].currentIndex = index;
bindEvents();
// Load slide
load(groups[activeGroup].currentIndex);
// Show slider
groups[activeGroup].slider.setAttribute('aria-hidden', 'false');
// Show lightbox
lightbox.setAttribute('aria-hidden', 'false');
updateLightbox();
// Preload previous and next slide
preload(groups[activeGroup].currentIndex + 1);
preload(groups[activeGroup].currentIndex - 1);
groups[activeGroup].slider.classList.add('tobii__slider--animate');
// Create and dispatch a new event
const openEvent = new window.CustomEvent('open', {
detail: {
group: activeGroup
}
});
lightbox.dispatchEvent(openEvent);
};
/**
* Close Tobii
*
*/
const close = () => {
if (!isOpen()) {
throw new Error('Ups, I\'m already closed.');
}
document.documentElement.classList.remove('tobii-is-open');
document.body.classList.remove('tobii-is-open');
document.body.classList.remove('tobii-is-open-' + activeGroup);
unbindEvents();
// Remove entry in browser history
if (window.history.state !== null) {
if (window.history.state.tobii === 'close') {
window.history.back();
}
}
// Reenable the user’s focus
lastFocus.focus();
// Don't forget to cleanup our current element
leave(groups[activeGroup].currentIndex);
cleanup(groups[activeGroup].currentIndex);
// Hide lightbox
lightbox.setAttribute('aria-hidden', 'true');
// Hide slider
groups[activeGroup].slider.setAttribute('aria-hidden', 'true');
// Reset current index
groups[activeGroup].currentIndex = 0;
// Remove the hack to prevent animation during opening
groups[activeGroup].slider.classList.remove('tobii__slider--animate');
// Create and dispatch a new event
const closeEvent = new window.CustomEvent('close', {
detail: {
group: activeGroup
}
});
lightbox.dispatchEvent(closeEvent);
};
/**
* Preload slide
*
* @param {number} index - Index to preload
*/
const preload = index => {
if (groups[activeGroup].sliderElements[index] === undefined) {
return;
}
const CONTAINER = groups[activeGroup].sliderElements[index].querySelector('[data-type]');
const model = getModel(CONTAINER);
model.onPreload(CONTAINER);
};
/**
* Load slide
* Will be called when opening the lightbox or moving index
*
* @param {number} index - Index to load
*/
const load = index => {
if (groups[activeGroup].sliderElements[index] === undefined) {
return;
}
const CONTAINER = groups[activeGroup].sliderElements[index].querySelector('[data-type]');
const model = getModel(CONTAINER);
model.onLoad(CONTAINER, activeGroup);
};
/**
* Select a slide
*
* @param {number} index - Index to select
*/
const select = index => {
const currIndex = groups[activeGroup].currentIndex;
if (!isOpen()) {
throw new Error('Ups, I\'m closed.');
}
if (isOpen()) {
if (!index && index !== 0) {
throw new Error('Ups, no slide specified.');
}
if (index === groups[activeGroup].currentIndex) {
throw new Error(`Ups, slide ${index} is already selected.`);
}
if (index === -1 || index >= groups[activeGroup].elementsLength) {
throw new Error(`Ups, I can't find slide ${index}.`);
}
}
// Set current index
groups[activeGroup].currentIndex = index;
leave(currIndex);
load(index);
if (index < currIndex) {
updateLightbox('left');
cleanup(currIndex);
preload(index - 1);
}
if (index > currIndex) {
updateLightbox('right');
cleanup(currIndex);
preload(index + 1);
}
};
/**
* Select the previous slide
*
*/
const previous = () => {
if (!isOpen()) {
throw new Error('Ups, I\'m closed.');
}
if (groups[activeGroup].currentIndex > 0) {
leave(groups[activeGroup].currentIndex);
load(--groups[activeGroup].currentIndex);
updateLightbox('left');
cleanup(groups[activeGroup].currentIndex + 1);
preload(groups[activeGroup].currentIndex - 1);
}
// Create and dispatch a new event
const previousEvent = new window.CustomEvent('previous', {
detail: {
group: activeGroup
}
});
lightbox.dispatchEvent(previousEvent);
};
/**
* Select the next slide
*
*/
const next = () => {
if (!isOpen()) {
throw new Error('Ups, I\'m closed.');
}
if (groups[activeGroup].currentIndex < groups[activeGroup].elementsLength - 1) {
leave(groups[activeGroup].currentIndex);
load(++groups[activeGroup].currentIndex);
updateLightbox('right');
cleanup(groups[activeGroup].currentIndex - 1);
preload(groups[activeGroup].currentIndex + 1);
}
// Create and dispatch a new event
const nextEvent = new window.CustomEvent('next', {
detail: {
group: activeGroup
}
});
lightbox.dispatchEvent(nextEvent);
};
/**
* Select a group
*
* @param {string} name - Name of the group to select
*/
const selectGroup = name => {
if (isOpen()) {
throw new Error('Ups, I\'m open.');
}
if (!name) {
throw new Error('Ups, no group specified.');
}
if (name && !Object.prototype.hasOwnProperty.call(groups, name)) {
throw new Error(`Ups, I don't have a group called "${name}".`);
}
activeGroup = name;
};
/**
* Leave slide
* Will be called before moving index
*
* @param {number} index - Index to leave
*/
const leave = index => {
if (groups[activeGroup].sliderElements[index] === undefined) {
return;
}
const CONTAINER = groups[activeGroup].sliderElements[index].querySelector('[data-type]');
const model = getModel(CONTAINER);
model.onLeave(CONTAINER);
};
/**
* Cleanup slide
* Will be called after moving index
*
* @param {number} index - Index to cleanup
*/
const cleanup = index => {
if (groups[activeGroup].sliderElements[index] === undefined) {
return;
}
const CONTAINER = groups[activeGroup].sliderElements[index].querySelector('[data-type]');
const model = getModel(CONTAINER);
model.onCleanup(CONTAINER);
DRAG.startX = 0;
DRAG.startY = 0;
DRAG.x = 0;
DRAG.y = 0;
DRAG.distance = 0;
lastTapTime = 0;
if (isZoomed()) resetZoom();
TRANSFORM.element = null;
};
/**
* Update offset
*
*/
const updateOffset = () => {
offset = -groups[activeGroup].currentIndex * lightbox.offsetWidth;
groups[activeGroup].slider.style.transform = `translate(${offset}px, 0)`;
};
/**
* Update counter
*
*/
const updateCounter = () => {
counter.innerHTML = `<p>${groups[activeGroup].currentIndex + 1}/${groups[activeGroup].elementsLength}</p>`;
};
/**
* Update focus
*
* @param {string|null} dir - Current slide direction
*/
const updateFocus = dir => {
const group = groups[activeGroup];
const isNavEnabled = userSettings.nav === true || userSettings.nav === 'auto';
const hasMultipleSlides = group.elementsLength > 1;
if (isNavEnabled && !isTouchDevice() && hasMultipleSlides) {
setButtonState(prevButton, true, true);
setButtonState(nextButton, true, true);
if (group.currentIndex === 0) {
setButtonState(nextButton, false, false);
nextButton.focus();
} else if (group.currentIndex === group.elementsLength - 1) {
setButtonState(prevButton, false, false);
prevButton.focus();
} else {
setButtonState(prevButton, false, false);
setButtonState(nextButton, false, false);
if (dir === 'left') {
prevButton.focus();
} else {
nextButton.focus();
}
}
} else if (userSettings.close) {
closeButton.focus();
}
};
/**
* Resize event
*
*/
const resizeHandler = () => {
updateOffset();
};
/**
* Click event handler to trigger Tobii
*
*/
const triggerTobii = event => {
event.preventDefault();
activeGroup = getGroupName(event.currentTarget);
open(groups[activeGroup].gallery.indexOf(event.currentTarget));
};
/**
* Click event handler
*
*/
const clickHandler = event => {
if (event.target === prevButton) {
previous();
} else if (event.target === nextButton) {
next();
} else if (event.target === closeButton || event.target.classList.contains('tobii__slide') || event.target.classList.contains('tobii') && userSettings.docClose) {
close();
}
event.stopPropagation();
};
/**
* Set the hidden/disabled state of a button
*
*/
const setButtonState = (button, hidden, disabled) => {
button.setAttribute('aria-hidden', hidden ? 'true' : 'false');
button.disabled = disabled;
};
/**
* Keydown event handler
*
*/
const keydownHandler = event => {
if (event.code === 'Tab') {
const FOCUSABLE = Array.from(lightbox.querySelectorAll(FOCUSABLE_ELEMENTS.join(', ')));
if (FOCUSABLE.length === 0) return;
const FOCUSED_INDEX = FOCUSABLE.findIndex(el => el === document.activeElement);
if (event.shiftKey && FOCUSED_INDEX === 0) {
// SHIFT+Tab on first → jump to last
FOCUSABLE[FOCUSABLE.length - 1].focus();
event.preventDefault();
} else if (!event.shiftKey && FOCUSED_INDEX === FOCUSABLE.length - 1) {
// Tab on last → jump to first
FOCUSABLE[0].focus();
event.preventDefault();
}
} else if (event.code === 'Escape') {
// `ESC` Key: Close Tobii
event.preventDefault();
close();
} else if (event.code === 'ArrowLeft') {
// `PREV` Key: Show the previous slide
event.preventDefault();
previous();
} else if (event.code === 'ArrowRight') {
// `NEXT` Key: Show the next slide
event.preventDefault();
next();
}
};
/**
* Contextmenu event handler
* This is a fix for chromium based browser on mac.
* The 'contextmenu' terminates a mouse event sequence.
* https://bugs.chromium.org/p/chromium/issues/detail?id=506801
*
*/
const contextmenuHandler = () => {
pointerDownCache = [];
updateOffset();
groups[activeGroup].slider.classList.remove('tobii__slider--is-' + (isZoomed() ? 'moving' : 'dragging'));
};
/**
* Pointerdown event handler
*
*/
const pointerdownHandler = event => {
// Prevent dragging / swiping on textareas, inputs and selects
if (isIgnoreElement(event.target)) {
return;
}
event.preventDefault();
event.stopPropagation();
DRAG.startX = DRAG.x = event.clientX;
DRAG.startY = DRAG.y = event.clientY;
DRAG.distance = 0;
// This event is cached to support 2-finger gestures
pointerDownCache.push(event);
if (pointerDownCache.length === 2) {
const {
x,
y
} = midPoint(pointerDownCache[0].clientX, pointerDownCache[0].clientY, pointerDownCache[1].clientX, pointerDownCache[1].clientY);
DRAG.startX = DRAG.x = x;
DRAG.startY = DRAG.y = y;
DRAG.distance = distance(pointerDownCache[0].clientX - pointerDownCache[1].clientX, pointerDownCache[0].clientY - pointerDownCache[1].clientY) / TRANSFORM.scale;
}
};
/**
* Pointermove event handler
*
*/
const pointermoveHandler = event => {
if (!pointerDownCache.length) return;
groups[activeGroup].slider.classList.add('tobii__slider--is-' + (isZoomed() ? 'moving' : 'dragging'));
// Find this event in the cache and update its record with this event
const index = pointerDownCache.findIndex(cachedEv => cachedEv.pointerId === event.pointerId);
pointerDownCache[index] = event;
if (pointerDownCache.length === 2) {
// 2-pointer horizontal pinch/zoom gesture
const {
x,
y
} = midPoint(pointerDownCache[0].clientX, pointerDownCache[0].clientY, pointerDownCache[1].clientX, pointerDownCache[1].clientY);
const scale = distance(pointerDownCache[0].clientX - pointerDownCache[1].clientX, pointerDownCache[0].clientY - pointerDownCache[1].clientY) / DRAG.distance;
zoomPan(event.target, clamp(scale, MIN_SCALE, MAX_SCALE), x, y, x - DRAG.x, y - DRAG.y);
DRAG.x = x;
DRAG.y = y;
return;
}
if (isZoomed()) {
const deltaX = event.clientX - DRAG.x;
const deltaY = event.clientY - DRAG.y;
pan(deltaX, deltaY);
}
DRAG.x = event.clientX;
DRAG.y = event.clientY;
if (!isZoomed()) {
// Drag animation
const deltaX = DRAG.startX - DRAG.x;
const deltaY = DRAG.startY - DRAG.y;
// Skip animation if drag distance is too low
if (distance(deltaX, deltaY) < 10) return;
if (Math.abs(deltaX) > Math.abs(deltaY) && groups[activeGroup].elementsLength > 1) {
// Horizontal swipe
groups[activeGroup].slider.style.transform = `translate(${offset - Math.round(deltaX)}px, 0)`;
} else if (userSettings.swipeClose) {
// Vertical swipe
groups[activeGroup].slider.style.transform = `translate(${offset}px, -${Math.round(deltaY)}px)`;
}
}
};
/**
* Pointerup event handler
*
*/
const pointerupHandler = event => {
// Intercept regular click handler
if (!pointerDownCache.length) return;
groups[activeGroup].slider.classList.remove('tobii__slider--is-' + (isZoomed() ? 'moving' : 'dragging'));
// Remove this event from the target's cache
const index = pointerDownCache.findIndex(cachedEv => cachedEv.pointerId === event.pointerId);
pointerDownCache.splice(index, 1);
const x = event.clientX;
const y = event.clientY;
const deltaX = DRAG.startX - x;
const deltaY = DRAG.startY - y;
const distanceX = Math.abs(deltaX);
const distanceY = Math.abs(deltaY);
if (distanceX > 8 || distanceY > 8) {
if (!isZoomed()) {
// Evaluate drag
if (deltaX < 0 && distanceX > userSettings.threshold && groups[activeGroup].currentIndex > 0) {
previous();
} else if (deltaX > 0 && distanceX > userSettings.threshold && groups[activeGroup].currentIndex !== groups[activeGroup].elementsLength - 1) {
next();
} else if (deltaY > 0 && distanceY > userSettings.threshold && userSettings.swipeClose) {
close();
} else {
updateOffset();
}
}
} else {
// Evaluate tap
const now = Date.now();
const tapLength = now - lastTapTime;
if (tapLength < DOUBLE_TAP_TIME && tapLength > 100) {
// Double click
event.preventDefault();
lastTapTime = 0;
if (isZoomed()) {
resetZoom();
} else {
zoomPan(event.target, MAX_SCALE / 2, x, y, 0, 0);
}
} else {
lastTapTime = now;
if (isTouchDevice()) {
// Delayed tap on mobile
window.setTimeout(() => {
const {
left,
top,
bottom,
right,
width
} = event.target.getBoundingClientRect();
if (y < top || y > bottom || !lastTapTime) return;
if (x > left && x < left + width / 2) {
previous();
} else if (x < right && x > right - width / 2) {
next();
}
}, DOUBLE_TAP_TIME);
}
}
}
};
/**
* Wheel event handler
*
*/
const wheelHandler = event => {
const deltaScale = Math.sign(event.deltaY) > 0 ? -1 : 1;
if (!isZoomed() && !deltaScale) return;
event.preventDefault();
const newScale = TRANSFORM.scale + deltaScale / (SCALE_SENSITIVITY / TRANSFORM.scale);
zoomPan(event.target, clamp(newScale, MIN_SCALE, MAX_SCALE), event.clientX, event.clientY, 0, 0);
};
const clampedTranslate = (axis, translate) => {
// Whole clamping functionality heavily inspired
// by https://github.com/Neophen/pinch-zoom-pan
const {
element,
scale,
originX,
originY
} = TRANSFORM;
const axisIsX = axis === 'x';
const origin = axisIsX ? originX : originY;
const axisKey = axisIsX ? 'offsetWidth' : 'offsetHeight';
const containerSize = element.parentNode[axisKey];
const imageSize = element[axisKey];
const bounds = element.getBoundingClientRect();
const imageScaledSize = axisIsX ? bounds.width : bounds.height;
const defaultOrigin = imageSize / 2;
const originOffset = (origin - defaultOrigin) * (scale - 1);
const range = Math.max(0, Math.round(imageScaledSize) - containerSize);
const max = Math.round(range / 2);
const min = 0 - max;
return clamp(translate, min + originOffset, max + originOffset);
};
const clamp = (value, min, max) => Math.max(Math.min(value, max), min);
const isZoomed = () => TRANSFORM.scale !== MIN_SCALE;
const pan = (deltaX, deltaY) => {
if (deltaX !== 0) {
TRANSFORM.translateX = clampedTranslate('x', TRANSFORM.translateX + deltaX);
}
if (deltaY !== 0) {
TRANSFORM.translateY = clampedTranslate('y', TRANSFORM.translateY + deltaY);
}
const {
element,
originX,
originY,
translateX,
translateY,
scale
} = TRANSFORM;
element.style.transformOrigin = `${originX}px ${originY}px`;
element.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
};
const zoomPan = (el, newScale, x, y, deltaX, deltaY) => {
if (el.tagName !== 'IMG') return;
const {
left,
top
} = el.getBoundingClientRect();
const originX = x - left;
const originY = y - top;
const newOriginX = originX / TRANSFORM.scale;
const newOriginY = originY / TRANSFORM.scale;
TRANSFORM.element = el;
TRANSFORM.originX = newOriginX;
TRANSFORM.originY = newOriginY;
TRANSFORM.scale = newScale;
pan(deltaX, deltaY);
};
const distance = (dx, dy) => Math.hypot(dx, dy);
const midPoint = (x1, y1, x2, y2) => ({
x: (x1 + x2) / 2,
y: (y1 + y2) / 2
});
const resetZoom = () => {
TRANSFORM.scale = MIN_SCALE;
TRANSFORM.originX = 0;
TRANSFORM.originY = 0;
TRANSFORM.translateX = 0;
TRANSFORM.translateY = 0;
pan(0, 0);
};
/**
* Bind events
*
*/
const bindEvents = () => {
if (userSettings.keyboard) {
window.addEventListener('keydown', keydownHandler);
}
// Resize event
window.addEventListener('resize', resizeHandler);
// Popstate event
window.addEventListener('popstate', close);
// Click event
on('click', clickHandler);
if (userSettings.draggable) {
// Pointer events
on('pointerdown', pointerdownHandler);
on('pointermove', pointermoveHandler);
on('pointerup', pointerupHandler);
on('pointercancel', contextmenuHandler);
on('pointerout', contextmenuHandler);
on('pointerleave', contextmenuHandler);
on('contextmenu', contextmenuHandler);
}
// Wheel event
on('wheel', wheelHandler);
};
/**
* Unbind events
*
*/
const unbindEvents = () => {
if (userSettings.keyboard) {
window.removeEventListener('keydown', keydownHandler);
}
// Resize event
window.removeEventListener('resize', resizeHandler);
// Popstate event
window.removeEventListener('popstate', close);
// Click event
off('click', clickHandler);
if (userSettings.draggable) {
// Pointer events
off('pointerdown', pointerdownHandler);
off('pointermove', pointermoveHandler);
off('pointerup', pointerupHandler);
off('pointercancel', contextmenuHandler);
off('pointerout', contextmenuHandler);
off('pointerleave', contextmenuHandler);
off('contextmenu', contextmenuHandler);
}
// Wheel event
off('wheel', wheelHandler);
};
/**
* Update userSettings
*
*/
const updateConfig = () => {
const group = groups[activeGroup];
const slider = group.slider;
if (userSettings.draggable && !slider.classList.contains('tobii__slider--is-draggable')) {
slider.classList.add('tobii__slider--is-draggable');
}
const hideButtons = !userSettings.nav || group.elementsLength === 1 || userSettings.nav === 'auto' && isTouchDevice();
setButtonState(prevButton, hideButtons, hideButtons);
setButtonState(nextButton, hideButtons, hideButtons);
const hideCounter = !userSettings.counter || group.elementsLength === 1;
counter.setAttribute('aria-hidden', hideCounter ? 'true' : 'false');
};
/**
* Update live region
*
*/
const updateAnnouncement = () => {
const group = groups[activeGroup];
const currIndex = group.currentIndex;
const total = group.elementsLength;
const trigger = group.gallery[currIndex];
const [slide, of] = userSettings.announcementLabel;
let extra;
if (trigger.hasAttribute('data-label')) {
extra = trigger.getAttribute('data-label');
} else {
const img = trigger.querySelector('img');
extra = img?.alt || '';
}
const base = `${slide} ${currIndex + 1} ${of} ${total}`;
// Announce reliably
liveRegion.textContent = '';
window.setTimeout(() => {
liveRegion.textContent = extra ? `${base}. ${extra}` : base;
}, 10);
};
/**
* Update lightbox
*
* @param {string|null} dir - Current slide direction
*/
const updateLightbox = (dir = null) => {
updateOffset();
updateCounter();
updateAnnouncement();
updateFocus(dir);
};
/**
* Reset Tobii
*
*/
const reset = () => {
if (isOpen()) close();
Object.values(groups).forEach(group => group.gallery.forEach(remove));
groups = {};
activeGroup = null;
Object.values(SUPPORTED_ELEMENTS).forEach(type => type.onReset());
};
/**
* Destroy Tobii
*
*/
const destroy = () => {
reset();
lightbox.parentNode.removeChild(lightbox);
};
/**
* Check if Tobii is open
*
*/
const isOpen = () => {
return lightbox.getAttribute('aria-hidden') === 'false';
};
/**
* Detect whether device is touch capable
*
*/
const isTouchDevice = () => {
return 'ontouchstart' in window;
};
/**
* Checks whether element's tagName is part of array
*
*/
const isIgnoreElement = el => {
return ['TEXTAREA', 'OPTION', 'INPUT', 'SELECT'].indexOf(el.tagName) !== -1 || el === prevButton || el === nextButton || el === closeButton;
};
/**
* Return current index
*
*/
const slidesIndex = () => {
return groups[activeGroup].currentIndex;
};
/**
* Return elements length
*
*/
const slidesCount = () => {
return groups[activeGroup].elementsLength;
};
/**
* Return current group
*
*/
const currentGroup = () => {
return activeGroup;
};
/**
* Bind events
* @param {String} eventName
* @param {function} callback - callback to call
*
*/
const on = (eventName, callback) => {
lightbox.addEventListener(eventName, callback);
};
/**
* Unbind events
* @param {String} eventName
* @param {function} callback - callback to call
*
*/
const off = (eventName, callback) => {
lightbox.removeEventListener(eventName, callback);
};
init(userOptions);
return {
open,
previous,
next,
close,
add: checkDependencies,
remove,
reset,
destroy,
isOpen,
slidesIndex,
select,
slidesCount,
selectGroup,
currentGroup,
on,
off
};
}
export { Tobii as default };
================================================
FILE: dist/tobii.umd.js
================================================
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.Tobii = factory());
})(this, (function () {
class ImageType {
constructor() {
this.figcaptionId = 0;
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const FIGURE = document.createElement('figure');
const IMAGE = document.createElement('img');
const THUMBNAIL = el.querySelector('img');
const LOADING_INDICATOR = document.createElement('div');
// Accessibility: allow setting focus programmatically on figure elements.
FIGURE.tabIndex = -1;
// Add role="group" to figure
FIGURE.setAttribute('role', 'group');
// Hide figure until the image is loaded
FIGURE.style.opacity = '0';
if (THUMBNAIL) {
IMAGE.alt = THUMBNAIL.alt || '';
}
IMAGE.setAttribute('data-src', el.href);
if (el.hasAttribute('data-srcset')) {
IMAGE.setAttribute('data-srcset', el.getAttribute('data-srcset'));
}
if (el.hasAttribute('data-sizes')) {
IMAGE.setAttribute('data-sizes', el.getAttribute('data-sizes'));
}
// Add image to figure
FIGURE.appendChild(IMAGE);
// Create figcaption
let captionContent;
if (typeof this.userSettings.captionText === 'function') {
captionContent = this.userSettings.captionText(el);
} else if (this.userSettings.captionsSelector === 'self' && el.hasAttribute(this.userSettings.captionAttribute)) {
captionContent = el.getAttribute(this.userSettings.captionAttribute);
} else if (this.userSettings.captionsSelector === 'img' && THUMBNAIL && THUMBNAIL.hasAttribute(this.userSettings.captionAttribute)) {
captionContent = THUMBNAIL.getAttribute(this.userSettings.captionAttribute);
}
if (this.userSettings.captions && captionContent) {
const FIGCAPTION = document.createElement('figcaption');
FIGCAPTION.id = `tobii-figcaption-${this.figcaptionId}`;
const SPAN = document.createElement('span');
if (this.userSettings.captionHTML) {
SPAN.innerHTML = captionContent;
} else {
SPAN.textContent = captionContent;
}
FIGCAPTION.appendChild(SPAN);
if (this.userSettings.captionToggle) {
const isMobile = window.innerWidth < 768;
const BUTTON = document.createElement('button');
BUTTON.className = 'caption-toggle';
BUTTON.textContent = BUTTON.title = this.userSettings.captionToggleLabel[isMobile ? 1 : 0];
BUTTON.setAttribute('aria-controls', FIGCAPTION.id);
BUTTON.setAttribute('aria-expanded', !isMobile);
if (isMobile) {
FIGCAPTION.classList.add('caption-hidden');
}
SPAN.setAttribute('aria-hidden', isMobile);
const preventAndStopEvent = event => {
event.preventDefault();
event.stopPropagation();
};
BUTTON.addEventListener('pointerdown', event => preventAndStopEvent(event));
BUTTON.addEventListener('pointerup', event => preventAndStopEvent(event));
BUTTON.addEventListener('click', event => {
preventAndStopEvent(event);
const isExpanded = BUTTON.getAttribute('aria-expanded') === 'true';
const buttonLabel = isExpanded ? this.userSettings.captionToggleLabel[1] : this.userSettings.captionToggleLabel[0];
BUTTON.textContent = BUTTON.title = buttonLabel;
BUTTON.setAttribute('aria-expanded', !isExpanded);
FIGCAPTION.classList.toggle('caption-hidden');
SPAN.setAttribute('aria-hidden', isExpanded);
});
FIGCAPTION.appendChild(BUTTON);
}
FIGURE.appendChild(FIGCAPTION);
IMAGE.setAttribute('aria-labelledby', FIGCAPTION.id);
// Add aria-label to the figure containing the caption content
FIGURE.setAttribute('aria-label', SPAN.textContent);
++this.figcaptionId;
}
// Add figure to container
container.appendChild(FIGURE);
// Create loading indicator
LOADING_INDICATOR.className = 'tobii__loader';
LOADING_INDICATOR.setAttribute('role', 'progressbar');
LOADING_INDICATOR.setAttribute('aria-label', this.userSettings.loadingIndicatorLabel);
// Add loading indicator to container
container.appendChild(LOADING_INDICATOR);
// Register type
container.setAttribute('data-type', 'image');
container.classList.add('tobii-image');
}
onPreload(container) {
// Same as preload
this.onLoad(container);
}
onLoad(container) {
const IMAGE = container.querySelector('img');
if (!IMAGE.hasAttribute('data-src')) {
return;
}
const FIGURE = container.querySelector('figure');
const LOADING_INDICATOR = container.querySelector('.tobii__loader');
const handleImageEvent = () => {
container.removeChild(LOADING_INDICATOR);
FIGURE.style.opacity = '1';
};
IMAGE.addEventListener('load', handleImageEvent);
IMAGE.addEventListener('error', handleImageEvent);
if (IMAGE.hasAttribute('data-srcset')) {
IMAGE.setAttribute('srcset', IMAGE.getAttribute('data-srcset'));
IMAGE.removeAttribute('data-srcset');
}
if (IMAGE.hasAttribute('data-sizes')) {
IMAGE.setAttribute('sizes', IMAGE.getAttribute('data-sizes'));
IMAGE.removeAttribute('data-sizes');
}
IMAGE.setAttribute('src', IMAGE.getAttribute('data-src'));
IMAGE.removeAttribute('data-src');
}
onLeave(container) {
// Nothing
}
onCleanup(container) {
// Nothing
}
onReset() {
this.figcaptionId = 0;
}
}
class IframeType {
constructor() {
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const HREF = el.hasAttribute('data-target') ? el.getAttribute('data-target') : el.getAttribute('href');
container.setAttribute('data-HREF', HREF);
if (el.hasAttribute('data-allow')) {
container.setAttribute('data-allow', el.getAttribute('data-allow'));
}
if (el.hasAttribute('data-width')) {
container.setAttribute('data-width', `${el.getAttribute('data-width')}`);
}
if (el.hasAttribute('data-height')) {
container.setAttribute('data-height', `${el.getAttribute('data-height')}`);
}
// dont create empty iframes here - very slow
// Register type
container.setAttribute('data-type', 'iframe');
container.classList.add('tobii-iframe');
}
onPreload(container) {
// Nothing
}
onLoad(container) {
let IFRAME = container.querySelector('iframe');
// Create loading indicator
const LOADING_INDICATOR = document.createElement('div');
LOADING_INDICATOR.className = 'tobii__loader';
LOADING_INDICATOR.setAttribute('role', 'progressbar');
LOADING_INDICATOR.setAttribute('aria-label', this.userSettings.loadingIndicatorLabel);
container.appendChild(LOADING_INDICATOR);
if (IFRAME == null) {
// create iframe
IFRAME = document.createElement('iframe');
const HREF = container.getAttribute('data-href');
IFRAME.setAttribute('frameborder', '0');
IFRAME.setAttribute('src', HREF);
// Set allow parameters
let allowValue = 'fullscreen';
if (HREF.includes('youtube.com')) {
allowValue += '; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
} else if (HREF.includes('vimeo.com')) {
allowValue += '; autoplay; picture-in-picture';
} else if (container.hasAttribute('data-allow')) {
allowValue = container.getAttribute('data-allow');
}
IFRAME.setAttribute('allow', allowValue);
if (container.hasAttribute('data-width')) {
IFRAME.style.maxWidth = `${container.getAttribute('data-width')}`;
}
if (container.hasAttribute('data-height')) {
IFRAME.style.maxHeight = `${container.getAttribute('data-height')}`;
}
// Hide until loaded
IFRAME.style.opacity = '0';
// Add iframe to container
container.appendChild(IFRAME);
// Handle load and error
const removeLoader = () => {
IFRAME.style.opacity = '1';
const LOADING_INDICATOR = container.querySelector('.tobii__loader');
if (LOADING_INDICATOR) container.removeChild(LOADING_INDICATOR);
};
IFRAME.addEventListener('load', removeLoader);
IFRAME.addEventListener('error', removeLoader);
} else {
// was already created
IFRAME.setAttribute('src', container.getAttribute('data-href'));
}
}
onLeave(container) {
// Nothing
}
onCleanup(container) {
const IFRAME = container.querySelector('iframe');
IFRAME.removeAttribute('src');
IFRAME.style.opacity = '0';
}
onReset() {
// Nothing
}
}
class HtmlType {
constructor() {
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const TARGET_SELECTOR = el.hasAttribute('data-target') ? el.getAttribute('data-target') : el.getAttribute('href');
const TARGET = document.querySelector(TARGET_SELECTOR);
if (!TARGET) {
throw new Error(`Ups, I can't find the target ${TARGET_SELECTOR}.`);
}
// Add content to container
container.appendChild(TARGET);
// Register type
container.setAttribute('data-type', 'html');
container.classList.add('tobii-html');
}
onPreload(container) {
// Nothing
}
onLoad(container, group) {
const VIDEO = container.querySelector('video');
if (VIDEO) {
if (VIDEO.hasAttribute('data-time') && VIDEO.readyState > 0) {
// Continue where video was stopped
VIDEO.currentTime = VIDEO.getAttribute('data-time');
}
// Start playback (and loading if necessary)
VIDEO.play();
}
const audio = container.querySelector('audio');
if (audio) {
// Start playback (and loading if necessary)
audio.play();
}
container.classList.add('tobii-group-' + group);
}
onLeave(container) {
const VIDEO = container.querySelector('video');
if (VIDEO) {
if (!VIDEO.paused) {
// Stop if video is playing
VIDEO.pause();
}
// Backup currentTime (needed for revisit)
if (VIDEO.readyState > 0) {
VIDEO.setAttribute('data-time', VIDEO.currentTime);
}
}
const audio = container.querySelector('audio');
if (audio) {
if (!audio.paused) {
// Stop if is playing
audio.pause();
}
}
}
onCleanup(container) {
const VIDEO = container.querySelector('video');
if (VIDEO) {
if (VIDEO.readyState > 0 && VIDEO.readyState < 3 && VIDEO.duration !== VIDEO.currentTime) {
// Some data has been loaded but not the whole package.
// In order to save bandwidth, stop downloading as soon as possible.
const VIDEO_CLONE = VIDEO.cloneNode(true);
this._removeSources(VIDEO);
VIDEO.load();
VIDEO.parentNode.removeChild(VIDEO);
container.appendChild(VIDEO_CLONE);
}
}
}
onReset() {
// Nothing
}
/**
* Remove all `src` attributes
*
* @param {HTMLElement} el - Element to remove all `src` attributes
*/
_removeSources(el) {
const SOURCES = el.querySelectorAll('src');
if (SOURCES) {
SOURCES.forEach(source => {
source.removeAttribute('src');
});
}
}
}
class YoutubeType {
constructor() {
this.playerId = 0;
this.PLAYER = [];
this.userSettings = null;
}
init(el, container, userSettings) {
this.userSettings = userSettings;
const IFRAME_PLACEHOLDER = document.createElement('div');
// Add iframePlaceholder to container
container.appendChild(IFRAME_PLACEHOLDER);
this.PLAYER[this.playerId] = new window.YT.Player(IFRAME_PLACEHOLDER, {
host: 'https://www.youtube-nocookie.com',
height: el.getAttribute('data-height') || '360',
width: el.getAttribute('data-width') || '640',
videoId: el.getAttribute('data-id'),
playerVars: {
controls: el.getAttribute('data-controls') || 1,
rel: 0,
playsinline: 1
}
});
// Set player ID
container.setAttribute('data-player', this.playerId);
// Register type
container.setAttribute('data-type', 'youtube');
container.classList.add('tobii-youtube');
this.playerId++;
}
onPreload(container) {
// Nothing
}
onLoad(container) {
this.PLAYER[container.getAttribute('data-player')].playVideo();
}
onLeave(container) {
if (this.PLAYER[container.getAttribute('data-player')].getPlayerState() === 1) {
this.PLAYER[container.getAttribute('data-player')].pauseVideo();
}
}
onCleanup(container) {
if (this.PLAYER[container.getAttribute('data-player')].getPlayerState() === 1) {
this.PLAYER[container.getAttribute('data-player')].pauseVideo();
}
}
onReset() {
// Nothing
}
}
/**
* Tobii
*
* @author midzer
* @version 3.2.0
* @url https://github.com/midzer/tobii
*
* MIT License
*/
function Tobii(userOptions) {
/**
* Global variables
*
*/
const SUPPORTED_ELEMENTS = {
image: new ImageType(),
// default
html: new HtmlType(),
iframe: new IframeType(),
youtube: new YoutubeType()
};
const FOCUSABLE_ELEMENTS = ['a[href]:not([tabindex^="-"]):not([inert])', 'area[href]:not([tabindex^="-"]):not([inert])', 'input:not([disabled]):not([inert])', 'select:not([disabled]):not([inert])', 'textarea:not([disabled]):not([inert])', 'button:not([disabled]):not([inert])', 'iframe:not([tabindex^="-"]):not([inert])', 'audio:not([tabindex^="-"]):not([inert])', 'video:not([tabindex^="-"]):not([inert])', '[contenteditable]:not([tabindex^="-"]):not([inert])', '[tabindex]:not([tabindex^="-"]):not([inert])'];
let userSettings = {};
const WAITING_ELS = [];
const GROUP_ATTS = {
gallery: [],
slider: null,
sliderElements: [],
elementsLength: 0,
currentIndex: 0,
x: 0
};
let lightbox = null;
let prevButton = null;
let nextButton = null;
let closeButton = null;
let counter = null;
let lastFocus = null;
let offset = null;
let isYouTubeDependencyLoaded = false;
let groups = {};
let activeGroup = null;
let pointerDownCache = [];
let lastTapTime = 0;
let liveRegion = null;
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const DOUBLE_TAP_TIME = 500; // milliseconds
const SCALE_SENSITIVITY = 10;
const TRANSFORM = {
element: null,
originX: 0,
originY: 0,
translateX: 0,
translateY: 0,
scale: MIN_SCALE
};
const DRAG = {
startX: 0,
startY: 0,
x: 0,
y: 0,
distance: 0
};
/**
* Merge default options with user options
*
* @param {Object} userOptions - Optional user options
* @returns {Object} - Custom options
*/
const mergeOptions = userOptions => {
// Default options
const OPTIONS = {
selector: '.lightbox',
captions: true,
captionsSelector: 'img',
captionAttribute: 'alt',
captionText: null,
captionHTML: false,
captionToggle: true,
captionToggleLabel: ['Hide caption', 'Show caption'],
nav: 'auto',
navText: ['<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>', '<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>'],
navLabel: ['Previous image', 'Next image'],
announcementLabel: ['Slide', 'of'],
close: true,
closeText: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path stroke="none" d="M0 0h24v24H0z"/><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>',
closeLabel: 'Close lightbox',
dialogTitle: 'Lightbox',
loadingIndicatorLabel: 'Image loading',
counter: true,
keyboard: true,
zoom: false,
zoomText: '<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="16 4 20 4 20 8" /><line x1="14" y1="10" x2="20" y2="4" /><polyline points="8 20 4 20 4 16" /><line x1="4" y1="20" x2="10" y2="14" /><polyline points="16 20 20 20 20 16" /><line x1="14" y1="14" x2="20" y2="20" /><polyline points="8 4 4 4 4 8" /><line x1="4" y1="4" x2="10" y2="10" /></svg>',
docClose: true,
swipeClose: true,
hideScrollbar: true,
draggable: true,
threshold: 100,
theme: 'tobii--theme-default'
};
return {
...OPTIONS,
...userOptions
};
};
/**
* Init
*
*/
const init = userOptions => {
// Merge user options into defaults
userSettings = mergeOptions(userOptions);
// Create the lightbox container
lightbox = document.createElement('div');
lightbox.setAttribute('role', 'dialog');
lightbox.setAttribute('aria-hidden', 'true');
lightbox.setAttribute('aria-modal', 'true');
lightbox.setAttribute('aria-label', userSettings.dialogTitle);
lightbox.classList.add('tobii');
// Add theme class
lightbox.classList.add(userSettings.theme);
// Create the previous button
prevButton = document.createElement('button');
prevButton.className = 'tobii__btn tobii__btn--previous';
prevButton.setAttribute('type', 'button');
prevButton.setAttribute('aria-label', userSettings.navLabel[0]);
prevButton.innerHTML = userSettings.navText[0];
lightbox.appendChild(prevButton);
// Create the next button
nextButton = document.createElement('button');
nextButton.className = 'tobii__btn tobii__btn--next';
nextButton.setAttribute('type', 'button');
nextButton.setAttribute('aria-label', userSettings.navLabel[1]);
nextButton.innerHTML = userSettings.navText[1];
lightbox.appendChild(nextButton);
// Create the close button
closeButton = document.createElement('button');
closeButton.className = 'tobii__btn tobii__btn--close';
closeButton.setAttribute('type', 'button');
closeButton.setAttribute('aria-label', userSettings.closeLabel);
closeButton.innerHTML = userSettings.closeText;
lightbox.appendChild(closeButton);
// Create the counter
counter = document.createElement('div');
counter.className = 'tobii__counter';
lightbox.appendChild(counter);
// Create the live region
liveRegion = document.createElement('div');
liveRegion.className = 'tobii__sr';
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
lightbox.appendChild(liveRegion);
// Append to body
document.body.appendChild(lightbox);
// Init only
if (!userSettings.selector) return;
// Get a list of all elements within the document
const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(userSettings.selector);
if (!LIGHTBOX_TRIGGER_ELS) {
throw new Error(`Ups, I can't find the selector ${userSettings.selector} on this website.`);
}
LIGHTBOX_TRIGGE
gitextract_tri8bljk/
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ └── npm-publish.yml
├── .gitignore
├── .nvmrc
├── .stylelintrc
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── add-banner.js
├── demo/
│ ├── index.html
│ └── styles.css
├── dist/
│ ├── tobii.js
│ ├── tobii.modern.js
│ ├── tobii.module.js
│ └── tobii.umd.js
├── eslint.config.js
├── package.json
└── src/
├── js/
│ ├── browser.js
│ ├── index.js
│ └── types/
│ ├── html.js
│ ├── iframe.js
│ ├── image.js
│ └── youtube.js
└── scss/
├── _variables.scss
└── tobii.scss
SYMBOL INDEX (171 symbols across 9 files)
FILE: dist/tobii.js
class ImageType (line 1) | class ImageType {
method constructor (line 2) | constructor() {
method init (line 6) | init(el, container, userSettings) {
method onPreload (line 105) | onPreload(container) {
method onLoad (line 109) | onLoad(container) {
method onLeave (line 133) | onLeave(container) {
method onCleanup (line 136) | onCleanup(container) {
method onReset (line 139) | onReset() {
class IframeType (line 144) | class IframeType {
method constructor (line 145) | constructor() {
method init (line 148) | init(el, container, userSettings) {
method onPreload (line 168) | onPreload(container) {
method onLoad (line 171) | onLoad(container) {
method onLeave (line 223) | onLeave(container) {
method onCleanup (line 226) | onCleanup(container) {
method onReset (line 231) | onReset() {
class HtmlType (line 236) | class HtmlType {
method constructor (line 237) | constructor() {
method init (line 240) | init(el, container, userSettings) {
method onPreload (line 255) | onPreload(container) {
method onLoad (line 258) | onLoad(container, group) {
method onLeave (line 276) | onLeave(container) {
method onCleanup (line 297) | onCleanup(container) {
method onReset (line 311) | onReset() {
method _removeSources (line 320) | _removeSources(el) {
class YoutubeType (line 330) | class YoutubeType {
method constructor (line 331) | constructor() {
method init (line 336) | init(el, container, userSettings) {
method onPreload (line 362) | onPreload(container) {
method onLoad (line 365) | onLoad(container) {
method onLeave (line 368) | onLeave(container) {
method onCleanup (line 373) | onCleanup(container) {
method onReset (line 378) | onReset() {
function Tobii (line 392) | function Tobii(userOptions) {
FILE: dist/tobii.modern.js
function _extends (line 1) | function _extends() {
class ImageType (line 11) | class ImageType {
method constructor (line 12) | constructor() {
method init (line 16) | init(el, container, userSettings) {
method onPreload (line 115) | onPreload(container) {
method onLoad (line 119) | onLoad(container) {
method onLeave (line 143) | onLeave(container) {
method onCleanup (line 146) | onCleanup(container) {
method onReset (line 149) | onReset() {
class IframeType (line 154) | class IframeType {
method constructor (line 155) | constructor() {
method init (line 158) | init(el, container, userSettings) {
method onPreload (line 178) | onPreload(container) {
method onLoad (line 181) | onLoad(container) {
method onLeave (line 233) | onLeave(container) {
method onCleanup (line 236) | onCleanup(container) {
method onReset (line 241) | onReset() {
class HtmlType (line 246) | class HtmlType {
method constructor (line 247) | constructor() {
method init (line 250) | init(el, container, userSettings) {
method onPreload (line 265) | onPreload(container) {
method onLoad (line 268) | onLoad(container, group) {
method onLeave (line 286) | onLeave(container) {
method onCleanup (line 307) | onCleanup(container) {
method onReset (line 321) | onReset() {
method _removeSources (line 330) | _removeSources(el) {
class YoutubeType (line 340) | class YoutubeType {
method constructor (line 341) | constructor() {
method init (line 346) | init(el, container, userSettings) {
method onPreload (line 372) | onPreload(container) {
method onLoad (line 375) | onLoad(container) {
method onLeave (line 378) | onLeave(container) {
method onCleanup (line 383) | onCleanup(container) {
method onReset (line 388) | onReset() {
function Tobii (line 393) | function Tobii(userOptions) {
FILE: dist/tobii.module.js
class ImageType (line 1) | class ImageType {
method constructor (line 2) | constructor() {
method init (line 6) | init(el, container, userSettings) {
method onPreload (line 105) | onPreload(container) {
method onLoad (line 109) | onLoad(container) {
method onLeave (line 133) | onLeave(container) {
method onCleanup (line 136) | onCleanup(container) {
method onReset (line 139) | onReset() {
class IframeType (line 144) | class IframeType {
method constructor (line 145) | constructor() {
method init (line 148) | init(el, container, userSettings) {
method onPreload (line 168) | onPreload(container) {
method onLoad (line 171) | onLoad(container) {
method onLeave (line 223) | onLeave(container) {
method onCleanup (line 226) | onCleanup(container) {
method onReset (line 231) | onReset() {
class HtmlType (line 236) | class HtmlType {
method constructor (line 237) | constructor() {
method init (line 240) | init(el, container, userSettings) {
method onPreload (line 255) | onPreload(container) {
method onLoad (line 258) | onLoad(container, group) {
method onLeave (line 276) | onLeave(container) {
method onCleanup (line 297) | onCleanup(container) {
method onReset (line 311) | onReset() {
method _removeSources (line 320) | _removeSources(el) {
class YoutubeType (line 330) | class YoutubeType {
method constructor (line 331) | constructor() {
method init (line 336) | init(el, container, userSettings) {
method onPreload (line 362) | onPreload(container) {
method onLoad (line 365) | onLoad(container) {
method onLeave (line 368) | onLeave(container) {
method onCleanup (line 373) | onCleanup(container) {
method onReset (line 378) | onReset() {
function Tobii (line 392) | function Tobii(userOptions) {
FILE: dist/tobii.umd.js
class ImageType (line 6) | class ImageType {
method constructor (line 7) | constructor() {
method init (line 11) | init(el, container, userSettings) {
method onPreload (line 110) | onPreload(container) {
method onLoad (line 114) | onLoad(container) {
method onLeave (line 138) | onLeave(container) {
method onCleanup (line 141) | onCleanup(container) {
method onReset (line 144) | onReset() {
class IframeType (line 149) | class IframeType {
method constructor (line 150) | constructor() {
method init (line 153) | init(el, container, userSettings) {
method onPreload (line 173) | onPreload(container) {
method onLoad (line 176) | onLoad(container) {
method onLeave (line 228) | onLeave(container) {
method onCleanup (line 231) | onCleanup(container) {
method onReset (line 236) | onReset() {
class HtmlType (line 241) | class HtmlType {
method constructor (line 242) | constructor() {
method init (line 245) | init(el, container, userSettings) {
method onPreload (line 260) | onPreload(container) {
method onLoad (line 263) | onLoad(container, group) {
method onLeave (line 281) | onLeave(container) {
method onCleanup (line 302) | onCleanup(container) {
method onReset (line 316) | onReset() {
method _removeSources (line 325) | _removeSources(el) {
class YoutubeType (line 335) | class YoutubeType {
method constructor (line 336) | constructor() {
method init (line 341) | init(el, container, userSettings) {
method onPreload (line 367) | onPreload(container) {
method onLoad (line 370) | onLoad(container) {
method onLeave (line 373) | onLeave(container) {
method onCleanup (line 378) | onCleanup(container) {
method onReset (line 383) | onReset() {
function Tobii (line 397) | function Tobii(userOptions) {
FILE: src/js/index.js
function Tobii (line 16) | function Tobii (userOptions) {
FILE: src/js/types/html.js
class HtmlType (line 1) | class HtmlType {
method constructor (line 2) | constructor () {
method init (line 6) | init (el, container, userSettings) {
method onPreload (line 24) | onPreload (container) {
method onLoad (line 28) | onLoad (container, group) {
method onLeave (line 50) | onLeave (container) {
method onCleanup (line 75) | onCleanup (container) {
method onReset (line 94) | onReset () {
method _removeSources (line 103) | _removeSources (el) {
FILE: src/js/types/iframe.js
class IframeType (line 1) | class IframeType {
method constructor (line 2) | constructor () {
method init (line 6) | init (el, container, userSettings) {
method onPreload (line 29) | onPreload (container) {
method onLoad (line 33) | onLoad (container) {
method onLeave (line 93) | onLeave (container) {
method onCleanup (line 97) | onCleanup (container) {
method onReset (line 103) | onReset () {
FILE: src/js/types/image.js
class ImageType (line 1) | class ImageType {
method constructor (line 2) | constructor () {
method init (line 7) | init (el, container, userSettings) {
method onPreload (line 123) | onPreload (container) {
method onLoad (line 128) | onLoad (container) {
method onLeave (line 160) | onLeave (container) {
method onCleanup (line 164) | onCleanup (container) {
method onReset (line 168) | onReset () {
FILE: src/js/types/youtube.js
class YoutubeType (line 1) | class YoutubeType {
method constructor (line 2) | constructor () {
method init (line 8) | init (el, container, userSettings) {
method onPreload (line 38) | onPreload (container) {
method onLoad (line 42) | onLoad (container) {
method onLeave (line 46) | onLeave (container) {
method onCleanup (line 52) | onCleanup (container) {
method onReset (line 58) | onReset () {
Condensed preview — 25 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (303K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 640,
"preview": "# These are supported funding model platforms\n\ngithub: midzer\npatreon: # Replace with a single Patreon username\nopen_col"
},
{
"path": ".github/workflows/npm-publish.yml",
"chars": 1136,
"preview": "# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created\n# For "
},
{
"path": ".gitignore",
"chars": 32,
"preview": ".vscode\n.idea\nnode_modules\ntest\n"
},
{
"path": ".nvmrc",
"chars": 13,
"preview": "lts/hydrogen\n"
},
{
"path": ".stylelintrc",
"chars": 1439,
"preview": "{\n \"rules\": {\n \"indentation\": 2,\n \"string-quotes\": \"single\",\n \"no-duplicate-selectors\": true,\n \"color-hex-c"
},
{
"path": "CHANGELOG.md",
"chars": 3594,
"preview": "# Changelog\n\n## v3.2.0\n\n### New\n\n- introduce ARIA live region instead of focus approach\n + configurable `announcementLa"
},
{
"path": "LICENSE.md",
"chars": 1097,
"preview": "# The MIT License (MIT)\n\nCopyright (c) 2017-2020 rqrauhvmra, 2021 midzer\n\nPermission is hereby granted, free of charge, "
},
{
"path": "README.md",
"chars": 12018,
"preview": "# Tobii\n\n[](https://github.com/midzer/tobii/releases)\n[ {\n this.figcaptionId = 0;\n this.userSettings = null;\n }\n init(el, container, u"
},
{
"path": "dist/tobii.modern.js",
"chars": 49579,
"preview": "function _extends() {\n return _extends = Object.assign ? Object.assign.bind() : function (n) {\n for (var e = 1; e < "
},
{
"path": "dist/tobii.module.js",
"chars": 49375,
"preview": "class ImageType {\n constructor() {\n this.figcaptionId = 0;\n this.userSettings = null;\n }\n init(el, container, u"
},
{
"path": "dist/tobii.umd.js",
"chars": 52592,
"preview": "(function (global, factory) {\n typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory("
},
{
"path": "eslint.config.js",
"chars": 90,
"preview": "import neostandard from 'neostandard'\n\nexport default neostandard({\n env: ['browser']\n})\n"
},
{
"path": "package.json",
"chars": 2044,
"preview": "{\n \"name\": \"@midzer/tobii\",\n \"version\": \"3.2.0\",\n \"description\": \"An accessible, open-source lightbox with no depende"
},
{
"path": "src/js/browser.js",
"chars": 141,
"preview": "import '../scss/tobii.scss'\nimport Tobii from './index'\n\nif (typeof module < 'u') {\n module.exports = Tobii\n} else {\n "
},
{
"path": "src/js/index.js",
"chars": 36623,
"preview": "/**\n * Tobii\n *\n * @author midzer\n * @version 3.2.0\n * @url https://github.com/midzer/tobii\n *\n * MIT License\n */\n\nimpor"
},
{
"path": "src/js/types/html.js",
"chars": 2640,
"preview": "class HtmlType {\n constructor () {\n this.userSettings = null\n }\n\n init (el, container, userSettings) {\n this.us"
},
{
"path": "src/js/types/iframe.js",
"chars": 3187,
"preview": "class IframeType {\n constructor () {\n this.userSettings = null\n }\n\n init (el, container, userSettings) {\n this."
},
{
"path": "src/js/types/image.js",
"chars": 5443,
"preview": "class ImageType {\n constructor () {\n this.figcaptionId = 0\n this.userSettings = null\n }\n\n init (el, container, "
},
{
"path": "src/js/types/youtube.js",
"chars": 1560,
"preview": "class YoutubeType {\n constructor () {\n this.playerId = 0\n this.PLAYER = []\n this.userSettings = null\n }\n\n in"
},
{
"path": "src/scss/_variables.scss",
"chars": 786,
"preview": ":root {\n --tobii-base-font-size: 1rem; /* also update --tobii-slide-max-height */\n\n --tobii-transition-duration: 0.3s;"
},
{
"path": "src/scss/tobii.scss",
"chars": 7207,
"preview": "@import 'variables';\n\n/**\n * Lightbox link\n *\n */\n\n.tobii-zoom {\n border: 0;\n box-shadow: none;\n display: inline-bloc"
}
]
About this extraction
This page contains the full source code of the midzer/tobii GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 25 files (283.4 KB), approximately 75.1k tokens, and a symbol index with 171 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.