Mobile

PinchZoomImage

Pinch and double tap to zoom an image, pan to its edges, with a rubber band at the limits and zoom buttons as well.

#mobile#interactive#gesture#pinch#image#tablet

Demo

Preview
Mountains under a pale sky
100%

Settled at 1. Pinch, double tap, or use the buttons.

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/pinch_zoom_image.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/pinch_zoom_image.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/pinch_zoom_image.qwe
// A picture with a size of its own, 3:2, that needs no network.let pinch_zoom_image_demo_src = "data:image/svg+xml," + url_encode("<svg xmlns='http://www.w3.org/2000/svg' width='1200' height='800' viewBox='0 0 1200 800'><defs><linearGradient id='s' x2='0' y2='1'><stop offset='0' stop-color='#9ec5fe'/><stop offset='1' stop-color='#fde2b3'/></linearGradient></defs><rect width='1200' height='800' fill='url(#s)'/><circle cx='930' cy='180' r='80' fill='#fff3bf'/><path d='M0 620 260 300 480 560 700 240 1200 660V800H0z' fill='#4b6a88'/><path d='M0 700 320 520 620 700 900 560 1200 720V800H0z' fill='#2f4858'/><g fill='#fff'><circle cx='180' cy='120' r='5'/><circle cx='420' cy='90' r='4'/><circle cx='640' cy='150' r='3'/></g></svg>")component PinchZoomImageDemoLive() {    state scale = 1    <div.demo-stack>        <PinchZoomImage src={pinch_zoom_image_demo_src} alt="Mountains under a pale sky" label="Mountains" height="320px" on_zoom={(s) => { scale = s }}/>        <p.demo-note id="pinch-zoom-said">Settled at {scale}. Pinch, double tap, or use the buttons.</p>        // Bad data, for the test to look at: nothing here should stop the page.        <div id="pinch-zoom-bad" hidden="true"><PinchZoomImage src="data:image/png;base64,AAAA" alt="Broken" limit={0}/><PinchZoomImage src=""/></div>    </div>}view PinchZoomImageDemo() = <PinchZoomImageDemoLive/>

Source

ui/pinch_zoom_image.qwe
// PinchZoomImage: an image to look into. Two fingers zoom about the point between them,// a double tap zooms in on that spot and a second one back out, and once zoomed one finger// moves it about, as far as its edges. Past the limits it gives a little and comes back.// Buttons do the same for anyone without a free hand, and the keys + - 0 and the arrows// work when it has focus. At its normal size a finger scrolls the page as ever. In a// wide container the buttons stand beside the image instead of under it.////   <PinchZoomImage src="/plans/floor-2.png" alt="Second floor plan" height="60dvh" limit={6}/>//// src, alt: the image. height: its box, as CSS ("320px"). limit: how far it zooms, as a// multiple (4). step: how much a button or key zooms (0.5). double: where a double tap// goes (2.5). label: what screen readers call the box. on_zoom(scale): inside one of your// components, called with each new scale once a gesture has settled. class.import ui/theme.qwe use ui_clslet pinch_zoom_image_css = css`.ui-pinch-zoom { position: relative; display: grid; gap: 8px; container-type: inline-size; color: var(--ui-fg); font: 14px/1.4 var(--ui-font); }.ui-pinch-zoom-inner { display: grid; gap: 8px; }/* pan-y: at its normal size a finger scrolls the page; zoomed, every finger is the image's. */.ui-pinch-zoom-stage { position: relative; overflow: hidden; display: grid; place-items: center; border-radius: var(--ui-radius); background: var(--ui-subtle); touch-action: pan-y;  outline: none; -webkit-touch-callout: none; user-select: none; -webkit-user-select: none; }.ui-pinch-zoom-stage:focus-visible { box-shadow: var(--ui-focus); }.ui-pinch-zoom-in { touch-action: none; cursor: grab; }.ui-pinch-zoom-img { display: block; width: 100%; height: 100%; object-fit: contain; pointer-events: none; -webkit-user-drag: none; transform-origin: 50% 50%;  transition: transform .22s cubic-bezier(.2, .8, .2, 1); will-change: transform; }.ui-pinch-zoom-live .ui-pinch-zoom-img { transition: none; }.ui-pinch-zoom-bad { padding: 16px; color: var(--ui-muted); text-align: center; }.ui-pinch-zoom-bar { display: flex; align-items: center; justify-content: center; gap: 6px; }.ui-pinch-zoom-btn { display: grid; place-items: center; min-width: 46px; min-height: 46px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm);  background: var(--ui-surface); color: var(--ui-fg); font: 500 15px/1 var(--ui-font); cursor: pointer; }.ui-pinch-zoom-btn[aria-disabled="true"] { color: var(--ui-muted); opacity: .55; cursor: default; }.ui-pinch-zoom-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-pinch-zoom-level { min-width: 52px; color: var(--ui-muted); font-variant-numeric: tabular-nums; text-align: center; }@media (hover: hover) { .ui-pinch-zoom-btn:not([aria-disabled="true"]):hover { background: var(--ui-subtle); } }@container (min-width: 520px) {  .ui-pinch-zoom-inner { grid-template-columns: minmax(0, 1fr) auto; }  .ui-pinch-zoom-bar { flex-direction: column; align-items: stretch; justify-content: flex-start; }}@media (prefers-reduced-motion: reduce) { .ui-pinch-zoom-img { transition: none; } }`fn pinch_zoom_image_clamp(v: num, lo: num, hi: num) -> num {    max([lo, min([v, hi])])}// A scale past the limits gives a quarter of what the fingers ask.fn pinch_zoom_image_stretch(s: num, lo: num, hi: num) -> num {    if s > hi { return hi + (s - hi) * 0.25 }    if s < lo { return lo - (lo - s) * 0.25 }    s}// A pan past the edge gives 30% of it.fn pinch_zoom_image_pull(p: num, edge: num) -> num {    if abs(p) <= edge { return p }    (p > 0 ? 1 : -1) * (edge + (abs(p) - edge) * 0.3)}// The size the picture itself shows at in a box: contain leaves bars, and a pan stops at the picture's// edge, not the box's. [box width, box height] when the image has not said its size.fn pinch_zoom_image_fit(nw: num, nh: num, w: num, h: num) -> list {    if nw <= 0 || nh <= 0 || w <= 0 || h <= 0 { return [w, h] }    let by = min([w / nw, h / nh])    [nw * by, nh * by]}// How far the picture of length c can move each way at scale s in a box of length box.fn pinch_zoom_image_edge(s: num, c: num, box: num) -> num {    max([0, (s * c - box) / 2])}// Where the picture must go for what is at f to stay at f, as the scale goes from s0 to s1.fn pinch_zoom_image_focus(f: num, p: num, s0: num, s1: num) -> num {    f - (f - p) * s1 / s0}// A view brought within its limits. Very close to the normal size is the normal size.fn pinch_zoom_image_settle(v, hi: num, cw: num, ch: num, w: num, h: num) {    let s = pinch_zoom_image_clamp(v.s, 1, hi)    if s < 1.01 { return { s: 1, x: 0, y: 0 } }    let ex = pinch_zoom_image_edge(s, cw, w)    let ey = pinch_zoom_image_edge(s, ch, h)    { s: round(s, 3), x: round(pinch_zoom_image_clamp(v.x, -ex, ex), 2), y: round(pinch_zoom_image_clamp(v.y, -ey, ey), 2) }}// How much further a pan goes after the finger has left: nothing from a slow one.fn pinch_zoom_image_carry(v: num) -> num {    abs(v) < 0.3 ? 0 : v * 140}fn pinch_zoom_image_gap(a, b) -> num {    math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y))}component PinchZoomImage(src: str = "", alt: str = "", height: str = "320px", limit: num = 4, step: num = 0.5, double: num = 2.5,                         label: str = "Zoomable image", on_zoom = null, class: str? = null) {    style global pinch_zoom_image_css    let hi = max([1, limit])    state stage = null    state img = null    state zoom = 1           // the settled scale, for what shows it    state live = false       // fingers on it: no easing    state broken = false    state spot = { s: 1, x: 0, y: 0 }     // where it is now, moved in handlers    state down = []          // the pointers on it: { id, x, y }    state g = null           // what the fingers started from    state tap = null         // a press that may be a tap: { t, x, y }    state last = null        // the tap before it    let paint = (v) => {        spot = v        img.style.transform = v.s == 1 && v.x == 0 && v.y == 0 ? "" : `translate(${round(v.x, 2)}px, ${round(v.y, 2)}px) scale(${round(v.s, 3)})`    }    let sized = () => {        let w = stage.clientWidth        let h = stage.clientHeight        let fit = pinch_zoom_image_fit(img.naturalWidth, img.naturalHeight, w, h)        { w, h, cw: fit[0], ch: fit[1] }    }    let settle = (v) => {        let b = sized()        let to = pinch_zoom_image_settle(v, hi, b.cw, b.ch, b.w, b.h)        paint(to)        if to.s != zoom {            zoom = to.s            on_zoom && on_zoom(to.s)        }    }    // The point of the box a pointer is at, from its middle.    let at = (e) => {        let r = stage.getBoundingClientRect()        { x: e.clientX - r.left - r.width / 2, y: e.clientY - r.top - r.height / 2 }    }    let zoom_to = (s: num, f) => {        let to = pinch_zoom_image_clamp(s, 1, hi)        settle({ s: to, x: pinch_zoom_image_focus(f.x, spot.x, spot.s, to), y: pinch_zoom_image_focus(f.y, spot.y, spot.s, to) })    }    let middle = () => { { x: 0, y: 0 } }    let start_pan = (p) => { g = { kind: "pan", x: p.x, y: p.y, vx: spot.x, vy: spot.y, moved: 0, s: { x: p.x, y: p.y, at: 0, vx: 0, vy: 0 } } }    let start_pinch = () => {        let a = down[0]        let b = down[1]        g = { kind: "pinch", gap: max([1, pinch_zoom_image_gap(a, b)]), s0: spot.s, vx: spot.x, vy: spot.y, mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 }    }    let moved_pinch = () => {        let a = down[0]        let b = down[1]        let want = g.s0 * pinch_zoom_image_gap(a, b) / g.gap        let s = pinch_zoom_image_stretch(want, 1, hi)        let fx = (a.x + b.x) / 2        let fy = (a.y + b.y) / 2        paint({ s, x: fx - (g.mx - g.vx) * s / g.s0, y: fy - (g.my - g.vy) * s / g.s0 })    }    let moved_pan = (p, now: num) => {        let b = sized()        let ex = pinch_zoom_image_edge(spot.s, b.cw, b.w)        let ey = pinch_zoom_image_edge(spot.s, b.ch, b.h)        paint({ s: spot.s, x: pinch_zoom_image_pull(g.vx + p.x - g.x, ex), y: pinch_zoom_image_pull(g.vy + p.y - g.y, ey) })        let dt = now - g.s.at        g = { ...g, moved: now, s: dt < 32 ? g.s : { x: p.x, y: p.y, at: now, vx: (p.x - g.s.x) / dt, vy: (p.y - g.s.y) / dt } }    }    let lifted = (e) => {        let was = g        let fresh = was != null && was.kind == "pan" && e.timeStamp - was.moved <= 80        down = down | filter((d) => d.id != e.pointerId)        if len(down) == 1 && spot.s > 1 { start_pan(down[0]) }        if len(down) == 0 {            g = null            live = false            // A pan let go at speed carries on a little, and stops at the edge.            settle(fresh ? { ...spot, x: spot.x + pinch_zoom_image_carry(was.s.vx), y: spot.y + pinch_zoom_image_carry(was.s.vy) } : spot)        }    }    let can_in = zoom < hi && !broken    let can_out = zoom > 1    <div class={ui_cls(["ui-pinch-zoom", class])}>        <div.ui-pinch-zoom-inner>            <div class={ui_cls(["ui-pinch-zoom-stage", zoom > 1 && "ui-pinch-zoom-in", live && "ui-pinch-zoom-live"])} style={`height: ${height}`}                 tabindex="0" role="group" aria-label={label}                 @mount={(me) => {                     stage = me                     // touch-action is read when a finger lands, so a second finger at the normal size, under                     // pan-y, would go to the page's scroll or zoom. Only a touchmove can still say no.                     let guard = (e) => { if e.cancelable && (e.touches.length > 1 || spot.s > 1) { e.preventDefault() } }                     let block = (e) => { e.preventDefault() }    // Safari's own pinch, past touch-action                     let wheel = (e) => {                         // A trackpad's pinch reads as a wheel with Ctrl down.                         if e.ctrlKey && !broken {                             e.preventDefault()                             zoom_to(spot.s * math.exp(-e.deltaY * 0.01), at(e))                         }                     }                     me.addEventListener("touchmove", guard, { passive: false })                     me.addEventListener("gesturestart", block, { passive: false })                     me.addEventListener("wheel", wheel, { passive: false })                     // A turned phone changes the box, and with it how far the picture may go.                     let watcher = js_new("ResizeObserver", (_a = null) => { if img != null && spot.s > 1 { settle(spot) } })                     watcher.observe(me)                     () => {                         watcher.disconnect()                         me.removeEventListener("touchmove", guard)                         me.removeEventListener("gesturestart", block)                         me.removeEventListener("wheel", wheel)                     }                 }}                 @keydown={(e) => {                     let k = broken ? "" : e.key                     if k == "+" || k == "=" { e.preventDefault(); zoom_to(spot.s + step, middle()) }                     if k == "-" { e.preventDefault(); zoom_to(spot.s - step, middle()) }                     if k == "0" { e.preventDefault(); settle({ s: 1, x: 0, y: 0 }) }                     if spot.s > 1 {                         let by = match k {                             "ArrowLeft" => [40, 0]                             "ArrowRight" => [-40, 0]                             "ArrowUp" => [0, 40]                             "ArrowDown" => [0, -40]                             _ => null                         }                         if by != null {                             e.preventDefault()                             settle({ ...spot, x: spot.x + by[0], y: spot.y + by[1] })                         }                     }                 }}                 @pointerdown={(e) => {                     if e.button == 0 && !broken {                         let p = at(e)                         down = down + [{ id: e.pointerId, x: p.x, y: p.y }]                         stage.setPointerCapture(e.pointerId)                         if len(down) == 1 {                             tap = { t: e.timeStamp, x: p.x, y: p.y }                             if spot.s > 1 {                                 live = true                                 start_pan(p)                             } else {                                 g = null                             }                         }                         if len(down) == 2 {                             tap = null                             live = true                             start_pinch()                         }                     }                 }}                 @pointermove={(e) => {                     if contains(down | map((d) => d.id), e.pointerId) {                         let p = at(e)                         down = down | map((d) => d.id == e.pointerId ? { id: d.id, x: p.x, y: p.y } : d)                         if tap != null && abs(p.x - tap.x) + abs(p.y - tap.y) > 10 { tap = null }                         if g != null && len(down) == 2 && g.kind == "pinch" { moved_pinch() }                         if g != null && len(down) == 1 && g.kind == "pan" { moved_pan(p, e.timeStamp) }                     }                 }}                 @pointerup={(e) => {                     let p = at(e)                     let quick = tap != null && e.timeStamp - tap.t < 300                     let twice = quick && last != null && e.timeStamp - last.t < 320 && abs(p.x - last.x) + abs(p.y - last.y) < 30                     lifted(e)                     if quick { last = twice ? null : { t: e.timeStamp, x: p.x, y: p.y } }                     tap = null                     if twice { if spot.s > 1 { settle({ s: 1, x: 0, y: 0 }) } else { zoom_to(double, p) } }                 }}                 @pointercancel={(e) => {                     tap = null                     lifted(e)                 }}>                {broken                    ? <p.ui-pinch-zoom-bad>This image could not be loaded.</p>                    : <img.ui-pinch-zoom-img src={src} alt={alt} draggable="false" @error={() => { broken = true }}                           @mount={(me) => {                               img = me                               // @error never runs for an image that failed before the component started, or has no address.                               if me.complete && me.naturalWidth == 0 { broken = true }                           }}/>}            </div>            <div.ui-pinch-zoom-bar role="group" aria-label="Zoom">                <button.ui-pinch-zoom-btn.ui-pinch-zoom-out type="button" aria-label="Zoom out" aria-disabled={can_out ? null : "true"}                        @click={() => { if can_out { zoom_to(spot.s - step, middle()) } }}>−</button>                <span.ui-pinch-zoom-level role="status">{round(zoom * 100)}%</span>                <button.ui-pinch-zoom-btn.ui-pinch-zoom-more type="button" aria-label="Zoom in" aria-disabled={can_in ? null : "true"}                        @click={() => { if can_in { zoom_to(spot.s + step, middle()) } }}>+</button>                <button.ui-pinch-zoom-btn.ui-pinch-zoom-reset type="button" aria-disabled={can_out ? null : "true"}                        @click={() => { if can_out { settle({ s: 1, x: 0, y: 0 }) } }}>Reset</button>            </div>        </div>    </div>}
Thread
27Useful? Like it, or add to its thread below.

Thread 0 posts

On PinchZoomImage: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.