Skip to content

Commit 75f1054

Browse files
authored
Add interaction callback details (#657)
1 parent 72c570e commit 75f1054

8 files changed

Lines changed: 103 additions & 20 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ See [#428](https://github.com/ValentinH/react-easy-crop/issues/428), [#409](http
127127
| `restrictPosition` | boolean | | Whether the position of the media should be restricted to the boundaries of the cropper. Useful setting in case of `zoom < 1` or if the cropper should preserve all media content while forcing a specific aspect ratio for media throughout the application. |
128128
| `initialCroppedAreaPercentages` | `{ width: number, height: number, x: number, y: number}` | | Use this to set the initial crop position/zoom of the cropper (for example, when editing a previously cropped media). The value should be the same as the `croppedArea` passed to [`onCropComplete`](#onCropCompleteProp). This is the preferred way of restoring the previously set crop because `croppedAreaPixels` is rounded, and when used for restoration, may result in a slight drifting crop/zoom |
129129
| `initialCroppedAreaPixels` | `{ width: number, height: number, x: number, y: number}` | | Use this to set the initial crop position/zoom of the cropper (for example, when editing a previously cropped media). The value should be the same as the `croppedAreaPixels` passed to [`onCropComplete`](#onCropCompleteProp). |
130-
| `onInteractionStart` | `Function` | | Called every time a user starts a wheel, touch, mousedown or keydown (for arrow keys only) event. |
131-
| `onInteractionEnd` | `Function` | | Called every time a user ends a wheel, touch, mousedown or keydown (for arrow keys only) event. |
130+
| `onInteractionStart` | `({ source }) => void` | | Called every time a user starts a wheel, touch, mousedown or keydown (for arrow keys only) event. `source` is `'mouse'`, `'touch'`, `'wheel'` or `'keyboard'`. |
131+
| `onInteractionEnd` | `({ source }) => void` | | Called every time a user ends a wheel, touch, mousedown or keydown (for arrow keys only) event. `source` is `'mouse'`, `'touch'`, `'wheel'` or `'keyboard'`. |
132132
| `onMediaLoaded` | `Function` | | Called when media gets loaded. Gets passed an `mediaSize` object like `{ width, height, naturalWidth, naturalHeight }` |
133133
| `onTouchRequest` | `(e: React.TouchEvent<HTMLDivElement>) => boolean` | | Can be used to cancel a touch request by returning `false`. |
134134
| `onWheelRequest` | `(e: WheelEvent) => boolean` | | Can be used to cancel a zoom with wheel request by returning `false`. |

cypress/integration/basic_spec.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,21 @@ describe('Basic assertions', function () {
7171
cy.percySnapshot()
7272
})
7373

74+
it('reports keyboard interaction source', function () {
75+
cy.window().then((win) => {
76+
cy.spy(win.console, 'log').as('consoleLog')
77+
})
78+
cy.get('[data-testid=cropper]')
79+
.focus()
80+
.trigger('keydown', { key: 'ArrowRight' })
81+
.trigger('keyup', { key: 'ArrowRight' })
82+
83+
cy.get('@consoleLog').should((spy) => {
84+
expect(spy).to.be.calledWith('user interaction started', { source: 'keyboard' })
85+
expect(spy).to.be.calledWith('user interaction ended', { source: 'keyboard' })
86+
})
87+
})
88+
7489
it('should debounce onCropComplete during a burst of window resizes', function () {
7590
// let a real frame elapse so the ResizeObserver notification for this
7691
// resize has actually been delivered (mirrors cy.setViewportStable above)

cypress/integration/mouse_spec.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ describe('Mouse assertions', function () {
55
})
66

77
it('Move the image with mouse', function () {
8+
cy.window().then((win) => {
9+
cy.spy(win.console, 'log').as('consoleLog')
10+
})
811
cy.get('[data-testid=container]').dragAndDrop({ x: 50, y: 0 })
12+
cy.get('@consoleLog').should((spy) => {
13+
expect(spy).to.be.calledWith('user interaction started', { source: 'mouse' })
14+
expect(spy).to.be.calledWith('user interaction ended', { source: 'mouse' })
15+
})
916
cy.percySnapshot()
1017
})
1118

@@ -20,7 +27,18 @@ describe('Mouse assertions', function () {
2027
})
2128

2229
it('Mouse wheel should zoom in and out', function () {
30+
cy.window().then((win) => {
31+
cy.spy(win.console, 'log').as('consoleLog')
32+
})
33+
cy.clock(Date.now(), ['setTimeout', 'clearTimeout'])
2334
cy.get('[data-testid=container]').trigger('wheel', { deltaY: -100, clientX: 500, clientY: 300 })
35+
cy.get('@consoleLog').should((spy) => {
36+
expect(spy).to.be.calledWith('user interaction started', { source: 'wheel' })
37+
})
38+
cy.tick(260)
39+
cy.get('@consoleLog').should((spy) => {
40+
expect(spy).to.be.calledWith('user interaction ended', { source: 'wheel' })
41+
})
2442
cy.percySnapshot('Mouse wheel should zoom in')
2543
cy.get('[data-testid=container]').trigger('wheel', { deltaY: 50, clientX: 500, clientY: 300 })
2644
cy.percySnapshot('Mouse wheel should zoom out')

cypress/integration/touch_spec.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ describe('Touch assertions', function () {
55
})
66

77
it('Move the image with touch', function () {
8+
cy.window().then((win) => {
9+
cy.spy(win.console, 'log').as('consoleLog')
10+
})
811
cy.get('[data-testid=container]').dragAndDropWithTouch({ x: 50, y: 0 })
12+
cy.get('@consoleLog').should((spy) => {
13+
expect(spy).to.be.calledWith('user interaction started', { source: 'touch' })
14+
expect(spy).to.be.calledWith('user interaction ended', { source: 'touch' })
15+
})
916
cy.percySnapshot()
1017
})
1118

@@ -20,7 +27,14 @@ describe('Touch assertions', function () {
2027
})
2128

2229
it('Zoom in and out with pinch', function () {
30+
cy.window().then((win) => {
31+
cy.spy(win.console, 'log').as('consoleLog')
32+
})
2333
cy.get('[data-testid=container]').pinch({ distance: 10 })
34+
cy.get('@consoleLog').should((spy) => {
35+
expect(spy).to.be.calledWith('user interaction started', { source: 'touch' })
36+
expect(spy).to.be.calledWith('user interaction ended', { source: 'touch' })
37+
})
2438
cy.percySnapshot('Zoom in with pinch')
2539
cy.get('[data-testid=container]').pinch({ distance: -4 })
2640
cy.percySnapshot('Zoom out with pinch')

docs/docs/callbacks.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,17 @@ Use `onWheelRequest` and `onTouchRequest` to allow or block interactions.
8181
```
8282

8383
`onInteractionStart` and `onInteractionEnd` fire around wheel, touch, mouse, and arrow-key interactions.
84+
85+
They receive an interaction object with a `source` field:
86+
87+
```tsx
88+
<Cropper
89+
onInteractionStart={({ source }) => {
90+
if (source === 'touch') {
91+
// ...
92+
}
93+
}}
94+
/>
95+
```
96+
97+
`source` is `'mouse'`, `'touch'`, `'wheel'`, or `'keyboard'`.

examples/src/index.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import queryString from 'query-string'
22
import * as React from 'react'
33
import { createRoot } from 'react-dom/client'
44
import debounce from 'lodash.debounce'
5-
import Cropper, { Area, Point } from '../../src/index'
5+
import Cropper, { Area, CropperInteraction, Point } from '../../src/index'
66
import './styles.css'
77
import Iframe from './iframe'
88

@@ -158,12 +158,12 @@ class App extends React.Component<{}, State> {
158158
this.setState({ rotation })
159159
}
160160

161-
onInteractionStart = () => {
162-
console.log('user interaction started')
161+
onInteractionStart = (interaction: CropperInteraction) => {
162+
console.log('user interaction started', interaction)
163163
}
164164

165-
onInteractionEnd = () => {
166-
console.log('user interaction ended')
165+
onInteractionEnd = (interaction: CropperInteraction) => {
166+
console.log('user interaction ended', interaction)
167167
}
168168

169169
onHashTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {

src/Cropper.tsx

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import * as React from 'react'
22
import normalizeWheel from 'normalize-wheel'
3-
import { Area, MediaSize, Point, Size, VideoSrc } from './types'
3+
import {
4+
Area,
5+
CropperInteraction,
6+
CropperInteractionSource,
7+
MediaSize,
8+
Point,
9+
Size,
10+
VideoSrc,
11+
} from './types'
412
import {
513
getCropSize,
614
restrictPosition,
@@ -43,8 +51,8 @@ export type CropperProps = {
4351
onCropComplete?: (croppedArea: Area, croppedAreaPixels: Area) => void
4452
onCropAreaChange?: (croppedArea: Area, croppedAreaPixels: Area) => void
4553
onCropSizeChange?: (cropSize: Size) => void
46-
onInteractionStart?: () => void
47-
onInteractionEnd?: () => void
54+
onInteractionStart?: (interaction: CropperInteraction) => void
55+
onInteractionEnd?: (interaction: CropperInteraction) => void
4856
onMediaLoaded?: (mediaSize: MediaSize) => void
4957
style: {
5058
containerStyle?: React.CSSProperties
@@ -134,6 +142,7 @@ class Cropper extends React.Component<CropperProps, State> {
134142
resizeObserver: ResizeObserver | null = null
135143
previousCropSize: Size | null = null
136144
isInitialized = false
145+
dragInteractionSource: CropperInteractionSource | null = null
137146

138147
state: State = {
139148
cropSize: null,
@@ -482,7 +491,7 @@ class Cropper extends React.Component<CropperProps, State> {
482491
this.currentDoc.addEventListener('mousemove', this.onMouseMove)
483492
this.currentDoc.addEventListener('mouseup', this.onDragStopped)
484493
this.saveContainerPosition()
485-
this.onDragStart(Cropper.getMousePoint(e))
494+
this.onDragStart(Cropper.getMousePoint(e), 'mouse')
486495
}
487496

488497
onMouseMove = (e: MouseEvent) => this.onDrag(Cropper.getMousePoint(e))
@@ -508,7 +517,7 @@ class Cropper extends React.Component<CropperProps, State> {
508517
if (e.touches.length === 2) {
509518
this.onPinchStart(e)
510519
} else if (e.touches.length === 1) {
511-
this.onDragStart(Cropper.getTouchPoint(e.touches[0]))
520+
this.onDragStart(Cropper.getTouchPoint(e.touches[0]), 'touch')
512521
}
513522
}
514523

@@ -551,10 +560,11 @@ class Cropper extends React.Component<CropperProps, State> {
551560
this.cleanEvents()
552561
}
553562

554-
onDragStart = ({ x, y }: Point) => {
563+
onDragStart = ({ x, y }: Point, source: CropperInteractionSource) => {
555564
this.dragStartPosition = { x, y }
556565
this.dragStartCrop = { ...this.props.crop }
557-
this.props.onInteractionStart?.()
566+
this.dragInteractionSource = source
567+
this.props.onInteractionStart?.({ source })
558568
}
559569

560570
onDrag = ({ x, y }: Point) => {
@@ -588,15 +598,16 @@ class Cropper extends React.Component<CropperProps, State> {
588598
this.isTouching = false
589599
this.cleanEvents()
590600
this.emitCropData()
591-
this.props.onInteractionEnd?.()
601+
this.props.onInteractionEnd?.({ source: this.dragInteractionSource ?? 'mouse' })
602+
this.dragInteractionSource = null
592603
}
593604

594605
onPinchStart(e: React.TouchEvent<HTMLDivElement>) {
595606
const pointA = Cropper.getTouchPoint(e.touches[0])
596607
const pointB = Cropper.getTouchPoint(e.touches[1])
597608
this.lastPinchDistance = getDistanceBetweenPoints(pointA, pointB)
598609
this.lastPinchRotation = getRotationBetweenPoints(pointA, pointB)
599-
this.onDragStart(getCenter(pointA, pointB))
610+
this.onDragStart(getCenter(pointA, pointB), 'touch')
600611
}
601612

602613
onPinchMove(e: TouchEvent) {
@@ -633,14 +644,19 @@ class Cropper extends React.Component<CropperProps, State> {
633644
this.setNewZoom(newZoom, point, { shouldUpdatePosition: true })
634645

635646
if (!this.state.hasWheelJustStarted) {
636-
this.setState({ hasWheelJustStarted: true }, () => this.props.onInteractionStart?.())
647+
this.setState({ hasWheelJustStarted: true }, () =>
648+
this.props.onInteractionStart?.({ source: 'wheel' })
649+
)
637650
}
638651

639652
if (this.wheelTimer) {
640653
clearTimeout(this.wheelTimer)
641654
}
642655
this.wheelTimer = this.currentWindow.setTimeout(
643-
() => this.setState({ hasWheelJustStarted: false }, () => this.props.onInteractionEnd?.()),
656+
() =>
657+
this.setState({ hasWheelJustStarted: false }, () =>
658+
this.props.onInteractionEnd?.({ source: 'wheel' })
659+
),
644660
250
645661
)
646662
}
@@ -840,7 +856,7 @@ class Cropper extends React.Component<CropperProps, State> {
840856
}
841857

842858
if (!event.repeat) {
843-
this.props.onInteractionStart?.()
859+
this.props.onInteractionStart?.({ source: 'keyboard' })
844860
}
845861

846862
onCropChange(newCrop)
@@ -858,7 +874,7 @@ class Cropper extends React.Component<CropperProps, State> {
858874
return
859875
}
860876
this.emitCropData()
861-
this.props.onInteractionEnd?.()
877+
this.props.onInteractionEnd?.({ source: 'keyboard' })
862878
}
863879

864880
render() {

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,9 @@ export type VideoSrc = {
2626
src: string
2727
type?: string
2828
}
29+
30+
export type CropperInteractionSource = 'mouse' | 'touch' | 'wheel' | 'keyboard'
31+
32+
export type CropperInteraction = {
33+
source: CropperInteractionSource
34+
}

0 commit comments

Comments
 (0)