From 94d0ba65c39ed20da0e0491173ecc9e7bfc62408 Mon Sep 17 00:00:00 2001 From: Eduard Kuzmenko Date: Fri, 17 Feb 2023 19:15:35 +0400 Subject: [PATCH] Pinch-to-Zoom Load full image in media viewer --- src/components/appMediaViewerBase.ts | 574 +++++++++++++++++++-------- src/components/swipeHandler.ts | 252 +++++++++++- src/helpers/lethargy.ts | 99 +++++ src/helpers/number/clamp.ts | 2 +- src/helpers/number/isBetween.ts | 3 + src/helpers/windowSize.ts | 9 +- src/index.hbs | 3 +- src/scss/partials/_mediaViewer.scss | 25 ++ webpack.common.js | 2 +- 9 files changed, 776 insertions(+), 193 deletions(-) create mode 100644 src/helpers/lethargy.ts create mode 100644 src/helpers/number/isBetween.ts diff --git a/src/components/appMediaViewerBase.ts b/src/components/appMediaViewerBase.ts index 6188f021..a81eba90 100644 --- a/src/components/appMediaViewerBase.ts +++ b/src/components/appMediaViewerBase.ts @@ -4,6 +4,9 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ +// * zoom part from WebZ +// * https://github.com/Ajaxy/telegram-tt/blob/069f4f5b2f2c7c22529ccced876842e7f9cb81f4/src/components/mediaViewer/MediaViewerSlides.tsx + import type {MyDocument} from '../lib/appManagers/appDocsManager'; import type {MyPhoto} from '../lib/appManagers/appPhotosManager'; import deferredPromise from '../helpers/cancellablePromise'; @@ -20,10 +23,10 @@ import ButtonIcon from './buttonIcon'; import {ButtonMenuItemOptions} from './buttonMenu'; import ButtonMenuToggle from './buttonMenuToggle'; import ProgressivePreloader from './preloader'; -import SwipeHandler from './swipeHandler'; +import SwipeHandler, {ZoomDetails} from './swipeHandler'; import {formatFullSentTime} from '../helpers/date'; import appNavigationController, {NavigationItem} from './appNavigationController'; -import {Message} from '../layer'; +import {Message, PhotoSize} from '../layer'; import findUpClassName from '../helpers/dom/findUpClassName'; import renderImageFromUrl, {renderImageFromUrlPromise} from '../helpers/dom/renderImageFromUrl'; import getVisibleRect from '../helpers/dom/getVisibleRect'; @@ -51,18 +54,23 @@ import overlayCounter from '../helpers/overlayCounter'; import appDownloadManager from '../lib/appManagers/appDownloadManager'; import wrapPeerTitle from './wrappers/peerTitle'; import {toastNew} from './toast'; +import clamp from '../helpers/number/clamp'; +import debounce from '../helpers/schedulers/debounce'; +import isBetween from '../helpers/number/isBetween'; const ZOOM_STEP = 0.5; const ZOOM_INITIAL_VALUE = 1; const ZOOM_MIN_VALUE = 0.5; const ZOOM_MAX_VALUE = 4; -// TODO: масштабирование картинок (не SVG) при ресайзе, и правильный возврат на исходную позицию -// TODO: картинки "обрезаются" если возвращаются или появляются с места, где есть их перекрытие (топбар, поле ввода) -// TODO: видео в мобильной вёрстке, если показываются элементы управления: если свайпнуть в сторону, то элементы вернутся на место, т.е. прыгнут - это не ок, надо бы замаскировать - export const MEDIA_VIEWER_CLASSNAME = 'media-viewer'; +type Transform = { + x: number; + y: number; + scale: number; +}; + export default class AppMediaViewerBase< ContentAdditionType extends string, ButtonsAdditionType extends string, @@ -83,15 +91,10 @@ export default class AppMediaViewerBase< protected preloader: ProgressivePreloader = null; protected preloaderStreamable: ProgressivePreloader = null; - // protected targetContainer: HTMLElement = null; - // protected loadMore: () => void = null; - protected log: ReturnType; protected isFirstOpen = true; - // protected needLoadMore = true; - protected pageEl = document.getElementById('page-chats') as HTMLDivElement; protected setMoverPromise: Promise; @@ -113,18 +116,27 @@ export default class AppMediaViewerBase< btnIn: HTMLElement, rangeSelector: RangeSelector } = {} as any; - // protected zoomValue = ZOOM_INITIAL_VALUE; - protected zoomSwipeHandler: SwipeHandler; - protected zoomSwipeStartX = 0; - protected zoomSwipeStartY = 0; - protected zoomSwipeX = 0; - protected zoomSwipeY = 0; + protected transform: Transform = {x: 0, y: 0, scale: ZOOM_INITIAL_VALUE}; + protected isZooming: boolean; + protected isGesturingNow: boolean; + protected isZoomingNow: boolean; + protected draggingType: 'wheel' | 'touchmove' | 'mousemove'; + protected initialContentRect: DOMRect; protected ctrlKeyDown: boolean; protected releaseSingleMedia: ReturnType; protected navigationItem: NavigationItem; protected managers: AppManagers; + protected swipeHandler: SwipeHandler; + protected closing: boolean; + + protected lastTransform: Transform = this.transform; + protected lastZoomCenter: {x: number, y: number} = this.transform; + protected lastDragOffset: {x: number, y: number} = this.transform; + protected lastDragDelta: {x: number, y: number} = this.transform; + protected lastGestureTime: number; + protected clampZoomDebounced: ReturnType void>>; get target() { return this.listLoader.current; @@ -204,12 +216,12 @@ export default class AppMediaViewerBase< this.zoomElements.container.classList.add('zoom-container'); this.zoomElements.btnOut = ButtonIcon('zoomout', {noRipple: true}); - attachClickEvent(this.zoomElements.btnOut, () => this.changeZoom(false)); + attachClickEvent(this.zoomElements.btnOut, () => this.addZoomStep(false)); this.zoomElements.btnIn = ButtonIcon('zoomin', {noRipple: true}); - attachClickEvent(this.zoomElements.btnIn, () => this.changeZoom(true)); + attachClickEvent(this.zoomElements.btnIn, () => this.addZoomStep(true)); this.zoomElements.rangeSelector = new RangeSelector({ - step: ZOOM_STEP, + step: 0.01, min: ZOOM_MIN_VALUE, max: ZOOM_MAX_VALUE, withTransition: true @@ -222,7 +234,9 @@ export default class AppMediaViewerBase< this.zoomElements.container.append(this.zoomElements.btnOut, this.zoomElements.rangeSelector.container, this.zoomElements.btnIn); - this.wholeDiv.append(this.zoomElements.container); + if(!IS_TOUCH_SUPPORTED && false) { + this.wholeDiv.append(this.zoomElements.container); + } // * content this.content.main = document.createElement('div'); @@ -286,9 +300,9 @@ export default class AppMediaViewerBase< }); attachClickEvent(this.buttons.zoom, () => { - if(this.isZooming()) this.toggleZoom(false); + if(this.isZooming) this.resetZoom(); else { - this.changeZoom(true); + this.addZoomStep(true); } }); @@ -301,117 +315,350 @@ export default class AppMediaViewerBase< else this.onPrevClick(item); }; - if(IS_TOUCH_SUPPORTED) { - const swipeHandler = new SwipeHandler({ - element: this.wholeDiv, - onSwipe: (xDiff, yDiff) => { - if(isFullScreen()) { - return; - } + const adjustPosition = (xDiff: number, yDiff: number) => { + const [x, y] = [xDiff - this.lastDragOffset.x, yDiff - this.lastDragOffset.y]; + const [transform, inBoundsX, inBoundsY] = this.calculateOffsetBoundaries({ + x: this.transform.x + x, + y: this.transform.y + y, + scale: this.transform.scale + }); - const percents = Math.abs(xDiff) / windowSize.width; - if(percents > .2 || xDiff > 125) { - // console.log('will swipe', xDiff); + this.lastDragDelta = { + x, + y + }; - if(xDiff > 0) { - this.buttons.prev.click(); - } else { - this.buttons.next.click(); - } + this.lastDragOffset = { + x: xDiff, + y: yDiff + }; - return true; - } + this.setTransform(transform); - const percentsY = Math.abs(yDiff) / windowSize.height; - if(percentsY > .2 || yDiff > 125) { - this.close(); - return true; - } + return {inBoundsX, inBoundsY}; + }; - return false; - }, - verifyTouchTarget: (e) => { - // * Fix for seek input - if(findUpClassName(e.target, 'ckin__controls') || - findUpClassName(e.target, 'media-viewer-caption') || - findUpClassName(e.target, 'media-viewer-topbar')) { - return false; + const setLastGestureTime = debounce(() => { + this.lastGestureTime = Date.now(); + }, 500, false, true); + + this.clampZoomDebounced = debounce(() => { + this.onSwipeReset(); + }, 300, false, true); + + this.swipeHandler = new SwipeHandler({ + element: this.wholeDiv, + onReset: this.onSwipeReset, + onFirstSwipe: this.onSwipeFirst, + onSwipe: (xDiff, yDiff, e, cancelDrag) => { + if(isFullScreen()) { + return; + } + + if(this.isZooming && !this.isZoomingNow) { + setLastGestureTime(); + + this.draggingType = e.type as any; + const {inBoundsX, inBoundsY} = adjustPosition(xDiff, yDiff); + cancelDrag?.(!inBoundsX, !inBoundsY); + + return; + } + + if(this.isZoomingNow || !IS_TOUCH_SUPPORTED) { + return; + } + + const percents = Math.abs(xDiff) / windowSize.width; + if(percents > .2 || Math.abs(xDiff) > 125) { + if(xDiff > 0) { + this.buttons.prev.click(); + } else { + this.buttons.next.click(); } return true; } - }); + + const percentsY = Math.abs(yDiff) / windowSize.height; + if(percentsY > .2 || Math.abs(yDiff) > 125) { + this.close(); + return true; + } + + return false; + }, + onZoom: this.onZoom, + onDoubleClick: ({centerX, centerY}) => { + if(this.isZooming) { + this.resetZoom(); + } else { + const scale = ZOOM_INITIAL_VALUE + 2; + this.changeZoomByPosition(centerX, centerY, scale); + } + }, + verifyTouchTarget: (e) => { + // * Fix for seek input + if(findUpClassName(e.target, 'ckin__controls') || + findUpClassName(e.target, 'media-viewer-caption') || + (findUpClassName(e.target, 'media-viewer-topbar') && e.type !== 'wheel')) { + return false; + } + + return true; + }, + cursor: '' + // cursor: 'move' + }); + } + + protected onSwipeFirst = () => { + this.lastDragOffset = this.lastDragDelta = {x: 0, y: 0}; + this.lastTransform = {...this.transform}; + this.moversContainer.classList.add('no-transition'); + this.isGesturingNow = true; + this.lastGestureTime = Date.now(); + this.clampZoomDebounced.clearTimeout(); + + if(!this.lastTransform.x && !this.lastTransform.y && !this.isZooming) { + this.initialContentRect = this.content.media.getBoundingClientRect(); } + }; + + protected onSwipeReset = () => { + // move + this.moversContainer.classList.remove('no-transition'); + this.clampZoomDebounced.clearTimeout(); + + const {draggingType} = this; + this.isZoomingNow = false; + this.isGesturingNow = false; + this.draggingType = undefined; + + if(this.closing) { + return; + } + + if(this.transform.scale > ZOOM_INITIAL_VALUE) { + // Get current content boundaries + const s1 = Math.min(this.transform.scale, ZOOM_MAX_VALUE); + const scaleFactor = s1 / this.transform.scale; + + // Calculate new position based on the last zoom center to keep the zoom center + // at the same position when bouncing back from max zoom + let x1 = this.transform.x * scaleFactor + (this.lastZoomCenter.x - scaleFactor * this.lastZoomCenter.x); + let y1 = this.transform.y * scaleFactor + (this.lastZoomCenter.y - scaleFactor * this.lastZoomCenter.y); + + // If scale didn't change, we need to add inertia to pan gesture + if(draggingType && draggingType !== 'wheel' && this.lastTransform.scale === this.transform.scale) { + // Arbitrary pan velocity coefficient + const k = 0.1; + + // Calculate user gesture velocity + const elapsedTime = Math.max(1, Date.now() - this.lastGestureTime); + const Vx = Math.abs(this.lastDragOffset.x) / elapsedTime; + const Vy = Math.abs(this.lastDragOffset.y) / elapsedTime; + + // Add extra distance based on gesture velocity and last pan delta + x1 -= Math.abs(this.lastDragOffset.x) * Vx * k * -this.lastDragDelta.x; + y1 -= Math.abs(this.lastDragOffset.y) * Vy * k * -this.lastDragDelta.y; + } + + const [transform] = this.calculateOffsetBoundaries({x: x1, y: y1, scale: s1}); + this.lastTransform = transform; + this.setTransform(transform); + } else if(this.transform.scale < ZOOM_INITIAL_VALUE) { + this.resetZoom(); + } + }; + + protected onZoom = ({ + initialCenterX, + initialCenterY, + zoom, + zoomAdd, + currentCenterX, + currentCenterY, + dragOffsetX, + dragOffsetY, + zoomFactor + }: ZoomDetails) => { + initialCenterX ||= windowSize.width / 2; + initialCenterY ||= windowSize.height / 2; + currentCenterX ||= windowSize.width / 2; + currentCenterY ||= windowSize.height / 2; + + this.isZoomingNow = true; + + const zoomMaxBounceValue = ZOOM_MAX_VALUE * 3; + const scale = zoomAdd ? clamp(this.lastTransform.scale + zoomAdd, ZOOM_MIN_VALUE, zoomMaxBounceValue) : (zoom ?? clamp(this.lastTransform.scale * zoomFactor, ZOOM_MIN_VALUE, zoomMaxBounceValue)); + const scaleFactor = scale / this.lastTransform.scale; + const offsetX = Math.abs(Math.min(this.lastTransform.x, 0)); + const offsetY = Math.abs(Math.min(this.lastTransform.y, 0)); + + // Save last zoom center for bounce back effect + this.lastZoomCenter = { + x: currentCenterX, + y: currentCenterY + }; + + // Calculate new center relative to the shifted image + const scaledCenterX = offsetX + initialCenterX; + const scaledCenterY = offsetY + initialCenterY; + + const {scaleOffsetX, scaleOffsetY} = this.calculateScaleOffset({x: scaledCenterX, y: scaledCenterY, scale: scaleFactor}); + + const [transform] = this.calculateOffsetBoundaries({ + x: this.lastTransform.x + scaleOffsetX + dragOffsetX, + y: this.lastTransform.y + scaleOffsetY + dragOffsetY, + scale + }); + + this.setTransform(transform); + }; + + protected changeZoomByPosition(x: number, y: number, scale: number) { + const {scaleOffsetX, scaleOffsetY} = this.calculateScaleOffset({x, y, scale}); + const transform = this.calculateOffsetBoundaries({ + x: scaleOffsetX, + y: scaleOffsetY, + scale + })[0]; + + this.setTransform(transform); + } + + protected setTransform(transform: Transform) { + this.transform = transform; + this.changeZoom(transform.scale); + } + + // Calculate how much we need to shift the image to keep the zoom center at the same position + protected calculateScaleOffset({x, y, scale}: { + x: number, + y: number, + scale: number + }) { + return { + scaleOffsetX: x - scale * x, + scaleOffsetY: y - scale * y + }; } protected toggleZoom(enable?: boolean) { - const isVisible = this.isZooming(); + const isVisible = this.isZooming; + const auto = enable === undefined; if(this.zoomElements.rangeSelector.mousedown || this.ctrlKeyDown) { enable = true; } - if(isVisible === enable) return; + enable ??= !isVisible; - if(enable === undefined) { - enable = !isVisible; + if(isVisible === enable) { + return; } this.buttons.zoom.classList.toggle('zoom-in', !enable); - this.zoomElements.container.classList.toggle('is-visible', enable); - const zoomValue = enable ? this.zoomElements.rangeSelector.value : 1; - this.setZoomValue(zoomValue); - this.zoomElements.rangeSelector.setProgress(zoomValue); + this.zoomElements.container.classList.toggle('is-visible', this.isZooming = enable); + this.wholeDiv.classList.toggle('is-zooming', enable); + + if(auto || !enable) { + const zoomValue = enable ? this.transform.scale : ZOOM_INITIAL_VALUE; + this.setZoomValue(zoomValue); + this.zoomElements.rangeSelector.setProgress(zoomValue); + } if(this.videoPlayer) { this.videoPlayer.lockControls(enable ? false : undefined); } + } - if(enable) { - if(!this.zoomSwipeHandler) { - let lastDiffX: number, lastDiffY: number; - const multiplier = 1; - this.zoomSwipeHandler = new SwipeHandler({ - element: this.moversContainer, - onFirstSwipe: () => { - lastDiffX = lastDiffY = 0; - this.moversContainer.classList.add('no-transition'); - }, - onSwipe: (xDiff, yDiff) => { - [xDiff, yDiff] = [xDiff * multiplier, yDiff * multiplier]; - this.zoomSwipeX += xDiff - lastDiffX; - this.zoomSwipeY += yDiff - lastDiffY; - [lastDiffX, lastDiffY] = [xDiff, yDiff]; + protected addZoomStep(add: boolean) { + this.addZoom(ZOOM_STEP * (add ? 1 : -1)); + } - this.setZoomValue(); - }, - onReset: () => { - this.moversContainer.classList.remove('no-transition'); - }, - cursor: 'move' - }); - } else { - this.zoomSwipeHandler.setListeners(); - } + protected resetZoom() { + this.setTransform({ + x: 0, + y: 0, + scale: ZOOM_INITIAL_VALUE + }); + } - this.zoomElements.rangeSelector.setProgress(zoomValue); - } else if(!enable) { - this.zoomSwipeHandler.removeListeners(); + protected changeZoom(value = this.transform.scale) { + this.transform.scale = value; + this.zoomElements.rangeSelector.setProgress(value); + this.setZoomValue(value); + } + + protected addZoom(value: number) { + this.lastTransform = this.transform; + this.onZoom({ + zoomAdd: value, + currentCenterX: 0, + currentCenterY: 0, + initialCenterX: 0, + initialCenterY: 0, + dragOffsetX: 0, + dragOffsetY: 0 + }); + this.lastTransform = this.transform; + this.clampZoomDebounced(); + } + + protected getZoomBounce() { + return this.isGesturingNow && IS_TOUCH_SUPPORTED ? 50 : 0; + } + + protected calculateOffsetBoundaries = ( + {x, y, scale}: Transform, + offsetTop = 0 + ): [Transform, boolean, boolean] => { + if(!this.initialContentRect) return [{x, y, scale}, true, true]; + // Get current content boundaries + let inBoundsX = true; + let inBoundsY = true; + + const {minX, maxX, minY, maxY} = this.getZoomBoundaries(scale, offsetTop); + + inBoundsX = isBetween(x, maxX, minX); + x = clamp(x, maxX, minX); + + inBoundsY = isBetween(y, maxY, minY); + y = clamp(y, maxY, minY); + + return [{x, y, scale}, inBoundsX, inBoundsY]; + }; + + protected getZoomBoundaries(scale = this.transform.scale, offsetTop = 0) { + if(!this.initialContentRect) { + return {minX: 0, maxX: 0, minY: 0, maxY: 0}; } + + const centerX = (windowSize.width - windowSize.width * scale) / 2; + const centerY = (windowSize.height - windowSize.height * scale) / 2; + + // If content is outside window we calculate offset boundaries + // based on initial content rect and current scale + const minX = Math.max(-this.initialContentRect.left * scale, centerX); + const maxX = windowSize.width - this.initialContentRect.right * scale; + + const minY = Math.max(-this.initialContentRect.top * scale + offsetTop, centerY); + const maxY = windowSize.height - this.initialContentRect.bottom * scale; + + return {minX, maxX, minY, maxY}; } - protected changeZoom(add: boolean) { - this.zoomElements.rangeSelector.addProgress(ZOOM_STEP * (add ? 1 : -1)); - this.setZoomValue(); - } + protected setZoomValue = (value = this.transform.scale) => { + this.initialContentRect ??= this.content.media.getBoundingClientRect(); - protected setZoomValue = (value = this.zoomElements.rangeSelector.value) => { // this.zoomValue = value; if(value === ZOOM_INITIAL_VALUE) { - this.zoomSwipeX = 0; - this.zoomSwipeY = 0; + this.transform.x = 0; + this.transform.y = 0; } - this.moversContainer.style.transform = `matrix(${value}, 0, 0, ${value}, ${this.zoomSwipeX}, ${this.zoomSwipeY})`; + this.moversContainer.style.transform = `translate3d(${this.transform.x.toFixed(3)}px, ${this.transform.y.toFixed(3)}px, 0px) scale(${value.toFixed(3)})`; this.zoomElements.btnOut.classList.toggle('inactive', value === ZOOM_MIN_VALUE); this.zoomElements.btnIn.classList.toggle('inactive', value === ZOOM_MAX_VALUE); @@ -419,10 +666,6 @@ export default class AppMediaViewerBase< this.toggleZoom(value !== ZOOM_INITIAL_VALUE); }; - protected isZooming() { - return this.zoomElements.container.classList.contains('is-visible'); - } - protected setBtnMenuToggle(buttons: ButtonMenuItemOptions[]) { const btnMenuToggle = ButtonMenuToggle({buttonOptions: {onlyMobile: true}, direction: 'bottom-left', buttons}); this.topbar.append(btnMenuToggle); @@ -435,6 +678,9 @@ export default class AppMediaViewerBase< if(this.setMoverAnimationPromise) return Promise.reject(); + this.closing = true; + this.swipeHandler?.removeListeners(); + if(this.navigationItem) { appNavigationController.removeItem(this.navigationItem); } @@ -459,8 +705,6 @@ export default class AppMediaViewerBase< this.removeGlobalListeners(); - this.zoomSwipeHandler = undefined; - promise.finally(() => { this.wholeDiv.remove(); this.toggleOverlay(false); @@ -480,23 +724,13 @@ export default class AppMediaViewerBase< } protected removeGlobalListeners() { - if(this.zoomSwipeHandler) { - this.zoomSwipeHandler.removeListeners(); - } - window.removeEventListener('keydown', this.onKeyDown); window.removeEventListener('keyup', this.onKeyUp); - window.removeEventListener('wheel', this.onWheel, {capture: true}); } protected setGlobalListeners() { - if(this.isZooming()) { - this.zoomSwipeHandler.setListeners(); - } - window.addEventListener('keydown', this.onKeyDown); window.addEventListener('keyup', this.onKeyUp); - if(!IS_TOUCH_SUPPORTED) window.addEventListener('wheel', this.onWheel, {passive: false, capture: true}); } onClick = (e: MouseEvent) => { @@ -521,7 +755,7 @@ export default class AppMediaViewerBase< return; } - const isZooming = this.isZooming(); + const isZooming = this.isZooming; let mover: HTMLElement = null; const classNames = ['ckin__player', 'media-viewer-buttons', 'media-viewer-author', 'media-viewer-caption', 'zoom-container']; if(isZooming) { @@ -550,12 +784,12 @@ export default class AppMediaViewerBase< let good = true; if(key === 'ArrowRight') { - this.buttons.next.click(); + !this.isZooming && this.buttons.next.click(); } else if(key === 'ArrowLeft') { - this.buttons.prev.click(); + !this.isZooming && this.buttons.prev.click(); } else if(key === '-' || key === '=') { if(this.ctrlKeyDown) { - this.changeZoom(key === '='); + this.addZoomStep(key === '='); } } else { good = false; @@ -578,26 +812,12 @@ export default class AppMediaViewerBase< if(!(e.ctrlKey || e.metaKey)) { this.ctrlKeyDown = false; - if(this.isZooming()) { + if(this.isZooming) { this.setZoomValue(); } } }; - private onWheel = (e: WheelEvent) => { - if(overlayCounter.overlaysActive > 1 || (findUpClassName(e.target, 'media-viewer-caption') && !this.ctrlKeyDown)) { - return; - } - - cancelEvent(e); - - if(this.ctrlKeyDown) { - const scrollingUp = e.deltaY < 0; - // if(!scrollingUp && !this.isZooming()) return; - this.changeZoom(!!scrollingUp); - } - }; - protected async setMoverToTarget(target: HTMLElement, closing = false, fromRight = 0) { this.dispatchEvent('setMoverBefore'); @@ -608,7 +828,7 @@ export default class AppMediaViewerBase< // mover.append(this.buttons.prev, this.buttons.next); } - const zoomValue = this.isZooming() && closing /* && false */ ? this.zoomElements.rangeSelector.value : ZOOM_INITIAL_VALUE; + const zoomValue = this.isZooming && closing /* && false */ ? this.transform.scale : ZOOM_INITIAL_VALUE; /* if(!(zoomValue > 1 && closing)) */ this.removeCenterFromMover(mover); const wasActive = fromRight !== 0; @@ -749,12 +969,8 @@ export default class AppMediaViewerBase< // let borderRadius = '0px 0px 0px 0px'; if(closing && zoomValue !== 1) { - // const width = this.moversContainer.scrollWidth * scaleX; - // const height = this.moversContainer.scrollHeight * scaleY; - const willBeLeft = windowSize.width / 2 - rect.width / 2; - const willBeTop = windowSize.height / 2 - rect.height / 2; - const left = rect.left - willBeLeft/* + (width - rect.width) / 2 */; - const top = rect.top - willBeTop/* + (height - rect.height) / 2 */; + const left = rect.left - (windowSize.width * scaleX - rect.width) / 2; + const top = rect.top - (windowSize.height * scaleY - rect.height) / 2; this.moversContainer.style.transform = `matrix(${scaleX}, 0, 0, ${scaleY}, ${left}, ${top})`; } else { mover.style.transform = transform; @@ -1288,6 +1504,23 @@ export default class AppMediaViewerBase< this.moveTheMover(this.content.mover, fromRight === 1); this.setNewMover(); } else { + this.navigationItem = { + type: 'media', + onPop: (canAnimate) => { + if(this.setMoverAnimationPromise) { + return false; + } + + if(!canAnimate && IS_MOBILE_SAFARI) { + this.wholeDiv.remove(); + } + + this.close(); + } + }; + + appNavigationController.pushItem(this.navigationItem); + this.toggleOverlay(true); this.setGlobalListeners(); await setAuthorPromise; @@ -1298,21 +1531,6 @@ export default class AppMediaViewerBase< } this.toggleWholeActive(true); - - if(!IS_MOBILE_SAFARI) { - this.navigationItem = { - type: 'media', - onPop: (canAnimate) => { - if(this.setMoverAnimationPromise) { - return false; - } - - this.close(); - } - }; - - appNavigationController.pushItem(this.navigationItem); - } } // //////this.log('wasActive:', wasActive); @@ -1358,8 +1576,8 @@ export default class AppMediaViewerBase< const supportsStreaming: boolean = !!(isDocument && media.supportsStreaming); const preloader = supportsStreaming ? this.preloaderStreamable : this.preloader; - const getCacheContext = () => { - return this.managers.thumbsStorage.getCacheContext(media, size?.type); + const getCacheContext = (type = size?.type) => { + return this.managers.thumbsStorage.getCacheContext(media, type); }; let setMoverPromise: Promise; @@ -1496,7 +1714,7 @@ export default class AppMediaViewerBase< this.videoPlayer = undefined; }, {once: true}); - if(this.isZooming()) { + if(this.isZooming) { this.videoPlayer.lockControls(false); } /* div.append(video); @@ -1630,6 +1848,10 @@ export default class AppMediaViewerBase< const load = async() => { const cancellablePromise = isDocument ? appDownloadManager.downloadMediaURL({media}) : appDownloadManager.downloadMediaURL({media, thumb: size}); + const photoSizes = !isDocument && media.sizes.slice().filter((size) => (size as PhotoSize.photoSize).w) as PhotoSize.photoSize[]; + photoSizes?.sort((a, b) => b.size - a.size); + const cancellableFullPromise = !isDocument && appDownloadManager.downloadMediaURL({media, thumb: photoSizes?.[0]}); + onAnimationEnd.then(async() => { if(!(await getCacheContext()).url) { this.preloader.attachPromise(cancellablePromise); @@ -1652,31 +1874,41 @@ export default class AppMediaViewerBase< if(mediaSizes.isMobile) { const imgs = mover.querySelectorAll('img'); - if(imgs && imgs.length) { - imgs.forEach((img) => { - img.classList.remove('thumbnail'); // может здесь это вообще не нужно - }); - } + imgs.forEach((img) => { + img.classList.remove('thumbnail'); // может здесь это вообще не нужно + }); } } else { const div = mover.firstElementChild && mover.firstElementChild.classList.contains('media-viewer-aspecter') ? mover.firstElementChild : mover; - const haveImage = div.firstElementChild?.tagName === 'IMG' ? div.firstElementChild as HTMLImageElement : null; - if(!haveImage || haveImage.src !== url) { + const haveImage = ['CANVAS', 'IMG'].includes(div.firstElementChild?.tagName) ? div.firstElementChild as HTMLElement : null; + if((haveImage as HTMLImageElement)?.src !== url) { const image = new Image(); image.classList.add('thumbnail'); // this.log('will renderImageFromUrl:', image, div, target); renderImageFromUrl(image, url, () => { - this.updateMediaSource(target, url, 'img'); + fastRaf(() => { + this.updateMediaSource(target, url, 'img'); - if(haveImage) { + if(haveImage) { + fastRaf(() => { + haveImage.remove(); + }); + } + + div.append(image); + }); + }, false); + + cancellableFullPromise?.then((url) => { + const fullImage = new Image(); + fullImage.classList.add('thumbnail'); + renderImageFromUrl(fullImage, url, () => { fastRaf(() => { - haveImage.remove(); + image.replaceWith(fullImage); }); - } - - div.append(image); + }, false); }); } } diff --git a/src/components/swipeHandler.ts b/src/components/swipeHandler.ts index 1587211c..1d961ead 100644 --- a/src/components/swipeHandler.ts +++ b/src/components/swipeHandler.ts @@ -4,6 +4,9 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ +// * zoom part from WebZ +// * https://github.com/Ajaxy/telegram-tt/blob/069f4f5b2f2c7c22529ccced876842e7f9cb81f4/src/util/captureEvents.ts + import cancelEvent from '../helpers/dom/cancelEvent'; import IS_TOUCH_SUPPORTED from '../environment/touchSupport'; import safeAssign from '../helpers/object/safeAssign'; @@ -13,12 +16,18 @@ import ListenerSetter, {Listener, ListenerOptions} from '../helpers/listenerSett import {attachContextMenuListener} from '../helpers/dom/attachContextMenuListener'; import pause from '../helpers/schedulers/pause'; import deferredPromise from '../helpers/cancellablePromise'; +import clamp from '../helpers/number/clamp'; +import debounce from '../helpers/schedulers/debounce'; +import {logger} from '../lib/logger'; +import isSwipingBackSafari from '../helpers/dom/isSwipingBackSafari'; +import windowSize from '../helpers/windowSize'; type E = { clientX: number, clientY: number, target: EventTarget, - button?: number + button?: number, + type?: string }; type EE = E | (Exclude & { @@ -29,6 +38,18 @@ const getEvent = (e: EE) => { return 'touches' in e ? e.touches[0] : e; }; +function getDistance(a: Touch, b?: Touch) { + if(!b) return 0; + return Math.hypot((b.pageX - a.pageX), (b.pageY - a.pageY)); +} + +function getTouchCenter(a: Touch, b: Touch) { + return { + x: (a.pageX + b.pageX) / 2, + y: (a.pageY + b.pageY) / 2 + }; +} + const attachGlobalListenerTo = document; let RESET_GLOBAL = false; @@ -43,24 +64,45 @@ export type SwipeHandlerOptions = { onFirstSwipe?: SwipeHandler['onFirstSwipe'], onReset?: SwipeHandler['onReset'], onStart?: SwipeHandler['onStart'], + onZoom?: SwipeHandler['onZoom'], + onDrag?: SwipeHandler['onDrag'], + onDoubleClick?: SwipeHandler['onDoubleClick'], cursor?: SwipeHandler['cursor'], cancelEvent?: SwipeHandler['cancelEvent'], listenerOptions?: SwipeHandler['listenerOptions'], setCursorTo?: HTMLElement, middleware?: Middleware, - withDelay?: boolean + withDelay?: boolean, + minZoom?: number, + maxZoom?: number }; const TOUCH_MOVE_OPTIONS: ListenerOptions = {passive: false}; const MOUSE_MOVE_OPTIONS: ListenerOptions = false as any; +const WHEEL_OPTIONS: ListenerOptions = {capture: true, passive: false}; + +export type ZoomDetails = { + zoom?: number; + zoomFactor?: number; + zoomAdd?: number; + initialCenterX: number; + initialCenterY: number; + dragOffsetX: number; + dragOffsetY: number; + currentCenterX: number; + currentCenterY: number; +}; export default class SwipeHandler { private element: HTMLElement; - private onSwipe: (xDiff: number, yDiff: number, e: EE) => boolean | void; + private onSwipe: (xDiff: number, yDiff: number, e: EE, cancelDrag?: (x: boolean, y: boolean) => void) => boolean | void; private verifyTouchTarget: (evt: EE) => boolean | Promise; private onFirstSwipe: (e: EE) => void; private onReset: () => void; private onStart: () => void; + private onZoom: (details: ZoomDetails) => void; + private onDrag: (e: EE, captureEvent: E, details: {dragOffsetX: number, dragOffsetY: number}, cancelDrag: (x: boolean, y: boolean) => void) => void; + private onDoubleClick: (details: {centerX: number, centerY: number}) => void; private cursor: 'grabbing' | 'move' | 'row-resize' | 'col-resize' | 'nesw-resize' | 'nwse-resize' | 'ne-resize' | 'se-resize' | 'sw-resize' | 'nw-resize' | 'n-resize' | 'e-resize' | 's-resize' | 'w-resize' | ''; private cancelEvent: boolean; private listenerOptions: ListenerOptions; @@ -79,9 +121,20 @@ export default class SwipeHandler { private withDelay: boolean; private listenerSetter: ListenerSetter; + private initialDistance: number; + private initialTouchCenter: ReturnType; + private initialDragOffset: {x: number, y: number}; + private isDragCanceled: {x: boolean, y: boolean}; + private wheelZoom: number; + private releaseWheelDrag: ReturnType void>>; + private releaseWheelZoom: ReturnType void>>; + + private log: ReturnType; + constructor(options: SwipeHandlerOptions) { safeAssign(this, options); + this.log = logger('SWIPE-HANDLER'); this.cursor ??= 'grabbing'; this.cancelEvent ??= true; // this.listenerOptions ??= false as any; @@ -98,6 +151,9 @@ export default class SwipeHandler { this.reset(); this.removeListeners(); }); + + this.releaseWheelDrag = debounce(this.reset, 150, false); + this.releaseWheelZoom = debounce(this.reset, 150, false); } public setListeners() { @@ -105,6 +161,10 @@ export default class SwipeHandler { // @ts-ignore this.listenerSetter.add(this.element)('mousedown', this.handleStart, this.listenerOptions); this.listenerSetter.add(attachGlobalListenerTo)('mouseup', this.reset); + + if(this.onZoom || this.onDoubleClick) { + this.listenerSetter.add(this.element)('wheel', this.handleWheel, WHEEL_OPTIONS); + } } else { if(this.withDelay) { attachContextMenuListener({ @@ -122,11 +182,19 @@ export default class SwipeHandler { this.listenerSetter.add(this.element)('touchstart', this.handleStart, this.listenerOptions); } + if(this.onDoubleClick) { + this.listenerSetter.add(this.element)('dblclick', (e) => { + this.onDoubleClick({centerX: e.pageX, centerY: e.pageY}); + }); + } + this.listenerSetter.add(attachGlobalListenerTo)('touchend', this.reset); } } public removeListeners() { + this.log('remove listeners'); + this.reset(); this.listenerSetter.removeAll(); } @@ -157,9 +225,21 @@ export default class SwipeHandler { this.eventUp = this.isMouseDown = undefined; + + if(this.onZoom) { + this.initialDistance = 0; + this.initialTouchCenter = { + x: windowSize.width / 2, + y: windowSize.height / 2 + }; + this.initialDragOffset = {x: 0, y: 0}; + this.isDragCanceled = {x: false, y: false}; + this.wheelZoom = 1; + } } protected reset = (e?: Event) => { + this.log('reset'); /* if(e) { cancelEvent(e); } */ @@ -175,11 +255,38 @@ export default class SwipeHandler { this.onReset?.(); } + this.releaseWheelDrag?.clearTimeout(); + this.releaseWheelZoom?.clearTimeout(); + this.resetValues(); }; + protected setHadMove(_e: EE) { + if(!this.hadMove) { + this.log('had move'); + this.hadMove = true; + this.setCursorTo.style.setProperty('cursor', this.cursor, 'important'); + this.onFirstSwipe?.(_e); + } + } + + protected dispatchOnSwipe(...args: Parameters) { + const onSwipeResult = this.onSwipe(...args); + if(onSwipeResult !== undefined && onSwipeResult) { + this.reset(); + } + } + protected handleStart = async(_e: EE) => { + this.log('start'); + if(this.isMouseDown) { + const touches = (_e as any as TouchEvent).touches; + if(touches?.length === 2) { + this.initialDistance = getDistance(touches[0], touches[1]); + this.initialTouchCenter = getTouchCenter(touches[0], touches[1]); + } + return; } @@ -188,11 +295,31 @@ export default class SwipeHandler { return; } - if(this.verifyTouchTarget && !(await this.verifyTouchTarget(_e))) { - return this.reset(); + if(isSwipingBackSafari(_e as any)) { + return; } const tempId = ++this.tempId; + + const verifyResult = this.verifyTouchTarget?.(_e); + if(verifyResult !== undefined) { + let result: any; + if(verifyResult instanceof Promise) { + // const tempId = this.tempId; + result = await verifyResult; + + if(this.tempId !== tempId) { + return; + } + } else { + result = verifyResult; + } + + if(!result) { + return this.reset(); + } + } + this.isMouseDown = true; if(this.withDelay && !IS_TOUCH_SUPPORTED) { @@ -245,6 +372,12 @@ export default class SwipeHandler { cancelEvent(_e as any); } + if(this.releaseWheelDrag?.isDebounced() || this.releaseWheelZoom?.isDebounced()) { + return; + } + + this.log('move'); + const e = this.eventUp = getEvent(_e); const xUp = e.clientX; const yUp = e.clientY; @@ -257,18 +390,109 @@ export default class SwipeHandler { return; } - this.hadMove = true; + this.setHadMove(_e); + } - if(!IS_TOUCH_SUPPORTED) { - this.setCursorTo.style.setProperty('cursor', this.cursor, 'important'); + const touches = (_e as any as TouchEvent).touches; + if(this.onZoom && this.initialDistance > 0 && touches.length === 2) { + const endDistance = getDistance(touches[0], touches[1]); + const touchCenter = getTouchCenter(touches[0], touches[1]); + const dragOffsetX = touchCenter.x - this.initialTouchCenter.x; + const dragOffsetY = touchCenter.y - this.initialTouchCenter.y; + const zoomFactor = endDistance / this.initialDistance; + const details: ZoomDetails = { + zoomFactor, + initialCenterX: this.initialTouchCenter.x, + initialCenterY: this.initialTouchCenter.y, + dragOffsetX, + dragOffsetY, + currentCenterX: touchCenter.x, + currentCenterY: touchCenter.y + }; + + this.onZoom(details); + } + + this.dispatchOnSwipe(xDiff, yDiff, _e); + }; + + protected handleWheel = (e: WheelEvent) => { + if(!this.hadMove && this.verifyTouchTarget) { + const result = this.verifyTouchTarget(e); + if(result !== undefined && !result) { + this.reset(e); + return; + } + } + + cancelEvent(e); + + this.log('wheel'); + + if(this.onDoubleClick && Object.is(e.deltaX, -0) && Object.is(e.deltaY, -0) && e.ctrlKey) { + this.onWheelCapture(e); + this.onDoubleClick({centerX: e.pageX, centerY: e.pageY}); + this.reset(); + return; + } + + const metaKeyPressed = e.metaKey || e.ctrlKey || e.shiftKey; + if(metaKeyPressed) { + // * fix zooming while dragging is in inertia + if(this.releaseWheelDrag?.isDebounced()) { + this.reset(); } - this.onFirstSwipe?.(_e); - } - - const onSwipeResult = this.onSwipe(xDiff, yDiff, _e); - if(onSwipeResult !== undefined && onSwipeResult) { - this.reset(); + this.onWheelZoom(e); + } else { + this.handleWheelDrag(e); } }; + + protected handleWheelDrag = (e: WheelEvent) => { + this.log('wheel drag'); + + this.onWheelCapture(e); + // Ignore wheel inertia if drag is canceled in this direction + if(!this.isDragCanceled.x || Math.sign(this.initialDragOffset.x) === Math.sign(e.deltaX)) { + this.initialDragOffset.x -= e.deltaX; + } + if(!this.isDragCanceled.y || Math.sign(this.initialDragOffset.y) === Math.sign(e.deltaY)) { + this.initialDragOffset.y -= e.deltaY; + } + const {x, y} = this.initialDragOffset; + this.releaseWheelDrag(e); + this.dispatchOnSwipe(x, y, e, (dx, dy) => { + this.isDragCanceled = {x: dx, y: dy}; + }); + }; + + protected onWheelCapture = (e: WheelEvent) => { + if(this.hadMove) return; + this.log('wheel capture'); + this.handleStart(e); + this.setHadMove(e); + this.initialTouchCenter = {x: e.x, y: e.y}; + }; + + protected onWheelZoom = (e: WheelEvent) => { + if(!this.onZoom) return; + this.log('wheel zoom'); + this.onWheelCapture(e); + const dragOffsetX = e.x - this.initialTouchCenter.x; + const dragOffsetY = e.y - this.initialTouchCenter.y; + const delta = clamp(e.deltaY, -25, 25); + this.wheelZoom -= delta * 0.01; + const details: ZoomDetails = { + zoomAdd: this.wheelZoom - 1, + initialCenterX: this.initialTouchCenter.x, + initialCenterY: this.initialTouchCenter.y, + dragOffsetX, + dragOffsetY, + currentCenterX: e.x, + currentCenterY: e.y + }; + this.onZoom(details); + this.releaseWheelZoom(e); + } } diff --git a/src/helpers/lethargy.ts b/src/helpers/lethargy.ts new file mode 100644 index 00000000..b1f5e206 --- /dev/null +++ b/src/helpers/lethargy.ts @@ -0,0 +1,99 @@ +/** + * Lethargy help distinguish between scroll events initiated by the user, and those by inertial scrolling. + * Lethargy does not have external dependencies. + * + * @param stability - Specifies the length of the rolling average. + * In effect, the larger the value, the smoother the curve will be. + * This attempts to prevent anomalies from firing 'real' events. Valid values are all positive integers, + * but in most cases, you would need to stay between 5 and around 30. + * + * @param sensitivity - Specifies the minimum value for wheelDelta for it to register as a valid scroll event. + * Because the tail of the curve have low wheelDelta values, + * this will stop them from registering as valid scroll events. + * The unofficial standard wheelDelta is 120, so valid values are positive integers below 120. + * + * @param tolerance - Prevent small fluctuations from affecting results. + * Valid values are decimals from 0, but should ideally be between 0.05 and 0.3. + * + * Based on https://github.com/d4nyll/lethargy + */ + +export type LethargyConfig = { + stability?: number; + sensitivity?: number; + tolerance?: number; + delay?: number; +}; + +export class Lethargy { + private stability: number; + + private sensitivity: number; + + private tolerance: number; + + private delay: number; + + private lastUpDeltas: Array; + + private lastDownDeltas: Array; + + private deltasTimestamp: Array; + + constructor({ + stability = 8, + sensitivity = 100, + tolerance = 1.1, + delay = 150 + }: LethargyConfig = {}) { + this.stability = stability; + this.sensitivity = sensitivity; + this.tolerance = tolerance; + this.delay = delay; + this.lastUpDeltas = new Array(this.stability * 2).fill(0); + this.lastDownDeltas = new Array(this.stability * 2).fill(0); + this.deltasTimestamp = new Array(this.stability * 2).fill(0); + } + + check(e: any) { + let lastDelta; + e = e.originalEvent || e; + if(e.wheelDelta !== undefined) { + lastDelta = e.wheelDelta; + } else if(e.deltaY !== undefined) { + lastDelta = e.deltaY * -40; + } else if(e.detail !== undefined || e.detail === 0) { + lastDelta = e.detail * -40; + } + this.deltasTimestamp.push(Date.now()); + this.deltasTimestamp.shift(); + if(lastDelta > 0) { + this.lastUpDeltas.push(lastDelta); + this.lastUpDeltas.shift(); + return this.isInertia(1); + } else { + this.lastDownDeltas.push(lastDelta); + this.lastDownDeltas.shift(); + return this.isInertia(-1); + } + } + + isInertia(direction: number) { + const lastDeltas = direction === -1 ? this.lastDownDeltas : this.lastUpDeltas; + if(lastDeltas[0] === undefined) return direction; + if( + this.deltasTimestamp[this.stability * 2 - 2] + this.delay > Date.now() && + lastDeltas[0] === lastDeltas[this.stability * 2 - 1] + ) { + return false; + } + const lastDeltasOld = lastDeltas.slice(0, this.stability); + const lastDeltasNew = lastDeltas.slice(this.stability, this.stability * 2); + const oldSum = lastDeltasOld.reduce((t, s) => t + s); + const newSum = lastDeltasNew.reduce((t, s) => t + s); + const oldAverage = oldSum / lastDeltasOld.length; + const newAverage = newSum / lastDeltasNew.length; + return Math.abs(oldAverage) < Math.abs(newAverage * this.tolerance) && + this.sensitivity < Math.abs(newAverage); + } +} diff --git a/src/helpers/number/clamp.ts b/src/helpers/number/clamp.ts index a29f2025..904d6ab5 100644 --- a/src/helpers/number/clamp.ts +++ b/src/helpers/number/clamp.ts @@ -1,3 +1,3 @@ export default function clamp(v: number, min: number, max: number): number { - return v < min ? min : ((v > max) ? max : v); + return Math.min(max, Math.max(min, v)); } diff --git a/src/helpers/number/isBetween.ts b/src/helpers/number/isBetween.ts new file mode 100644 index 00000000..548796fe --- /dev/null +++ b/src/helpers/number/isBetween.ts @@ -0,0 +1,3 @@ +export default function isBetween(num: number, min: number, max: number) { + return num >= min && num <= max; +} diff --git a/src/helpers/windowSize.ts b/src/helpers/windowSize.ts index 10367974..d3fa75eb 100644 --- a/src/helpers/windowSize.ts +++ b/src/helpers/windowSize.ts @@ -4,6 +4,7 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ +import {MOUNT_CLASS_TO} from '../config/debug'; import {IS_WORKER} from './context'; export class WindowSize { @@ -15,11 +16,10 @@ export class WindowSize { return; } - // @ts-ignore - const w: any = 'visualViewport' in window ? window.visualViewport : window; + const w = 'visualViewport' in window ? window.visualViewport : window; const set = () => { - this.width = w.width || w.innerWidth; - this.height = w.height || w.innerHeight; + this.width = w.width || (w as any as Window).innerWidth; + this.height = w.height || (w as any as Window).innerHeight; }; w.addEventListener('resize', set); set(); @@ -27,4 +27,5 @@ export class WindowSize { } const windowSize = new WindowSize(); +MOUNT_CLASS_TO && (MOUNT_CLASS_TO.windowSize = windowSize); export default windowSize; diff --git a/src/index.hbs b/src/index.hbs index 495823a6..759544a9 100644 --- a/src/index.hbs +++ b/src/index.hbs @@ -5,8 +5,7 @@ {{htmlWebpackPlugin.options.title}} - - + diff --git a/src/scss/partials/_mediaViewer.scss b/src/scss/partials/_mediaViewer.scss index 770a5413..e52bf8ac 100644 --- a/src/scss/partials/_mediaViewer.scss +++ b/src/scss/partials/_mediaViewer.scss @@ -559,6 +559,14 @@ $inactive-opacity: .4; } } + &.is-zooming { + .media-viewer-caption, + .media-viewer-switcher .tgico-down { + opacity: 0 !important; + pointer-events: none; + } + } + &.highlight-switchers { .media-viewer-switcher > span { opacity: 1; @@ -584,12 +592,29 @@ $inactive-opacity: .4; bottom: 0; left: 0; z-index: 4; + // transform: matrix(1, 0, 0, 1, 0, 0); + transform: translate3d(1, 1, 0) scale(1); + will-change: transform; + transform-origin: 0 0; @include animation-level(2) { transition: transform var(--open-duration); } } + // &-whole { + // &:after { + // content: " "; + // position: absolute; + // top: 0; + // bottom: 0; + // left: 50%; + // background-color: green; + // z-index: 500; + // width: 1px; + // } + // } + /* &-switchers { position: relative; width: $large-screen; diff --git a/webpack.common.js b/webpack.common.js index 98fcec60..d5a5ed38 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -195,7 +195,7 @@ module.exports = { // }, compress: true, http2: useLocalNotLocal ? true : (useLocal ? undefined : true), - https: useLocal ? undefined : { // generated keys using mkcert + https: useLocal && false ? undefined : { // generated keys using mkcert key: fs.readFileSync(__dirname + '/certs/localhost-key.pem', 'utf8'), cert: fs.readFileSync(__dirname + '/certs/localhost.pem', 'utf8') },