Canvas-Based Image Viewer with Touch Gestures for H5
The article presents a full‑featured H5 image viewer built on the native canvas API that supports touch gestures such as swipe, drag, pinch‑zoom and double‑tap, handles slide switching, boundary control, initial centering, mouse‑wheel emulation, and high‑resolution rendering via devicePixelRatio, eliminating the need for native bridges.
The article describes a complete H5 image‑viewer solution built on the native Web canvas API, aiming to provide rich interactive experiences comparable to native apps.
Background : The team needed a large‑image browsing component for an automotive image library. Existing solutions (jsbridge, Swiper) either required native integration or did not expose user interaction data, leading to a cumbersome user flow.
Canvas Core API : The key rendering method is drawImage(image, dx, dy, dWidth, dHeight), which maps a source image onto the canvas using calculated offsets (imgX, imgY) and a scale factor (imgScale).
export interface IQuickCheck {<br/> startTime?: number; // 起始时间<br/> startX?: number; // 起始 x 点<br/>}<br/><br/>const quickSlideCheckRef: {current: IQuickCheck} = { current: {} };<br/>export const quickSlideCheckFn = {<br/> tapStart: (startX) => {<br/> quickSlideCheckRef.current = { startX, startTime: performance.now() };<br/> },<br/> /**<br/> * 双击结束校验<br/> * @param endX 结束点 x 坐标<br/> * @param toLeft 向左滑的回调函数<br/> * @param toRight 向右滑的回调函数<br/> */<br/> tapEnd: (endX, toLeft, toRight) => {<br/> const { startX, startTime } = quickSlideCheckRef.current;<br/> const endTime = performance.now();<br/> const speed = (endX - startX) / (endTime - startTime);<br/> if (Math.abs(speed) > 0.3) {<br/> if (speed > 0) toRight(); else toLeft();<br/> return true;<br/> }<br/> return false;<br/> }<br/>};This logic detects fast horizontal swipes by measuring the distance and duration between tapStart and tapEnd.
export interface IDoubleTapCheck {<br/> firstTapStart?: number;<br/> firstTapEnd?: number;<br/> secondTapStart?: number;<br/>}<br/><br/>const clickTime = 200;<br/>const doubleTapCheckRef: {current: IDoubleTapCheck} = { current: {} };<br/>export const doubleClickCheckFn = {<br/> tapStart: () => {<br/> const now = performance.now();<br/> if (!doubleTapCheckRef.current.firstTapStart) {<br/> doubleTapCheckRef.current.firstTapStart = now;<br/> } else {<br/> if (now - doubleTapCheckRef.current.firstTapEnd < clickTime) {<br/> doubleTapCheckRef.current.secondTapStart = now;<br/> } else {<br/> doubleTapCheckRef.current = {firstTapStart: now};<br/> }<br/> }<br/> },<br/> tapEnd: (callback = () => {}) => {<br/> const now = performance.now();<br/> let isDoubleTap = false;<br/> if (!doubleTapCheckRef.current.secondTapStart) {<br/> if (now - doubleTapCheckRef.current.firstTapStart < clickTime) {<br/> doubleTapCheckRef.current.firstTapEnd = now;<br/> } else {<br/> doubleTapCheckRef.current = {};<br/> }<br/> } else {<br/> if (now - doubleTapCheckRef.current.secondTapStart < clickTime) {<br/> callback();<br/> isDoubleTap = true;<br/> }<br/> doubleTapCheckRef.current = {};<br/> }<br/> return isDoubleTap;<br/> }<br/>};Double‑tap detection relies on measuring the interval between two consecutive taps.
Swipe, Drag, Pinch : Simple drag updates imgX and imgY based on touch movement. Pinch‑zoom calculates the distance between two fingers, derives a scale factor, and recomputes the image’s top‑left corner to keep the focal point under the fingers.
const handleZoom = (e: TouchEvent) => {<br/> const { touches: curTouches } = e;<br/> const { imgX, imgY, imgScale } = drawParamsRef.current;<br/> const { clientX, clientY } = curTouches[0];<br/> const pos1 = getPositionInCanvas(clientX, clientY, canvasRef.current); // first finger after move<br/> const prePos1 = getPositionInCanvas(touchesRef.current[0].clientX, touchesRef.current[0].clientY, canvasRef.current); // before move<br/> const curDistance = getDistance(curTouches[0], curTouches[1]);<br/> const prevDistance = getDistance(touchesRef.current[0], touchesRef.current[1]);<br/> const curScale = curDistance / prevDistance;<br/> const newImgXY = getNewImgXY(scalePos(prePos1), scalePos(pos1), {imgX, imgY}, curScale);<br/> const newImgScale = imgScale * curScale;<br/> drawParamsRef.current = { imgScale: newImgScale, ...newImgXY };<br/> touchesRef.current = curTouches;<br/>};Slide Switching : When the image is dragged beyond a threshold, slideToLeft or slideToRight is triggered.
const slideCheck = () => {<br/> let { imgScale, imgX } = drawParamsRef.current;<br/> const { width: canvasWidth } = canvasRef.current;<br/> let { width: imgWidth, height: imgHeight } = curImgRef.current;<br/> imgWidth = imgWidth * imgScale * devicePixelRatio;<br/> imgHeight = imgHeight * imgScale * devicePixelRatio;<br/> let doSlide = true;<br/> if (imgX > (canvasWidth * 2 / 5) && preImgRef.current) { // right swipe<br/> slideToRight();<br/> } else if (imgX < 0 && ((imgWidth + imgX) < (canvasWidth * 3 / 5)) && nextImgRef.current) { // left swipe<br/> slideToLeft();<br/> } else {<br/> doSlide = false;<br/> }<br/> return doSlide;<br/>};Initial Centering ensures the image fills the canvas width and is vertically centered.
const initDrawParams = () => {<br/> const { width: imgWidth, height: imgHeight } = curImgRef.current;<br/> const { width: canvasWidth, height: canvasHeight } = canvasRef.current;<br/> const imgScale = canvasWidth / imgWidth;<br/> const imgX = 0;<br/> const imgY = (canvasHeight - imgHeight * imgScale) / 2;<br/> drawParamsRef.current = {imgX, imgY, imgScale};<br/>};Boundary Control prevents blank spaces at the edges and keeps the image within the canvas.
const boundaryCheck = (drawParams = null) => {<br/> const oldDrawParams = drawParams || drawParamsRef.current;<br/> let { imgScale, imgX, imgY } = oldDrawParams;<br/> const { width: canvasWidth, height: canvasHeight } = canvasRef.current;<br/> let { width: imgWidth, height: imgHeight } = curImgRef.current;<br/> imgWidth = imgWidth * imgScale * devicePixelRatio;<br/> imgHeight = imgHeight * imgScale * devicePixelRatio;<br/> // vertical bounce<br/> if (imgHeight >= canvasHeight) {<br/> if (imgY > 0) imgY = 0;<br/> else if (imgY + imgHeight < canvasHeight) imgY = canvasHeight - imgHeight;<br/> } else {<br/> if (imgY < 0) imgY = 0;<br/> else if (imgY + imgHeight > canvasHeight) imgY = canvasHeight - imgHeight;<br/> }<br/> // horizontal bounce<br/> if (imgWidth >= canvasWidth) {<br/> if (imgX > 0) imgX = 0;<br/> else if (imgX + imgWidth < canvasWidth) imgX = canvasWidth - imgWidth;<br/> } else {<br/> if (imgX < 0) imgX = 0;<br/> else if (imgX + imgWidth > canvasWidth) imgX = canvasWidth - imgWidth;<br/> }<br/> // center when image smaller than canvas<br/> if (canvasHeight >= imgHeight) imgY = (canvasHeight - imgHeight) / 2;<br/> return { imgY, imgX, imgScale };<br/>};For PC compatibility, mouse wheel events are wrapped with a timeout to emulate an “end” signal.
const mouseWheelWatcher = (e: WheelEvent & { wheelDelta: number }) => {<br/> mouseWheel(e);<br/> if (wheelWatcherRef.current) {<br/> clearTimeout(wheelWatcherRef.current);<br/> }<br/> wheelWatcherRef.current = window.setTimeout(() => {<br/> rescaleImg({ clientX: e.clientX, clientY: e.clientY });<br/> }, 100);<br/>};Finally, the implementation delivers a smooth, high‑resolution image browsing experience on both mobile and desktop, leveraging devicePixelRatio to render crisp graphics.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
