mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2025-12-24 07:12:57 +00:00
Merge pull request #43 from koloml/feature/fullscreen-component
Fullscreen Viewer: Moved viewer logic into separate component, added closing with swipe for touch devices, added scroll lock
This commit is contained in:
211
src/lib/components/FullscreenViewer.js
Normal file
211
src/lib/components/FullscreenViewer.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
|
||||
export class FullscreenViewer extends BaseComponent {
|
||||
/** @type {HTMLVideoElement} */
|
||||
#videoElement;
|
||||
/** @type {HTMLImageElement} */
|
||||
#imageElement;
|
||||
|
||||
/** @type {number|null} */
|
||||
#touchId = null;
|
||||
/** @type {number|null} */
|
||||
#startX = null;
|
||||
/** @type {number|null} */
|
||||
#startY = null;
|
||||
/** @type {boolean|null} */
|
||||
#isClosingSwipeStarted = null;
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
build() {
|
||||
this.container.classList.add('fullscreen-viewer');
|
||||
|
||||
this.#videoElement = document.createElement('video');
|
||||
this.#imageElement = document.createElement('img');
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
init() {
|
||||
document.addEventListener('keydown', this.#onDocumentKeyPressed.bind(this));
|
||||
this.on('click', this.#close.bind(this));
|
||||
|
||||
this.on('touchstart', this.#onTouchStart.bind(this));
|
||||
this.on('touchmove', this.#onTouchMove.bind(this));
|
||||
this.on('touchend', this.#onTouchEnd.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
#onTouchStart(event) {
|
||||
if (this.#touchId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstTouch = event.touches.item(0);
|
||||
|
||||
if (!firstTouch) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#touchId = firstTouch.identifier;
|
||||
this.#startX = firstTouch.clientX;
|
||||
this.#startY = firstTouch.clientY;
|
||||
this.container.classList.add(FullscreenViewer.#swipeState);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
#onTouchEnd(event) {
|
||||
if (this.#touchId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const endedTouch = Array.from(event.changedTouches)
|
||||
.find(touch => touch.identifier === this.#touchId);
|
||||
|
||||
if (!endedTouch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const verticalDistance = Math.abs(endedTouch.clientY - this.#startY);
|
||||
const requiredClosingDistance = window.innerHeight / 3;
|
||||
|
||||
if (this.#isClosingSwipeStarted && verticalDistance > requiredClosingDistance) {
|
||||
this.#close();
|
||||
}
|
||||
|
||||
this.#touchId = null;
|
||||
this.#startX = null;
|
||||
this.#startY = null;
|
||||
this.#isClosingSwipeStarted = null;
|
||||
|
||||
this.container.classList.remove(FullscreenViewer.#swipeState);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.container.style.removeProperty(FullscreenViewer.#offsetProperty);
|
||||
this.container.style.removeProperty(FullscreenViewer.#opacityProperty);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
#onTouchMove(event) {
|
||||
if (this.#touchId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#isClosingSwipeStarted === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const changedTouch of event.changedTouches) {
|
||||
if (changedTouch.identifier !== this.#touchId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const verticalDistance = changedTouch.clientY - this.#startY;
|
||||
|
||||
if (this.#isClosingSwipeStarted === null) {
|
||||
const horizontalDistance = changedTouch.clientX - this.#startX;
|
||||
|
||||
if (Math.abs(verticalDistance) >= FullscreenViewer.#minRequiredDistance) {
|
||||
this.#isClosingSwipeStarted = true;
|
||||
} else if (Math.abs(horizontalDistance) >= FullscreenViewer.#minRequiredDistance) {
|
||||
this.#isClosingSwipeStarted = false;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.container.style.setProperty(
|
||||
FullscreenViewer.#offsetProperty,
|
||||
verticalDistance.toString().concat('px')
|
||||
);
|
||||
|
||||
const maxDistance = window.innerHeight * 2;
|
||||
let opacity = 1;
|
||||
|
||||
if (verticalDistance !== 0) {
|
||||
opacity -= Math.min(1, Math.abs(verticalDistance) / maxDistance);
|
||||
}
|
||||
|
||||
this.container.style.setProperty(
|
||||
FullscreenViewer.#opacityProperty,
|
||||
opacity.toString()
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#onDocumentKeyPressed(event) {
|
||||
if (event.code === 'Escape' || event.code === 'Esc') {
|
||||
this.#close();
|
||||
}
|
||||
}
|
||||
|
||||
#close() {
|
||||
this.container.classList.remove(FullscreenViewer.#shownState);
|
||||
document.body.style.overflow = null;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.#videoElement.volume = 0;
|
||||
this.#videoElement.pause();
|
||||
this.#videoElement.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
*/
|
||||
show(url) {
|
||||
requestAnimationFrame(() => {
|
||||
this.container.classList.add(FullscreenViewer.#shownState);
|
||||
document.body.style.overflow = 'hidden';
|
||||
});
|
||||
|
||||
if (FullscreenViewer.#isVideoUrl(url)) {
|
||||
this.#imageElement.remove();
|
||||
|
||||
this.#videoElement.src = url;
|
||||
this.#videoElement.volume = 0;
|
||||
this.#videoElement.autoplay = true;
|
||||
this.#videoElement.loop = true;
|
||||
this.#videoElement.controls = true;
|
||||
|
||||
this.container.append(this.#videoElement);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.#videoElement.remove();
|
||||
|
||||
this.#imageElement.src = url;
|
||||
|
||||
this.container.append(this.#imageElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @return {boolean}
|
||||
*/
|
||||
static #isVideoUrl(url) {
|
||||
return url.endsWith('.mp4') || url.endsWith('.webm');
|
||||
}
|
||||
|
||||
static #offsetProperty = '--offset';
|
||||
static #opacityProperty = '--opacity';
|
||||
static #shownState = 'shown';
|
||||
static #swipeState = 'swiped';
|
||||
static #minRequiredDistance = 50;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings.js";
|
||||
import {FullscreenViewer} from "$lib/components/FullscreenViewer.js";
|
||||
|
||||
export class ImageShowFullscreenButton extends BaseComponent {
|
||||
/**
|
||||
@@ -11,7 +12,6 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
|
||||
build() {
|
||||
this.container.innerText = '🔍';
|
||||
ImageShowFullscreenButton.#resolveFullscreenViewer();
|
||||
|
||||
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
|
||||
}
|
||||
@@ -45,95 +45,36 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
}
|
||||
|
||||
#onButtonClicked() {
|
||||
const imageViewer = ImageShowFullscreenButton.#resolveFullscreenViewer();
|
||||
const largeSourceUrl = this.#mediaBoxTools.mediaBox.imageLinks.large;
|
||||
|
||||
let imageElement = imageViewer.querySelector('img');
|
||||
let videoElement = imageViewer.querySelector('video');
|
||||
|
||||
if (imageElement) {
|
||||
imageElement.remove();
|
||||
}
|
||||
|
||||
if (videoElement) {
|
||||
videoElement.remove();
|
||||
}
|
||||
|
||||
if (largeSourceUrl.endsWith('.webm') || largeSourceUrl.endsWith('.mp4')) {
|
||||
videoElement ??= document.createElement('video');
|
||||
videoElement.src = largeSourceUrl;
|
||||
videoElement.volume = 0;
|
||||
videoElement.autoplay = true;
|
||||
videoElement.loop = true;
|
||||
videoElement.controls = true;
|
||||
|
||||
imageViewer.appendChild(videoElement);
|
||||
} else {
|
||||
imageElement ??= document.createElement('img');
|
||||
imageElement.src = largeSourceUrl;
|
||||
|
||||
imageViewer.appendChild(imageElement);
|
||||
}
|
||||
|
||||
imageViewer.classList.add('shown');
|
||||
ImageShowFullscreenButton
|
||||
.#resolveViewer()
|
||||
.show(this.#mediaBoxTools.mediaBox.imageLinks.large);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {HTMLElement|null}
|
||||
* @type {FullscreenViewer|null}
|
||||
*/
|
||||
static #fullscreenViewerElement = null;
|
||||
static #viewer = null;
|
||||
|
||||
/**
|
||||
* @return {HTMLElement}
|
||||
* @return {FullscreenViewer}
|
||||
*/
|
||||
static #resolveFullscreenViewer() {
|
||||
this.#fullscreenViewerElement ??= this.#buildFullscreenViewer();
|
||||
return this.#fullscreenViewerElement;
|
||||
static #resolveViewer() {
|
||||
this.#viewer ??= this.#buildViewer();
|
||||
return this.#viewer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {HTMLElement}
|
||||
* @return {FullscreenViewer}
|
||||
*/
|
||||
static #buildFullscreenViewer() {
|
||||
static #buildViewer() {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('fullscreen-viewer');
|
||||
const viewer = new FullscreenViewer(element);
|
||||
|
||||
viewer.initialize();
|
||||
|
||||
document.body.append(element);
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
// When ESC pressed
|
||||
if (event.code === 'Escape' || event.code === 'Esc') {
|
||||
this.#closeFullscreenViewer(element);
|
||||
}
|
||||
});
|
||||
|
||||
element.addEventListener('click', () => {
|
||||
this.#closeFullscreenViewer(element);
|
||||
});
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} [viewerElement]
|
||||
*/
|
||||
static #closeFullscreenViewer(viewerElement = null) {
|
||||
viewerElement ??= this.#resolveFullscreenViewer();
|
||||
viewerElement.classList.remove('shown');
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const videoElement = viewerElement.querySelector('video');
|
||||
|
||||
if (!videoElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stopping and muting the video
|
||||
requestAnimationFrame(() => {
|
||||
videoElement.volume = 0;
|
||||
videoElement.pause();
|
||||
videoElement.remove();
|
||||
})
|
||||
return viewer;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,10 +5,17 @@ export default class MiscSettings extends CacheableSettings {
|
||||
super("misc");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<boolean>}
|
||||
*/
|
||||
async resolveFullscreenViewerEnabled() {
|
||||
return this._resolveSetting("fullscreenViewer", true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} isEnabled
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async setFullscreenViewerEnabled(isEnabled) {
|
||||
return this._writeSetting("fullscreenViewer", isEnabled);
|
||||
}
|
||||
|
||||
@@ -160,9 +160,9 @@
|
||||
.fullscreen-viewer {
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
opacity: var(--opacity, 0);
|
||||
background-color: black;
|
||||
transition: opacity 0.1s;
|
||||
transition: opacity 0.1s, transform 0.1s;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
@@ -171,6 +171,7 @@
|
||||
display: flex;
|
||||
justify-content: stretch;
|
||||
align-items: stretch;
|
||||
transform: translateY(var(--offset, 0));
|
||||
|
||||
img, video {
|
||||
object-fit: contain;
|
||||
@@ -179,7 +180,12 @@
|
||||
}
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
opacity: var(--opacity, 1);
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
&.swiped {
|
||||
opacity: var(--opacity, 1);
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user