@plone-collective/volto-image-editor 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.changelog.draft +19 -0
  2. package/.release-it.json +34 -0
  3. package/CHANGELOG.md +27 -0
  4. package/README.md +209 -0
  5. package/babel.config.js +17 -0
  6. package/locales/de/LC_MESSAGES/volto.po +187 -0
  7. package/locales/en/LC_MESSAGES/volto.po +187 -0
  8. package/locales/es/LC_MESSAGES/volto.po +194 -0
  9. package/locales/pt_BR/LC_MESSAGES/volto.po +192 -0
  10. package/locales/volto.pot +189 -0
  11. package/news/.gitkeep +0 -0
  12. package/package.json +50 -0
  13. package/public/.gitkeep +0 -0
  14. package/src/components/.gitkeep +0 -0
  15. package/src/components/ImageEditor/ImageEditor.scss +59 -0
  16. package/src/components/ImageEditor/ImageEditor.tsx +290 -0
  17. package/src/components/ImageEditor/ImageEditorWrapper.scss +13 -0
  18. package/src/components/ImageEditor/ImageEditorWrapper.tsx +91 -0
  19. package/src/components/ImageEditor/components/AdjustableCropperBackground.tsx +60 -0
  20. package/src/components/ImageEditor/components/AdjustableImage.scss +3 -0
  21. package/src/components/ImageEditor/components/AdjustableImage.tsx +85 -0
  22. package/src/components/ImageEditor/components/AdjustablePreviewBackground.tsx +59 -0
  23. package/src/components/ImageEditor/components/Button.scss +30 -0
  24. package/src/components/ImageEditor/components/Button.tsx +27 -0
  25. package/src/components/ImageEditor/components/Navigation.scss +38 -0
  26. package/src/components/ImageEditor/components/Navigation.tsx +153 -0
  27. package/src/components/ImageEditor/components/SettingsModal.scss +369 -0
  28. package/src/components/ImageEditor/components/SettingsModal.tsx +582 -0
  29. package/src/components/ImageEditor/components/Slider.scss +231 -0
  30. package/src/components/ImageEditor/components/Slider.tsx +322 -0
  31. package/src/components/ImageEditor/types/ImageSettings.ts +23 -0
  32. package/src/components/Widgets/FileWidget.scss +14 -0
  33. package/src/components/Widgets/FileWidget.tsx +239 -0
  34. package/src/config/settings.ts +5 -0
  35. package/src/customizations/volto/components/manage/Widgets/FileWidget.jsx +3 -0
  36. package/src/icons/AspectRatioIcons.tsx +39 -0
  37. package/src/icons/BrightnessIcon.tsx +19 -0
  38. package/src/icons/ContrastIcon.tsx +19 -0
  39. package/src/icons/CropIcon.tsx +19 -0
  40. package/src/icons/DownloadIcon.tsx +20 -0
  41. package/src/icons/FlipHorizontalIcon.tsx +21 -0
  42. package/src/icons/FlipVerticalIcon.tsx +27 -0
  43. package/src/icons/HueIcon.tsx +20 -0
  44. package/src/icons/ResetIcon.tsx +19 -0
  45. package/src/icons/ResetRotationIcon.tsx +20 -0
  46. package/src/icons/RestrictionIcons.tsx +19 -0
  47. package/src/icons/RotateLeftIcon.tsx +21 -0
  48. package/src/icons/RotateRightIcon.tsx +21 -0
  49. package/src/icons/SaturationIcon.tsx +20 -0
  50. package/src/icons/SettingsIcon.tsx +21 -0
  51. package/src/icons/StencilIcons.tsx +8 -0
  52. package/src/index.ts +10 -0
  53. package/src/theme/constants.scss +13 -0
  54. package/src/theme/custom.css +82 -0
  55. package/src/theme/mixins.scss +91 -0
  56. package/towncrier.toml +33 -0
  57. package/tsconfig.json +30 -0
@@ -0,0 +1,290 @@
1
+ import React, { useState, useRef, useLayoutEffect } from 'react';
2
+ import cn from 'classnames';
3
+ import {
4
+ Cropper,
5
+ CropperPreview,
6
+ RectangleStencil,
7
+ CircleStencil,
8
+ ImageRestriction,
9
+ } from 'react-advanced-cropper';
10
+ import type { CropperRef, CropperPreviewRef } from 'react-advanced-cropper';
11
+ import { AdjustablePreviewBackground } from './components/AdjustablePreviewBackground';
12
+ import { Navigation } from './components/Navigation';
13
+ import { Slider } from './components/Slider';
14
+ import { AdjustableCropperBackground } from './components/AdjustableCropperBackground';
15
+ import { Button } from './components/Button';
16
+ import { SettingsModal } from './components/SettingsModal';
17
+ import type { ImageSettings } from './types/ImageSettings';
18
+ import { ResetIcon } from '@plone-collective/volto-image-editor/icons/ResetIcon';
19
+ import './ImageEditor.scss';
20
+
21
+ if (typeof window !== 'undefined') {
22
+ require('context-filter-polyfill');
23
+ }
24
+
25
+ const ImageEditor = ({ src, onImageSave, onCancel }) => {
26
+ const cropperRef = useRef<CropperRef>(null);
27
+ const previewRef = useRef<CropperPreviewRef>(null);
28
+
29
+ const [mode, setMode] = useState('crop');
30
+ const [settingsOpen, setSettingsOpen] = useState(false);
31
+ const [hasRotationChanges, setHasRotationChanges] = useState(false);
32
+
33
+ const [adjustments, setAdjustments] = useState({
34
+ brightness: 0,
35
+ hue: 0,
36
+ saturation: 0,
37
+ contrast: 0,
38
+ });
39
+
40
+ const [imageSettings, setImageSettings] = useState<ImageSettings>({
41
+ aspectRatio: 'free',
42
+ imageRestriction: 'fit-area',
43
+ stencilType: 'rectangle',
44
+ minWidth: 50,
45
+ minHeight: 50,
46
+ maxCropWidth: undefined,
47
+ maxCropHeight: undefined,
48
+ scalable: true,
49
+ stencilGrid: false,
50
+ minScale: 0.1,
51
+ maxScale: 3,
52
+ });
53
+
54
+ const onAction = (action: string) => {
55
+ switch (action) {
56
+ case 'rotate-left':
57
+ rotate(-90);
58
+ setHasRotationChanges(true);
59
+ break;
60
+ case 'rotate-right':
61
+ rotate(90);
62
+ setHasRotationChanges(true);
63
+ break;
64
+ case 'flip-horizontal':
65
+ flip(true, false);
66
+ setHasRotationChanges(true);
67
+ break;
68
+ case 'flip-vertical':
69
+ flip(false, true);
70
+ setHasRotationChanges(true);
71
+ break;
72
+ }
73
+ };
74
+
75
+ const onChangeValue = (value: number) => {
76
+ if (mode && mode in adjustments) {
77
+ setAdjustments((previousValue) => ({
78
+ ...previousValue,
79
+ [mode]: value,
80
+ }));
81
+ }
82
+ };
83
+
84
+ const onReset = () => {
85
+ setMode('crop');
86
+ setAdjustments({
87
+ brightness: 0,
88
+ hue: 0,
89
+ saturation: 0,
90
+ contrast: 0,
91
+ });
92
+ if (cropperRef.current) {
93
+ cropperRef.current.reset();
94
+ }
95
+ };
96
+
97
+ const onResetRotation = () => {
98
+ if (cropperRef.current) {
99
+ const coordinates = cropperRef.current.getCoordinates();
100
+ cropperRef.current.reset();
101
+ if (coordinates && coordinates.width > 0 && coordinates.height > 0) {
102
+ cropperRef.current.setCoordinates(coordinates);
103
+ }
104
+ }
105
+ setHasRotationChanges(false);
106
+ };
107
+
108
+ useLayoutEffect(() => {
109
+ const timer = setTimeout(() => {
110
+ if (cropperRef.current) {
111
+ onResetRotation();
112
+ }
113
+ }, 100);
114
+
115
+ return () => clearTimeout(timer);
116
+ }, [src]);
117
+
118
+ const onSave = () => {
119
+ if (cropperRef.current) {
120
+ const canvas = cropperRef.current.getCanvas();
121
+ if (canvas) {
122
+ let finalCanvas = canvas;
123
+
124
+ if (imageSettings.maxWidth || imageSettings.maxHeight) {
125
+ const tempCanvas = document.createElement('canvas');
126
+ const ctx = tempCanvas.getContext('2d');
127
+
128
+ if (ctx) {
129
+ let { width, height } = canvas;
130
+
131
+ if (imageSettings.maxWidth && width > imageSettings.maxWidth) {
132
+ const ratio = imageSettings.maxWidth / width;
133
+ width = imageSettings.maxWidth;
134
+ height = imageSettings.maintainAspectRatio
135
+ ? height * ratio
136
+ : height;
137
+ }
138
+
139
+ if (imageSettings.maxHeight && height > imageSettings.maxHeight) {
140
+ const ratio = imageSettings.maxHeight / height;
141
+ height = imageSettings.maxHeight;
142
+ width = imageSettings.maintainAspectRatio ? width * ratio : width;
143
+ }
144
+
145
+ tempCanvas.width = width;
146
+ tempCanvas.height = height;
147
+ ctx.drawImage(canvas, 0, 0, width, height);
148
+ finalCanvas = tempCanvas;
149
+ }
150
+ }
151
+
152
+ const mimeType = `image/${imageSettings.format}`;
153
+ const newData = finalCanvas.toDataURL(mimeType, imageSettings.quality);
154
+ onImageSave(newData);
155
+ }
156
+ }
157
+ };
158
+
159
+ const flip = (horizontal: boolean, vertical: boolean) => {
160
+ if (cropperRef.current) {
161
+ cropperRef.current.flipImage(horizontal, vertical);
162
+ }
163
+ };
164
+ const rotate = (angle: number) => {
165
+ if (cropperRef.current) {
166
+ cropperRef.current.rotateImage(angle);
167
+ }
168
+ };
169
+
170
+ const onUpdate = (cropper: CropperRef) => {
171
+ previewRef.current?.update(cropper);
172
+ };
173
+
174
+ const changed = Object.values(adjustments).some(
175
+ (el: number) => Math.abs(el) > 0.01,
176
+ );
177
+
178
+ const cropperEnabled = mode === 'crop';
179
+
180
+ const getAspectRatio = (): number | undefined => {
181
+ if (imageSettings.aspectRatio === 'free') return undefined;
182
+ const [width, height] = imageSettings.aspectRatio.split(':').map(Number);
183
+ return width / height;
184
+ };
185
+
186
+ const getImageRestriction = () => {
187
+ switch (imageSettings.imageRestriction) {
188
+ case 'fill-area':
189
+ return ImageRestriction.fillArea;
190
+ case 'fit-area':
191
+ return ImageRestriction.fitArea;
192
+ case 'stencil':
193
+ return ImageRestriction.stencil;
194
+ case 'none':
195
+ return ImageRestriction.none;
196
+ default:
197
+ return ImageRestriction.fillArea;
198
+ }
199
+ };
200
+
201
+ return (
202
+ <div className={'image-editor'}>
203
+ <div className="image-editor__cropper">
204
+ <Cropper
205
+ src={src}
206
+ ref={cropperRef}
207
+ stencilComponent={
208
+ imageSettings.stencilType === 'circle'
209
+ ? CircleStencil
210
+ : RectangleStencil
211
+ }
212
+ stencilProps={{
213
+ movable: cropperEnabled,
214
+ resizable: cropperEnabled,
215
+ lines: cropperEnabled,
216
+ handlers: cropperEnabled,
217
+ grid: imageSettings.stencilGrid,
218
+ aspectRatio:
219
+ imageSettings.aspectRatio === 'free'
220
+ ? undefined
221
+ : getAspectRatio(),
222
+ overlayClassName: cn(
223
+ 'image-editor__cropper-overlay',
224
+ !cropperEnabled && 'image-editor__cropper-overlay--faded',
225
+ ),
226
+ }}
227
+ sizeRestrictions={{
228
+ minWidth: imageSettings.minWidth,
229
+ minHeight: imageSettings.minHeight,
230
+ maxWidth: imageSettings.maxCropWidth,
231
+ maxHeight: imageSettings.maxCropHeight,
232
+ }}
233
+ imageRestriction={getImageRestriction()}
234
+ backgroundWrapperProps={{
235
+ scaleImage: cropperEnabled && imageSettings.scalable,
236
+ moveImage: cropperEnabled,
237
+ }}
238
+ backgroundComponent={AdjustableCropperBackground}
239
+ backgroundProps={adjustments}
240
+ onUpdate={onUpdate}
241
+ onReady={() => {
242
+ if (cropperRef.current) {
243
+ cropperRef.current.refresh();
244
+ }
245
+ }}
246
+ />
247
+ {mode !== 'crop' && mode && mode in adjustments && (
248
+ <Slider
249
+ className="image-editor__slider"
250
+ value={adjustments[mode as keyof typeof adjustments]}
251
+ onChange={onChangeValue}
252
+ />
253
+ )}
254
+ <CropperPreview
255
+ className={'image-editor__preview'}
256
+ ref={previewRef}
257
+ backgroundComponent={AdjustablePreviewBackground}
258
+ backgroundProps={adjustments}
259
+ />
260
+ <Button
261
+ className={cn(
262
+ 'image-editor__reset-button',
263
+ !changed && 'image-editor__reset-button--hidden',
264
+ )}
265
+ onClick={onReset}
266
+ >
267
+ <ResetIcon />
268
+ </Button>
269
+
270
+ <SettingsModal
271
+ settings={imageSettings}
272
+ onChange={setImageSettings}
273
+ isOpen={settingsOpen}
274
+ onToggle={() => setSettingsOpen(!settingsOpen)}
275
+ />
276
+ </div>
277
+ <Navigation
278
+ mode={mode}
279
+ onAction={onAction}
280
+ onResetRotation={onResetRotation}
281
+ hasRotationChanges={hasRotationChanges}
282
+ onChange={setMode}
283
+ onSave={onSave}
284
+ onCancel={onCancel}
285
+ />
286
+ </div>
287
+ );
288
+ };
289
+
290
+ export default ImageEditor;
@@ -0,0 +1,13 @@
1
+ .react-aria-ModalOverlay {
2
+ .react-aria-Modal.image-cropper-modal {
3
+ max-width: 70vw;
4
+ border: 0;
5
+ background-color: black;
6
+ .react-aria-Dialog {
7
+ display: flex;
8
+ align-items: center;
9
+ justify-content: center;
10
+ padding: 0;
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,91 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { FormattedMessage } from 'react-intl';
3
+ import { Button, Dialog, Modal } from '@plone/components';
4
+ import { DialogTrigger } from 'react-aria-components';
5
+ import ImageEditor from '@plone-collective/volto-image-editor/components/ImageEditor/ImageEditor';
6
+ import '@plone/components/dist/basic.css';
7
+ import './ImageEditorWrapper.scss';
8
+
9
+ function parseDataURL(
10
+ dataURL: string,
11
+ value: object,
12
+ ): {
13
+ data: string;
14
+ encoding: string;
15
+ 'content-type': string;
16
+ filename: string;
17
+ } {
18
+ const match = /^data:(.+?);(base64),(.+)$/.exec(dataURL);
19
+ if (!match) {
20
+ throw new Error('Invalid data URL format');
21
+ }
22
+
23
+ const [, contentType, encoding, data] = match;
24
+
25
+ const extension = contentType.split('/')[1] || 'bin';
26
+ const filename = value?.filename
27
+ ? value.filename
28
+ : `image-${Math.floor(Math.random() * 100)
29
+ .toString()
30
+ .padStart(2, '0')}.${extension}`;
31
+
32
+ return {
33
+ data,
34
+ encoding,
35
+ 'content-type': contentType,
36
+ filename,
37
+ };
38
+ }
39
+
40
+ const ImageEditorWrapper = ({ value, setData }) => {
41
+ const [src, setSrc] = useState('');
42
+
43
+ useEffect(() => {
44
+ if (value?.download) {
45
+ setSrc(value.download);
46
+ } else if (value?.data) {
47
+ const newSrc = `data:${value['content-type']};base64,${value.data}`;
48
+ setSrc(newSrc);
49
+ } else {
50
+ setSrc('');
51
+ }
52
+ }, [value]);
53
+
54
+ const [isOpen, setOpen] = useState(false);
55
+ const onClose = () => setOpen(false);
56
+ const onImageSave = (newData) => {
57
+ setOpen(false);
58
+ const newValue = parseDataURL(newData, value);
59
+ setData(newValue);
60
+ };
61
+
62
+ return (
63
+ <div className="image-widget">
64
+ <div className="image-widget-cropper">
65
+ <DialogTrigger>
66
+ <Button onPress={() => setOpen(true)}>
67
+ <FormattedMessage id="Edit image" defaultMessage="Edit image" />
68
+ </Button>
69
+ <Modal
70
+ className={'react-aria-Modal image-cropper-modal'}
71
+ isDismissable={true}
72
+ isOpen={isOpen}
73
+ >
74
+ <Dialog>
75
+ {() => (
76
+ <ImageEditor
77
+ key={'default-cropper'}
78
+ src={src}
79
+ onImageSave={onImageSave}
80
+ onCancel={onClose}
81
+ />
82
+ )}
83
+ </Dialog>
84
+ </Modal>
85
+ </DialogTrigger>
86
+ </div>
87
+ </div>
88
+ );
89
+ };
90
+
91
+ export default ImageEditorWrapper;
@@ -0,0 +1,60 @@
1
+ import React, { forwardRef } from 'react';
2
+ import type {
3
+ CropperTransitions,
4
+ CropperImage,
5
+ CropperState,
6
+ } from 'react-advanced-cropper';
7
+ import { getBackgroundStyle } from 'advanced-cropper';
8
+ import { AdjustableImage } from './AdjustableImage';
9
+
10
+ interface DesiredCropperRef {
11
+ getState: () => CropperState;
12
+ getTransitions: () => CropperTransitions;
13
+ getImage: () => CropperImage;
14
+ }
15
+
16
+ interface Props {
17
+ className?: string;
18
+ cropper: DesiredCropperRef;
19
+ crossOrigin?: 'anonymous' | 'use-credentials' | boolean;
20
+ brightness?: number;
21
+ saturation?: number;
22
+ hue?: number;
23
+ contrast?: number;
24
+ }
25
+
26
+ export const AdjustableCropperBackground = forwardRef<HTMLCanvasElement, Props>(
27
+ (
28
+ {
29
+ className,
30
+ cropper,
31
+ crossOrigin,
32
+ brightness = 0,
33
+ saturation = 0,
34
+ hue = 0,
35
+ contrast = 0,
36
+ }: Props,
37
+ ref,
38
+ ) => {
39
+ const state = cropper.getState();
40
+ const transitions = cropper.getTransitions();
41
+ const image = cropper.getImage();
42
+
43
+ const style =
44
+ image && state ? getBackgroundStyle(image, state, transitions) : {};
45
+
46
+ return (
47
+ <AdjustableImage
48
+ src={image?.src}
49
+ crossOrigin={crossOrigin}
50
+ brightness={brightness}
51
+ saturation={saturation}
52
+ hue={hue}
53
+ contrast={contrast}
54
+ ref={ref}
55
+ className={className}
56
+ style={style}
57
+ />
58
+ );
59
+ },
60
+ );
@@ -0,0 +1,3 @@
1
+ .adjustable-image-source {
2
+ display: none;
3
+ }
@@ -0,0 +1,85 @@
1
+ import React, {
2
+ forwardRef,
3
+ useRef,
4
+ CSSProperties,
5
+ useLayoutEffect,
6
+ } from 'react';
7
+ import cn from 'classnames';
8
+ import { mergeRefs, CropperSource } from 'react-advanced-cropper';
9
+ import './AdjustableImage.scss';
10
+
11
+ interface Props {
12
+ src?: string;
13
+ className?: string;
14
+ crossOrigin?: 'anonymous' | 'use-credentials' | boolean;
15
+ brightness?: number;
16
+ saturation?: number;
17
+ hue?: number;
18
+ contrast?: number;
19
+ style?: CSSProperties;
20
+ }
21
+
22
+ export const AdjustableImage = forwardRef<HTMLCanvasElement, Props>(
23
+ (
24
+ {
25
+ src,
26
+ className,
27
+ crossOrigin,
28
+ brightness = 0,
29
+ saturation = 0,
30
+ hue = 0,
31
+ contrast = 0,
32
+ style,
33
+ }: Props,
34
+ ref,
35
+ ) => {
36
+ const imageRef = useRef<HTMLImageElement>(null);
37
+ const canvasRef = useRef<HTMLCanvasElement>(null);
38
+
39
+ const drawImage = React.useCallback(() => {
40
+ const image = imageRef.current;
41
+ const canvas = canvasRef.current;
42
+ if (canvas && image && image.complete) {
43
+ const ctx = canvas.getContext('2d');
44
+ canvas.width = image.naturalWidth;
45
+ canvas.height = image.naturalHeight;
46
+
47
+ if (ctx) {
48
+ ctx.filter = [
49
+ `brightness(${100 + brightness * 100}%)`,
50
+ `contrast(${100 + contrast * 100}%)`,
51
+ `saturate(${100 + saturation * 100}%)`,
52
+ `hue-rotate(${hue * 360}deg)`,
53
+ ].join(' ');
54
+
55
+ ctx.drawImage(image, 0, 0, image.naturalWidth, image.naturalHeight);
56
+ }
57
+ }
58
+ }, [brightness, contrast, hue, saturation]);
59
+
60
+ useLayoutEffect(() => {
61
+ drawImage();
62
+ }, [src, brightness, saturation, hue, contrast, drawImage]);
63
+
64
+ return (
65
+ <>
66
+ <canvas
67
+ key={`${src}-canvas`}
68
+ ref={mergeRefs([ref, canvasRef])}
69
+ className={cn('adjustable-image-element', className)}
70
+ style={style}
71
+ />
72
+ <CropperSource
73
+ key={`${src}-img`}
74
+ ref={imageRef}
75
+ className={'adjustable-image-source'}
76
+ src={src}
77
+ crossOrigin={crossOrigin}
78
+ onLoad={drawImage}
79
+ />
80
+ </>
81
+ );
82
+ },
83
+ );
84
+
85
+ AdjustableImage.displayName = 'AdjustableImage';
@@ -0,0 +1,59 @@
1
+ import React from 'react';
2
+ import type {
3
+ CropperTransitions,
4
+ CropperImage,
5
+ CropperState,
6
+ Size,
7
+ } from 'react-advanced-cropper';
8
+ import { getPreviewStyle } from 'advanced-cropper';
9
+ import { AdjustableImage } from './AdjustableImage';
10
+
11
+ interface DesiredCropperRef {
12
+ getState: () => CropperState;
13
+ getTransitions: () => CropperTransitions;
14
+ getImage: () => CropperImage;
15
+ }
16
+
17
+ interface Props {
18
+ className?: string;
19
+ cropper: DesiredCropperRef;
20
+ crossOrigin?: 'anonymous' | 'use-credentials' | boolean;
21
+ brightness?: number;
22
+ saturation?: number;
23
+ hue?: number;
24
+ contrast?: number;
25
+ size?: Size | null;
26
+ }
27
+
28
+ export const AdjustablePreviewBackground = ({
29
+ className,
30
+ cropper,
31
+ crossOrigin,
32
+ brightness = 0,
33
+ saturation = 0,
34
+ hue = 0,
35
+ contrast = 0,
36
+ size,
37
+ }: Props) => {
38
+ const state = cropper.getState();
39
+ const transitions = cropper.getTransitions();
40
+ const image = cropper.getImage();
41
+
42
+ const style =
43
+ image && state && size
44
+ ? getPreviewStyle(image, state, size, transitions)
45
+ : {};
46
+
47
+ return (
48
+ <AdjustableImage
49
+ src={image?.src}
50
+ crossOrigin={crossOrigin}
51
+ brightness={brightness}
52
+ saturation={saturation}
53
+ hue={hue}
54
+ contrast={contrast}
55
+ className={className}
56
+ style={style}
57
+ />
58
+ );
59
+ };
@@ -0,0 +1,30 @@
1
+ @import '../../../theme/mixins';
2
+
3
+ .image-editor-button {
4
+ display: flex;
5
+ width: 46px;
6
+ height: 46px;
7
+ align-items: center;
8
+ justify-content: center;
9
+ padding: 0;
10
+ border: none;
11
+ border-radius: 50%;
12
+ background: transparent;
13
+ color: currentColor;
14
+ cursor: pointer;
15
+ fill: #88888b;
16
+ outline: none;
17
+ transition: 0.5s;
18
+ &--active {
19
+ background: rgba(white, 0.03);
20
+ fill: currentColor;
21
+ }
22
+ &:hover,
23
+ &:focus {
24
+ background: rgba(white, 0.03);
25
+ }
26
+ @include small-mobile() {
27
+ width: 32px;
28
+ height: 32px;
29
+ }
30
+ }
@@ -0,0 +1,27 @@
1
+ import React, { FC, ButtonHTMLAttributes } from 'react';
2
+ import cn from 'classnames';
3
+ import './Button.scss';
4
+
5
+ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
6
+ active?: boolean;
7
+ }
8
+
9
+ export const Button: FC<Props> = ({
10
+ className,
11
+ active,
12
+ children,
13
+ ...props
14
+ }) => {
15
+ return (
16
+ <button
17
+ className={cn(
18
+ 'image-editor-button',
19
+ active && 'image-editor-button--active',
20
+ className,
21
+ )}
22
+ {...props}
23
+ >
24
+ {children}
25
+ </button>
26
+ );
27
+ };
@@ -0,0 +1,38 @@
1
+ @import '../../../theme/mixins';
2
+
3
+ .image-editor-navigation {
4
+ display: flex;
5
+ height: 100%;
6
+ align-items: center;
7
+ justify-content: space-between;
8
+ padding: 16px;
9
+ border-top: solid 1px #2b2a30;
10
+ background: #1b1a21;
11
+ &__button {
12
+ &--disabled {
13
+ cursor: not-allowed;
14
+ opacity: 0.4;
15
+ pointer-events: none;
16
+ }
17
+ }
18
+
19
+ &__reset-rotation {
20
+ border-left: 1px solid rgba(white, 0.1);
21
+ margin-left: 12px;
22
+ }
23
+ &__buttons {
24
+ display: flex;
25
+ flex-wrap: wrap;
26
+ align-items: center;
27
+ justify-content: center;
28
+ gap: 8px;
29
+ }
30
+ &__upload-input {
31
+ display: none;
32
+ }
33
+
34
+ @include small-mobile() {
35
+ padding-right: 8px;
36
+ padding-left: 8px;
37
+ }
38
+ }