├── .eslintignore ├── .eslintrc.cjs ├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ └── issue-template.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── quality-gate.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── cypress.config.ts ├── cypress ├── e2e │ ├── basic_spec.js │ ├── mouse_spec.js │ └── touch_spec.js ├── support │ ├── commands.js │ └── e2e.js └── tsconfig.json ├── package.json ├── src ├── app.d.ts ├── app.html ├── lib │ ├── Cropper.svelte │ ├── helpers.test.ts │ ├── helpers.ts │ ├── index.ts │ └── types.ts └── routes │ └── +page.svelte ├── static ├── favicon.png └── images │ ├── cat.jpeg │ └── dog.jpeg ├── svelte.config.js ├── tsconfig.json ├── vite.config.ts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'prettier', 8 | 'plugin:svelte/recommended', 9 | ], 10 | plugins: ['@typescript-eslint', 'cypress'], 11 | ignorePatterns: ['*.cjs', 'dist/*'], 12 | overrides: [ 13 | { 14 | files: ['*.svelte'], 15 | parser: 'svelte-eslint-parser', 16 | parserOptions: { 17 | parser: '@typescript-eslint/parser', 18 | }, 19 | }, 20 | ], 21 | parserOptions: { 22 | sourceType: 'module', 23 | ecmaVersion: 2020, 24 | extraFileExtensions: ['.svelte'], 25 | }, 26 | env: { 27 | browser: true, 28 | es2017: true, 29 | node: true, 30 | 'cypress/globals': true, 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to svelte-easy-crop 2 | 3 | **Working on your first Pull Request?** You can learn how from this _free_ series [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github) 4 | 5 | ## Setting Up the project locally 6 | 7 | To install the project you need to have `yarn` and `node` 8 | 9 | 1. [Fork](https://help.github.com/articles/fork-a-repo/) the project, clone your fork: 10 | 11 | ``` 12 | # Clone your fork 13 | git clone https://github.com//svelte-easy-crop.git 14 | 15 | # Navigate to the newly cloned directory 16 | cd svelte-easy-crop 17 | ``` 18 | 19 | 2. `yarn` to install dependencies 20 | 3. `yarn start` to start the example app 21 | 22 | > Tip: Keep your `master` branch pointing at the original repository and make 23 | > pull requests from branches on your fork. To do this, run: 24 | > 25 | > ``` 26 | > git remote add upstream git@github.com:ricardo-ch/svelte-easy-crop.git 27 | > git fetch upstream 28 | > git branch --set-upstream-to=upstream/master master 29 | > ``` 30 | > 31 | > This will add the original repository as a "remote" called "upstream," 32 | > Then fetch the git information from that remote, then set your local `master` 33 | > branch to use the upstream master branch whenever you run `git pull`. 34 | > Then you can make all of your pull request branches based on this `master` 35 | > branch. Whenever you want to update your version of `master`, do a regular 36 | > `git pull`. 37 | 38 | ## Submitting a Pull Request 39 | 40 | Please go through existing issues and pull requests to check if somebody else is already working on it. 41 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ValentinH] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Are you reporting a bug? 2 | 3 | - Please create a CodeSandbox that demonstrates your issue. You can fork the [Basic example](https://codesandbox.io/s/q80jom5ql6). 4 | 5 | - Provide the steps to reproduce the issue, e.g.: 6 | 7 | 1. Move the image 8 | 2. Resize the window 9 | 10 | **Observed behaviour:** The cropper is outside the image 11 | 12 | **Expected behaviour:** The cropper stays within the image 13 | 14 | ## Are you making a feature request? 15 | 16 | - Please describe your use case from user journey point of view, e.g.: 17 | 18 | > In my application, when user use the cropper on mobile, I'd like her to be able to zoom without the slider. 19 | 20 | - If you have ideas how to implement your new feature, please share! 21 | 22 | - If you know any examples online that already implement such functionality, please share a link. 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue template 3 | about: Describe this issue template's purpose here. 4 | --- 5 | 6 | ## Are you reporting a bug? 7 | 8 | - Please create a CodeSandbox that demonstrates your issue. You can fork the [Basic example](https://codesandbox.io/s/q80jom5ql6). 9 | 10 | - Provide the steps to reproduce the issue, e.g.: 11 | 12 | 1. Move the image 13 | 2. Resize the window 14 | 15 | **Observed behaviour:** The cropper is outside the image 16 | 17 | **Expected behaviour:** The cropper stays within the image 18 | 19 | ## Are you making a feature request? 20 | 21 | - Please describe your use case from user journey point of view, e.g.: 22 | 23 | > In my application, when user use the cropper on mobile, I'd like her to be able to zoom without the slider. 24 | 25 | - If you have ideas how to implement your new feature, please share! 26 | 27 | - If you know any examples online that already implement such functionality, please share a link. 28 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Thanks a lot for contributing to svelte-easy-crop :beers: 2 | 3 | Before submitting the Pull Request, please: 4 | 5 | - write a clear description of what this Pull Request is trying to achieve 6 | - run `yarn dev` and open the demo to see that you didn't break anything 7 | -------------------------------------------------------------------------------- /.github/workflows/quality-gate.yml: -------------------------------------------------------------------------------- 1 | name: Quality Gate 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | quality-gate: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Prepare repository 11 | run: git fetch --unshallow --tags 12 | 13 | - name: Use Node.js 18.x 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: 18.x 17 | 18 | - name: Cache node modules 19 | uses: actions/cache@v1 20 | with: 21 | path: node_modules 22 | key: yarn-deps-${{ hashFiles('yarn.lock') }} 23 | restore-keys: | 24 | yarn-deps-${{ hashFiles('yarn.lock') }} 25 | - run: yarn install --frozen-lockfile 26 | - name: Run unit tests 27 | run: yarn unit 28 | 29 | - name: Cypress run 30 | uses: cypress-io/github-action@v5.0.5 31 | with: 32 | build: yarn build 33 | start: yarn dev 34 | record: true 35 | env: 36 | CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /dist 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | cypress/fixtures 12 | cypress/videos 13 | cypress/screenshots 14 | yarn-error.log 15 | package-lock.json 16 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /dist 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "es5", 6 | "arrowParens": "avoid", 7 | "useTabs": false, 8 | "tabWidth": 2, 9 | "plugins": ["prettier-plugin-svelte"], 10 | "pluginSearchDirs": ["."], 11 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 12 | } 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | You can see the changelog on the [releases page](../../releases). 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at valentin@hervi.eu. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Valentin Hervieu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # svelte-easy-crop 2 | 3 | A Svelte component to crop images with easy interactions 4 | 5 | This is a rewrite of `react-easy-crop` (https://github.com/valentinh/react-easy-crop). 6 | 7 | 8 | > ℹ️ The minimum supported Svelte version is v5. If you are still on an older version, you need to use the 3.x.x versions of `svelte-easy-crop` 9 | 10 | [![version][version-badge]][package] [![Monthly downloads][npmstats-badge]][npmstats] ![gzip size][gzip-badge] [![MIT License][license-badge]][license] [![PRs Welcome][prs-badge]][prs] 11 | 12 | ![svelte-easy-crop Demo](https://user-images.githubusercontent.com/2678610/41561426-365e7a44-734a-11e8-8e0e-1c04251f53e4.gif) 13 | 14 | 15 | ## Demo 16 | 17 | - [Basic example](https://codesandbox.io/s/svelte-easy-crop-basic-demo-q1005?file=/App.svelte) 18 | 19 | ## Features 20 | 21 | - Supports drag and zoom interactions 22 | - Provides crop dimensions as pixels and percentages 23 | - Supports any images format (JPEG, PNG, even GIF) as url or base 64 string 24 | - Mobile friendly 25 | 26 | ## Installation 27 | 28 | ```shell 29 | yarn add svelte-easy-crop 30 | ``` 31 | 32 | or 33 | 34 | ```shell 35 | npm install svelte-easy-crop --save 36 | ``` 37 | 38 | ## Basic usage 39 | 40 | > The Cropper is styled with `position: absolute` to take the full space of its parent. 41 | > Thus, you need to wrap it with an element that uses `position: relative` or the Cropper will fill the whole page. 42 | 43 | ```jsx 44 | 51 | 52 | console.log(e.detail)} 57 | /> 58 | ``` 59 | 60 | ## Props 61 | 62 | | Prop | Type | Required | Description | 63 | | :----------------- | :---------------------------------- | :------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 64 | | `image` | string | ✓ | The image to be cropped. | 65 | | `crop` | `{ x: number, y: number }` | ✓ | Position of the image. `{ x: 0, y: 0 }` will center the image under the cropper. | 66 | | `zoom` | number | | Zoom of the image between `minZoom` and `maxZoom`. Defaults to 1. | 67 | | `aspect` | number | | Aspect of the cropper. The value is the ratio between its width and its height. The default value is `4/3` | 68 | | `minZoom` | number | | Minimum zoom of the image. Defaults to 1. | 69 | | `maxZoom` | number | | Maximum zoom of the image. Defaults to 3. | 70 | | `cropShape` | 'rect' \| 'round' | | Shape of the crop area. Defaults to 'rect'. | 71 | | `cropSize` | `{ width: number, height: number }` | | Size of the crop area (in pixels). If you don't provide it, it will be computed automatically using the `aspect` prop and the image size. | 72 | | `showGrid` | boolean | | Whether to show or not the grid (third-lines). Defaults to `true`. | 73 | | `zoomSpeed` | number | | Multiplies the value by which the zoom changes. Defaults to 1. | 74 | | `crossOrigin` | string | | Allows setting the crossOrigin attribute on the image. | 75 | | `restrictPosition` | boolean | | Whether the position of the image should be restricted to the boundaries of the cropper. Useful setting in case of `zoom < 1` or if the cropper should preserve all image content while forcing a specific aspect ratio for image throughout the application. Example: https://codesandbox.io/s/1rmqky233q. | 76 | | `oncropcomplete` | function(details) | | This callback is the one you should use to save the cropped area of the image. | 77 | 78 | ## Callbacks 79 | 80 | ### oncropcomplete 81 | 82 | ```tsx 83 | console.log(e)} 88 | /> 89 | ``` 90 | 91 | The `detail` property is an object with 2 values: 92 | 93 | 1. `percent`: coordinates and dimensions of the cropped area in percentage of the image dimension 94 | 1. `pixels`: coordinates and dimensions of the cropped area in pixels. 95 | 96 | Both arguments have the following shape: 97 | 98 | ```js 99 | const area = { 100 | x: number, // x/y are the coordinates of the top/left corner of the cropped area 101 | y: number, 102 | width: number, // width of the cropped area 103 | height: number, // height of the cropped area 104 | } 105 | ``` 106 | 107 | 108 | ## Development 109 | 110 | ```shell 111 | yarn 112 | yarn dev 113 | ``` 114 | 115 | Now, open `http://localhost:5000` and start hacking! 116 | 117 | ## License 118 | 119 | [MIT](https://github.com/ValentinH/svelte-easy-crop/blob/master/LICENSE) 120 | 121 | [npm]: https://www.npmjs.com/ 122 | [node]: https://nodejs.org 123 | [version-badge]: https://img.shields.io/npm/v/svelte-easy-crop.svg?style=flat-square 124 | [package]: https://www.npmjs.com/package/svelte-easy-crop 125 | [downloads-badge]: https://img.shields.io/npm/dm/svelte-easy-crop.svg?style=flat-square 126 | [npmstats]: http://npm-stat.com/charts.html?package=svelte-easy-crop&from=2018-06-18 127 | [npmstats-badge]: https://img.shields.io/npm/dm/svelte-easy-crop.svg?style=flat-square 128 | [gzip-badge]: http://img.badgesize.io/https://unpkg.com/svelte-easy-crop@1.0.0/index.js?compression=gzip&style=flat-square 129 | [license-badge]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square 130 | [license]: https://github.com/ValentinH/svelte-easy-crop/blob/master/LICENSE 131 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square 132 | [prs]: http://makeapullrequest.com 133 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | 3 | export default defineConfig({ 4 | projectId: 'y9ik32', 5 | e2e: { 6 | baseUrl: 'http://localhost:3000', 7 | specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}', 8 | }, 9 | }) 10 | -------------------------------------------------------------------------------- /cypress/e2e/basic_spec.js: -------------------------------------------------------------------------------- 1 | describe('Basic assertions', function () { 2 | beforeEach(function () { 3 | cy.viewport(1000, 600) 4 | cy.visit('/') 5 | cy.get('[data-testid=cropper]').should('be.visible') // ensure the cropper is initialized 6 | }) 7 | 8 | it('Display the image and cropper with correct dimension', function () { 9 | cy.get('img').should('have.css', 'width', '1000px') 10 | cy.get('img').should('have.css', 'height', '523.546875px') 11 | cy.get('[data-testid=cropper]').should('have.css', 'width', '698.65625px') // 4/3 the height of the image 12 | cy.get('[data-testid=cropper]').should('have.css', 'height', '524px') // height of the image 13 | }) 14 | 15 | it('Display tall images and set the image and cropper with correct dimension', function () { 16 | cy.visit('/?img=/images/cat.jpeg') 17 | cy.get('img').should('have.css', 'width', '338.4375px') 18 | cy.get('img').should('have.css', 'height', '600px') 19 | cy.get('[data-testid=cropper]').should('have.css', 'width', '338px') // width of the image 20 | cy.get('[data-testid=cropper]').should('have.css', 'height', '253.5px') // 3/4 of height of the image 21 | }) 22 | 23 | it('Display the image and cropper with correct dimension after window resize', function () { 24 | cy.viewport(600, 1000) 25 | cy.get('img').should('have.css', 'width', '600px') 26 | cy.get('img').should('have.css', 'height', '314.125px') 27 | cy.get('[data-testid=cropper]').should('have.css', 'width', '418.65625px') // 4/3 the height of the image 28 | cy.get('[data-testid=cropper]').should('have.css', 'height', '314px') // height of the image 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /cypress/e2e/mouse_spec.js: -------------------------------------------------------------------------------- 1 | describe('Mouse assertions', function () { 2 | beforeEach(function () { 3 | cy.viewport(1000, 600) 4 | cy.visit('/') 5 | cy.get('[data-testid=cropper]').should('be.visible') // ensure the cropper is initialized 6 | }) 7 | 8 | it('Move the image with mouse', function () { 9 | cy.get('[data-testid=container]').dragAndDrop({ x: 50, y: 0 }) 10 | cy.get('img').should('have.css', 'transform', 'matrix(1, 0, 0, 1, 50, 0)') 11 | }) 12 | 13 | it('Limit the left drag if too far', function () { 14 | cy.get('[data-testid=container]').dragAndDrop({ x: -1000, y: 0 }) 15 | cy.get('img').should('have.css', 'transform', 'matrix(1, 0, 0, 1, -150.667, 0)') 16 | }) 17 | 18 | it('Limit the right drag if too far', function () { 19 | cy.get('[data-testid=container]').dragAndDrop({ x: 1000, y: 0 }) 20 | cy.get('img').should('have.css', 'transform', 'matrix(1, 0, 0, 1, 150.667, 0)') 21 | }) 22 | 23 | it('Mouse wheel should zoom in and out', function () { 24 | cy.get('[data-testid=container]').trigger('wheel', { 25 | deltaY: -100, 26 | clientX: 500, 27 | clientY: 300, 28 | }) 29 | cy.get('img').should('have.css', 'transform', 'matrix(1.5, 0, 0, 1.5, 0, 0)') 30 | cy.get('[data-testid=container]').trigger('wheel', { deltaY: 50, clientX: 500, clientY: 300 }) 31 | cy.get('img').should('have.css', 'transform', 'matrix(1.25, 0, 0, 1.25, 0, 0)') 32 | }) 33 | 34 | it('Mouse wheel should zoom in and out following the pointer', function () { 35 | cy.get('[data-testid=container]').trigger('wheel', { deltaY: -100, clientX: 0, clientY: 0 }) 36 | cy.get('img').should('have.css', 'transform', 'matrix(1.5, 0, 0, 1.5, 250, 131)') 37 | cy.get('[data-testid=container]').trigger('wheel', { deltaY: 50, clientX: 800, clientY: 400 }) 38 | cy.get('img').should('have.css', 'transform', 'matrix(1.25, 0, 0, 1.25, 258.333, 65.5)') 39 | }) 40 | 41 | it('Move down and right after zoom', function () { 42 | cy.get('[data-testid=container]').trigger('wheel', { 43 | deltaY: -100, 44 | clientX: 500, 45 | clientY: 300, 46 | }) 47 | cy.get('[data-testid=container]').dragAndDrop({ x: 50, y: 50 }) 48 | cy.get('img').should('have.css', 'transform', 'matrix(1.5, 0, 0, 1.5, 50, 50)') 49 | }) 50 | 51 | it('Move up and left after zoom', function () { 52 | cy.get('[data-testid=container]').trigger('wheel', { 53 | deltaY: -100, 54 | clientX: 500, 55 | clientY: 300, 56 | }) 57 | cy.get('[data-testid=container]').dragAndDrop({ x: -50, y: -50 }) 58 | cy.get('img').should('have.css', 'transform', 'matrix(1.5, 0, 0, 1.5, -50, -50)') 59 | }) 60 | 61 | it('Limit top after zoom ', function () { 62 | cy.get('[data-testid=container]').trigger('wheel', { 63 | deltaY: -100, 64 | clientX: 500, 65 | clientY: 300, 66 | }) 67 | cy.get('[data-testid=container]').dragAndDrop({ x: 0, y: -1000 }) 68 | cy.get('img').should('have.css', 'transform', 'matrix(1.5, 0, 0, 1.5, 0, -131)') 69 | }) 70 | 71 | it('Limit bottom after zoom ', function () { 72 | cy.get('[data-testid=container]').trigger('wheel', { 73 | deltaY: -100, 74 | clientX: 500, 75 | clientY: 300, 76 | }) 77 | cy.get('[data-testid=container]').dragAndDrop({ x: 0, y: 1000 }) 78 | cy.get('img').should('have.css', 'transform', 'matrix(1.5, 0, 0, 1.5, 0, 131)') 79 | }) 80 | 81 | it('Keep image under crop area after zoom out', function () { 82 | cy.get('[data-testid=container]').trigger('wheel', { 83 | deltaY: -100, 84 | clientX: 500, 85 | clientY: 300, 86 | }) // zoom-in 87 | cy.get('[data-testid=container]').dragAndDrop({ x: 0, y: 1000 }) // move image to bottom 88 | cy.get('[data-testid=container]').trigger('wheel', { deltaY: 100, clientX: 500, clientY: 300 }) // zoom-out 89 | cy.get('img').should('have.css', 'transform', 'matrix(1, 0, 0, 1, 0, 0)') 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /cypress/e2e/touch_spec.js: -------------------------------------------------------------------------------- 1 | describe('Touch assertions', function () { 2 | beforeEach(function () { 3 | cy.viewport(1000, 600) 4 | cy.visit('/') 5 | cy.get('[data-testid=cropper]').should('be.visible') // ensure the cropper is initialized 6 | }) 7 | 8 | it('Move the image with touch', function () { 9 | cy.get('[data-testid=container]').dragAndDropWithTouch({ x: 50, y: 0 }) 10 | cy.get('img').should('have.css', 'transform', 'matrix(1, 0, 0, 1, 50, 0)') 11 | }) 12 | 13 | it('Limit the left drag if too far', function () { 14 | cy.get('[data-testid=container]').dragAndDropWithTouch({ x: -1000, y: 0 }) 15 | cy.get('img').should('have.css', 'transform', 'matrix(1, 0, 0, 1, -150.667, 0)') 16 | }) 17 | 18 | it('Limit the right drag if too far', function () { 19 | cy.get('[data-testid=container]').dragAndDropWithTouch({ x: 1000, y: 0 }) 20 | cy.get('img').should('have.css', 'transform', 'matrix(1, 0, 0, 1, 150.667, 0)') 21 | }) 22 | 23 | it('Zoom in and out with pinch', function () { 24 | cy.get('[data-testid=container]').pinch({ distance: 10 }) 25 | cy.get('img').should('have.css', 'transform', 'matrix(2, 0, 0, 2, 500, 262)') 26 | cy.get('[data-testid=container]').pinch({ distance: -4 }) 27 | cy.get('img').should('have.css', 'transform', 'matrix(1.2, 0, 0, 1.2, 100, 37.2)') 28 | }) 29 | 30 | it('Zoom in and out with pinch based on the center between 2 fingers', function () { 31 | cy.get('[data-testid=container]') 32 | .trigger('touchstart', { 33 | touches: [ 34 | { clientX: 500, clientY: 200 }, 35 | { clientX: 500, clientY: 300 }, 36 | ], 37 | }) 38 | .trigger('touchmove', { 39 | touches: [ 40 | { clientX: 500, clientY: 200 }, 41 | { clientX: 500, clientY: 310 }, 42 | ], 43 | }) 44 | .trigger('touchend') 45 | cy.get('img').should('have.css', 'transform', 'matrix(1.1, 0, 0, 1.1, 0, 4.5)') 46 | cy.get('[data-testid=container]') 47 | .trigger('touchstart', { 48 | touches: [ 49 | { clientX: 100, clientY: 50 }, 50 | { clientX: 200, clientY: 50 }, 51 | ], 52 | }) 53 | .trigger('touchmove', { 54 | touches: [ 55 | { clientX: 100, clientY: 50 }, 56 | { clientX: 190, clientY: 50 }, 57 | ], 58 | }) 59 | .trigger('touchend') 60 | cy.get('img').should('have.css', 'transform', 'matrix(0.99, 0, 0, 0.99, -145.667, -2.62)') 61 | }) 62 | 63 | it('Move image with pinch based on the center between 2 fingers', function () { 64 | cy.get('[data-testid=container]') 65 | .trigger('touchstart', { 66 | touches: [ 67 | { clientX: 500, clientY: 200 }, 68 | { clientX: 600, clientY: 300 }, 69 | ], 70 | }) 71 | .trigger('touchmove', { 72 | touches: [ 73 | { clientX: 600, clientY: 200 }, 74 | { clientX: 700, clientY: 300 }, 75 | ], 76 | }) 77 | .trigger('touchend') 78 | cy.get('img').should('have.css', 'transform', 'matrix(1, 0, 0, 1, 100, 0)') 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('dragAndDrop', { prevSubject: 'element' }, (subject, options) => { 2 | cy.wrap(subject) 3 | .trigger('mousedown', { clientX: 0, clientY: 0 }) 4 | .trigger('mousemove', { clientX: options.x, clientY: options.y }) 5 | .trigger('mouseup') 6 | }) 7 | 8 | Cypress.Commands.add('dragAndDropWithTouch', { prevSubject: 'element' }, (subject, options) => { 9 | cy.wrap(subject) 10 | .trigger('touchstart', { touches: [{ clientX: 0, clientY: 0 }] }) 11 | .trigger('touchmove', { touches: [{ clientX: options.x, clientY: options.y }] }) 12 | .trigger('touchend') 13 | }) 14 | 15 | Cypress.Commands.add('pinch', { prevSubject: 'element' }, (subject, options) => { 16 | const startTouches = [ 17 | { clientX: 0, clientY: 0 }, 18 | { clientX: 0, clientY: 10 }, 19 | ] 20 | const moveTouches = [ 21 | { clientX: 0, clientY: 0 }, 22 | { clientX: 0, clientY: 10 + options.distance }, 23 | ] 24 | cy.wrap(subject) 25 | .trigger('touchstart', { touches: startTouches }) 26 | .trigger('touchmove', { touches: moveTouches }) 27 | .trigger('touchend') 28 | }) 29 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["es5", "dom"], 5 | "types": ["cypress", "node"] 6 | }, 7 | "include": ["**/*.ts", "**/*.js"] 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-easy-crop", 3 | "version": "4.0.1", 4 | "description": "A Svelte component to crop images with easy interactions", 5 | "homepage": "https://github.com/ValentinH/svelte-easy-crop", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/ValentinH/svelte-easy-crop" 9 | }, 10 | "publishConfig": { 11 | "registry": "https://registry.npmjs.org/" 12 | }, 13 | "author": "Valentin Hervieu ", 14 | "license": "MIT", 15 | "keywords": [ 16 | "svelte", 17 | "image crop", 18 | "cropper" 19 | ], 20 | "scripts": { 21 | "dev": "vite dev --port 3000", 22 | "build": "svelte-kit sync && yarn package", 23 | "prepublishOnly": "yarn package", 24 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 25 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 26 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 27 | "format": "prettier --plugin-search-dir . --write .", 28 | "package": "svelte-package", 29 | "test": "vitest run && yarn e2e", 30 | "unit": "vitest", 31 | "e2e": "start-server-and-test dev http://localhost:3000 cy:run", 32 | "e2e:ci": "start-server-and-test dev http://localhost:3000 cy:ci", 33 | "cy:open": "cypress open", 34 | "cy:run": "cypress run", 35 | "cy:ci": "cypress run --record" 36 | }, 37 | "devDependencies": { 38 | "@sveltejs/adapter-auto": "^3.0.0", 39 | "@sveltejs/kit": "^2.15.0", 40 | "@sveltejs/package": "^2.3.7", 41 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 42 | "@typescript-eslint/eslint-plugin": "^6.15.0", 43 | "@typescript-eslint/parser": "^6.15.0", 44 | "cypress": "^12.4.1", 45 | "eslint": "^8.28.0", 46 | "eslint-config-prettier": "^8.5.0", 47 | "eslint-plugin-cypress": "^2.12.1", 48 | "eslint-plugin-svelte": "^2.35.1", 49 | "prettier": "^3.4.2", 50 | "prettier-plugin-svelte": "^3.3.2", 51 | "query-string": "^8.1.0", 52 | "start-server-and-test": "^1.15.3", 53 | "svelte": "^5.15.0", 54 | "svelte-check": "^3.4.3", 55 | "tslib": "^2.4.1", 56 | "typescript": "^5.0.0", 57 | "vite": "^6.0.5", 58 | "vitest": "^1.0.0" 59 | }, 60 | "peerDependencies": { 61 | "svelte": "^5.0.0" 62 | }, 63 | "type": "module", 64 | "svelte": "./dist/index.js", 65 | "types": "./dist/index.d.ts", 66 | "exports": { 67 | ".": { 68 | "types": "./dist/index.d.ts", 69 | "svelte": "./dist/index.js" 70 | } 71 | }, 72 | "files": [ 73 | "dist", 74 | "!dist/**/*.test.*", 75 | "!dist/**/*.spec.*" 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface Platform {} 9 | } 10 | } 11 | 12 | export {} 13 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/Cropper.svelte: -------------------------------------------------------------------------------- 1 | 271 | 272 | 273 |
281 | 290 | {#if cropperSize} 291 |
298 | {/if} 299 |
300 | 301 | 368 | -------------------------------------------------------------------------------- /src/lib/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import * as helpers from './helpers' 2 | import { describe, test, expect } from 'vitest' 3 | 4 | describe('Helpers', () => { 5 | describe('getCropSize', () => { 6 | test('when image width is higher than the height based on the aspect', () => { 7 | const cropSize = helpers.getCropSize(1200, 600, 4 / 3) 8 | expect(cropSize).toEqual({ height: 600, width: 800 }) 9 | }) 10 | test('when image width is smaller than the height based on the aspect', () => { 11 | const cropSize = helpers.getCropSize(600, 1200, 4 / 3) 12 | expect(cropSize).toEqual({ height: 450, width: 600 }) 13 | }) 14 | test('when image dimensions exactly match the aspect', () => { 15 | const cropSize = helpers.getCropSize(800, 600, 4 / 3) 16 | expect(cropSize).toEqual({ height: 600, width: 800 }) 17 | }) 18 | }) 19 | 20 | describe('restrictPosition', () => { 21 | test('position within the cropped area should be returned as-is', () => { 22 | const position = { x: 0, y: 0 } 23 | const imgSize = { width: 1000, height: 600 } 24 | const cropSize = { width: 500, height: 200 } 25 | const zoom = 1 26 | const result = helpers.restrictPosition(position, imgSize, cropSize, zoom) 27 | expect(result).toEqual({ x: 0, y: 0 }) 28 | }) 29 | 30 | test('position too far on the bottom-right should be limited', () => { 31 | const position = { x: 600, y: 500 } 32 | const imgSize = { width: 1000, height: 500 } 33 | const cropSize = { width: 500, height: 200 } 34 | const zoom = 1 35 | const result = helpers.restrictPosition(position, imgSize, cropSize, zoom) 36 | expect(result).toEqual({ x: 250, y: 150 }) 37 | }) 38 | 39 | test('position too far on the top-left should be limited', () => { 40 | const position = { x: -600, y: -500 } 41 | const imgSize = { width: 1000, height: 500 } 42 | const cropSize = { width: 500, height: 200 } 43 | const zoom = 1 44 | const result = helpers.restrictPosition(position, imgSize, cropSize, zoom) 45 | expect(result).toEqual({ x: -250, y: -150 }) 46 | }) 47 | 48 | test('when zoomed, we should be able to drag the image further', () => { 49 | const position = { x: 500, y: 300 } 50 | const imgSize = { width: 1000, height: 500 } 51 | const cropSize = { width: 500, height: 200 } 52 | const zoom = 2 53 | const result = helpers.restrictPosition(position, imgSize, cropSize, zoom) 54 | expect(result).toEqual({ x: 500, y: 300 }) 55 | }) 56 | 57 | test('when zoomed, position should still be limited', () => { 58 | const position = { x: 5000, y: 3000 } 59 | const imgSize = { width: 1000, height: 500 } 60 | const cropSize = { width: 500, height: 200 } 61 | const zoom = 3 62 | const result = helpers.restrictPosition(position, imgSize, cropSize, zoom) 63 | expect(result).toEqual({ x: 1250, y: 650 }) 64 | }) 65 | }) 66 | 67 | describe('getDistanceBetweenPoints', () => { 68 | test('should handle horizontal distance only', () => { 69 | const a = { x: 0, y: 0 } 70 | const b = { x: 100, y: 0 } 71 | const distance = helpers.getDistanceBetweenPoints(a, b) 72 | expect(distance).toEqual(100) 73 | }) 74 | 75 | test('should handle vertical distance only', () => { 76 | const a = { x: 0, y: 200 } 77 | const b = { x: 0, y: 0 } 78 | const distance = helpers.getDistanceBetweenPoints(a, b) 79 | expect(distance).toEqual(200) 80 | }) 81 | 82 | test('should handle horizontal and vertical distance', () => { 83 | const a = { x: 0, y: 50 } 84 | const b = { x: 25, y: 0 } 85 | const distance = helpers.getDistanceBetweenPoints(a, b) 86 | expect(distance).toBeCloseTo(55.9) 87 | }) 88 | }) 89 | 90 | describe('computeCroppedArea', () => { 91 | test('should compute the correct areas when the image was not moved', () => { 92 | const crop = { x: 0, y: 0 } 93 | const imgSize = { width: 1000, height: 600, naturalWidth: 2000, naturalHeight: 1200 } 94 | const cropSize = { width: 500, height: 300 } 95 | const aspect = 5 / 3 96 | const zoom = 1 97 | const areas = helpers.computeCroppedArea(crop, imgSize, cropSize, aspect, zoom) 98 | expect(areas.croppedAreaPercentages).toEqual({ x: 25, y: 25, width: 50, height: 50 }) 99 | expect(areas.croppedAreaPixels).toEqual({ height: 600, width: 1000, x: 500, y: 300 }) 100 | }) 101 | 102 | test('should compute the correct areas when the image was moved', () => { 103 | const crop = { x: 100, y: 30 } 104 | const imgSize = { width: 1000, height: 600, naturalWidth: 2000, naturalHeight: 1200 } 105 | const cropSize = { width: 500, height: 300 } 106 | const aspect = 5 / 3 107 | const zoom = 1 108 | const areas = helpers.computeCroppedArea(crop, imgSize, cropSize, aspect, zoom) 109 | expect(areas.croppedAreaPercentages).toEqual({ height: 50, width: 50, x: 15, y: 20 }) 110 | expect(areas.croppedAreaPixels).toEqual({ height: 600, width: 1000, x: 300, y: 240 }) 111 | }) 112 | 113 | test('should compute the correct areas when there is a zoom', () => { 114 | const crop = { x: 0, y: 0 } 115 | const imgSize = { width: 1000, height: 600, naturalWidth: 2000, naturalHeight: 1200 } 116 | const cropSize = { width: 500, height: 300 } 117 | const aspect = 5 / 3 118 | const zoom = 2 119 | const areas = helpers.computeCroppedArea(crop, imgSize, cropSize, aspect, zoom) 120 | expect(areas.croppedAreaPercentages).toEqual({ height: 25, width: 25, x: 37.5, y: 37.5 }) 121 | expect(areas.croppedAreaPixels).toEqual({ height: 300, width: 500, x: 750, y: 450 }) 122 | }) 123 | 124 | test('should not limit the position within image bounds when restrictPosition is false', () => { 125 | const crop = { x: 1000, y: 600 } 126 | const imgSize = { width: 1000, height: 600, naturalWidth: 2000, naturalHeight: 1200 } 127 | const cropSize = { width: 500, height: 300 } 128 | const aspect = 4 / 3 129 | const zoom = 1 130 | const areas = helpers.computeCroppedArea(crop, imgSize, cropSize, aspect, zoom, false) 131 | expect(areas.croppedAreaPercentages).toEqual({ height: 50, width: 50, x: -75, y: -75 }) 132 | expect(areas.croppedAreaPixels).toEqual({ height: 600, width: 800, x: -1500, y: -900 }) 133 | }) 134 | }) 135 | 136 | describe('getCenter', () => { 137 | test('should simply return the center between a and b', () => { 138 | const center = helpers.getCenter({ x: 0, y: 0 }, { x: 100, y: 0 }) 139 | expect(center).toEqual({ x: 50, y: 0 }) 140 | }) 141 | 142 | test.each([ 143 | [ 144 | { x: 0, y: 0 }, 145 | { x: 100, y: 0 }, 146 | { x: 50, y: 0 }, 147 | ], 148 | [ 149 | { x: 0, y: 0 }, 150 | { x: 0, y: 100 }, 151 | { x: 0, y: 50 }, 152 | ], 153 | [ 154 | { x: 0, y: 0 }, 155 | { x: 100, y: 100 }, 156 | { x: 50, y: 50 }, 157 | ], 158 | [ 159 | { x: 100, y: 1000 }, 160 | { x: 0, y: 400 }, 161 | { x: 50, y: 700 }, 162 | ], 163 | [ 164 | { x: 0, y: 0 }, 165 | { x: 0, y: 0 }, 166 | { x: 0, y: 0 }, 167 | ], 168 | ])('.getCenter(%o, %o)', (a, b, expected) => { 169 | const center = helpers.getCenter(a, b) 170 | expect(center).toEqual(expected) 171 | }) 172 | }) 173 | }) 174 | -------------------------------------------------------------------------------- /src/lib/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { Size, ImageSize, Point } from './types' 2 | 3 | /** 4 | * Compute the dimension of the crop area based on image size and aspect ratio 5 | * @param imgWidth width of the src image in pixels 6 | * @param imgHeight height of the src image in pixels 7 | * @param aspect aspect ratio of the crop 8 | */ 9 | export function getCropSize(imgWidth: number, imgHeight: number, aspect: number) { 10 | if (imgWidth >= imgHeight * aspect) { 11 | return { 12 | width: imgHeight * aspect, 13 | height: imgHeight, 14 | } 15 | } 16 | return { 17 | width: imgWidth, 18 | height: imgWidth / aspect, 19 | } 20 | } 21 | 22 | /** 23 | * Ensure a new image position stays in the crop area. 24 | * @param position new x/y position requested for the image 25 | * @param imageSize width/height of the src image 26 | * @param cropSize width/height of the crop area 27 | * @param zoom zoom value 28 | * @returns 29 | */ 30 | export function restrictPosition( 31 | position: Point, 32 | imageSize: Size, 33 | cropSize: Size, 34 | zoom: number 35 | ): Point { 36 | return { 37 | x: restrictPositionCoord(position.x, imageSize.width, cropSize.width, zoom), 38 | y: restrictPositionCoord(position.y, imageSize.height, cropSize.height, zoom), 39 | } 40 | } 41 | 42 | function restrictPositionCoord( 43 | position: number, 44 | imageSize: number, 45 | cropSize: number, 46 | zoom: number 47 | ) { 48 | // Default max position calculation 49 | let maxPosition = (imageSize * zoom) / 2 - cropSize / 2 50 | 51 | // Allow free movement of the image inside the crop area if zoom is less than 1 52 | // But limit the image's position to inside the cropBox 53 | if (zoom < 1) { 54 | maxPosition = cropSize / 2 - (imageSize * zoom) / 2 55 | } 56 | 57 | return Math.min(maxPosition, Math.max(position, -maxPosition)) 58 | } 59 | 60 | export function getDistanceBetweenPoints(pointA: Point, pointB: Point) { 61 | return Math.sqrt(Math.pow(pointA.y - pointB.y, 2) + Math.pow(pointA.x - pointB.x, 2)) 62 | } 63 | 64 | /** 65 | * Compute the output cropped area of the image in percentages and pixels. 66 | * x/y are the top-left coordinates on the src image 67 | * @param crop x/y position of the current center of the image 68 | * @param imageSize width/height of the src image (default is size on the screen, natural is the original size) 69 | * @param cropSize width/height of the crop area 70 | * @param aspect aspect value 71 | * @param zoom zoom value 72 | * @param restrictPosition whether we should limit or not the cropped area 73 | */ 74 | export function computeCroppedArea( 75 | crop: Point, 76 | imgSize: ImageSize, 77 | cropSize: Size, 78 | aspect: number, 79 | zoom: number, 80 | restrictPosition = true 81 | ) { 82 | const limitAreaFn = restrictPosition ? limitArea : noOp 83 | const croppedAreaPercentages = { 84 | x: limitAreaFn( 85 | 100, 86 | (((imgSize.width - cropSize.width / zoom) / 2 - crop.x / zoom) / imgSize.width) * 100 87 | ), 88 | y: limitAreaFn( 89 | 100, 90 | (((imgSize.height - cropSize.height / zoom) / 2 - crop.y / zoom) / imgSize.height) * 100 91 | ), 92 | width: limitAreaFn(100, ((cropSize.width / imgSize.width) * 100) / zoom), 93 | height: limitAreaFn(100, ((cropSize.height / imgSize.height) * 100) / zoom), 94 | } 95 | 96 | // we compute the pixels size naively 97 | const widthInPixels = limitAreaFn( 98 | imgSize.naturalWidth, 99 | (croppedAreaPercentages.width * imgSize.naturalWidth) / 100, 100 | true 101 | ) 102 | const heightInPixels = limitAreaFn( 103 | imgSize.naturalHeight, 104 | (croppedAreaPercentages.height * imgSize.naturalHeight) / 100, 105 | true 106 | ) 107 | const isImgWiderThanHigh = imgSize.naturalWidth >= imgSize.naturalHeight * aspect 108 | 109 | // then we ensure the width and height exactly match the aspect (to avoid rounding approximations) 110 | // if the image is wider than high, when zoom is 0, the crop height will be equals to iamge height 111 | // thus we want to compute the width from the height and aspect for accuracy. 112 | // Otherwise, we compute the height from width and aspect. 113 | const sizePixels = isImgWiderThanHigh 114 | ? { 115 | width: Math.round(heightInPixels * aspect), 116 | height: heightInPixels, 117 | } 118 | : { 119 | width: widthInPixels, 120 | height: Math.round(widthInPixels / aspect), 121 | } 122 | const croppedAreaPixels = { 123 | ...sizePixels, 124 | x: limitAreaFn( 125 | imgSize.naturalWidth - sizePixels.width, 126 | (croppedAreaPercentages.x * imgSize.naturalWidth) / 100, 127 | true 128 | ), 129 | y: limitAreaFn( 130 | imgSize.naturalHeight - sizePixels.height, 131 | (croppedAreaPercentages.y * imgSize.naturalHeight) / 100, 132 | true 133 | ), 134 | } 135 | return { croppedAreaPercentages, croppedAreaPixels } 136 | } 137 | 138 | /** 139 | * Ensure the returned value is between 0 and max 140 | * @param max 141 | * @param value 142 | * @param shouldRound 143 | */ 144 | function limitArea(max: number, value: number, shouldRound = false) { 145 | const v = shouldRound ? Math.round(value) : value 146 | return Math.min(max, Math.max(0, v)) 147 | } 148 | 149 | function noOp(max: number, value: number) { 150 | return value 151 | } 152 | 153 | /** 154 | * Return the point that is the center of point a and b 155 | * @param a 156 | * @param b 157 | */ 158 | export function getCenter(a: Point, b: Point) { 159 | return { 160 | x: (b.x + a.x) / 2, 161 | y: (b.y + a.y) / 2, 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | import Cropper from './Cropper.svelte' 2 | 3 | export * from "./types" 4 | export default Cropper 5 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { HTMLImgAttributes } from 'svelte/elements' 2 | 3 | export type CropShape = 'rect' | 'round' 4 | 5 | export type OnCropCompleteEvent = { percent: CropArea; pixels: CropArea } 6 | export type OnCropComplete = (event: OnCropCompleteEvent) => void 7 | 8 | export type CropperProps = { 9 | image: string 10 | crop: Point 11 | zoom: number 12 | aspect: number 13 | minZoom: number 14 | maxZoom: number 15 | cropSize: Size | null 16 | cropShape: CropShape 17 | showGrid: boolean 18 | zoomSpeed: number 19 | crossOrigin: HTMLImgAttributes['crossorigin'] 20 | restrictPosition: boolean 21 | tabindex: number | undefined 22 | oncropcomplete: OnCropComplete 23 | } 24 | 25 | export interface Size { 26 | width: number 27 | height: number 28 | } 29 | 30 | export interface ImageSize { 31 | width: number 32 | height: number 33 | naturalWidth: number 34 | naturalHeight: number 35 | } 36 | 37 | export interface Point { 38 | x: number 39 | y: number 40 | } 41 | 42 | export interface CropArea { 43 | x: number 44 | y: number 45 | width: number 46 | height: number 47 | } 48 | 49 | export interface DispatchEvents { 50 | cropcomplete: { 51 | percent: CropArea 52 | pixels: CropArea 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | console.log(e)} 21 | /> 22 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ValentinH/svelte-easy-crop/47a38aa04068d03b262a2ec80963b29f19a5bde3/static/favicon.png -------------------------------------------------------------------------------- /static/images/cat.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ValentinH/svelte-easy-crop/47a38aa04068d03b262a2ec80963b29f19a5bde3/static/images/cat.jpeg -------------------------------------------------------------------------------- /static/images/dog.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ValentinH/svelte-easy-crop/47a38aa04068d03b262a2ec80963b29f19a5bde3/static/images/dog.jpeg -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto' 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | adapter: adapter(), 12 | }, 13 | } 14 | 15 | export default config 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true 12 | } 13 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 14 | // 15 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 16 | // from the referenced tsconfig.json - TypeScript does not merge them in 17 | } 18 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite' 2 | import type { UserConfig } from 'vite' 3 | 4 | const config: UserConfig = { 5 | plugins: [sveltekit()], 6 | } 7 | 8 | export default config 9 | --------------------------------------------------------------------------------