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
*/
// * 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<typeof logger>;
protected isFirstOpen = true;
// protected needLoadMore = true;
protected pageEl = document.getElementById('page-chats') as HTMLDivElement;
protected setMoverPromise: Promise<void>;
@ -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<AppMediaPlaybackController['setSingleMedia']>;
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<typeof debounce<() => 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<void>;
@ -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);
});
}
}

View File

@ -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<E, 'clientX' | 'clientY'> & {
@ -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<boolean>;
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<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) {
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<SwipeHandlerOptions['onSwipe']>) {
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);
}
}

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 {
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
*/
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;

View File

@ -5,8 +5,7 @@
<meta charset="utf-8">
<title>{{htmlWebpackPlugin.options.title}}</title>
<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.0, viewport-fit=cover">
<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="mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-title" content="{{htmlWebpackPlugin.options.title}}">
<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 {
.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;

View File

@ -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')
},