Pinch-to-Zoom

Load full image in media viewer
This commit is contained in:
Eduard Kuzmenko 2023-02-17 19:15:35 +04:00
parent 6009b0595e
commit 94d0ba65c3
9 changed files with 776 additions and 193 deletions

View File

@ -4,6 +4,9 @@
* https://github.com/morethanwords/tweb/blob/master/LICENSE * 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 {MyDocument} from '../lib/appManagers/appDocsManager';
import type {MyPhoto} from '../lib/appManagers/appPhotosManager'; import type {MyPhoto} from '../lib/appManagers/appPhotosManager';
import deferredPromise from '../helpers/cancellablePromise'; import deferredPromise from '../helpers/cancellablePromise';
@ -20,10 +23,10 @@ import ButtonIcon from './buttonIcon';
import {ButtonMenuItemOptions} from './buttonMenu'; import {ButtonMenuItemOptions} from './buttonMenu';
import ButtonMenuToggle from './buttonMenuToggle'; import ButtonMenuToggle from './buttonMenuToggle';
import ProgressivePreloader from './preloader'; import ProgressivePreloader from './preloader';
import SwipeHandler from './swipeHandler'; import SwipeHandler, {ZoomDetails} from './swipeHandler';
import {formatFullSentTime} from '../helpers/date'; import {formatFullSentTime} from '../helpers/date';
import appNavigationController, {NavigationItem} from './appNavigationController'; import appNavigationController, {NavigationItem} from './appNavigationController';
import {Message} from '../layer'; import {Message, PhotoSize} from '../layer';
import findUpClassName from '../helpers/dom/findUpClassName'; import findUpClassName from '../helpers/dom/findUpClassName';
import renderImageFromUrl, {renderImageFromUrlPromise} from '../helpers/dom/renderImageFromUrl'; import renderImageFromUrl, {renderImageFromUrlPromise} from '../helpers/dom/renderImageFromUrl';
import getVisibleRect from '../helpers/dom/getVisibleRect'; import getVisibleRect from '../helpers/dom/getVisibleRect';
@ -51,18 +54,23 @@ import overlayCounter from '../helpers/overlayCounter';
import appDownloadManager from '../lib/appManagers/appDownloadManager'; import appDownloadManager from '../lib/appManagers/appDownloadManager';
import wrapPeerTitle from './wrappers/peerTitle'; import wrapPeerTitle from './wrappers/peerTitle';
import {toastNew} from './toast'; 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_STEP = 0.5;
const ZOOM_INITIAL_VALUE = 1; const ZOOM_INITIAL_VALUE = 1;
const ZOOM_MIN_VALUE = 0.5; const ZOOM_MIN_VALUE = 0.5;
const ZOOM_MAX_VALUE = 4; const ZOOM_MAX_VALUE = 4;
// TODO: масштабирование картинок (не SVG) при ресайзе, и правильный возврат на исходную позицию
// TODO: картинки "обрезаются" если возвращаются или появляются с места, где есть их перекрытие (топбар, поле ввода)
// TODO: видео в мобильной вёрстке, если показываются элементы управления: если свайпнуть в сторону, то элементы вернутся на место, т.е. прыгнут - это не ок, надо бы замаскировать
export const MEDIA_VIEWER_CLASSNAME = 'media-viewer'; export const MEDIA_VIEWER_CLASSNAME = 'media-viewer';
type Transform = {
x: number;
y: number;
scale: number;
};
export default class AppMediaViewerBase< export default class AppMediaViewerBase<
ContentAdditionType extends string, ContentAdditionType extends string,
ButtonsAdditionType extends string, ButtonsAdditionType extends string,
@ -83,15 +91,10 @@ export default class AppMediaViewerBase<
protected preloader: ProgressivePreloader = null; protected preloader: ProgressivePreloader = null;
protected preloaderStreamable: ProgressivePreloader = null; protected preloaderStreamable: ProgressivePreloader = null;
// protected targetContainer: HTMLElement = null;
// protected loadMore: () => void = null;
protected log: ReturnType<typeof logger>; protected log: ReturnType<typeof logger>;
protected isFirstOpen = true; protected isFirstOpen = true;
// protected needLoadMore = true;
protected pageEl = document.getElementById('page-chats') as HTMLDivElement; protected pageEl = document.getElementById('page-chats') as HTMLDivElement;
protected setMoverPromise: Promise<void>; protected setMoverPromise: Promise<void>;
@ -113,18 +116,27 @@ export default class AppMediaViewerBase<
btnIn: HTMLElement, btnIn: HTMLElement,
rangeSelector: RangeSelector rangeSelector: RangeSelector
} = {} as any; } = {} as any;
// protected zoomValue = ZOOM_INITIAL_VALUE; protected transform: Transform = {x: 0, y: 0, scale: ZOOM_INITIAL_VALUE};
protected zoomSwipeHandler: SwipeHandler; protected isZooming: boolean;
protected zoomSwipeStartX = 0; protected isGesturingNow: boolean;
protected zoomSwipeStartY = 0; protected isZoomingNow: boolean;
protected zoomSwipeX = 0; protected draggingType: 'wheel' | 'touchmove' | 'mousemove';
protected zoomSwipeY = 0; protected initialContentRect: DOMRect;
protected ctrlKeyDown: boolean; protected ctrlKeyDown: boolean;
protected releaseSingleMedia: ReturnType<AppMediaPlaybackController['setSingleMedia']>; protected releaseSingleMedia: ReturnType<AppMediaPlaybackController['setSingleMedia']>;
protected navigationItem: NavigationItem; protected navigationItem: NavigationItem;
protected managers: AppManagers; 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<typeof debounce<() => void>>;
get target() { get target() {
return this.listLoader.current; return this.listLoader.current;
@ -204,12 +216,12 @@ export default class AppMediaViewerBase<
this.zoomElements.container.classList.add('zoom-container'); this.zoomElements.container.classList.add('zoom-container');
this.zoomElements.btnOut = ButtonIcon('zoomout', {noRipple: true}); 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}); 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({ this.zoomElements.rangeSelector = new RangeSelector({
step: ZOOM_STEP, step: 0.01,
min: ZOOM_MIN_VALUE, min: ZOOM_MIN_VALUE,
max: ZOOM_MAX_VALUE, max: ZOOM_MAX_VALUE,
withTransition: true 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.zoomElements.container.append(this.zoomElements.btnOut, this.zoomElements.rangeSelector.container, this.zoomElements.btnIn);
if(!IS_TOUCH_SUPPORTED && false) {
this.wholeDiv.append(this.zoomElements.container); this.wholeDiv.append(this.zoomElements.container);
}
// * content // * content
this.content.main = document.createElement('div'); this.content.main = document.createElement('div');
@ -286,9 +300,9 @@ export default class AppMediaViewerBase<
}); });
attachClickEvent(this.buttons.zoom, () => { attachClickEvent(this.buttons.zoom, () => {
if(this.isZooming()) this.toggleZoom(false); if(this.isZooming) this.resetZoom();
else { else {
this.changeZoom(true); this.addZoomStep(true);
} }
}); });
@ -301,18 +315,62 @@ export default class AppMediaViewerBase<
else this.onPrevClick(item); else this.onPrevClick(item);
}; };
if(IS_TOUCH_SUPPORTED) { const adjustPosition = (xDiff: number, yDiff: number) => {
const swipeHandler = new SwipeHandler({ 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
});
this.lastDragDelta = {
x,
y
};
this.lastDragOffset = {
x: xDiff,
y: yDiff
};
this.setTransform(transform);
return {inBoundsX, inBoundsY};
};
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, element: this.wholeDiv,
onSwipe: (xDiff, yDiff) => { onReset: this.onSwipeReset,
onFirstSwipe: this.onSwipeFirst,
onSwipe: (xDiff, yDiff, e, cancelDrag) => {
if(isFullScreen()) { if(isFullScreen()) {
return; return;
} }
const percents = Math.abs(xDiff) / windowSize.width; if(this.isZooming && !this.isZoomingNow) {
if(percents > .2 || xDiff > 125) { setLastGestureTime();
// console.log('will swipe', xDiff);
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) { if(xDiff > 0) {
this.buttons.prev.click(); this.buttons.prev.click();
} else { } else {
@ -323,95 +381,284 @@ export default class AppMediaViewerBase<
} }
const percentsY = Math.abs(yDiff) / windowSize.height; const percentsY = Math.abs(yDiff) / windowSize.height;
if(percentsY > .2 || yDiff > 125) { if(percentsY > .2 || Math.abs(yDiff) > 125) {
this.close(); this.close();
return true; return true;
} }
return false; 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) => { verifyTouchTarget: (e) => {
// * Fix for seek input // * Fix for seek input
if(findUpClassName(e.target, 'ckin__controls') || if(findUpClassName(e.target, 'ckin__controls') ||
findUpClassName(e.target, 'media-viewer-caption') || findUpClassName(e.target, 'media-viewer-caption') ||
findUpClassName(e.target, 'media-viewer-topbar')) { (findUpClassName(e.target, 'media-viewer-topbar') && e.type !== 'wheel')) {
return false; return false;
} }
return true; 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) { protected toggleZoom(enable?: boolean) {
const isVisible = this.isZooming(); const isVisible = this.isZooming;
const auto = enable === undefined;
if(this.zoomElements.rangeSelector.mousedown || this.ctrlKeyDown) { if(this.zoomElements.rangeSelector.mousedown || this.ctrlKeyDown) {
enable = true; enable = true;
} }
if(isVisible === enable) return; enable ??= !isVisible;
if(enable === undefined) { if(isVisible === enable) {
enable = !isVisible; return;
} }
this.buttons.zoom.classList.toggle('zoom-in', !enable); this.buttons.zoom.classList.toggle('zoom-in', !enable);
this.zoomElements.container.classList.toggle('is-visible', enable); this.zoomElements.container.classList.toggle('is-visible', this.isZooming = enable);
const zoomValue = enable ? this.zoomElements.rangeSelector.value : 1; this.wholeDiv.classList.toggle('is-zooming', enable);
if(auto || !enable) {
const zoomValue = enable ? this.transform.scale : ZOOM_INITIAL_VALUE;
this.setZoomValue(zoomValue); this.setZoomValue(zoomValue);
this.zoomElements.rangeSelector.setProgress(zoomValue); this.zoomElements.rangeSelector.setProgress(zoomValue);
}
if(this.videoPlayer) { if(this.videoPlayer) {
this.videoPlayer.lockControls(enable ? false : undefined); this.videoPlayer.lockControls(enable ? false : undefined);
} }
}
if(enable) { protected addZoomStep(add: boolean) {
if(!this.zoomSwipeHandler) { this.addZoom(ZOOM_STEP * (add ? 1 : -1));
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];
this.setZoomValue(); protected resetZoom() {
}, this.setTransform({
onReset: () => { x: 0,
this.moversContainer.classList.remove('no-transition'); y: 0,
}, scale: ZOOM_INITIAL_VALUE
cursor: 'move'
}); });
} else {
this.zoomSwipeHandler.setListeners();
} }
this.zoomElements.rangeSelector.setProgress(zoomValue); protected changeZoom(value = this.transform.scale) {
} else if(!enable) { this.transform.scale = value;
this.zoomSwipeHandler.removeListeners(); this.zoomElements.rangeSelector.setProgress(value);
} this.setZoomValue(value);
} }
protected changeZoom(add: boolean) { protected addZoom(value: number) {
this.zoomElements.rangeSelector.addProgress(ZOOM_STEP * (add ? 1 : -1)); this.lastTransform = this.transform;
this.setZoomValue(); this.onZoom({
zoomAdd: value,
currentCenterX: 0,
currentCenterY: 0,
initialCenterX: 0,
initialCenterY: 0,
dragOffsetX: 0,
dragOffsetY: 0
});
this.lastTransform = this.transform;
this.clampZoomDebounced();
} }
protected setZoomValue = (value = this.zoomElements.rangeSelector.value) => { 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 setZoomValue = (value = this.transform.scale) => {
this.initialContentRect ??= this.content.media.getBoundingClientRect();
// this.zoomValue = value; // this.zoomValue = value;
if(value === ZOOM_INITIAL_VALUE) { if(value === ZOOM_INITIAL_VALUE) {
this.zoomSwipeX = 0; this.transform.x = 0;
this.zoomSwipeY = 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.btnOut.classList.toggle('inactive', value === ZOOM_MIN_VALUE);
this.zoomElements.btnIn.classList.toggle('inactive', value === ZOOM_MAX_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); this.toggleZoom(value !== ZOOM_INITIAL_VALUE);
}; };
protected isZooming() {
return this.zoomElements.container.classList.contains('is-visible');
}
protected setBtnMenuToggle(buttons: ButtonMenuItemOptions[]) { protected setBtnMenuToggle(buttons: ButtonMenuItemOptions[]) {
const btnMenuToggle = ButtonMenuToggle({buttonOptions: {onlyMobile: true}, direction: 'bottom-left', buttons}); const btnMenuToggle = ButtonMenuToggle({buttonOptions: {onlyMobile: true}, direction: 'bottom-left', buttons});
this.topbar.append(btnMenuToggle); this.topbar.append(btnMenuToggle);
@ -435,6 +678,9 @@ export default class AppMediaViewerBase<
if(this.setMoverAnimationPromise) return Promise.reject(); if(this.setMoverAnimationPromise) return Promise.reject();
this.closing = true;
this.swipeHandler?.removeListeners();
if(this.navigationItem) { if(this.navigationItem) {
appNavigationController.removeItem(this.navigationItem); appNavigationController.removeItem(this.navigationItem);
} }
@ -459,8 +705,6 @@ export default class AppMediaViewerBase<
this.removeGlobalListeners(); this.removeGlobalListeners();
this.zoomSwipeHandler = undefined;
promise.finally(() => { promise.finally(() => {
this.wholeDiv.remove(); this.wholeDiv.remove();
this.toggleOverlay(false); this.toggleOverlay(false);
@ -480,23 +724,13 @@ export default class AppMediaViewerBase<
} }
protected removeGlobalListeners() { protected removeGlobalListeners() {
if(this.zoomSwipeHandler) {
this.zoomSwipeHandler.removeListeners();
}
window.removeEventListener('keydown', this.onKeyDown); window.removeEventListener('keydown', this.onKeyDown);
window.removeEventListener('keyup', this.onKeyUp); window.removeEventListener('keyup', this.onKeyUp);
window.removeEventListener('wheel', this.onWheel, {capture: true});
} }
protected setGlobalListeners() { protected setGlobalListeners() {
if(this.isZooming()) {
this.zoomSwipeHandler.setListeners();
}
window.addEventListener('keydown', this.onKeyDown); window.addEventListener('keydown', this.onKeyDown);
window.addEventListener('keyup', this.onKeyUp); window.addEventListener('keyup', this.onKeyUp);
if(!IS_TOUCH_SUPPORTED) window.addEventListener('wheel', this.onWheel, {passive: false, capture: true});
} }
onClick = (e: MouseEvent) => { onClick = (e: MouseEvent) => {
@ -521,7 +755,7 @@ export default class AppMediaViewerBase<
return; return;
} }
const isZooming = this.isZooming(); const isZooming = this.isZooming;
let mover: HTMLElement = null; let mover: HTMLElement = null;
const classNames = ['ckin__player', 'media-viewer-buttons', 'media-viewer-author', 'media-viewer-caption', 'zoom-container']; const classNames = ['ckin__player', 'media-viewer-buttons', 'media-viewer-author', 'media-viewer-caption', 'zoom-container'];
if(isZooming) { if(isZooming) {
@ -550,12 +784,12 @@ export default class AppMediaViewerBase<
let good = true; let good = true;
if(key === 'ArrowRight') { if(key === 'ArrowRight') {
this.buttons.next.click(); !this.isZooming && this.buttons.next.click();
} else if(key === 'ArrowLeft') { } else if(key === 'ArrowLeft') {
this.buttons.prev.click(); !this.isZooming && this.buttons.prev.click();
} else if(key === '-' || key === '=') { } else if(key === '-' || key === '=') {
if(this.ctrlKeyDown) { if(this.ctrlKeyDown) {
this.changeZoom(key === '='); this.addZoomStep(key === '=');
} }
} else { } else {
good = false; good = false;
@ -578,26 +812,12 @@ export default class AppMediaViewerBase<
if(!(e.ctrlKey || e.metaKey)) { if(!(e.ctrlKey || e.metaKey)) {
this.ctrlKeyDown = false; this.ctrlKeyDown = false;
if(this.isZooming()) { if(this.isZooming) {
this.setZoomValue(); 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) { protected async setMoverToTarget(target: HTMLElement, closing = false, fromRight = 0) {
this.dispatchEvent('setMoverBefore'); this.dispatchEvent('setMoverBefore');
@ -608,7 +828,7 @@ export default class AppMediaViewerBase<
// mover.append(this.buttons.prev, this.buttons.next); // 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); /* if(!(zoomValue > 1 && closing)) */ this.removeCenterFromMover(mover);
const wasActive = fromRight !== 0; const wasActive = fromRight !== 0;
@ -749,12 +969,8 @@ export default class AppMediaViewerBase<
// let borderRadius = '0px 0px 0px 0px'; // let borderRadius = '0px 0px 0px 0px';
if(closing && zoomValue !== 1) { if(closing && zoomValue !== 1) {
// const width = this.moversContainer.scrollWidth * scaleX; const left = rect.left - (windowSize.width * scaleX - rect.width) / 2;
// const height = this.moversContainer.scrollHeight * scaleY; const top = rect.top - (windowSize.height * scaleY - rect.height) / 2;
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 */;
this.moversContainer.style.transform = `matrix(${scaleX}, 0, 0, ${scaleY}, ${left}, ${top})`; this.moversContainer.style.transform = `matrix(${scaleX}, 0, 0, ${scaleY}, ${left}, ${top})`;
} else { } else {
mover.style.transform = transform; mover.style.transform = transform;
@ -1288,6 +1504,23 @@ export default class AppMediaViewerBase<
this.moveTheMover(this.content.mover, fromRight === 1); this.moveTheMover(this.content.mover, fromRight === 1);
this.setNewMover(); this.setNewMover();
} else { } 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.toggleOverlay(true);
this.setGlobalListeners(); this.setGlobalListeners();
await setAuthorPromise; await setAuthorPromise;
@ -1298,21 +1531,6 @@ export default class AppMediaViewerBase<
} }
this.toggleWholeActive(true); 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); // //////this.log('wasActive:', wasActive);
@ -1358,8 +1576,8 @@ export default class AppMediaViewerBase<
const supportsStreaming: boolean = !!(isDocument && media.supportsStreaming); const supportsStreaming: boolean = !!(isDocument && media.supportsStreaming);
const preloader = supportsStreaming ? this.preloaderStreamable : this.preloader; const preloader = supportsStreaming ? this.preloaderStreamable : this.preloader;
const getCacheContext = () => { const getCacheContext = (type = size?.type) => {
return this.managers.thumbsStorage.getCacheContext(media, size?.type); return this.managers.thumbsStorage.getCacheContext(media, type);
}; };
let setMoverPromise: Promise<void>; let setMoverPromise: Promise<void>;
@ -1496,7 +1714,7 @@ export default class AppMediaViewerBase<
this.videoPlayer = undefined; this.videoPlayer = undefined;
}, {once: true}); }, {once: true});
if(this.isZooming()) { if(this.isZooming) {
this.videoPlayer.lockControls(false); this.videoPlayer.lockControls(false);
} }
/* div.append(video); /* div.append(video);
@ -1630,6 +1848,10 @@ export default class AppMediaViewerBase<
const load = async() => { const load = async() => {
const cancellablePromise = isDocument ? appDownloadManager.downloadMediaURL({media}) : appDownloadManager.downloadMediaURL({media, thumb: size}); 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() => { onAnimationEnd.then(async() => {
if(!(await getCacheContext()).url) { if(!(await getCacheContext()).url) {
this.preloader.attachPromise(cancellablePromise); this.preloader.attachPromise(cancellablePromise);
@ -1652,22 +1874,21 @@ export default class AppMediaViewerBase<
if(mediaSizes.isMobile) { if(mediaSizes.isMobile) {
const imgs = mover.querySelectorAll('img'); const imgs = mover.querySelectorAll('img');
if(imgs && imgs.length) {
imgs.forEach((img) => { imgs.forEach((img) => {
img.classList.remove('thumbnail'); // может здесь это вообще не нужно img.classList.remove('thumbnail'); // может здесь это вообще не нужно
}); });
} }
}
} else { } else {
const div = mover.firstElementChild && mover.firstElementChild.classList.contains('media-viewer-aspecter') ? mover.firstElementChild : mover; 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; const haveImage = ['CANVAS', 'IMG'].includes(div.firstElementChild?.tagName) ? div.firstElementChild as HTMLElement : null;
if(!haveImage || haveImage.src !== url) { if((haveImage as HTMLImageElement)?.src !== url) {
const image = new Image(); const image = new Image();
image.classList.add('thumbnail'); image.classList.add('thumbnail');
// this.log('will renderImageFromUrl:', image, div, target); // this.log('will renderImageFromUrl:', image, div, target);
renderImageFromUrl(image, url, () => { renderImageFromUrl(image, url, () => {
fastRaf(() => {
this.updateMediaSource(target, url, 'img'); this.updateMediaSource(target, url, 'img');
if(haveImage) { if(haveImage) {
@ -1678,6 +1899,17 @@ export default class AppMediaViewerBase<
div.append(image); div.append(image);
}); });
}, false);
cancellableFullPromise?.then((url) => {
const fullImage = new Image();
fullImage.classList.add('thumbnail');
renderImageFromUrl(fullImage, url, () => {
fastRaf(() => {
image.replaceWith(fullImage);
});
}, false);
});
} }
} }

View File

@ -4,6 +4,9 @@
* https://github.com/morethanwords/tweb/blob/master/LICENSE * 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 cancelEvent from '../helpers/dom/cancelEvent';
import IS_TOUCH_SUPPORTED from '../environment/touchSupport'; import IS_TOUCH_SUPPORTED from '../environment/touchSupport';
import safeAssign from '../helpers/object/safeAssign'; import safeAssign from '../helpers/object/safeAssign';
@ -13,12 +16,18 @@ import ListenerSetter, {Listener, ListenerOptions} from '../helpers/listenerSett
import {attachContextMenuListener} from '../helpers/dom/attachContextMenuListener'; import {attachContextMenuListener} from '../helpers/dom/attachContextMenuListener';
import pause from '../helpers/schedulers/pause'; import pause from '../helpers/schedulers/pause';
import deferredPromise from '../helpers/cancellablePromise'; 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 = { type E = {
clientX: number, clientX: number,
clientY: number, clientY: number,
target: EventTarget, target: EventTarget,
button?: number button?: number,
type?: string
}; };
type EE = E | (Exclude<E, 'clientX' | 'clientY'> & { type EE = E | (Exclude<E, 'clientX' | 'clientY'> & {
@ -29,6 +38,18 @@ const getEvent = (e: EE) => {
return 'touches' in e ? e.touches[0] : e; 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; const attachGlobalListenerTo = document;
let RESET_GLOBAL = false; let RESET_GLOBAL = false;
@ -43,24 +64,45 @@ export type SwipeHandlerOptions = {
onFirstSwipe?: SwipeHandler['onFirstSwipe'], onFirstSwipe?: SwipeHandler['onFirstSwipe'],
onReset?: SwipeHandler['onReset'], onReset?: SwipeHandler['onReset'],
onStart?: SwipeHandler['onStart'], onStart?: SwipeHandler['onStart'],
onZoom?: SwipeHandler['onZoom'],
onDrag?: SwipeHandler['onDrag'],
onDoubleClick?: SwipeHandler['onDoubleClick'],
cursor?: SwipeHandler['cursor'], cursor?: SwipeHandler['cursor'],
cancelEvent?: SwipeHandler['cancelEvent'], cancelEvent?: SwipeHandler['cancelEvent'],
listenerOptions?: SwipeHandler['listenerOptions'], listenerOptions?: SwipeHandler['listenerOptions'],
setCursorTo?: HTMLElement, setCursorTo?: HTMLElement,
middleware?: Middleware, middleware?: Middleware,
withDelay?: boolean withDelay?: boolean,
minZoom?: number,
maxZoom?: number
}; };
const TOUCH_MOVE_OPTIONS: ListenerOptions = {passive: false}; const TOUCH_MOVE_OPTIONS: ListenerOptions = {passive: false};
const MOUSE_MOVE_OPTIONS: ListenerOptions = false as any; 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 { export default class SwipeHandler {
private element: HTMLElement; 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<boolean>; private verifyTouchTarget: (evt: EE) => boolean | Promise<boolean>;
private onFirstSwipe: (e: EE) => void; private onFirstSwipe: (e: EE) => void;
private onReset: () => void; private onReset: () => void;
private onStart: () => 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 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 cancelEvent: boolean;
private listenerOptions: ListenerOptions; private listenerOptions: ListenerOptions;
@ -79,9 +121,20 @@ export default class SwipeHandler {
private withDelay: boolean; private withDelay: boolean;
private listenerSetter: ListenerSetter; private listenerSetter: ListenerSetter;
private initialDistance: number;
private initialTouchCenter: ReturnType<typeof getTouchCenter>;
private initialDragOffset: {x: number, y: number};
private isDragCanceled: {x: boolean, y: boolean};
private wheelZoom: number;
private releaseWheelDrag: ReturnType<typeof debounce<(e: Event) => void>>;
private releaseWheelZoom: ReturnType<typeof debounce<(e: Event) => void>>;
private log: ReturnType<typeof logger>;
constructor(options: SwipeHandlerOptions) { constructor(options: SwipeHandlerOptions) {
safeAssign(this, options); safeAssign(this, options);
this.log = logger('SWIPE-HANDLER');
this.cursor ??= 'grabbing'; this.cursor ??= 'grabbing';
this.cancelEvent ??= true; this.cancelEvent ??= true;
// this.listenerOptions ??= false as any; // this.listenerOptions ??= false as any;
@ -98,6 +151,9 @@ export default class SwipeHandler {
this.reset(); this.reset();
this.removeListeners(); this.removeListeners();
}); });
this.releaseWheelDrag = debounce(this.reset, 150, false);
this.releaseWheelZoom = debounce(this.reset, 150, false);
} }
public setListeners() { public setListeners() {
@ -105,6 +161,10 @@ export default class SwipeHandler {
// @ts-ignore // @ts-ignore
this.listenerSetter.add(this.element)('mousedown', this.handleStart, this.listenerOptions); this.listenerSetter.add(this.element)('mousedown', this.handleStart, this.listenerOptions);
this.listenerSetter.add(attachGlobalListenerTo)('mouseup', this.reset); this.listenerSetter.add(attachGlobalListenerTo)('mouseup', this.reset);
if(this.onZoom || this.onDoubleClick) {
this.listenerSetter.add(this.element)('wheel', this.handleWheel, WHEEL_OPTIONS);
}
} else { } else {
if(this.withDelay) { if(this.withDelay) {
attachContextMenuListener({ attachContextMenuListener({
@ -122,11 +182,19 @@ export default class SwipeHandler {
this.listenerSetter.add(this.element)('touchstart', this.handleStart, this.listenerOptions); 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); this.listenerSetter.add(attachGlobalListenerTo)('touchend', this.reset);
} }
} }
public removeListeners() { public removeListeners() {
this.log('remove listeners');
this.reset();
this.listenerSetter.removeAll(); this.listenerSetter.removeAll();
} }
@ -157,9 +225,21 @@ export default class SwipeHandler {
this.eventUp = this.eventUp =
this.isMouseDown = this.isMouseDown =
undefined; 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) => { protected reset = (e?: Event) => {
this.log('reset');
/* if(e) { /* if(e) {
cancelEvent(e); cancelEvent(e);
} */ } */
@ -175,11 +255,38 @@ export default class SwipeHandler {
this.onReset?.(); this.onReset?.();
} }
this.releaseWheelDrag?.clearTimeout();
this.releaseWheelZoom?.clearTimeout();
this.resetValues(); 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<SwipeHandlerOptions['onSwipe']>) {
const onSwipeResult = this.onSwipe(...args);
if(onSwipeResult !== undefined && onSwipeResult) {
this.reset();
}
}
protected handleStart = async(_e: EE) => { protected handleStart = async(_e: EE) => {
this.log('start');
if(this.isMouseDown) { 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; return;
} }
@ -188,11 +295,31 @@ export default class SwipeHandler {
return; return;
} }
if(this.verifyTouchTarget && !(await this.verifyTouchTarget(_e))) { if(isSwipingBackSafari(_e as any)) {
return this.reset(); return;
} }
const tempId = ++this.tempId; 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; this.isMouseDown = true;
if(this.withDelay && !IS_TOUCH_SUPPORTED) { if(this.withDelay && !IS_TOUCH_SUPPORTED) {
@ -245,6 +372,12 @@ export default class SwipeHandler {
cancelEvent(_e as any); cancelEvent(_e as any);
} }
if(this.releaseWheelDrag?.isDebounced() || this.releaseWheelZoom?.isDebounced()) {
return;
}
this.log('move');
const e = this.eventUp = getEvent(_e); const e = this.eventUp = getEvent(_e);
const xUp = e.clientX; const xUp = e.clientX;
const yUp = e.clientY; const yUp = e.clientY;
@ -257,18 +390,109 @@ export default class SwipeHandler {
return; return;
} }
this.hadMove = true; this.setHadMove(_e);
if(!IS_TOUCH_SUPPORTED) {
this.setCursorTo.style.setProperty('cursor', this.cursor, 'important');
} }
this.onFirstSwipe?.(_e); 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);
} }
const onSwipeResult = this.onSwipe(xDiff, yDiff, _e); this.dispatchOnSwipe(xDiff, yDiff, _e);
if(onSwipeResult !== undefined && onSwipeResult) { };
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.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);
}
} }

99
src/helpers/lethargy.ts Normal file
View File

@ -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<number>;
private lastDownDeltas: Array<number>;
private deltasTimestamp: Array<number>;
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);
}
}

View File

@ -1,3 +1,3 @@
export default function clamp(v: number, min: number, max: number): number { 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));
} }

View File

@ -0,0 +1,3 @@
export default function isBetween(num: number, min: number, max: number) {
return num >= min && num <= max;
}

View File

@ -4,6 +4,7 @@
* https://github.com/morethanwords/tweb/blob/master/LICENSE * https://github.com/morethanwords/tweb/blob/master/LICENSE
*/ */
import {MOUNT_CLASS_TO} from '../config/debug';
import {IS_WORKER} from './context'; import {IS_WORKER} from './context';
export class WindowSize { export class WindowSize {
@ -15,11 +16,10 @@ export class WindowSize {
return; return;
} }
// @ts-ignore const w = 'visualViewport' in window ? window.visualViewport : window;
const w: any = 'visualViewport' in window ? window.visualViewport : window;
const set = () => { const set = () => {
this.width = w.width || w.innerWidth; this.width = w.width || (w as any as Window).innerWidth;
this.height = w.height || w.innerHeight; this.height = w.height || (w as any as Window).innerHeight;
}; };
w.addEventListener('resize', set); w.addEventListener('resize', set);
set(); set();
@ -27,4 +27,5 @@ export class WindowSize {
} }
const windowSize = new WindowSize(); const windowSize = new WindowSize();
MOUNT_CLASS_TO && (MOUNT_CLASS_TO.windowSize = windowSize);
export default windowSize; export default windowSize;

View File

@ -5,8 +5,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>{{htmlWebpackPlugin.options.title}}</title> <title>{{htmlWebpackPlugin.options.title}}</title>
<meta name="description" content="{{htmlWebpackPlugin.options.description}}"> <meta name="description" content="{{htmlWebpackPlugin.options.description}}">
<!--<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />--> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,shrink-to-fit=no,viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-title" content="{{htmlWebpackPlugin.options.title}}"> <meta name="mobile-web-app-title" content="{{htmlWebpackPlugin.options.title}}">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">

View File

@ -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 { &.highlight-switchers {
.media-viewer-switcher > span { .media-viewer-switcher > span {
opacity: 1; opacity: 1;
@ -584,12 +592,29 @@ $inactive-opacity: .4;
bottom: 0; bottom: 0;
left: 0; left: 0;
z-index: 4; 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) { @include animation-level(2) {
transition: transform var(--open-duration); 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 { /* &-switchers {
position: relative; position: relative;
width: $large-screen; width: $large-screen;

View File

@ -195,7 +195,7 @@ module.exports = {
// }, // },
compress: true, compress: true,
http2: useLocalNotLocal ? true : (useLocal ? undefined : 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'), key: fs.readFileSync(__dirname + '/certs/localhost-key.pem', 'utf8'),
cert: fs.readFileSync(__dirname + '/certs/localhost.pem', 'utf8') cert: fs.readFileSync(__dirname + '/certs/localhost.pem', 'utf8')
}, },