├── .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 | 
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 |
128 |
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 |
53 | {:else if media && media.content_type == 'video'}
54 |
66 | {#each Array.isArray(media.url) ? media.url : [{ src: media.url }] as item}
67 |
68 | {/each}
69 |
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 |
21 |
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 |
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 |
105 |
106 |
107 |
108 |
109 |
110 |
(video = !video)}>{video ? 'video' : 'image'}
111 |
112 |
Result
113 |
117 | {#if video}
118 |
133 | {:else}
134 |
147 | {/if}
148 |
149 |
150 |
Aspect ratio
151 |
152 | {
154 | value.aspect = 8;
155 | }}>8:1
157 | {
159 | value.aspect = 16 / 9;
160 | }}>16:9
162 | {
164 | value.aspect = 4 / 3;
165 | }}>4:3
167 | {
169 | value.aspect = 1;
170 | }}>1:1
172 | {
174 | value.aspect = 3 / 4;
175 | }}>3:4
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 |
252 | rectangle
253 | round
254 |
255 |
256 | show third lines
257 | don't show third lines
258 |
259 |
260 |
261 |
262 |
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 |
335 |
336 |
340 | {#if video}
341 |
356 | {:else}
357 |
370 | {/if}
371 |
372 |
373 |
Display in HTML without actually cropping:
374 |
375 |
379 |
387 | `}
388 | />
389 |
390 |
391 |
392 |
393 |
394 | Choose a target_height
and calculate the width for the cropped image:
395 |
399 |
400 |
401 | Calculate factor by which to scale:
402 |
406 |
407 |
408 | Scale media by s
:
409 |
410 |
411 |
412 | Rotate media by value.rotation
:
413 |
418 |
419 |
420 | Calculate top left position of area to extract:
429 |
430 |
431 | Extract area:
432 |
438 |
439 |
440 |
441 |
442 |
443 |
446 |
447 |
448 | Does not modify/crop the image, you have to do that by whatever means make sense for
449 | your application.
450 |
451 |
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 |
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 |
--------------------------------------------------------------------------------