fix: the image viewer pans without unzooming, scrolls with the wheel, and opens a long strip at full width
- The click that ends a drag no longer resets the zoom. - The wheel scrolls a zoomed image (shift+wheel goes sideways). Ctrl+wheel or a trackpad pinch zooms at the pointer. - Clicking a long strip or panorama zooms it to fill the canvas, so a comic strip opens at reading width. Anything else zooms 2x. The clicked point stays under the cursor. - The zoom limit follows the image's own resolution instead of a flat 4x. - Panning stops at the image's edges. - A zoom bar (−, slider, +, size against full resolution, fit) fades in at the bottom of the canvas, and the shortcut sheet lists the gestures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,15 @@
|
||||
<div
|
||||
ref="canvasEl"
|
||||
class="fc-canvas"
|
||||
:class="{ 'fc-canvas--zoomed': panZoom.state.scale > 1 }"
|
||||
:class="{
|
||||
'fc-canvas--zoomed': panZoom.state.scale > 1,
|
||||
'fc-canvas--moving': !panZoom.state.smooth,
|
||||
}"
|
||||
@wheel="panZoom.handlers.onWheel"
|
||||
@pointerdown="panZoom.handlers.onPointerDown"
|
||||
@pointermove="panZoom.handlers.onPointerMove"
|
||||
@pointerup="panZoom.handlers.onPointerUp"
|
||||
@pointercancel="panZoom.handlers.onPointerCancel"
|
||||
@click="onCanvasClick"
|
||||
>
|
||||
<img
|
||||
@@ -18,6 +22,34 @@
|
||||
draggable="false"
|
||||
@load="onImgLoad"
|
||||
>
|
||||
<!-- Zoom controls. They sit on the canvas but are not part of it: a press,
|
||||
click or scroll on them never pans, toggles or closes. -->
|
||||
<div
|
||||
class="fc-canvas__zoombar"
|
||||
@pointerdown.stop @click.stop @wheel.stop
|
||||
>
|
||||
<button
|
||||
type="button" class="fc-canvas__zoombtn" aria-label="Zoom out"
|
||||
:disabled="panZoom.state.scale <= 1" @click="panZoom.zoomBy(-1)"
|
||||
><v-icon size="18">mdi-minus</v-icon></button>
|
||||
<input
|
||||
type="range" class="fc-canvas__zoomslider" aria-label="Zoom"
|
||||
min="0" max="1" step="0.001" :value="sliderValue"
|
||||
@input="onSlider"
|
||||
>
|
||||
<button
|
||||
type="button" class="fc-canvas__zoombtn" aria-label="Zoom in"
|
||||
:disabled="panZoom.state.scale >= maxScale" @click="panZoom.zoomBy(1)"
|
||||
><v-icon size="18">mdi-plus</v-icon></button>
|
||||
<span class="fc-canvas__zoomlabel" :title="'Shown at ' + zoomLabel + ' of its full size'">
|
||||
{{ zoomLabel }}
|
||||
</span>
|
||||
<button
|
||||
type="button" class="fc-canvas__zoombtn" aria-label="Fit to the window"
|
||||
title="Fit to the window"
|
||||
:disabled="panZoom.state.scale <= 1" @click="panZoom.reset()"
|
||||
><v-icon size="18">mdi-fit-to-screen-outline</v-icon></button>
|
||||
</div>
|
||||
<!-- #1206 grounding overlay: tracks the <img>'s live rendered rect (so it's
|
||||
correct under object-fit letterboxing + pan/zoom) and draws the crop
|
||||
region a hovered suggestion came from. Null grounding → a subtle
|
||||
@@ -60,10 +92,37 @@ const props = defineProps({
|
||||
hover: { type: Object, default: null },
|
||||
})
|
||||
const emit = defineEmits(['close-request'])
|
||||
const panZoom = usePanZoom()
|
||||
|
||||
const canvasEl = ref(null)
|
||||
const imgEl = ref(null)
|
||||
// Where the picture sits before any zoom: the canvas size, the picture's
|
||||
// layout box (the zoom is a transform on top of it) and its natural width.
|
||||
// Read again when the picture loads and when the window resizes.
|
||||
const fitted = ref(null)
|
||||
function refit() {
|
||||
const img = imgEl.value
|
||||
const canvas = canvasEl.value
|
||||
fitted.value = img && canvas ? {
|
||||
cw: canvas.clientWidth, ch: canvas.clientHeight,
|
||||
iw: img.offsetWidth, ih: img.offsetHeight, nw: img.naturalWidth,
|
||||
} : null
|
||||
panZoom.clamp()
|
||||
}
|
||||
const panZoom = usePanZoom(() => fitted.value)
|
||||
|
||||
// The slider runs on a log scale, so each stretch of it is the same zoom step
|
||||
// whether the picture is at 1.2x or 12x.
|
||||
const maxScale = computed(() => panZoom.maxScale())
|
||||
const sliderValue = computed(() =>
|
||||
Math.log(panZoom.state.scale) / Math.log(maxScale.value))
|
||||
function onSlider(ev) {
|
||||
panZoom.setScale(maxScale.value ** Number(ev.target.value))
|
||||
}
|
||||
// How big it is against its full-size pixels.
|
||||
const zoomLabel = computed(() => {
|
||||
const g = fitted.value
|
||||
const pct = g?.iw && g?.nw ? (g.iw * panZoom.state.scale) / g.nw : panZoom.state.scale
|
||||
return `${Math.round(pct * 100)}%`
|
||||
})
|
||||
const overlayBox = ref(null) // the <img>'s rect relative to the canvas
|
||||
|
||||
// The <img> renders aspect-fit + transformed, so its getBoundingClientRect is
|
||||
@@ -107,22 +166,21 @@ watch(
|
||||
() => [panZoom.state.scale, panZoom.state.x, panZoom.state.y],
|
||||
() => { if (props.hover) measure() },
|
||||
)
|
||||
function onResize () { if (props.hover) measure() }
|
||||
function onImgLoad () { if (props.hover) measure() }
|
||||
function onResize () { refit(); if (props.hover) measure() }
|
||||
function onImgLoad () { refit(); if (props.hover) measure() }
|
||||
onMounted(() => window.addEventListener('resize', onResize))
|
||||
onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||
|
||||
// Reset zoom when the src changes (prev/next nav).
|
||||
watch(() => props.src, () => panZoom.reset())
|
||||
|
||||
// Click on the image → toggle zoom (IR/expected behavior). Click on
|
||||
// the haze area around the image → request close. We use the image's
|
||||
// own bounding rect rather than @click.self because the <img> sets
|
||||
// pointer-events: none for the pan-drag pathway, so the click always
|
||||
// targets the canvas div regardless of where the cursor was.
|
||||
// Click on the image → toggle zoom. Click on the haze around it → request
|
||||
// close. The <img> has pointer-events: none so a drag always lands on the
|
||||
// canvas, which is why "on the image" is worked out from its rect. The click
|
||||
// that ends a drag does neither.
|
||||
function onCanvasClick(ev) {
|
||||
if (panZoom.endedDrag()) return
|
||||
if (panZoom.state.scale > 1) {
|
||||
// When zoomed, any click resets zoom — preserve old behavior.
|
||||
panZoom.handlers.onClick(ev)
|
||||
return
|
||||
}
|
||||
@@ -160,6 +218,49 @@ function onCanvasClick(ev) {
|
||||
transition: transform 100ms ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
.fc-canvas--moving img { transition: none; }
|
||||
|
||||
/* Zoom controls, bottom centre of the canvas: quiet until the canvas is
|
||||
hovered or the picture is zoomed. */
|
||||
.fc-canvas__zoombar {
|
||||
position: absolute;
|
||||
bottom: 16px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 3;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--v-theme-background), 0.7);
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
cursor: default;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.fc-canvas:hover .fc-canvas__zoombar,
|
||||
.fc-canvas--zoomed .fc-canvas__zoombar,
|
||||
.fc-canvas__zoombar:focus-within { opacity: 1; }
|
||||
.fc-canvas__zoombtn {
|
||||
width: 28px; height: 28px;
|
||||
display: grid; place-items: center;
|
||||
border-radius: 50%;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fc-canvas__zoombtn:hover:not(:disabled) { background: rgb(var(--v-theme-surface)); }
|
||||
.fc-canvas__zoombtn:disabled { opacity: 0.35; cursor: default; }
|
||||
.fc-canvas__zoomslider {
|
||||
width: 140px;
|
||||
accent-color: rgb(var(--v-theme-accent));
|
||||
cursor: pointer;
|
||||
}
|
||||
.fc-canvas__zoomlabel {
|
||||
min-width: 44px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
|
||||
/* Grounding overlay — never intercepts pan/zoom/click. */
|
||||
.fc-canvas__overlay {
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
<h3>Keyboard shortcuts</h3>
|
||||
<dl>
|
||||
<div><dt>← / →</dt><dd>Previous / next image</dd></div>
|
||||
<div><dt>Click</dt><dd>Zoom in (a long strip opens at full width) / back to fit</dd></div>
|
||||
<div><dt>Scroll · drag</dt><dd>Move around a zoomed image</dd></div>
|
||||
<div><dt>Ctrl + scroll</dt><dd>Zoom at the pointer</dd></div>
|
||||
<div><dt>T or /</dt><dd>Jump to the tag input</dd></div>
|
||||
<div><dt>↑ / ↓</dt><dd>Move through tag suggestions</dd></div>
|
||||
<div><dt>Enter / Tab</dt><dd>Accept the highlighted tag</dd></div>
|
||||
|
||||
@@ -1,72 +1,174 @@
|
||||
// Click-to-toggle zoom + drag-to-pan when zoomed + wheel-to-zoom.
|
||||
// Returns reactive state + handlers a component can spread onto its
|
||||
// root element.
|
||||
// Zoom and pan for one picture in a canvas.
|
||||
//
|
||||
// - Click toggles between the fitted view and a zoomed one. A long strip (or a
|
||||
// wide panorama) zooms to fill the canvas across its short side, so a comic
|
||||
// strip opens at reading width; anything else zooms to 2x. The clicked point
|
||||
// stays under the cursor.
|
||||
// - A drag pans, and the click that ends a drag does not toggle the zoom.
|
||||
// - The wheel scrolls the zoomed picture (shift+wheel goes sideways); with
|
||||
// ctrl held, which is also what a trackpad pinch sends, it zooms at the
|
||||
// cursor.
|
||||
// - zoomBy/setScale drive the toolbar's buttons and slider.
|
||||
//
|
||||
// `measure()` returns the canvas size, the picture's fitted (unzoomed) size
|
||||
// and its natural width: { cw, ch, iw, ih, nw }, or null before it has laid
|
||||
// out. The picture is centred in the canvas and scaled about its centre, so a
|
||||
// translate of (x, y) at scale s puts picture point p at centre + (x, y) + s*p.
|
||||
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const MIN_SCALE = 1
|
||||
const MAX_SCALE = 4
|
||||
const WHEEL_SENSITIVITY = 0.0015
|
||||
export const MIN_SCALE = 1
|
||||
const CLICK_SCALE = 2
|
||||
// A picture that would have to grow more than this to fill the canvas is a
|
||||
// strip or a panorama: its click zoom fills the canvas instead of doubling.
|
||||
const LONG_FILL = 2
|
||||
const MAX_FLOOR = 4 // the most you can zoom a small picture
|
||||
const MAX_CEIL = 32
|
||||
const STEP = 1.25 // one press of + or -
|
||||
const DRAG_PX = 4 // movement before a press counts as a drag
|
||||
const PINCH_SENSITIVITY = 0.01
|
||||
const LINE_PX = 40 // a wheel "line" (deltaMode 1)
|
||||
|
||||
export function usePanZoom() {
|
||||
export function usePanZoom(measure = () => null) {
|
||||
const state = reactive({
|
||||
scale: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
panning: false
|
||||
panning: false,
|
||||
// Off while dragging or scrolling, so the picture follows the hand
|
||||
// instead of easing behind it.
|
||||
smooth: true,
|
||||
})
|
||||
|
||||
let dragOriginX = 0
|
||||
let dragOriginY = 0
|
||||
let panOriginX = 0
|
||||
let panOriginY = 0
|
||||
let press = null // { x, y, panX, panY, id } while a button is down
|
||||
let dragged = false // the press that is ending moved: swallow its click
|
||||
|
||||
function geometry() {
|
||||
const g = measure()
|
||||
return g && g.iw > 0 && g.ih > 0 && g.cw > 0 && g.ch > 0 ? g : null
|
||||
}
|
||||
|
||||
// The scale at which the picture covers the canvas both ways.
|
||||
function fillScale(g = geometry()) {
|
||||
return g ? Math.max(g.cw / g.iw, g.ch / g.ih) : CLICK_SCALE
|
||||
}
|
||||
|
||||
function maxScale(g = geometry()) {
|
||||
if (!g) return MAX_FLOOR
|
||||
const actualPixels = g.nw ? g.nw / g.iw : 0
|
||||
return Math.min(MAX_CEIL, Math.max(MAX_FLOOR, fillScale(g), actualPixels * 2))
|
||||
}
|
||||
|
||||
function clickScale(g = geometry()) {
|
||||
const fill = fillScale(g)
|
||||
return Math.min(maxScale(g), fill > LONG_FILL ? fill : CLICK_SCALE)
|
||||
}
|
||||
|
||||
// Keep the picture over the canvas: no panning past its edges, and a side
|
||||
// that is smaller than the canvas stays centred.
|
||||
function clamp() {
|
||||
const g = geometry()
|
||||
if (!g) return
|
||||
const mx = Math.max(0, (g.iw * state.scale - g.cw) / 2)
|
||||
const my = Math.max(0, (g.ih * state.scale - g.ch) / 2)
|
||||
state.x = Math.min(mx, Math.max(-mx, state.x))
|
||||
state.y = Math.min(my, Math.max(-my, state.y))
|
||||
}
|
||||
|
||||
function reset() {
|
||||
state.smooth = true
|
||||
state.scale = 1
|
||||
state.x = 0
|
||||
state.y = 0
|
||||
}
|
||||
|
||||
// Zoom to `scale` keeping the point (px, py), measured from the canvas
|
||||
// centre, where it is on screen.
|
||||
function zoomAt(scale, px = 0, py = 0) {
|
||||
const next = Math.min(maxScale(), Math.max(MIN_SCALE, scale))
|
||||
if (next === MIN_SCALE) { state.scale = next; state.x = 0; state.y = 0; return }
|
||||
const k = next / state.scale
|
||||
state.x = px - (px - state.x) * k
|
||||
state.y = py - (py - state.y) * k
|
||||
state.scale = next
|
||||
clamp()
|
||||
}
|
||||
|
||||
function setScale(scale) {
|
||||
state.smooth = true
|
||||
zoomAt(scale)
|
||||
}
|
||||
|
||||
function zoomBy(steps) {
|
||||
setScale(state.scale * STEP ** steps)
|
||||
}
|
||||
|
||||
function fromCentre(ev) {
|
||||
const r = ev.currentTarget?.getBoundingClientRect?.()
|
||||
if (!r) return [0, 0]
|
||||
return [ev.clientX - r.left - r.width / 2, ev.clientY - r.top - r.height / 2]
|
||||
}
|
||||
|
||||
function toggleZoom(ev) {
|
||||
if (state.scale > 1) { reset(); return }
|
||||
state.scale = 2
|
||||
// Zoom centered on click point
|
||||
if (ev && ev.currentTarget) {
|
||||
const rect = ev.currentTarget.getBoundingClientRect()
|
||||
const cx = ev.clientX - rect.left - rect.width / 2
|
||||
const cy = ev.clientY - rect.top - rect.height / 2
|
||||
state.x = -cx
|
||||
state.y = -cy
|
||||
}
|
||||
if (state.scale > MIN_SCALE) { reset(); return }
|
||||
state.smooth = true
|
||||
zoomAt(clickScale(), ...(ev ? fromCentre(ev) : [0, 0]))
|
||||
}
|
||||
|
||||
function onWheel(ev) {
|
||||
ev.preventDefault()
|
||||
const delta = -ev.deltaY * WHEEL_SENSITIVITY
|
||||
const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale + delta * state.scale))
|
||||
state.scale = next
|
||||
if (next === MIN_SCALE) { state.x = 0; state.y = 0 }
|
||||
state.smooth = false
|
||||
if (ev.ctrlKey) {
|
||||
const [px, py] = fromCentre(ev)
|
||||
zoomAt(state.scale * Math.exp(-ev.deltaY * PINCH_SENSITIVITY), px, py)
|
||||
return
|
||||
}
|
||||
if (state.scale === MIN_SCALE) return
|
||||
const unit = ev.deltaMode === 1 ? LINE_PX
|
||||
: ev.deltaMode === 2 ? (geometry()?.ch || 800) : 1
|
||||
let dx = ev.deltaX * unit
|
||||
let dy = ev.deltaY * unit
|
||||
if (ev.shiftKey && !dx) { dx = dy; dy = 0 }
|
||||
state.x -= dx
|
||||
state.y -= dy
|
||||
clamp()
|
||||
}
|
||||
|
||||
function onPointerDown(ev) {
|
||||
if (state.scale === 1) return
|
||||
if (ev.button !== 0) return
|
||||
dragged = false
|
||||
press = { x: ev.clientX, y: ev.clientY, panX: state.x, panY: state.y, id: ev.pointerId }
|
||||
if (state.scale === MIN_SCALE) return
|
||||
state.panning = true
|
||||
dragOriginX = ev.clientX
|
||||
dragOriginY = ev.clientY
|
||||
panOriginX = state.x
|
||||
panOriginY = state.y
|
||||
ev.currentTarget.setPointerCapture(ev.pointerId)
|
||||
state.smooth = false
|
||||
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch {}
|
||||
}
|
||||
|
||||
function onPointerMove(ev) {
|
||||
if (!press) return
|
||||
const dx = ev.clientX - press.x
|
||||
const dy = ev.clientY - press.y
|
||||
if (!dragged && Math.hypot(dx, dy) > DRAG_PX) dragged = true
|
||||
if (!state.panning) return
|
||||
state.x = panOriginX + (ev.clientX - dragOriginX)
|
||||
state.y = panOriginY + (ev.clientY - dragOriginY)
|
||||
state.x = press.panX + dx
|
||||
state.y = press.panY + dy
|
||||
clamp()
|
||||
}
|
||||
|
||||
function onPointerUp(ev) {
|
||||
press = null
|
||||
if (!state.panning) return
|
||||
state.panning = false
|
||||
try { ev.currentTarget.releasePointerCapture(ev.pointerId) } catch {}
|
||||
}
|
||||
|
||||
// True once for the click that ends a drag, so the canvas can ignore it.
|
||||
function endedDrag() {
|
||||
const was = dragged
|
||||
dragged = false
|
||||
return was
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
handlers: {
|
||||
@@ -74,8 +176,14 @@ export function usePanZoom() {
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onClick: toggleZoom
|
||||
onPointerCancel: onPointerUp,
|
||||
onClick: toggleZoom,
|
||||
},
|
||||
reset
|
||||
endedDrag,
|
||||
zoomBy,
|
||||
setScale,
|
||||
maxScale,
|
||||
clamp,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { usePanZoom } from '../src/composables/usePanZoom.js'
|
||||
|
||||
// A 1400x1300 canvas. The strip is 3600x24000, fitted to 195x1300.
|
||||
const STRIP = { cw: 1400, ch: 1300, iw: 195, ih: 1300, nw: 3600 }
|
||||
const PHOTO = { cw: 1400, ch: 1300, iw: 1200, ih: 900, nw: 4000 }
|
||||
|
||||
const RECT = { left: 0, top: 0, width: 1400, height: 1300 }
|
||||
const target = { getBoundingClientRect: () => RECT, setPointerCapture() {}, releasePointerCapture() {} }
|
||||
const at = (x, y, extra = {}) => ({ clientX: x, clientY: y, currentTarget: target,
|
||||
button: 0, pointerId: 1, preventDefault() {}, ...extra })
|
||||
|
||||
describe('usePanZoom', () => {
|
||||
it('opens a long strip at the canvas width, keeping the clicked point under the cursor', () => {
|
||||
const pz = usePanZoom(() => STRIP)
|
||||
pz.handlers.onClick(at(700, 650 - 300))
|
||||
expect(pz.state.scale).toBeCloseTo(1400 / 195)
|
||||
expect(pz.state.x).toBe(0)
|
||||
// The picture point that was 300px above centre (picture y = -300 at
|
||||
// scale 1) is still 300px above centre: y + scale * -300 === -300.
|
||||
expect(pz.state.y + pz.state.scale * -300).toBeCloseTo(-300)
|
||||
})
|
||||
|
||||
it('doubles an ordinary picture', () => {
|
||||
const pz = usePanZoom(() => PHOTO)
|
||||
pz.handlers.onClick(at(700, 650))
|
||||
expect(pz.state.scale).toBe(2)
|
||||
})
|
||||
|
||||
it('does not unzoom on the click that ends a drag', () => {
|
||||
const pz = usePanZoom(() => STRIP)
|
||||
pz.handlers.onClick(at(700, 650))
|
||||
pz.handlers.onPointerDown(at(700, 650))
|
||||
pz.handlers.onPointerMove(at(700, 450))
|
||||
pz.handlers.onPointerUp(at(700, 450))
|
||||
expect(pz.endedDrag()).toBe(true)
|
||||
expect(pz.state.y).toBe(-200)
|
||||
// A plain click afterwards is a click again.
|
||||
pz.handlers.onPointerDown(at(700, 450))
|
||||
pz.handlers.onPointerUp(at(700, 450))
|
||||
expect(pz.endedDrag()).toBe(false)
|
||||
})
|
||||
|
||||
it('scrolls a zoomed picture with the wheel and stops at its ends', () => {
|
||||
const pz = usePanZoom(() => STRIP)
|
||||
pz.handlers.onClick(at(700, 650))
|
||||
const scale = pz.state.scale
|
||||
pz.handlers.onWheel(at(700, 650, { deltaY: 100, deltaX: 0, deltaMode: 0 }))
|
||||
expect(pz.state.scale).toBe(scale)
|
||||
expect(pz.state.y).toBe(-100)
|
||||
pz.handlers.onWheel(at(700, 650, { deltaY: 1e6, deltaX: 0, deltaMode: 0 }))
|
||||
expect(pz.state.y).toBeCloseTo(-(1300 * scale - 1300) / 2)
|
||||
})
|
||||
|
||||
it('leaves the fitted view alone on a plain wheel and zooms with ctrl held', () => {
|
||||
const pz = usePanZoom(() => PHOTO)
|
||||
pz.handlers.onWheel(at(700, 650, { deltaY: 100, deltaX: 0, deltaMode: 0 }))
|
||||
expect(pz.state.scale).toBe(1)
|
||||
pz.handlers.onWheel(at(700, 650, { deltaY: -100, deltaX: 0, deltaMode: 0, ctrlKey: true }))
|
||||
expect(pz.state.scale).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('steps with the buttons and never goes past full size twice over', () => {
|
||||
const pz = usePanZoom(() => PHOTO)
|
||||
pz.zoomBy(1)
|
||||
expect(pz.state.scale).toBeCloseTo(1.25)
|
||||
pz.zoomBy(100)
|
||||
expect(pz.state.scale).toBeCloseTo(pz.maxScale())
|
||||
expect(pz.maxScale()).toBeCloseTo((4000 / 1200) * 2)
|
||||
pz.zoomBy(-100)
|
||||
expect(pz.state.scale).toBe(1)
|
||||
expect([pz.state.x, pz.state.y]).toEqual([0, 0])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user