Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve zoom gestures #13

Merged
merged 4 commits into from
Aug 27, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions cypress/integration/mouse_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,40 +20,47 @@ describe('Mouse assertions', function() {
})

it('Mouse wheel should zoom in and out', function() {
cy.get('[data-testid=container]').trigger('wheel', { deltaY: -100 })
cy.get('[data-testid=container]').trigger('wheel', { deltaY: -100, clientX: 500, clientY: 300 })
cy.get('img').should('have.css', 'transform', 'matrix(1.5, 0, 0, 1.5, 0, 0)')
cy.get('[data-testid=container]').trigger('wheel', { deltaY: 50 })
cy.get('[data-testid=container]').trigger('wheel', { deltaY: 50, clientX: 500, clientY: 300 })
cy.get('img').should('have.css', 'transform', 'matrix(1.25, 0, 0, 1.25, 0, 0)')
})

it('Mouse wheel should zoom in and out following the pointer', function() {
cy.get('[data-testid=container]').trigger('wheel', { deltaY: -100, clientX: 0, clientY: 0 })
cy.get('img').should('have.css', 'transform', 'matrix(1.5, 0, 0, 1.5, 250, 131)')
cy.get('[data-testid=container]').trigger('wheel', { deltaY: 50, clientX: 800, clientY: 400 })
cy.get('img').should('have.css', 'transform', 'matrix(1.25, 0, 0, 1.25, 258.333, 65.5)')
})

it('Move down and right after zoom', function() {
cy.get('[data-testid=container]').trigger('wheel', { deltaY: -100 })
cy.get('[data-testid=container]').trigger('wheel', { deltaY: -100, clientX: 500, clientY: 300 })
cy.get('[data-testid=container]').dragAndDrop({ x: 50, y: 50 })
cy.get('img').should('have.css', 'transform', 'matrix(1.5, 0, 0, 1.5, 50, 50)')
})

it('Move up and left after zoom', function() {
cy.get('[data-testid=container]').trigger('wheel', { deltaY: -100 })
cy.get('[data-testid=container]').trigger('wheel', { deltaY: -100, clientX: 500, clientY: 300 })
cy.get('[data-testid=container]').dragAndDrop({ x: -50, y: -50 })
cy.get('img').should('have.css', 'transform', 'matrix(1.5, 0, 0, 1.5, -50, -50)')
})

it('Limit top after zoom ', function() {
cy.get('[data-testid=container]').trigger('wheel', { deltaY: -100 })
cy.get('[data-testid=container]').trigger('wheel', { deltaY: -100, clientX: 500, clientY: 300 })
cy.get('[data-testid=container]').dragAndDrop({ x: 0, y: -1000 })
cy.get('img').should('have.css', 'transform', 'matrix(1.5, 0, 0, 1.5, 0, -131)')
})

it('Limit bottom after zoom ', function() {
cy.get('[data-testid=container]').trigger('wheel', { deltaY: -100 })
cy.get('[data-testid=container]').trigger('wheel', { deltaY: -100, clientX: 500, clientY: 300 })
cy.get('[data-testid=container]').dragAndDrop({ x: 0, y: 1000 })
cy.get('img').should('have.css', 'transform', 'matrix(1.5, 0, 0, 1.5, 0, 131)')
})

it('Keep image under crop area after zoom out', function() {
cy.get('[data-testid=container]').trigger('wheel', { deltaY: -100 }) // zoom-in
cy.get('[data-testid=container]').trigger('wheel', { deltaY: -100, clientX: 500, clientY: 300 }) // zoom-in
cy.get('[data-testid=container]').dragAndDrop({ x: 0, y: 1000 }) // move image to bottom
cy.get('[data-testid=container]').trigger('wheel', { deltaY: 100 }) // zoom-out
cy.get('[data-testid=container]').trigger('wheel', { deltaY: 100, clientX: 500, clientY: 300 }) // zoom-out
cy.get('img').should('have.css', 'transform', 'matrix(1, 0, 0, 1, 0, 0)')
})
})
41 changes: 39 additions & 2 deletions cypress/integration/touch_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,45 @@ describe('Touch assertions', function() {

it('Zoom in and out with pinch', function() {
cy.get('[data-testid=container]').pinch({ distance: 10 })
cy.get('img').should('have.css', 'transform', 'matrix(2, 0, 0, 2, 0, 0)')
cy.get('img').should('have.css', 'transform', 'matrix(2, 0, 0, 2, 500, 262)')
cy.get('[data-testid=container]').pinch({ distance: -4 })
cy.get('img').should('have.css', 'transform', 'matrix(1.2, 0, 0, 1.2, 0, 0)')
cy.get('img').should('have.css', 'transform', 'matrix(1.2, 0, 0, 1.2, 100, 37.2)')
})

it('Zoom in and out with pinch based on the center between 2 fingers', function() {
cy.get('[data-testid=container]')
.trigger('touchstart', {
touches: [{ clientX: 500, clientY: 200 }, { clientX: 600, clientY: 300 }],
})
.trigger('touchmove', {
touches: [{ clientX: 500, clientY: 200 }, { clientX: 600, clientY: 310 }],
})
.trigger('touchend')
cy.get('img').should(
'have.css',
'transform',
'matrix(1.05119, 0, 0, 1.05119, -2.55949, 2.30354)'
)
cy.get('[data-testid=container]')
.trigger('touchstart', {
touches: [{ clientX: 100, clientY: 50 }, { clientX: 200, clientY: 100 }],
})
.trigger('touchmove', {
touches: [{ clientX: 100, clientY: 50 }, { clientX: 190, clientY: 80 }],
})
.trigger('touchend')
cy.get('img').should('have.css', 'transform', 'matrix(1, 0, 0, 1, -24.4788, 0)')
})

it('Move image with pinch based on the center between 2 fingers', function() {
cy.get('[data-testid=container]')
.trigger('touchstart', {
touches: [{ clientX: 500, clientY: 200 }, { clientX: 600, clientY: 300 }],
})
.trigger('touchmove', {
touches: [{ clientX: 600, clientY: 200 }, { clientX: 700, clientY: 300 }],
})
.trigger('touchend')
cy.get('img').should('have.css', 'transform', 'matrix(1, 0, 0, 1, 100, 0)')
})
})
12 changes: 12 additions & 0 deletions src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,15 @@ export function computeCroppedArea(crop, imgSize, cropSize, zoom) {
function limitArea(max, value) {
return Math.min(max, Math.max(0, value))
}

/**
* Return the point that is the center of point a and b
* @param {{x: number, y: number}} a
* @param {{x: number, y: number}} b
*/
export function getCenter(a, b) {
return {
x: (b.x + a.x) / 2,
y: (b.y + a.y) / 2,
}
}
18 changes: 18 additions & 0 deletions src/helpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,22 @@ describe('Helpers', () => {
expect(areas.croppedAreaPixels).toEqual({ height: 300, width: 500, x: 750, y: 450 })
})
})

describe('getCenter', () => {
test('should simply return the center between a and b', () => {
const center = helpers.getCenter({ x: 0, y: 0 }, { x: 100, y: 0 })
expect(center).toEqual({ x: 50, y: 0 })
})

test.each([
[{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 50, y: 0 }],
[{ x: 0, y: 0 }, { x: 0, y: 100 }, { x: 0, y: 50 }],
[{ x: 0, y: 0 }, { x: 100, y: 100 }, { x: 50, y: 50 }],
[{ x: 100, y: 1000 }, { x: 0, y: 400 }, { x: 50, y: 700 }],
[{ x: 0, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }],
])('.getCenter(%o, %o)', (a, b, expected) => {
const center = helpers.getCenter(a, b)
expect(center).toEqual(expected)
})
})
})
65 changes: 54 additions & 11 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
restrictPosition,
getDistanceBetweenPoints,
computeCroppedArea,
getCenter,
} from './helpers'
import { Container, Img, CropArea } from './styles'

Expand All @@ -13,11 +14,13 @@ const MAX_ZOOM = 3
class Cropper extends React.Component {
image = null
container = null
containerRect = {}
imageSize = { width: 0, height: 0, naturalWidth: 0, naturalHeight: 0 }
dragStartPosition = { x: 0, y: 0 }
dragStartCrop = { x: 0, y: 0 }
lastPinchDistance = 0
rafTimeout = null
rafDragTimeout = null
rafZoomTimeout = null
state = {
cropSize: null,
}
Expand Down Expand Up @@ -69,6 +72,9 @@ class Cropper extends React.Component {
const cropSize = getCropSize(this.image.width, this.image.height, this.props.aspect)
this.setState({ cropSize }, this.recomputeCropPosition)
}
if (this.container) {
this.containerRect = this.container.getBoundingClientRect()
}
}

static getMousePoint = e => ({ x: Number(e.clientX), y: Number(e.clientY) })
Expand Down Expand Up @@ -112,9 +118,9 @@ class Cropper extends React.Component {
}

onDrag = ({ x, y }) => {
if (this.rafTimeout) window.cancelAnimationFrame(this.rafTimeout)
if (this.rafDragTimeout) window.cancelAnimationFrame(this.rafDragTimeout)

this.rafTimeout = window.requestAnimationFrame(() => {
this.rafDragTimeout = window.requestAnimationFrame(() => {
if (x === undefined || y === undefined) return
const offsetX = x - this.dragStartPosition.x
const offsetY = y - this.dragStartPosition.y
Expand Down Expand Up @@ -142,29 +148,66 @@ class Cropper extends React.Component {
const pointA = Cropper.getTouchPoint(e.touches[0])
const pointB = Cropper.getTouchPoint(e.touches[1])
this.lastPinchDistance = getDistanceBetweenPoints(pointA, pointB)
this.onDragStart(getCenter(pointA, pointB))
}

onPinchMove(e) {
if (this.rafTimeout) window.cancelAnimationFrame(this.rafTimeout)
this.rafTimeout = window.requestAnimationFrame(() => {
const pointA = Cropper.getTouchPoint(e.touches[0])
const pointB = Cropper.getTouchPoint(e.touches[1])
const distance = getDistanceBetweenPoints(pointA, pointB)
const pointA = Cropper.getTouchPoint(e.touches[0])
const pointB = Cropper.getTouchPoint(e.touches[1])
const center = getCenter(pointA, pointB)
this.onDrag(center)

if (this.rafZoomTimeout) window.cancelAnimationFrame(this.rafZoomTimeout)
this.rafZoomTimeout = window.requestAnimationFrame(() => {
const distance = getDistanceBetweenPoints(pointA, pointB)
const newZoom = this.props.zoom * (distance / this.lastPinchDistance)
this.setNewZoom(newZoom)
this.setNewZoom(newZoom, center)
this.lastPinchDistance = distance
})
}

onWheel = e => {
e.preventDefault()
const point = Cropper.getMousePoint(e)
const newZoom = this.props.zoom - e.deltaY / 200
this.setNewZoom(newZoom)
this.setNewZoom(newZoom, point)
}

getPointOnContainer = ({ x, y }, zoom) => {
if (!this.containerRect) {
throw new Error('The Cropper is not mounted')
}
return {
x: this.containerRect.width / 2 - (x - this.containerRect.left),
y: this.containerRect.height / 2 - (y - this.containerRect.top),
}
}

getPointOnImage = ({ x, y }) => {
const { crop, zoom } = this.props
return {
x: (x + crop.x) / zoom,
y: (y + crop.y) / zoom,
}
}

setNewZoom = zoom => {
setNewZoom = (zoom, point) => {
const zoomPoint = this.getPointOnContainer(point)
const zoomTarget = this.getPointOnImage(zoomPoint)
const newZoom = Math.min(this.props.maxZoom, Math.max(zoom, this.props.minZoom))
const requestedPosition = {
x: zoomTarget.x * newZoom - zoomPoint.x,
y: zoomTarget.y * newZoom - zoomPoint.y,
}
const newPosition = restrictPosition(
requestedPosition,
this.imageSize,
this.state.cropSize,
newZoom
)

this.props.onCropChange(newPosition)

this.props.onZoomChange && this.props.onZoomChange(newZoom)
}

Expand Down