diff --git a/cypress/integration/mouse_spec.js b/cypress/integration/mouse_spec.js index 0ee66ac..34c2522 100644 --- a/cypress/integration/mouse_spec.js +++ b/cypress/integration/mouse_spec.js @@ -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)') }) }) diff --git a/cypress/integration/touch_spec.js b/cypress/integration/touch_spec.js index 5ff9974..28f39be 100644 --- a/cypress/integration/touch_spec.js +++ b/cypress/integration/touch_spec.js @@ -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)') }) }) diff --git a/src/helpers.js b/src/helpers.js index e540eb6..76b3887 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -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, + } +} diff --git a/src/helpers.test.js b/src/helpers.test.js index fc9a1e8..6749cc2 100644 --- a/src/helpers.test.js +++ b/src/helpers.test.js @@ -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) + }) + }) }) diff --git a/src/index.js b/src/index.js index 03c1f11..3c7b840 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import { restrictPosition, getDistanceBetweenPoints, computeCroppedArea, + getCenter, } from './helpers' import { Container, Img, CropArea } from './styles' @@ -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, } @@ -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) }) @@ -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 @@ -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) }