From 2cb4c6b4b23f8b87d68c8088653fe16d0ac6dc9f Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 20 Oct 2024 03:47:18 +0400 Subject: [PATCH] Refactoring fullscreen viewer, added close swipe action for mobile --- src/lib/components/FullscreenViewer.js | 211 ++++++++++++++++++ .../components/ImageShowFullscreenButton.js | 91 ++------ src/styles/content/listing.scss | 12 +- 3 files changed, 236 insertions(+), 78 deletions(-) create mode 100644 src/lib/components/FullscreenViewer.js diff --git a/src/lib/components/FullscreenViewer.js b/src/lib/components/FullscreenViewer.js new file mode 100644 index 0000000..9df1cd5 --- /dev/null +++ b/src/lib/components/FullscreenViewer.js @@ -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; +} diff --git a/src/lib/components/ImageShowFullscreenButton.js b/src/lib/components/ImageShowFullscreenButton.js index dd50015..7a2fc09 100644 --- a/src/lib/components/ImageShowFullscreenButton.js +++ b/src/lib/components/ImageShowFullscreenButton.js @@ -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; } /** diff --git a/src/styles/content/listing.scss b/src/styles/content/listing.scss index 24e1b65..7251a96 100644 --- a/src/styles/content/listing.scss +++ b/src/styles/content/listing.scss @@ -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; + } }