release: v0.20.0
This commit is contained in:
@@ -4,19 +4,54 @@ interface UseSwipeProps {
|
||||
onSwipeLeft: () => void;
|
||||
onSwipeRight: () => void;
|
||||
threshold?: number;
|
||||
/**
|
||||
* False while a swipe must not navigate — the lightbox while zoomed, where
|
||||
* one finger pans the photo (#467). Read when the touch starts and when it
|
||||
* ends, so a gesture that began zoomed never turns into a swipe.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useSwipe({ onSwipeLeft, onSwipeRight, threshold = 60 }: UseSwipeProps) {
|
||||
/**
|
||||
* A one-finger horizontal swipe.
|
||||
*
|
||||
* Anything with a second finger is not a swipe: a pinch that drifts sideways
|
||||
* used to be read as one and jump to the next photo, because only the first
|
||||
* touch was ever looked at. Once a second finger lands, the whole touch
|
||||
* sequence is ignored, up to the moment the last finger lifts.
|
||||
*/
|
||||
export function useSwipe({
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
threshold = 60,
|
||||
enabled = true,
|
||||
}: UseSwipeProps) {
|
||||
const touchStartX = useRef<number>(0);
|
||||
const touchStartY = useRef<number>(0);
|
||||
/** The current touch sequence is out: multi-touch, or started while disabled. */
|
||||
const ignored = useRef(false);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent | TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
}, []);
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent | TouchEvent) => {
|
||||
if (e.touches.length > 1) {
|
||||
ignored.current = true;
|
||||
return;
|
||||
}
|
||||
ignored.current = !enabled;
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback(
|
||||
(e: React.TouchEvent | TouchEvent) => {
|
||||
// Fingers still down: the sequence is not over yet.
|
||||
if (e.touches.length > 0) return;
|
||||
const skip = ignored.current || !enabled;
|
||||
ignored.current = false;
|
||||
if (skip) return;
|
||||
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||
const dy = e.changedTouches[0].clientY - touchStartY.current;
|
||||
|
||||
@@ -25,7 +60,7 @@ export function useSwipe({ onSwipeLeft, onSwipeRight, threshold = 60 }: UseSwipe
|
||||
else onSwipeLeft();
|
||||
}
|
||||
},
|
||||
[onSwipeLeft, onSwipeRight, threshold],
|
||||
[onSwipeLeft, onSwipeRight, threshold, enabled],
|
||||
);
|
||||
|
||||
return { handleTouchStart, handleTouchEnd };
|
||||
|
||||
484
hooks/useZoom.ts
Normal file
484
hooks/useZoom.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Gesture wiring for the lightbox zoom (#467). The geometry is in lib/zoom.ts;
|
||||
* this hook measures the page, listens to the pointer, wheel and Safari
|
||||
* gesture events on the photo, and keeps the view.
|
||||
*
|
||||
* Pointer events throughout, never native drag: AssetProtection cancels
|
||||
* `dragstart` on every image when image dragging is disabled, which would
|
||||
* leave a drag-based pan dead on exactly the sites that care most about their
|
||||
* photos.
|
||||
*
|
||||
* The full-resolution file is asked for the first time the view leaves fit —
|
||||
* never before, and never for a neighbour. Until it arrives the preview is
|
||||
* shown scaled up, so a gesture answers at once; the file then takes its
|
||||
* place in the same box under the same transform, so nothing moves.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import {
|
||||
FIT_VIEW,
|
||||
canZoom,
|
||||
clampView,
|
||||
isZoomed,
|
||||
maxZoomScale,
|
||||
panBy,
|
||||
pinchView,
|
||||
stepView,
|
||||
toggleView,
|
||||
viewTransform,
|
||||
zoomAround,
|
||||
type FitBox,
|
||||
type Point,
|
||||
type Viewport,
|
||||
type ZoomView,
|
||||
} from '@/lib/zoom';
|
||||
|
||||
export type ZoomStatus = 'idle' | 'loading' | 'loaded' | 'failed';
|
||||
|
||||
interface UseZoomOptions {
|
||||
/** Identifies the photo on screen; a new one resets the zoom. */
|
||||
photoKey: string;
|
||||
/**
|
||||
* Width in pixels of the full-resolution image, upright. Undefined when the
|
||||
* photo cannot be zoomed, which switches every gesture off.
|
||||
*/
|
||||
naturalWidth?: number;
|
||||
/** The photo at fit size: measured for the geometry, and where gestures land. */
|
||||
imageRef: RefObject<HTMLImageElement | null>;
|
||||
/**
|
||||
* The element that receives the gestures (the photo's container). An
|
||||
* element rather than a ref: the lightbox renders nothing until it has
|
||||
* mounted, so the listeners have to be attached when the element appears,
|
||||
* not when the hook first runs.
|
||||
*/
|
||||
surface: HTMLElement | null;
|
||||
}
|
||||
|
||||
/** Two taps closer than this in time and space are a double-tap. */
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
const DOUBLE_TAP_PX = 30;
|
||||
/** A pointer that moved further than this was a drag, not a tap. */
|
||||
const TAP_SLOP_PX = 10;
|
||||
/** The `+` / `-` step. */
|
||||
const KEY_STEP = 2;
|
||||
/** Wheel delta per e-fold of zoom; see onWheel. */
|
||||
const WHEEL_DIVISOR = 300;
|
||||
|
||||
type Gesture =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'pan'; pointerId: number; start: Point; startView: ZoomView }
|
||||
| { kind: 'pinch'; startView: ZoomView; startMid: Point; startDistance: number };
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
!!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
}
|
||||
|
||||
export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOptions) {
|
||||
const enabled = !!naturalWidth && naturalWidth > 0;
|
||||
|
||||
const [view, setViewState] = useState<ZoomView>(FIT_VIEW);
|
||||
const [animate, setAnimate] = useState(false);
|
||||
const [status, setStatus] = useState<ZoomStatus>('idle');
|
||||
|
||||
// A new photo starts at fit, with nothing requested. Adjusted while
|
||||
// rendering rather than in an effect, so the new photo never renders once
|
||||
// with the old one's transform.
|
||||
const [shownKey, setShownKey] = useState(photoKey);
|
||||
if (shownKey !== photoKey) {
|
||||
setShownKey(photoKey);
|
||||
setViewState(FIT_VIEW);
|
||||
setAnimate(false);
|
||||
setStatus('idle');
|
||||
}
|
||||
|
||||
// The handlers below are attached once per photo and read the latest values
|
||||
// from refs, so a pan does not re-subscribe on every frame.
|
||||
const viewRef = useRef<ZoomView>(FIT_VIEW);
|
||||
const naturalRef = useRef(naturalWidth ?? 0);
|
||||
const statusRef = useRef<ZoomStatus>('idle');
|
||||
useEffect(() => {
|
||||
viewRef.current = view;
|
||||
statusRef.current = status;
|
||||
});
|
||||
useEffect(() => {
|
||||
naturalRef.current = naturalWidth ?? 0;
|
||||
}, [photoKey, naturalWidth]);
|
||||
|
||||
/** The fit box, the viewport and the 1:1 scale, measured now. */
|
||||
const measure = useCallback((): {
|
||||
fit: FitBox;
|
||||
viewport: Viewport;
|
||||
maxScale: number;
|
||||
} | null => {
|
||||
const img = imageRef.current;
|
||||
const parent = img?.parentElement;
|
||||
if (!img || !parent || !img.offsetWidth || !img.offsetHeight) return null;
|
||||
// Layout sizes, not getBoundingClientRect() of the image: that includes
|
||||
// the zoom transform. The container is never transformed.
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const fit = {
|
||||
width: img.offsetWidth,
|
||||
height: img.offsetHeight,
|
||||
cx: rect.left + img.offsetLeft + img.offsetWidth / 2,
|
||||
cy: rect.top + img.offsetTop + img.offsetHeight / 2,
|
||||
};
|
||||
const viewport = { width: window.innerWidth, height: window.innerHeight };
|
||||
const maxScale = maxZoomScale(naturalRef.current, fit.width, window.devicePixelRatio || 1);
|
||||
return { fit, viewport, maxScale };
|
||||
}, [imageRef]);
|
||||
|
||||
/** Show a view. `animated` for discrete steps; gestures follow the finger. */
|
||||
const apply = useCallback((next: ZoomView, animated: boolean) => {
|
||||
viewRef.current = next;
|
||||
setAnimate(animated && !prefersReducedMotion());
|
||||
setViewState(next);
|
||||
if (isZoomed(next) && statusRef.current === 'idle') {
|
||||
statusRef.current = 'loading';
|
||||
setStatus('loading');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const usable = useCallback(() => {
|
||||
if (!enabled || statusRef.current === 'failed') return null;
|
||||
const m = measure();
|
||||
return m && canZoom(m.maxScale) ? m : null;
|
||||
}, [enabled, measure]);
|
||||
|
||||
/** Fit ↔ 1:1 around a point; around the photo's centre without one. */
|
||||
const toggle = useCallback(
|
||||
(point?: Point) => {
|
||||
const m = usable();
|
||||
if (!m) {
|
||||
if (isZoomed(viewRef.current)) apply(FIT_VIEW, true);
|
||||
return;
|
||||
}
|
||||
const at = point ?? { x: m.fit.cx, y: m.fit.cy };
|
||||
apply(toggleView(viewRef.current, at, m.fit, m.viewport, m.maxScale), true);
|
||||
},
|
||||
[apply, usable],
|
||||
);
|
||||
|
||||
const step = useCallback(
|
||||
(factor: number) => {
|
||||
const m = usable();
|
||||
if (!m) return;
|
||||
apply(stepView(viewRef.current, factor, m.fit, m.viewport, m.maxScale), true);
|
||||
},
|
||||
[apply, usable],
|
||||
);
|
||||
|
||||
const zoomIn = useCallback(() => step(KEY_STEP), [step]);
|
||||
const zoomOut = useCallback(() => step(1 / KEY_STEP), [step]);
|
||||
const reset = useCallback(() => apply(FIT_VIEW, true), [apply]);
|
||||
|
||||
/** The full-resolution file arrived: correct 1:1 if the estimate was off. */
|
||||
const onFullLoad = useCallback(
|
||||
(img: HTMLImageElement) => {
|
||||
const before = measure();
|
||||
if (img.naturalWidth > 0) naturalRef.current = img.naturalWidth;
|
||||
statusRef.current = 'loaded';
|
||||
setStatus('loaded');
|
||||
const after = measure();
|
||||
if (!before || !after || before.maxScale === after.maxScale) return;
|
||||
const current = viewRef.current;
|
||||
// Someone who asked for 1:1 still gets 1:1 against the real size.
|
||||
const atMax = Math.abs(current.scale - before.maxScale) < 1e-3;
|
||||
const next = atMax
|
||||
? zoomAround(
|
||||
current,
|
||||
after.maxScale,
|
||||
{ x: after.viewport.width / 2, y: after.viewport.height / 2 },
|
||||
after.fit,
|
||||
after.viewport,
|
||||
after.maxScale,
|
||||
)
|
||||
: clampView(current, after.fit, after.viewport, after.maxScale);
|
||||
apply(next, false);
|
||||
},
|
||||
[apply, measure],
|
||||
);
|
||||
|
||||
/** No full-resolution file (Immich has none, or the request failed). */
|
||||
const onFullError = useCallback(() => {
|
||||
statusRef.current = 'failed';
|
||||
setStatus('failed');
|
||||
apply(FIT_VIEW, false);
|
||||
}, [apply]);
|
||||
|
||||
// A resized window changes the fit box under the view; starting over at fit
|
||||
// is simpler and less surprising than re-deriving the pan.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onResize = () => {
|
||||
if (isZoomed(viewRef.current)) apply(FIT_VIEW, false);
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [enabled, apply]);
|
||||
|
||||
// ── Gestures ──────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!enabled || !surface) return;
|
||||
|
||||
const pointers = new Map<number, Point>();
|
||||
const downs = new Map<number, { t: number; x: number; y: number; moved: boolean }>();
|
||||
let gesture: Gesture = { kind: 'none' };
|
||||
/** Two fingers were down at some point of the current touch sequence. */
|
||||
let multi = false;
|
||||
let lastTap: { t: number; x: number; y: number } | null = null;
|
||||
let lastPointerType = '';
|
||||
|
||||
const midAndDistance = () => {
|
||||
const [a, b] = [...pointers.values()];
|
||||
return {
|
||||
mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
|
||||
distance: Math.hypot(a.x - b.x, a.y - b.y),
|
||||
};
|
||||
};
|
||||
|
||||
const startPan = (pointerId: number, at: Point) => {
|
||||
gesture = { kind: 'pan', pointerId, start: at, startView: viewRef.current };
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
lastPointerType = e.pointerType;
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
if (statusRef.current === 'failed') return;
|
||||
const at = { x: e.clientX, y: e.clientY };
|
||||
pointers.set(e.pointerId, at);
|
||||
downs.set(e.pointerId, { t: e.timeStamp, ...at, moved: false });
|
||||
try {
|
||||
surface.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// Synthetic events (tests) have no active pointer to capture.
|
||||
}
|
||||
|
||||
if (pointers.size === 2) {
|
||||
multi = true;
|
||||
const { mid, distance } = midAndDistance();
|
||||
gesture = {
|
||||
kind: 'pinch',
|
||||
startView: viewRef.current,
|
||||
startMid: mid,
|
||||
startDistance: distance,
|
||||
};
|
||||
} else if (pointers.size === 1 && isZoomed(viewRef.current)) {
|
||||
startPan(e.pointerId, at);
|
||||
// No text selection or image drag behind a mouse pan.
|
||||
if (e.pointerType === 'mouse') e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!pointers.has(e.pointerId)) return;
|
||||
const at = { x: e.clientX, y: e.clientY };
|
||||
pointers.set(e.pointerId, at);
|
||||
const down = downs.get(e.pointerId);
|
||||
if (down && Math.hypot(at.x - down.x, at.y - down.y) > TAP_SLOP_PX) down.moved = true;
|
||||
|
||||
if (gesture.kind === 'pinch' && pointers.size >= 2) {
|
||||
const m = measure();
|
||||
if (!m || !canZoom(m.maxScale)) return;
|
||||
const { mid, distance } = midAndDistance();
|
||||
const g = gesture;
|
||||
apply(
|
||||
pinchView(
|
||||
g.startView,
|
||||
g.startMid,
|
||||
g.startDistance,
|
||||
mid,
|
||||
distance,
|
||||
m.fit,
|
||||
m.viewport,
|
||||
m.maxScale,
|
||||
),
|
||||
false,
|
||||
);
|
||||
} else if (gesture.kind === 'pan' && gesture.pointerId === e.pointerId) {
|
||||
const m = measure();
|
||||
if (!m) return;
|
||||
const g = gesture;
|
||||
apply(
|
||||
panBy(g.startView, at.x - g.start.x, at.y - g.start.y, m.fit, m.viewport, m.maxScale),
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerEnd = (e: PointerEvent) => {
|
||||
if (!pointers.has(e.pointerId)) return;
|
||||
const down = downs.get(e.pointerId);
|
||||
pointers.delete(e.pointerId);
|
||||
downs.delete(e.pointerId);
|
||||
|
||||
if (gesture.kind === 'pinch') {
|
||||
if (pointers.size === 1 && isZoomed(viewRef.current)) {
|
||||
const [[id, at]] = [...pointers.entries()];
|
||||
startPan(id, at);
|
||||
} else if (pointers.size < 2) {
|
||||
gesture = { kind: 'none' };
|
||||
}
|
||||
// A pinch let go just above fit lands on fit.
|
||||
if (pointers.size === 0 && !isZoomed(viewRef.current)) apply(FIT_VIEW, false);
|
||||
} else if (gesture.kind === 'pan' && pointers.size === 0) {
|
||||
gesture = { kind: 'none' };
|
||||
}
|
||||
|
||||
const wasTap =
|
||||
e.type === 'pointerup' &&
|
||||
e.pointerType !== 'mouse' &&
|
||||
!multi &&
|
||||
down &&
|
||||
!down.moved &&
|
||||
e.timeStamp - down.t < DOUBLE_TAP_MS;
|
||||
if (pointers.size === 0) multi = false;
|
||||
if (!wasTap) return;
|
||||
|
||||
// Double-tap: a second tap close to the first, soon after it.
|
||||
if (
|
||||
lastTap &&
|
||||
e.timeStamp - lastTap.t < DOUBLE_TAP_MS &&
|
||||
Math.hypot(e.clientX - lastTap.x, e.clientY - lastTap.y) < DOUBLE_TAP_PX
|
||||
) {
|
||||
lastTap = null;
|
||||
toggle({ x: e.clientX, y: e.clientY });
|
||||
} else {
|
||||
lastTap = { t: e.timeStamp, x: e.clientX, y: e.clientY };
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse only: touch has its own double-tap above, and some browsers also
|
||||
// synthesise a dblclick from it, which would toggle straight back.
|
||||
const onDoubleClick = (e: MouseEvent) => {
|
||||
if (lastPointerType && lastPointerType !== 'mouse') return;
|
||||
e.preventDefault();
|
||||
toggle({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
// Ctrl+wheel is how Chrome, Firefox and Edge report a trackpad pinch (and
|
||||
// what a mouse user presses to zoom); a plain wheel pans once zoomed.
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (statusRef.current === 'failed') return;
|
||||
const perLine = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? window.innerHeight : 1;
|
||||
if (e.ctrlKey) {
|
||||
const m = measure();
|
||||
if (!m || !canZoom(m.maxScale)) return;
|
||||
e.preventDefault();
|
||||
// A trackpad pinch sends many small deltas, a mouse wheel one large
|
||||
// one per notch (100 or more): about 1.4× a notch, never more than
|
||||
// 2× per event, so a single notch cannot jump straight to 1:1.
|
||||
const factor = Math.min(2, Math.max(0.5, Math.exp((-e.deltaY * perLine) / WHEEL_DIVISOR)));
|
||||
const current = viewRef.current;
|
||||
const next = zoomAround(
|
||||
current,
|
||||
current.scale * factor,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
m.fit,
|
||||
m.viewport,
|
||||
m.maxScale,
|
||||
);
|
||||
apply(isZoomed(next) ? next : FIT_VIEW, false);
|
||||
} else if (isZoomed(viewRef.current)) {
|
||||
const m = measure();
|
||||
if (!m) return;
|
||||
e.preventDefault();
|
||||
apply(
|
||||
panBy(
|
||||
viewRef.current,
|
||||
-e.deltaX * perLine,
|
||||
-e.deltaY * perLine,
|
||||
m.fit,
|
||||
m.viewport,
|
||||
m.maxScale,
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Safari reports a trackpad pinch as gesture events, not as ctrl+wheel.
|
||||
// On iOS the same events accompany a two-finger touch, which the pointer
|
||||
// handlers already follow — so they are only used with no pointer down.
|
||||
let gestureStart: ZoomView | null = null;
|
||||
type SafariGesture = Event & { scale: number; clientX: number; clientY: number };
|
||||
const onGestureStart = (e: Event) => {
|
||||
if (pointers.size > 0 || statusRef.current === 'failed') return;
|
||||
e.preventDefault();
|
||||
gestureStart = viewRef.current;
|
||||
};
|
||||
const onGestureChange = (e: Event) => {
|
||||
if (!gestureStart || pointers.size > 0) return;
|
||||
e.preventDefault();
|
||||
const g = e as SafariGesture;
|
||||
const m = measure();
|
||||
if (!m || !canZoom(m.maxScale)) return;
|
||||
const next = zoomAround(
|
||||
viewRef.current,
|
||||
gestureStart.scale * g.scale,
|
||||
{ x: g.clientX, y: g.clientY },
|
||||
m.fit,
|
||||
m.viewport,
|
||||
m.maxScale,
|
||||
);
|
||||
apply(isZoomed(next) ? next : FIT_VIEW, false);
|
||||
};
|
||||
const onGestureEnd = () => {
|
||||
gestureStart = null;
|
||||
};
|
||||
|
||||
surface.addEventListener('pointerdown', onPointerDown);
|
||||
surface.addEventListener('pointermove', onPointerMove);
|
||||
surface.addEventListener('pointerup', onPointerEnd);
|
||||
surface.addEventListener('pointercancel', onPointerEnd);
|
||||
surface.addEventListener('dblclick', onDoubleClick);
|
||||
surface.addEventListener('wheel', onWheel, { passive: false });
|
||||
surface.addEventListener('gesturestart', onGestureStart);
|
||||
surface.addEventListener('gesturechange', onGestureChange);
|
||||
surface.addEventListener('gestureend', onGestureEnd);
|
||||
return () => {
|
||||
surface.removeEventListener('pointerdown', onPointerDown);
|
||||
surface.removeEventListener('pointermove', onPointerMove);
|
||||
surface.removeEventListener('pointerup', onPointerEnd);
|
||||
surface.removeEventListener('pointercancel', onPointerEnd);
|
||||
surface.removeEventListener('dblclick', onDoubleClick);
|
||||
surface.removeEventListener('wheel', onWheel);
|
||||
surface.removeEventListener('gesturestart', onGestureStart);
|
||||
surface.removeEventListener('gesturechange', onGestureChange);
|
||||
surface.removeEventListener('gestureend', onGestureEnd);
|
||||
};
|
||||
}, [enabled, photoKey, surface, measure, apply, toggle]);
|
||||
|
||||
const zoomed = isZoomed(view);
|
||||
/** The full-resolution image is in the page (requested, loading or shown). */
|
||||
const requested = status !== 'idle';
|
||||
|
||||
return {
|
||||
/** Gestures do something for this photo. */
|
||||
enabled: enabled && status !== 'failed',
|
||||
zoomed,
|
||||
status,
|
||||
requested,
|
||||
/**
|
||||
* Inline style for both images. Absent until zoom has been used on this
|
||||
* photo, so the stylesheet's own entry animation is left alone.
|
||||
*/
|
||||
imageStyle:
|
||||
zoomed || requested
|
||||
? {
|
||||
transform: viewTransform(view),
|
||||
transition: animate ? 'transform 220ms cubic-bezier(0.2, 0.7, 0.3, 1)' : 'none',
|
||||
}
|
||||
: undefined,
|
||||
toggle,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
reset,
|
||||
onFullLoad,
|
||||
onFullError,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user