fix (imageviewer): better handling of zoom in viewer

This commit is contained in:
MickaelK 2025-05-16 02:20:07 +10:00
parent e84c12293d
commit ea30adad2f

View file

@ -4,141 +4,129 @@ import { qs } from "../../../lib/dom.js";
export default function ({ $img, $page }) {
const $navigation = qs($page, `[data-bind="component_navigation"]`);
initZoomDesktop({ $img, $navigation });
initZoomMobile({ $img, $navigation });
effect(rxjs.merge(...builder({ $img })).pipe(
rxjs.tap(() => $navigation.classList.add("hidden")),
rxjs.scan((state, { clientX, clientY, moveX, moveY, scale, duration }) => {
state.x += moveX ?? 0;
state.y += moveY ?? 0;
const next = Math.min(20, Math.max(1, state.scale * (scale ?? 1)));
if (next > 1) {
const rect = $img.getBoundingClientRect();
const ox = (clientX ?? rect.left + rect.width / 2) - rect.left;
const oy = (clientY ?? rect.top + rect.height / 2) - rect.top;
const f = next / state.scale;
state.x += (1 - f) * ox;
state.y += (1 - f) * oy;
} else {
state.x = 0;
state.y = 0;
}
state.scale = next;
state.duration = duration ?? (next === 1 ? 500 : 200);
return state;
}, { scale: 1, x: 0, y: 0, duration: 0 }),
rxjs.tap(({ scale, x, y, duration }) => {
$img.style.transition = `transform ${duration}ms ease`;
$img.style.transform = `translate(${x}px,${y}px) scale(${scale})`;
if (scale === 1) $navigation.classList.remove("hidden");
}),
));
}
function initZoomMobile({ $img, $navigation }) {
const state = {
active: false,
x: null,
y: null,
distance: null,
};
const distance = ({ touches }) => Math.hypot(touches[0].pageX - touches[1].pageX, touches[0].pageY - touches[1].pageY);
effect(rxjs.fromEvent($img.parentElement, "touchstart").pipe(rxjs.tap((event) => {
if (event.touches.length < 2) return;
state.active = true;
event.preventDefault();
$img.style.transition = "0.1s ease transform";
$img.style.transformOrigin = "50% 50%";
$navigation.classList.add("hidden");
state.x = (event.touches[0].pageX + event.touches[1].pageX) / 2;
state.y = (event.touches[0].pageY + event.touches[1].pageY) / 2;
state.distance = distance(event);
})));
effect(rxjs.fromEvent($img.parentElement, "touchmove").pipe(rxjs.tap((event) => {
if (event.touches.length < 2) return;
event.preventDefault();
let scale = distance(event) / state.distance;
if (scale < 1) scale = 1;
else if (scale > 20) scale = 20;
const deltaX = (((event.touches[0].pageX + event.touches[1].pageX) / 2) - state.x) * 2;
const deltaY = (((event.touches[0].pageY + event.touches[1].pageY) / 2) - state.y) * 2;
$img.style.transform = `translate3d(${deltaX}px, ${deltaY}px, 0) scale(${scale})`;
})));
effect(rxjs.fromEvent($img.parentElement, "touchend").pipe(rxjs.tap((event) => {
if (state.active === false) return;
state.active = true;
$img.style.transition = "0.3s ease transform";
$img.style.transform = "";
$navigation.classList.remove("hidden");
})));
}
function initZoomDesktop({ $img, $navigation }) {
const params = {
scale: 1,
x: 0,
y: 0,
};
function builder({ $img }) {
$img.style.transformOrigin = "0 0";
$img.style.transition = "";
const apply = (transitionTime = null) => {
if (transitionTime !== null) $img.style.transition = `${transitionTime}ms ease transform`;
$img.style.transform = `translate(${params.x}px,${params.y}px) scale(${params.scale})`;
};
// zoom in / out using either: wheel, double click, keyboard shortcut
effect(rxjs.merge(
return [
// zoom via double click
rxjs.fromEvent($img.parentElement, "dblclick").pipe(
rxjs.filter((e) => e.target === $img),
rxjs.map((e) => ({ scale: 2, clientX: e.clientX, clientY: e.clientY })),
),
// zoom via scroll wheel
rxjs.fromEvent($img.parentElement, "wheel", { passive: true }).pipe(
rxjs.throttleTime(100),
rxjs.map((e) => ({ scale: Math.exp(-e.deltaY / 300), clientX: e.clientX, clientY: e.clientY })),
),
// zoom via keyboard shortcut
rxjs.fromEvent(window, "keydown").pipe(
rxjs.filter(({ key }) => ["Escape", "+", "-", "ArrowUp", "ArrowDown"].indexOf(key) !== -1),
rxjs.withLatestFrom(rxjs.fromEvent(window, "mousemove").pipe(
rxjs.startWith({ clientX: null, clientY: null }),
)),
rxjs.mergeMap(([{ key }, { clientX, clientY }]) => {
rxjs.map(([{ key }, { clientX, clientY }]) => {
let scale = 0;
if (["+", "ArrowUp"].indexOf(key) !== -1) scale = 3/2;
else if (["-", "ArrowDown"].indexOf(key) !== -1) scale = 2/3;
return rxjs.of({ clientX, clientY, scale });
return { clientX, clientY, scale };
}),
),
).pipe(rxjs.tap(({ clientX, clientY, scale }) => {
$navigation.classList.add("hidden");
const rect = $img.getBoundingClientRect();
const ox = clientX !== null ? clientX - rect.left : rect.width / 2;
const oy = clientY !== null ? clientY - rect.top : rect.height / 2;
let ns = params.scale * scale;
if (ns > 20) return;
params.x = ns <= 1 ? 0: params.x+(1-scale)*ox;
params.y = ns <= 1 ? 0: params.y+(1-scale)*oy;
params.scale = ns < 1 ? 1: ns;
if (params.scale === 1) $navigation.classList.remove("hidden");
apply(ns < 1 ? 500: 200);
})));
// grab / panning
effect(rxjs.fromEvent($img.parentElement, "mousedown").pipe(
rxjs.filter((event) => event.target === $img && event.button === 0 && params.scale > 1),
rxjs.tap(() => {
$img.style.cursor = "move";
$navigation.classList.add("hidden");
}),
rxjs.switchMap((event) => rxjs.fromEvent(window, "mousemove").pipe(
rxjs.pairwise(),
rxjs.takeUntil(rxjs.fromEvent(window, "mouseup")),
rxjs.map(([prev, curr]) => {
const dt = curr.timeStamp - prev.timeStamp;
const dx = curr.clientX - prev.clientX;
const dy = curr.clientY - prev.clientY;
params.x += dx;
params.y += dy;
return [dx/dt, dy/dt];
}),
rxjs.tap(() => apply(0)),
rxjs.startWith([0, 0]),
rxjs.last(),
rxjs.switchMap(([velocityX, velocityY]) => rxjs.EMPTY.pipe(
rxjs.finalize(() => $img.style.cursor = "default"),
rxjs.finalize((a) => {
const decay = 0.8;
const frameMs = 16;
const stopV = 0.05;
const speed = Math.hypot(velocityX, velocityY);
if (speed < stopV) return;
const nFramesTillStop = Math.ceil(Math.log(stopV / speed) / Math.log(decay));
const geosum = (decay * (1 - Math.pow(decay, nFramesTillStop))) / (1 - decay);
params.x += geosum * velocityX * frameMs;
params.y += geosum * velocityY * frameMs;
$img.style.transition = `transform ${nFramesTillStop * frameMs}ms cubic-bezier(0,0,0,1)`;
apply();
}),
// pinch zoom
rxjs.fromEvent($img.parentElement, "touchstart").pipe(
rxjs.filter((e) => e.touches.length === 2),
rxjs.switchMap((event) => rxjs.fromEvent($img.parentElement, "touchmove").pipe(
rxjs.filter((event) => event.touches.length >= 2),
rxjs.tap((event) => event.preventDefault()),
rxjs.takeUntil(rxjs.fromEvent(window, "touchend")),
rxjs.map((event) => ({
clientX: (event.touches[0].pageX + event.touches[1].pageX) / 2,
clientY: (event.touches[0].pageY + event.touches[1].pageY) / 2,
distance: Math.hypot(
event.touches[0].pageX - event.touches[1].pageX,
event.touches[0].pageY - event.touches[1].pageY,
),
})),
rxjs.pairwise(),
rxjs.map(([curr, prev]) => ({
clientX: curr.clientX,
clientY: curr.clientY,
scale: Math.sign(curr.distance - prev.distance) === 1 ? 0.95 : 1.025,
moveX: prev.clientX - curr.clientX,
moveY: prev.clientY - curr.clientY,
duration: 0,
})),
)),
)),
));
),
// grab and drag
rxjs.fromEvent($img.parentElement, "mousedown").pipe(
rxjs.filter(e => e.target === $img && e.button === 0),
rxjs.switchMap((down) => {
let prev = { x: down.clientX, y: down.clientY, t: down.timeStamp };
const move$ = rxjs.fromEvent(window, "mousemove").pipe(
rxjs.takeUntil(rxjs.fromEvent(window, "mouseup")),
rxjs.map(m => {
const dx = m.clientX - prev.x;
const dy = m.clientY - prev.y;
const dt = m.timeStamp - prev.t || 1;
prev = { x: m.clientX, y: m.clientY, t: m.timeStamp };
return { moveX: dx, moveY: dy, dt, duration: 0 };
}),
rxjs.tap(() => ($img.style.cursor = "move")),
rxjs.share(),
);
const $inertia = move$.pipe(
rxjs.startWith({ moveX: 0, moveY: 0, dt: 1 }),
rxjs.last(),
rxjs.tap(() => ($img.style.cursor = "default")),
rxjs.switchMap(({ moveX, moveY, dt }) => {
const DECAY = 0.8;
const FRAME = 16;
const STOPV = 0.05;
const vx = moveX / dt;
const vy = moveY / dt;
const speed = Math.hypot(vx, vy);
if (speed < STOPV) return rxjs.EMPTY;
const nFrames = Math.ceil(Math.log(STOPV / speed) / Math.log(DECAY));
const gsum = (DECAY * (1 - Math.pow(DECAY, nFrames))) / (1 - DECAY);
return rxjs.of({
moveX: vx * gsum * FRAME,
moveY: vy * gsum * FRAME,
duration: nFrames * FRAME
});
}),
);
return rxjs.merge(move$, $inertia);
}),
),
]
}