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

Add Svelte 5 runes support #59

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,24 @@
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/package": "^2.2.4",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@sveltejs/kit": "^2.15.0",
"@sveltejs/package": "^2.3.7",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
"cypress": "^12.4.1",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-svelte": "^2.35.1",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"query-string": "^8.1.0",
"start-server-and-test": "^1.15.3",
"svelte-check": "^3.4.3",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^5.0.0",
"vite": "^6.0.5",
"vitest": "^1.0.0"
},
"peerDependencies": {
Expand All @@ -74,6 +74,6 @@
"!dist/**/*.spec.*"
],
"dependencies": {
"svelte": "^4.2.8"
"svelte": "^5.15.0"
}
}
115 changes: 71 additions & 44 deletions src/lib/Cropper.svelte
Original file line number Diff line number Diff line change
@@ -1,35 +1,36 @@
<script lang="ts">
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import type { HTMLImgAttributes } from 'svelte/elements'
import { onDestroy, onMount } from 'svelte'
import * as helpers from './helpers'
import type { CropShape, DispatchEvents, ImageSize, Point, Size } from './types'

export let image: string
export let crop: Point = { x: 0, y: 0 }
export let zoom = 1
export let aspect = 4 / 3
export let minZoom = 1
export let maxZoom = 3
export let cropSize: Size | null = null
export let cropShape: CropShape = 'rect'
export let showGrid = true
export let zoomSpeed = 1
export let crossOrigin: HTMLImgAttributes['crossorigin'] = null
export let restrictPosition = true
export let tabindex: number | undefined = undefined

let cropperSize: Size | null = null
let imageSize: ImageSize = { width: 0, height: 0, naturalWidth: 0, naturalHeight: 0 }
let containerEl: HTMLDivElement | null = null
let containerRect: DOMRect | null = null
let imgEl: HTMLImageElement | null = null
let dragStartPosition: Point = { x: 0, y: 0 }
let dragStartCrop: Point = { x: 0, y: 0 }
let lastPinchDistance = 0
let rafDragTimeout: number | null = null
let rafZoomTimeout: number | null = null

const dispatch = createEventDispatcher<DispatchEvents>()
import type { ImageSize, Point, CropperProps, Size } from './types'
import type { Action } from 'svelte/action'

let {
image,
crop = $bindable({ x: 0, y: 0 }),
zoom = $bindable(1),
minZoom = $bindable(1),
maxZoom = $bindable(3),
aspect = 4 / 3,
cropSize = null,
cropShape = 'rect',
showGrid = true,
zoomSpeed = 1,
crossOrigin = null,
restrictPosition = true,
tabindex = undefined,
oncropcomplete,
}: Partial<CropperProps> = $props()

let cropperSize = $state<Size | null>(null)
let imageSize = $state<ImageSize>({ width: 0, height: 0, naturalWidth: 0, naturalHeight: 0 })
let containerEl = $state<HTMLDivElement | null>(null)
let containerRect = $state<DOMRect | null>(null)
let imgEl = $state<HTMLImageElement | null>(null)
let dragStartPosition = $state<Point>({ x: 0, y: 0 })
let dragStartCrop = $state<Point>({ x: 0, y: 0 })
let lastPinchDistance = $state(0)
let rafDragTimeout = $state<number | null>(null)
let rafZoomTimeout = $state<number | null>(null)

onMount(() => {
// when rendered via SSR, the image can already be loaded and its onLoad callback will never be called
Expand Down Expand Up @@ -100,6 +101,7 @@
})

const onMouseDown = (e: MouseEvent) => {
e.preventDefault()
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onDragStopped)
onDragStart(getMousePoint(e))
Expand All @@ -108,6 +110,7 @@
const onMouseMove = (e: MouseEvent) => onDrag(getMousePoint(e))

const onTouchStart = (e: TouchEvent) => {
e.preventDefault()
document.addEventListener('touchmove', onTouchMove, { passive: false }) // iOS 11 now defaults to passive: true
document.addEventListener('touchend', onDragStopped)

Expand Down Expand Up @@ -179,6 +182,7 @@
}

const onWheel = (e: WheelEvent) => {
e.preventDefault()
const point = getMousePoint(e)
const newZoom = zoom - (e.deltaY * zoomSpeed) / 200
setNewZoom(newZoom, point)
Expand Down Expand Up @@ -229,34 +233,57 @@
restrictPosition
)

dispatch('cropcomplete', {
oncropcomplete?.({
percent: croppedAreaPercentages,
pixels: croppedAreaPixels,
})
}

// ------ Reactive statement ------
//when aspect changes, we reset the cropperSize
$: if (imgEl) {
cropperSize = cropSize ? cropSize : helpers.getCropSize(imgEl.width, imgEl.height, aspect)
}
$effect(() => {
if (imgEl) {
cropperSize = cropSize ? cropSize : helpers.getCropSize(imgEl.width, imgEl.height, aspect)
}
})

// when zoom changes, we recompute the cropped area
$: zoom && emitCropData()
$effect(() => {
if (zoom) {
emitCropData()
}
})

$: if(aspect) {
computeSizes()
emitCropData()
}
// this variable is required to prevent the effect into entering an infinite loop (https://github.com/ValentinH/svelte-easy-crop/pull/59#discussion_r1896254867)
let lastAspect = $state(aspect)
$effect(() => {
if (aspect !== lastAspect) {
lastAspect = aspect
computeSizes()
emitCropData()
}
})
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When do you reach the infinite loop? When changing the aspect prop? With the additional state, I expect this effect to run twice for every time the aspect prop changes. 🤔

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image
This is what happens with only

$effect(() => {
    if (aspect) {
      lastAspect = aspect
      computeSizes()
      emitCropData()
    }
  })

This is the loop warning:
image

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is because of the lastAspect assignment. Why do we need it? This variable wasn't here before.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

$effect(() => {
    if (aspect) {
      computeSizes()
      emitCropData()
    }
  })

same thing happens without it. Will try to find out why this is happening.

I think this is because of the lastAspect assignment. Why do we need it? This variable wasn't here before.

I added it there to prevent the loop, it was the only fix I found

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, do you know the use of this effect right here? It seems that the component works as it should without it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After testing for some while, it seems that this effect has no use, even when the aspect is dynamically updated.


const containerAction: Action<HTMLDivElement> = node => {
$effect(() => {
node.addEventListener('touchstart', onTouchStart)
node.addEventListener('mousedown', onMouseDown)
node.addEventListener('wheel', onWheel, { passive: false })

return () => {
node.removeEventListener('touchstart', onTouchStart)
node.removeEventListener('mousedown', onMouseDown)
node.removeEventListener('wheel', onWheel)
}
})
}
</script>

<svelte:window on:resize={computeSizes} />
<div
class="svelte-easy-crop-container"
bind:this={containerEl}
on:mousedown|preventDefault={onMouseDown}
on:touchstart|nonpassive|preventDefault={onTouchStart}
ValentinH marked this conversation as resolved.
Show resolved Hide resolved
on:wheel|nonpassive|preventDefault={onWheel}
use:containerAction
{tabindex}
role="button"
data-testid="container"
Expand All @@ -265,7 +292,7 @@
bind:this={imgEl}
class="svelte-easy-crop-image"
src={image}
on:load={onImgLoad}
onload={onImgLoad}
alt=""
style="transform: translate({crop.x}px, {crop.y}px) scale({zoom});"
crossorigin={crossOrigin}
Expand All @@ -277,7 +304,7 @@
class:svelte-easy-crop-grid={showGrid}
style="width: {cropperSize.width}px; height: {cropperSize.height}px;"
data-testid="cropper"
/>
></div>
{/if}
</div>

Expand Down
1 change: 1 addition & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Cropper from './Cropper.svelte'

export * from "./types"
export default Cropper
22 changes: 22 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
import type { HTMLImgAttributes } from 'svelte/elements'

export type CropShape = 'rect' | 'round'

export type OnCropCompleteEvent = { percent: CropArea; pixels: CropArea }
export type OnCropComplete = (event: OnCropCompleteEvent) => void

export type CropperProps = {
image: string
crop: Point
zoom: number
aspect: number
minZoom: number
maxZoom: number
cropSize: Size | null
cropShape: CropShape
showGrid: boolean
zoomSpeed: number
crossOrigin: HTMLImgAttributes['crossorigin']
restrictPosition: boolean
tabindex: number | undefined
oncropcomplete: OnCropComplete
}

export interface Size {
width: number
height: number
Expand Down
10 changes: 5 additions & 5 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
import queryString from 'query-string'
import Cropper from '../lib'

let crop = { x: 0, y: 0 }
let zoom = 1
let minZoom = 0.5;
let maxZoom = 3;
let crop = $state({ x: 0, y: 0 })
let zoom = $state(1)
let minZoom = $state(0.5)
let maxZoom = $state(3)

const urlArgs = typeof window !== 'undefined' ? queryString.parse(window.location.search) : null
let image = typeof urlArgs?.img === 'string' ? urlArgs.img : '/images/dog.jpeg' // so we can change the image from our tests
Expand All @@ -17,5 +17,5 @@
bind:zoom
bind:minZoom
bind:maxZoom
on:cropcomplete={e => console.log(e.detail)}
oncropcomplete={(e) => console.log(e)}
/>
Loading