├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── build-and-publish-npm-package.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── clean-dependencies.js ├── gh-pages.cjs ├── package-lock.json ├── package.json ├── src ├── app.d.ts ├── app.html ├── custom_events.d.ts ├── lib │ ├── crop_window │ │ ├── CropMediaView.svelte │ │ ├── CropWindow.svelte │ │ ├── GestureMediaView.svelte │ │ ├── TransformMediaView.svelte │ │ ├── animate_position.ts │ │ └── geometry.ts │ ├── gestures │ │ ├── mouse_events.ts │ │ └── touch_scale_pan_rotate.ts │ ├── index.ts │ ├── overlay │ │ ├── Overlay.svelte │ │ └── overlay.ts │ ├── types.ts │ └── utils │ │ └── throttle.ts └── routes │ ├── +layout.ts │ └── +page.svelte ├── static ├── .nojekyll ├── Mountain - 8837.mp4 ├── favicon.png ├── hintersee-3601004.jpg ├── logo.svg └── videocrop.gif ├── svelte.config.js ├── tsconfig.json └── vite.config.ts /.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: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 5 | plugins: ['svelte3', '@typescript-eslint'], 6 | ignorePatterns: ['*.cjs'], 7 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], 8 | settings: { 9 | 'svelte3/typescript': () => require('typescript') 10 | }, 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020 14 | }, 15 | env: { 16 | browser: true, 17 | es2017: true, 18 | node: true 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /.github/workflows/build-and-publish-npm-package.yml: -------------------------------------------------------------------------------- 1 | name: build_and_publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | tags: 8 | - 'v*' 9 | 10 | jobs: 11 | build-check-and-dry-run-publish-npm: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 16 18 | registry-url: https://registry.npmjs.org/ 19 | - run: npm ci 20 | - run: npm run build 21 | - run: npm run check 22 | - run: npm publish --dry-run 23 | working-directory: package 24 | env: 25 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 26 | 27 | 28 | publish-npm: 29 | needs: build-check-and-dry-run-publish-npm 30 | if: startsWith(github.ref, 'refs/tags/v') 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v3 34 | - uses: actions/setup-node@v3 35 | with: 36 | node-version: 16 37 | registry-url: https://registry.npmjs.org/ 38 | - run: npm ci 39 | - run: npm run build 40 | - run: npm publish 41 | working-directory: package 42 | env: 43 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | /static 10 | gh-pages.js 11 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "tabWidth": 4, 4 | "singleQuote": true, 5 | "trailingComma": "none", 6 | "printWidth": 100, 7 | "plugins": ["prettier-plugin-svelte"], 8 | "pluginSearchDirs": ["."], 9 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Sabine Schmaltz 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-crop-window 2 | 3 | A crop window component for images and videos that supports touch gestures (pinch zoom, rotate, pan), as well as mousewheel zoom, mouse-dragging the image, and rotating on right mouse button. 4 | 5 | Currently looking for contributors / feature requests / feedback to help improve this component. 6 | 7 | ![video cropper](/static/videocrop.gif) 8 | 9 | If you can do code-review, that's very welcome. 10 | 11 | Here's a [demo page](https://sabine.github.io/svelte-crop-window/). 12 | 13 | And here's a [minimal REPL where you can play with the code](https://svelte.dev/repl/2db644efd08841958f2dd3209f00bf51?version=3.52.0) and a [fancier REPL](https://svelte.dev/repl/c246300e4ffd42a0b01ff318f7abd91d?version=3.52.0). 14 | 15 | ## Installation 16 | 17 | ```bash 18 | npm install svelte-crop-window 19 | ``` 20 | 21 | ## Basic use 22 | 23 | You must wrap the `CropWindow` component with an Element that determines the height. 24 | ```html 25 | 35 | 36 |
37 | 38 |
39 | ``` 40 | 41 | ## `CropWindow.svelte` Component 42 | 43 | ### Props 44 | 45 | | name | type | required | purpose | 46 | | --------- | ----------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------- | 47 | | `media` | `Media` | ✓ | image or video to be cropped | 48 | | `value` | `CropValue` | | value that describes [how to crop](#how-to-crop) - will be initialized if undefined | 49 | | `options` | [`Options`](#options) | | options for the crop window and overlay, see below | 50 | 51 | ```typescript 52 | type Media = { 53 | content_type: 'image' | 'video'; 54 | url: string; 55 | } 56 | 57 | type CropValue = { 58 | position: { x: number; y: number }; 59 | aspect: number; 60 | rotation: number; 61 | scale: number; } 62 | } 63 | 64 | const defaultValue: CropValue = { 65 | position: { x: 0, y: 0 }, 66 | aspect: 1.0, 67 | rotation: 0, 68 | scale: 0 69 | }; 70 | ``` 71 | 72 | ### Options 73 | 74 | | name | type | purpose | 75 | | -------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 76 | | `shape` | `'rect' \| 'round'` | shape of the crop area (yes, an ellipse will work) | 77 | | `crop_window_margin` | `number` | Margin of the crop window, in pixels. The crop window will always scale to the maximum possible size in its containing element. | 78 | | `overlay` | a Svelte component | The overlay component which visually highlights the crop area. You can pass your own Svelte component with props `options: T, gesture_in_progress: boolean, shape: 'rect' \| 'round'` here, or use the included [Overlay.svelte](/src/lib/overlay/Overlay.svelte). | 79 | | `overlay_options` | `T` | Options for your overlay component. See below for the options of the included overlay component. | 80 | 81 | ```typescript 82 | const defaultOptions: Options = { 83 | shape: 'rect', 84 | crop_window_margin: 10, 85 | overlay: Overlay, 86 | overlay_options: defaultOverlayOptions 87 | }; 88 | ``` 89 | 90 | ## `Overlay.svelte` Component 91 | 92 | ### Options 93 | 94 | | name | type | purpose | 95 | | ------------------ | --------- | -------------------------------------------------------------------- | 96 | | `overlay_color` | `string` | the color of the overlay that covers everything except the crop area | 97 | | `line_color` | `string` | the color of the lines | 98 | | `show_third_lines` | `boolean` | whether to show third lines or not when a gesture is in progress | 99 | 100 | ```typescript 101 | const defaultOverlayOptions: OverlayOptions = { 102 | overlay_color: 'rgb(11, 11, 11, 0.7)', 103 | line_color: 'rgba(167, 167, 167, 0.5)', 104 | show_third_lines: true 105 | }; 106 | ``` 107 | 108 | ## How to Crop 109 | 110 | ### Display in HTML Without Actually Cropping: 111 | 112 | ```html 113 |
119 |
129 | ``` 130 | 131 | Note: You must choose a `HEIGHT`, because the crop value is normalized against the target height. 132 | 133 | ### Pseudo Code to Crop 134 | 135 | 1. Choose a `target_height` and calculate the `target_width` for the cropped image: 136 | 137 | ```javascript 138 | let target_width = value.aspect * target_height; 139 | ``` 140 | 141 | 2. Calculate factor `s` by which to scale: 142 | 143 | ```javascript 144 | let s = (value.scale * target_height) / media.height; 145 | ``` 146 | 147 | 3. Scale media by `s`: 148 | 149 | ```javascript 150 | let resized_media = scale(media, s); 151 | ``` 152 | 153 | 4. Rotate media by `value.rotation`: 154 | 155 | ```javascript 156 | let resized_and_rotated_media = rotate(resized_media, value.rotation); 157 | ``` 158 | 159 | 5. Calculate top left position of the area to extract: 160 | 161 | ```javascript 162 | let left = (resized_and_rotated_media.width - target_width) / 2.0 163 | - value.x * target_height; 164 | let top = (resized_and_rotated_media.height - target_height) / 2.0 165 | - value.y * target_height; 166 | ``` 167 | 168 | 6. Extract area: 169 | 170 | ```javascript 171 | let cropped_media = 172 | extract_area(resized_and_rotated_media, 173 | left, top, target_width, target_height); 174 | ``` 175 | 176 | ## What this component doesn't do 177 | 178 | 1. Does not modify/crop the image, you have to do that by whatever means make sense for your application. Doesn't (yet) provide usable controls. Currently, you need to implement your own. 179 | 2. Similar to the overlay, it would be nice to include some controls to make this more usable out of the box. Contributions are very welcome. 180 | 181 | ## Developing 182 | 183 | Once you've cloned the project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 184 | 185 | ```bash 186 | npm run dev 187 | 188 | # or start the server and open the app in a new browser tab 189 | npm run dev -- --open 190 | ``` 191 | 192 | ## Acknowledgements 193 | 194 | One big inspiration for this component was the Android library 195 | [uCrop by Yalantis](https://github.com/Yalantis/uCrop). What is particularly 196 | valuable is that the developers shared their thought process in 197 | [this blog post](https://yalantis.com/blog/how-we-created-ucrop-our-own-image-cropping-library-for-android/). 198 | 199 | Another very helpful resource was [svelte-easy-crop](https://www.npmjs.com/package/svelte-easy-crop) 200 | which gave me a basic understanding of how to implement a crop window component in Svelte 201 | (and HTML/JS in general). 202 | 203 | There's no code reuse between either of these components and this one. All 204 | calculations had to be recreated from textbook math. 205 | -------------------------------------------------------------------------------- /clean-dependencies.js: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | let r = JSON.parse(fs.readFileSync("package.json")); 4 | let p = JSON.parse(fs.readFileSync("package/package.json")); 5 | 6 | delete p["devDependencies"]; 7 | p["devDependencies"] = { 8 | "svelte": r["devDependencies"]["svelte"], 9 | }; 10 | 11 | fs.writeFileSync("package/package.json", JSON.stringify(p, null, 2)); 12 | 13 | console.log("Pruned devDependencies"); 14 | -------------------------------------------------------------------------------- /gh-pages.cjs: -------------------------------------------------------------------------------- 1 | var ghpages = require('gh-pages'); 2 | 3 | ghpages.publish( 4 | 'build', 5 | { 6 | dotfiles: true, 7 | branch: 'gh-pages', 8 | user: { 9 | name: 'sabine', 10 | email: 'sabineschmaltz@gmail.com' 11 | } 12 | }, 13 | () => { 14 | console.log('Deploy Complete!'); 15 | } 16 | ); 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-crop-window", 3 | "version": "0.2.0", 4 | "description": "A Svelte component to crop images and videos with touch and mouse gestures", 5 | "homepage": "https://sabine.github.io/svelte-crop-window", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/sabine/svelte-crop-window" 9 | }, 10 | "author": "Sabine Schmaltz ", 11 | "license": "MIT", 12 | "keywords": [ 13 | "cropper", 14 | "crop window", 15 | "image crop", 16 | "video crop", 17 | "svelte", 18 | "svelte component" 19 | ], 20 | "scripts": { 21 | "dev": "vite dev --port 3000 --host", 22 | "gh-pages": "vite build && node gh-pages.cjs", 23 | "build": "svelte-kit sync && svelte-package && node clean-dependencies.js", 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 | }, 29 | "devDependencies": { 30 | "@sveltejs/kit": "1.0.0-next.511", 31 | "@sveltejs/package": "1.0.0-next.1", 32 | "@sveltejs/adapter-static": "1.0.0-next.44", 33 | "@typescript-eslint/eslint-plugin": "^5.27.0", 34 | "@typescript-eslint/parser": "^5.27.0", 35 | "eslint": "^8.16.0", 36 | "eslint-config-prettier": "^8.3.0", 37 | "eslint-plugin-svelte3": "^4.0.0", 38 | "prettier": "^2.6.2", 39 | "prettier-plugin-svelte": "^2.7.0", 40 | "svelte": "^3.44.0", 41 | "svelte-check": "^2.7.1", 42 | "svelte-highlight": "^6.2.1", 43 | "svelte-preprocess": "^4.10.6", 44 | "tslib": "^2.3.1", 45 | "typescript": "^4.7.4", 46 | "vite": "^3.1.0", 47 | "gh-pages": "^4.0.0" 48 | }, 49 | "type": "module", 50 | "dependencies": {} 51 | } 52 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // See https://kit.svelte.dev/docs/types#app 4 | // for information about these interfaces 5 | // and what to do when importing types 6 | declare namespace App { 7 | // interface Locals {} 8 | // interface PageData {} 9 | // interface Error {} 10 | // interface Platform {} 11 | } 12 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 88 | 89 | 90 | 91 |
%sveltekit.body%
92 | 93 | 94 | -------------------------------------------------------------------------------- /src/custom_events.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@composi/gestures'; 2 | 3 | declare namespace svelte.JSX { 4 | import type { 5 | DragEndEvent, 6 | DragMoveEvent, 7 | DragStartEvent 8 | } from '@utils/use_directives/draggable'; 9 | import type { 10 | MouseDragEndEvent, 11 | MouseDragMoveEvent, 12 | MouseDragStartEvent 13 | } from '@utils/use_directives/mouse_draggable'; 14 | import type { TouchScalePanRotateEvent } from '@components/by_entity/media/inputs/new_cropper/touch_scale_pan_rotate'; 15 | interface HTMLAttributes { 16 | // use:mouse_draggable 17 | onmouse_draggable_start?: (e: MouseDragStartEvent) => void; 18 | onmouse_draggable_move?: (e: MouseDragMoveEvent) => void; 19 | onmouse_draggable_end?: (e: MouseDragEndEvent) => void; 20 | 21 | // use:touch_zoom_pan_rotate 22 | ontouch_scale_pan_rotate?: (e: TouchScalePanRotateEvent) => void; 23 | onnumber_of_touch_points_changed?: (e: CustomEvent) => void; 24 | ontouchend_scale_pan_rotate?: (e: CustomEvent) => void; 25 | 26 | ongesturestart?: (e: Event) => void; 27 | ongesturechange?: (e: Event) => void; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/crop_window/CropMediaView.svelte: -------------------------------------------------------------------------------- 1 | 423 | 424 | {#if crop_window_size && outer_size} 425 | 444 | 514 | {/if} 515 | -------------------------------------------------------------------------------- /src/lib/crop_window/CropWindow.svelte: -------------------------------------------------------------------------------- 1 | 67 | 68 | 69 | 70 |
78 | {#if crop_window_size && outer_size && center_point} 79 | 90 | {/if} 91 |
92 | 93 | 99 | -------------------------------------------------------------------------------- /src/lib/crop_window/GestureMediaView.svelte: -------------------------------------------------------------------------------- 1 | 157 | 158 |
172 | 181 |
182 | 183 |
184 |
185 | 186 | 205 | -------------------------------------------------------------------------------- /src/lib/crop_window/TransformMediaView.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 | {#if media && media.content_type == 'image'} 43 | {'image 53 | {:else if media && media.content_type == 'video'} 54 | 70 | {/if} 71 | 72 | 81 | -------------------------------------------------------------------------------- /src/lib/crop_window/animate_position.ts: -------------------------------------------------------------------------------- 1 | import type { Point } from './geometry'; 2 | 3 | function easeInOutCubic(x: number): number { 4 | return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; 5 | } 6 | 7 | export type AnimationState = { 8 | start: DOMHighResTimeStamp | null; 9 | offset: Point | undefined; 10 | scale: number | undefined; 11 | }; 12 | 13 | function animate(animation: AnimatePosition) { 14 | if (animation.rafTimeout) window.cancelAnimationFrame(animation.rafTimeout); 15 | animation.rafTimeout = window.requestAnimationFrame((timestamp: DOMHighResTimeStamp) => { 16 | if (!animation.start_time) animation.start_time = timestamp; 17 | 18 | const elapsed = Math.min((timestamp - animation.start_time) / animation.duration, 1.0); 19 | let z = easeInOutCubic(elapsed); 20 | 21 | if (animation.offset === undefined || animation.scale === undefined) 22 | throw 'animation lacks start or end position/scale'; 23 | 24 | animation.on_progress( 25 | { 26 | x: (1 - z) * animation.offset.x, 27 | y: (1 - z) * animation.offset.y 28 | }, 29 | (1 - z) * animation.scale + z * 1.0 30 | ); 31 | 32 | if (elapsed < 1.0) { 33 | animate(animation); 34 | } else { 35 | animation.abort(); 36 | } 37 | }); 38 | } 39 | 40 | export class AnimatePosition { 41 | duration: number = 500; 42 | start_time: DOMHighResTimeStamp | null = null; 43 | offset: Point | undefined; 44 | scale: number | undefined; 45 | 46 | rafTimeout: number | null = null; 47 | 48 | start = (offset: Point, scale: number) => { 49 | this.start_time = null; 50 | this.offset = offset; 51 | this.scale = scale; 52 | 53 | animate(this); 54 | }; 55 | abort = () => { 56 | if (this.rafTimeout) window.cancelAnimationFrame(this.rafTimeout); 57 | this.on_end(); 58 | 59 | this.start_time = null; 60 | this.offset = undefined; 61 | this.scale = undefined; 62 | }; 63 | 64 | on_progress: (position: Point, scale: number) => void; 65 | on_end: () => void; 66 | 67 | constructor(on_progress: (position: Point, scale: number) => void, on_end: () => void) { 68 | this.on_progress = on_progress; 69 | this.on_end = on_end; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/lib/crop_window/geometry.ts: -------------------------------------------------------------------------------- 1 | export type Size = { 2 | width: number; 3 | height: number; 4 | }; 5 | 6 | export type Point = { 7 | x: number; 8 | y: number; 9 | }; 10 | 11 | export function rotate_point(p: Point, rotation: number): Point { 12 | let rot = (rotation / 180) * Math.PI; 13 | return { 14 | x: p.x * Math.cos(rot) - p.y * Math.sin(rot), 15 | y: p.y * Math.cos(rot) + p.x * Math.sin(rot) 16 | }; 17 | } 18 | export function add_point(...args: Point[]): Point { 19 | let r: Point = { 20 | x: 0, 21 | y: 0 22 | }; 23 | for (var i = 0; i < args.length; i++) { 24 | r.x += args[i].x; 25 | r.y += args[i].y; 26 | } 27 | return r; 28 | } 29 | 30 | export function sub_point(p: Point, offset: Point): Point { 31 | return { 32 | x: p.x - offset.x, 33 | y: p.y - offset.y 34 | }; 35 | } 36 | 37 | export function mul_point(p: Point, v: number): Point { 38 | return { 39 | x: p.x * v, 40 | y: p.y * v 41 | }; 42 | } 43 | 44 | export function rotate_point_around_center(p: Point, center: Point, rotation: number): Point { 45 | let { x, y } = rotate_point( 46 | { 47 | x: p.x - center.x, 48 | y: p.y - center.y 49 | }, 50 | rotation 51 | ); 52 | return { 53 | x: x + center.x, 54 | y: y + center.y 55 | }; 56 | } 57 | 58 | export function get_center(a: Point, b: Point): Point { 59 | return { 60 | x: (b.x + a.x) / 2, 61 | y: (b.y + a.y) / 2 62 | }; 63 | } 64 | 65 | export function get_angle_between_points(a: Point, b: Point): number { 66 | let angle = -Math.atan2(a.y - b.y, b.x - a.x) * (180 / Math.PI); 67 | return angle < 0 ? angle + 360 : angle; 68 | } 69 | 70 | export function get_distance_between_points(a: Point, b: Point): number { 71 | return Math.sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y)); 72 | } 73 | -------------------------------------------------------------------------------- /src/lib/gestures/mouse_events.ts: -------------------------------------------------------------------------------- 1 | export function pos_from_mouse_or_touch_event(event: MouseEvent | TouchEvent): { 2 | x: number; 3 | y: number; 4 | } { 5 | let pos: MouseEvent | Touch; 6 | if (event.type === 'touchstart') { 7 | pos = (event as TouchEvent).touches[0]; 8 | } else { 9 | if (event.type === 'touchmove') { 10 | pos = (event as TouchEvent).changedTouches[0]; 11 | } else { 12 | pos = event as MouseEvent; 13 | } 14 | } 15 | return { 16 | x: pos.clientX, 17 | y: pos.clientY 18 | }; 19 | } 20 | 21 | export type MouseDragMove = { 22 | x: number; 23 | y: number; 24 | dx: number; 25 | dy: number; 26 | mouse_button: number; 27 | }; 28 | export type MouseDragStartEnd = { 29 | x: number; 30 | y: number; 31 | mouse_button: number; 32 | }; 33 | 34 | export type MouseDragMoveEvent = { 35 | detail: MouseDragMove; 36 | }; 37 | 38 | export type MouseDragStartEvent = { 39 | detail: MouseDragStartEnd; 40 | }; 41 | 42 | export type MouseDragEndEvent = { 43 | detail: MouseDragStartEnd; 44 | }; 45 | 46 | function prevent_context_menu(event: Event) { 47 | event.preventDefault(); 48 | return false; 49 | } 50 | 51 | export function mouse_draggable(node: HTMLElement) { 52 | let x: number | undefined; 53 | let y: number | undefined; 54 | let mouse_button: number | undefined; 55 | 56 | function handle_dragstart(event: MouseEvent) { 57 | // https://stackoverflow.com/questions/5429827/how-can-i-prevent-text-element-selection-with-cursor-drag 58 | event.preventDefault(); 59 | 60 | if (mouse_button === undefined) { 61 | mouse_button = event.button; 62 | let p = pos_from_mouse_or_touch_event(event); 63 | let detail: MouseDragStartEnd = { 64 | x: p.x, 65 | y: p.y, 66 | mouse_button 67 | }; 68 | 69 | node.dispatchEvent( 70 | new CustomEvent('mouse_draggable_start', { 71 | detail 72 | }) 73 | ); 74 | window.addEventListener('mousemove', handle_dragmove); 75 | window.addEventListener('mouseup', handle_dragend); 76 | } 77 | } 78 | function handle_dragmove(event: MouseEvent) { 79 | event.preventDefault(); 80 | 81 | let p = pos_from_mouse_or_touch_event(event); 82 | 83 | const dx = p.x - (x || p.x); 84 | const dy = p.y - (y || p.y); 85 | x = p.x; 86 | y = p.y; 87 | let detail: MouseDragMove = { 88 | x, 89 | y, 90 | dx, 91 | dy, 92 | mouse_button: mouse_button || 0 93 | }; 94 | node.dispatchEvent( 95 | new CustomEvent('mouse_draggable_move', { 96 | detail 97 | }) 98 | ); 99 | } 100 | function handle_dragend(event: MouseEvent) { 101 | event.preventDefault(); 102 | x = undefined; 103 | y = undefined; 104 | mouse_button = undefined; 105 | 106 | let p = pos_from_mouse_or_touch_event(event); 107 | let detail: MouseDragStartEnd = { 108 | x: p.x, 109 | y: p.y, 110 | mouse_button: mouse_button || 0 111 | }; 112 | node.dispatchEvent( 113 | new CustomEvent('mouse_draggable_end', { 114 | detail 115 | }) 116 | ); 117 | window.removeEventListener('mousemove', handle_dragmove); 118 | window.removeEventListener('mouseup', handle_dragend); 119 | } 120 | node.addEventListener('mousedown', handle_dragstart); 121 | node.addEventListener('contextmenu', prevent_context_menu); 122 | return { 123 | destroy() { 124 | node.removeEventListener('mousedown', handle_dragstart); 125 | node.removeEventListener('contextmenu', prevent_context_menu); 126 | } 127 | }; 128 | } 129 | 130 | /* 131 | export function clamp(x: number, min: number, max: number) { 132 | if (x < min) return min; 133 | if (x > max) return max; 134 | return x; 135 | } 136 | */ 137 | -------------------------------------------------------------------------------- /src/lib/gestures/touch_scale_pan_rotate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | add_point, 3 | get_angle_between_points, 4 | get_center, 5 | get_distance_between_points, 6 | sub_point, 7 | type Point 8 | } from '../crop_window/geometry'; 9 | 10 | export type TouchScalePanRotateEvent = { 11 | detail: TouchScalePanRotate; 12 | }; 13 | export type TouchScalePanRotate = { 14 | focal_point: Point; 15 | pan: Point; 16 | rotation: number; 17 | scale: number; 18 | }; 19 | 20 | const MIN_ROTATION: number = 25; 21 | const MIN_PAN: number = 35; 22 | const MIN_SCALE: number = 1.25; 23 | 24 | function scale_unlocked(s: number): boolean { 25 | return s > MIN_SCALE || s < 1 / MIN_SCALE; 26 | } 27 | 28 | function rotation_unlocked(r: number): boolean { 29 | return r < -MIN_ROTATION || r > MIN_ROTATION; 30 | } 31 | 32 | function pan_unlocked(p: Point): boolean { 33 | return get_distance_between_points(p, { x: 0, y: 0 }) > MIN_PAN; 34 | } 35 | 36 | export function touch_scale_pan_rotate(node: HTMLElement) { 37 | let touches: { p: Point; identifier: number }[] = []; 38 | let rafTimeout: number | undefined; 39 | let rotation_accumulated: number = 0; 40 | let pan_accumulated: Point = { x: 0, y: 0 }; 41 | let scale_accumulated: number = 1; 42 | 43 | function point_from_touch(t: Touch): Point { 44 | let rect = node.getBoundingClientRect(); 45 | return sub_point( 46 | { 47 | x: t.clientX, 48 | y: t.clientY 49 | }, 50 | rect 51 | ); 52 | } 53 | 54 | function update_touch(t: Touch) { 55 | let existing_touch_index = touches.findIndex((tt) => tt.identifier == t.identifier); 56 | if (existing_touch_index != -1) { 57 | touches[existing_touch_index].p = point_from_touch(t); 58 | } else { 59 | touches.push({ p: point_from_touch(t), identifier: t.identifier }); 60 | } 61 | } 62 | 63 | function touch_by_identifier(identifier: number): Point | undefined { 64 | let existing_touch_index = touches.findIndex((tt) => tt.identifier == identifier); 65 | if (existing_touch_index != -1) { 66 | return touches[existing_touch_index].p; 67 | } 68 | } 69 | 70 | function handle_touchstart(event: TouchEvent) { 71 | // https://stackoverflow.com/questions/5429827/how-can-i-prevent-text-element-selection-with-cursor-drag 72 | event.preventDefault(); 73 | 74 | for (let t of event.changedTouches) { 75 | update_touch(t); 76 | } 77 | 78 | rotation_accumulated = 0; 79 | pan_accumulated = { x: 0, y: 0 }; 80 | scale_accumulated = 1; 81 | 82 | if (touches.length > 1) { 83 | node.dispatchEvent( 84 | new CustomEvent('number_of_touch_points_changed', { 85 | detail: null 86 | }) 87 | ); 88 | } 89 | 90 | window.addEventListener('touchmove', handle_touchmove); 91 | window.addEventListener('touchend', handle_touchend); 92 | } 93 | 94 | function handle_touchmove(event: TouchEvent) { 95 | if (event.touches.length == 1) { 96 | const old_focal_point = touch_by_identifier(event.touches[0].identifier); 97 | const focal_point = point_from_touch(event.touches[0]); 98 | 99 | if (!focal_point) throw 'no focal_point'; 100 | let pan = { x: 0, y: 0 }; 101 | if (old_focal_point) { 102 | pan = sub_point(focal_point, old_focal_point); 103 | } 104 | 105 | let e: TouchScalePanRotate = { 106 | focal_point, 107 | pan, 108 | rotation: 0, 109 | scale: 1 110 | }; 111 | 112 | dispatch_touch_scale_pan_rotate(e, event.touches); 113 | } 114 | 115 | if (event.touches.length >= 2) { 116 | let old1 = touch_by_identifier(event.touches[0].identifier); 117 | let old2 = touch_by_identifier(event.touches[1].identifier); 118 | 119 | let new1 = point_from_touch(event.touches[0]); 120 | let new2 = point_from_touch(event.touches[1]); 121 | 122 | const old_focal_point = old1 && old2 ? get_center(old1, old2) : undefined; 123 | const focal_point = get_center(new1, new2); 124 | 125 | let rotation: number = 0; 126 | if (old1 && old2) { 127 | rotation = 128 | get_angle_between_points(new1, new2) - get_angle_between_points(old1, old2); 129 | if (!rotation_unlocked(rotation_accumulated)) { 130 | rotation_accumulated += rotation; 131 | } 132 | } 133 | 134 | let pan: Point = { x: 0, y: 0 }; 135 | if (old_focal_point) { 136 | pan = sub_point(focal_point, old_focal_point); 137 | if (!pan_unlocked(pan_accumulated)) { 138 | pan_accumulated = add_point(pan_accumulated, pan); 139 | } 140 | } 141 | 142 | let scale: number = 1; 143 | if (old1 && old2) { 144 | scale = 145 | get_distance_between_points(new1, new2) / 146 | get_distance_between_points(old1, old2); 147 | if (!scale_unlocked(scale_accumulated)) { 148 | scale_accumulated *= scale; 149 | } 150 | } 151 | 152 | let e: TouchScalePanRotate = { 153 | focal_point, 154 | pan: pan_unlocked(pan_accumulated) ? pan : { x: 0, y: 0 }, 155 | rotation: rotation_unlocked(rotation_accumulated) ? rotation : 0, 156 | scale: scale_unlocked(scale_accumulated) ? scale : 1 157 | }; 158 | 159 | dispatch_touch_scale_pan_rotate(e, event.touches); 160 | } 161 | } 162 | 163 | function dispatch_touch_scale_pan_rotate(e: TouchScalePanRotate, touches: TouchList) { 164 | if (rafTimeout) window.cancelAnimationFrame(rafTimeout); 165 | rafTimeout = window.requestAnimationFrame(() => { 166 | node.dispatchEvent(new CustomEvent('touch_scale_pan_rotate', { detail: e })); 167 | 168 | for (let t of touches) { 169 | update_touch(t); 170 | } 171 | }); 172 | } 173 | 174 | function handle_touchend(event: TouchEvent) { 175 | touches = []; 176 | rotation_accumulated = 0; 177 | scale_accumulated = 1; 178 | pan_accumulated = { x: 0, y: 0 }; 179 | for (let t of event.touches) { 180 | update_touch(t); 181 | } 182 | 183 | node.dispatchEvent( 184 | new CustomEvent('touchend_scale_pan_rotate', { 185 | detail: null 186 | }) 187 | ); 188 | window.removeEventListener('touchmove', handle_touchmove); 189 | window.removeEventListener('touchend', handle_touchend); 190 | } 191 | 192 | node.addEventListener('touchstart', handle_touchstart); 193 | return { 194 | destroy() { 195 | node.removeEventListener('touchstart', handle_touchstart); 196 | } 197 | }; 198 | } 199 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { type CropValue, type Media, type Options, type Point, defaultOptions, defaultValue } from './types'; 2 | export { default as CropWindow } from './crop_window/CropWindow.svelte'; 3 | export { default as Overlay } from './overlay/Overlay.svelte'; 4 | export { type OverlayOptions, defaultOverlayOptions } from './overlay/overlay'; 5 | -------------------------------------------------------------------------------- /src/lib/overlay/Overlay.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
17 | {#if gesture_in_progress && options.show_third_lines} 18 |
19 |
20 |
21 |
22 |
27 |
28 | {/if} 29 | 30 |
31 |
32 | 33 | 85 | -------------------------------------------------------------------------------- /src/lib/overlay/overlay.ts: -------------------------------------------------------------------------------- 1 | export type OverlayOptions = { 2 | overlay_color: string; 3 | line_color: string; 4 | show_third_lines: boolean; 5 | }; 6 | 7 | export const defaultOverlayOptions: OverlayOptions = { 8 | overlay_color: 'rgb(11, 11, 11, 0.7)', 9 | line_color: 'rgba(167, 167, 167, 0.5)', 10 | show_third_lines: true 11 | }; 12 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { SvelteComponentTyped } from 'svelte'; 2 | import type { Point } from './crop_window/geometry'; 3 | import { defaultOverlayOptions, type OverlayOptions } from './overlay/overlay'; 4 | import Overlay from './overlay/Overlay.svelte'; 5 | export { type Point } from './crop_window/geometry'; 6 | 7 | type IWantToAcceptAComponent> = new ( 8 | ...args: any 9 | ) => SvelteComponentTyped; 10 | 11 | export type OverlayComponent = IWantToAcceptAComponent<{ 12 | options: T; 13 | shape: 'rect' | 'round'; 14 | gesture_in_progress: boolean; 15 | }>; 16 | 17 | export type CropShape = 'rect' | 'round'; 18 | 19 | export type Options = { 20 | /* shape of the crop area */ 21 | shape: CropShape; 22 | 23 | /* margin of the crop window wrt to its containing HTMLElement, in pixels */ 24 | crop_window_margin: number; 25 | 26 | /* The overlay which visually highlights the crop area. 27 | You can pass your own Svelte component here, for a custom overlay. 28 | Look at the included Overlay.svelte to see how to create your own. */ 29 | overlay: OverlayComponent; 30 | 31 | /* The options for the overlay component. */ 32 | overlay_options: T; 33 | }; 34 | 35 | export const defaultOptions: Options = { 36 | shape: 'rect', 37 | crop_window_margin: 10, 38 | 39 | overlay: Overlay, 40 | overlay_options: defaultOverlayOptions 41 | }; 42 | 43 | export type CropValue = { 44 | position: Point; 45 | aspect: number; 46 | rotation: number; 47 | scale: number; 48 | }; 49 | 50 | export type Media = { 51 | content_type: 'image' | 'video'; 52 | url: string; 53 | }; 54 | 55 | export const defaultValue: CropValue = { 56 | position: { x: 0, y: 0 }, 57 | aspect: 1.0, 58 | rotation: 0, 59 | scale: 0 60 | }; 61 | -------------------------------------------------------------------------------- /src/lib/utils/throttle.ts: -------------------------------------------------------------------------------- 1 | export function keep_delaying_while_triggered(callback: { (): void }, delay: number) { 2 | var timer: number | null = null; 3 | return function () { 4 | if (timer) window.clearTimeout(timer); 5 | timer = window.setTimeout(async function () { 6 | callback(); 7 | timer = null; 8 | }, delay); 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true; 2 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 56 | 57 | 58 | {@html atomOneDark} 59 | 60 | 61 |
62 |
63 |

64 | 68 | svelte media crop 73 | svelte-crop-window 75 |

76 | 77 |

78 | A crop window with touch and mouse gestures to zoom, pan and rotate an image or video. 79 |

80 |

Media snaps back to cover the crop area.

81 |
82 | 83 |
84 |

85 | Looking for contributions, code review and feedback! Feel free to open an issue on the GitHub repository or connect with me via the Svelte discord (sabine#8815). 88 |

89 | 90 |

91 | This is still in an experimental stage. Changes to the API are to be expected. After 92 | v1.0.0, this package will strictly follow semantic versioning. 93 |

94 | 95 |

96 | The code of this demo page is here. 100 |

101 |
102 | 103 |
104 |

Example with controls

105 | 106 |
107 | 108 |
109 | 110 | 111 | 112 |

Result

113 |
117 | {#if video} 118 |
149 | 150 |

Aspect ratio

151 |
152 | 157 | 162 | 167 | 172 | 177 |
178 | 179 |

Scale / Rotation / Position

180 | 181 |
182 |
scale
183 |
184 | crop_window_el.set_zoom(parseFloat(e.currentTarget.value))} 190 | on:change={(e) => { 191 | crop_window_el.commit(); 192 | e.currentTarget.value = value.scale.toString(); 193 | }} 194 | value={value.scale} 195 | /> 196 | 198 | {Math.round(value.scale * 100) / 100} 199 |
200 |
rotation
201 |
202 | crop_window_el.set_rotation(parseFloat(e.currentTarget.value))} 208 | on:change={crop_window_el.commit} 209 | value={value.rotation} 210 | /> 211 | {Math.round(value.rotation * 100) / 100} 212 |
213 |
x
214 | crop_window_el.set_pan(position)} 218 | on:change={() => { 219 | crop_window_el.commit(); 220 | position.x = value.position.x; 221 | }} 222 | bind:value={position.x} 223 | /> 224 | 225 |
y
226 | crop_window_el.set_pan(position)} 230 | on:change={() => { 231 | crop_window_el.commit(); 232 | position.y = value.position.y; 233 | }} 234 | bind:value={position.y} 235 | /> 236 |
237 | 238 |

Colors

239 |
242 |
overlay color
243 | 244 |
line color
245 | 246 |
background color
247 | 248 |
249 |

Shape

250 |
251 | 255 | 259 |
260 |
261 |
262 |

Minimal code example

263 |
264 | 265 |
266 | Note: You must wrap the component with an element that has a determined height (and width), as 267 | the component will always take up 100% of the available width and height. 268 |
269 | 283 | 284 |
285 | `} 286 | /> 287 |
288 |
289 | 290 |
291 |

Props and defaults

292 | See here. 293 | 294 | 296 | 301 |
302 | `} 303 | /> 304 | 305 | = { 316 | shape: 'rect', 317 | crop_window_margin: 10, 318 | 319 | overlay: Overlay, 320 | overlay_options: defaultOverlayOptions, 321 | } 322 | 323 | const defaultValue: CropValue = { 324 | position: { x: 0, y: 0 }, 325 | aspect: 1.0, 326 | rotation: 0, 327 | scale: 0 328 | }; 329 | `} 330 | /> 331 |
332 | 333 |
334 |

How to Crop

335 | 336 |
340 | {#if video} 341 |
372 | 373 |

Display in HTML without actually cropping:

374 | 375 | 379 |
`} 388 | /> 389 | 390 |

Pseudo code to crop

391 | 392 |
    393 |
  1. 394 | Choose a target_height and calculate the width for the cropped image: 395 | 399 |
  2. 400 |
  3. 401 | Calculate factor by which to scale: 402 | 406 |
  4. 407 |
  5. 408 | Scale media by s: 409 | 410 |
  6. 411 |
  7. 412 | Rotate media by value.rotation: 413 | 418 |
  8. 419 |
  9. 420 | Calculate top left position of area to extract: 429 |
  10. 430 |
  11. 431 | Extract area: 432 | 438 |
  12. 439 |
440 |
441 | 442 |
443 |

444 | What this component doesn't do 445 |

446 |
    447 |
  1. 448 | Does not modify/crop the image, you have to do that by whatever means make sense for 449 | your application. 450 |
  2. 451 |
  3. 452 | Doesn't (yet) provide usable controls. Currently, you need to implement your own. 453 |

    454 | Similar to the overlay, it would be nice to include some controls to make this 455 | more usable out of the box. Contributions are very welcome. 456 |

    457 |
  4. 458 |
459 |
460 | 461 |
462 |

Inspiration and Acknowledgements

463 | 464 |

465 | One big inspiration for this component was the Android library 466 | uCrop by Yalantis. What is particularly 467 | valuable is that the developers shared their thought process in 468 | this blog post. 472 |

473 |

474 | Another very helpful resource was svelte-easy-crop which gave me a basic understanding of how to implement a crop window component in Svelte 477 | (and HTML/JS in general). 478 |

479 |

480 | There's no code being reused between either of these components and this one (all 481 | calculations had to be recreated from textbook math). 482 |

483 | 484 | 485 |

486 | Video from shantararam at pixabay: mountain-nature-snow-old-mountain-8837 490 |

491 |
492 |
493 |
494 | 495 | 542 | -------------------------------------------------------------------------------- /static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sabine/svelte-crop-window/e1a94fff0bd67c3082b0c2adde3662511f0d89a5/static/.nojekyll -------------------------------------------------------------------------------- /static/Mountain - 8837.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sabine/svelte-crop-window/e1a94fff0bd67c3082b0c2adde3662511f0d89a5/static/Mountain - 8837.mp4 -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sabine/svelte-crop-window/e1a94fff0bd67c3082b0c2adde3662511f0d89a5/static/favicon.png -------------------------------------------------------------------------------- /static/hintersee-3601004.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sabine/svelte-crop-window/e1a94fff0bd67c3082b0c2adde3662511f0d89a5/static/hintersee-3601004.jpg -------------------------------------------------------------------------------- /static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 59 | -------------------------------------------------------------------------------- /static/videocrop.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sabine/svelte-crop-window/e1a94fff0bd67c3082b0c2adde3662511f0d89a5/static/videocrop.gif -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-static'; 2 | import preprocess from 'svelte-preprocess'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://github.com/sveltejs/svelte-preprocess 7 | // for more information about preprocessors 8 | preprocess: preprocess(), 9 | 10 | kit: { 11 | adapter: adapter(), 12 | paths: { 13 | base: '/svelte-crop-window' 14 | } 15 | } 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /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 | "declaration": true 13 | } 14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 15 | // 16 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 17 | // from the referenced tsconfig.json - TypeScript does not merge them in 18 | } 19 | -------------------------------------------------------------------------------- /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 | optimizeDeps: { 7 | include: ['highlight.js', 'highlight.js/lib/core'] 8 | } 9 | }; 10 | 11 | export default config; 12 | --------------------------------------------------------------------------------