@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,231 @@
1
+ @import '../../../theme/constants';
2
+
3
+ .image-editor-slider {
4
+ position: relative;
5
+ display: flex;
6
+ width: 100%;
7
+ max-width: 380px;
8
+ height: 40px;
9
+ flex-direction: column;
10
+ align-items: center;
11
+ justify-content: center;
12
+ padding: 20px 16px;
13
+ border-radius: 12px;
14
+ background: rgba(#343338, 0.5);
15
+ cursor: pointer;
16
+ outline: none;
17
+ transition: all 0.2s ease;
18
+
19
+ &:focus-visible {
20
+ box-shadow: 0 0 0 2px rgba(white, 0.3);
21
+ }
22
+
23
+ &:hover:not(&--disabled) {
24
+ background: rgba(#504f54, 0.6);
25
+ }
26
+
27
+ &--active {
28
+ background: rgba(#626164, 0.7);
29
+ }
30
+
31
+ &--disabled {
32
+ cursor: not-allowed;
33
+ opacity: 0.5;
34
+
35
+ * {
36
+ cursor: not-allowed !important;
37
+ }
38
+ }
39
+
40
+ &__wrapper {
41
+ display: flex;
42
+ width: 100%;
43
+ max-width: 400px;
44
+ align-items: center;
45
+ }
46
+
47
+ &__ticks {
48
+ position: absolute;
49
+ top: -8px;
50
+ right: 0;
51
+ left: 0;
52
+ height: 16px;
53
+ pointer-events: none;
54
+ }
55
+
56
+ &__tick {
57
+ position: absolute;
58
+ width: 1px;
59
+ height: 4px;
60
+ background: rgba(white, 0.3);
61
+ cursor: pointer;
62
+ pointer-events: auto;
63
+ transform: translateX(-50%);
64
+ transition: all 0.15s ease;
65
+
66
+ &--center {
67
+ width: 2px;
68
+ height: 6px;
69
+ background: rgba(white, 0.6);
70
+ }
71
+
72
+ &:hover {
73
+ height: 6px;
74
+ background: rgba(white, 0.8);
75
+
76
+ &.image-editor-slider__tick--center {
77
+ height: 8px;
78
+ }
79
+ }
80
+
81
+ .image-editor-slider--disabled & {
82
+ &:hover {
83
+ height: 4px;
84
+ background: rgba(white, 0.3);
85
+
86
+ &.image-editor-slider__tick--center {
87
+ height: 6px;
88
+ background: rgba(white, 0.6);
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ &__line {
95
+ position: relative;
96
+ display: flex;
97
+ width: 100%;
98
+ height: 3px;
99
+ align-items: center;
100
+ border-radius: 2px;
101
+ background: rgba(white, 0.4);
102
+ cursor: pointer;
103
+ transition: all 0.2s ease;
104
+
105
+ &:hover {
106
+ height: 4px;
107
+ background: rgba(white, 0.6);
108
+ }
109
+
110
+ .image-editor-slider--active & {
111
+ height: 4px;
112
+ background: rgba(white, 0.7);
113
+ }
114
+
115
+ .image-editor-slider--disabled & {
116
+ &:hover {
117
+ height: 3px;
118
+ background: rgba(white, 0.4);
119
+ }
120
+ }
121
+ }
122
+ &__fill {
123
+ position: absolute;
124
+ height: 2px;
125
+ flex-basis: auto;
126
+ flex-direction: column;
127
+ flex-shrink: 0;
128
+ align-self: stretch;
129
+ border-radius: 2px;
130
+ background: white;
131
+ transition: all 0.2s ease;
132
+ }
133
+
134
+ &__dot {
135
+ position: absolute;
136
+ top: 50%;
137
+ left: 50%;
138
+ width: 10px;
139
+ height: 10px;
140
+ border-radius: 50%;
141
+ background: white;
142
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
143
+ transform: translate(-50%, -50%);
144
+ transition: all 0.2s ease;
145
+
146
+ .image-editor-slider--active & {
147
+ width: 12px;
148
+ height: 12px;
149
+ box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
150
+ }
151
+ }
152
+
153
+ &__value {
154
+ position: absolute;
155
+ top: -30px;
156
+ display: flex;
157
+ align-items: center;
158
+ justify-content: center;
159
+ padding: 2px 6px;
160
+ border-radius: 4px;
161
+ background: rgba(0, 0, 0, 0.8);
162
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
163
+ color: white;
164
+ font-size: 11px;
165
+ font-weight: 600;
166
+ transform: translate(-50%);
167
+ transition: all 0.2s ease;
168
+ white-space: nowrap;
169
+
170
+ &--hidden {
171
+ opacity: 0;
172
+ transform: translate(-50%) scale(0.8);
173
+ }
174
+ }
175
+
176
+ &__handler {
177
+ position: absolute;
178
+ top: 50%;
179
+ display: flex;
180
+ width: 20px;
181
+ height: 20px;
182
+ align-items: center;
183
+ justify-content: center;
184
+ border-radius: 50%;
185
+ background-color: white;
186
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
187
+ cursor: grab;
188
+ transform: translate(-50%, -50%);
189
+ transition: all 0.2s ease;
190
+
191
+ &:hover {
192
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
193
+ transform: translate(-50%, -50%) scale(1.1);
194
+ }
195
+
196
+ &--focus {
197
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
198
+ cursor: grabbing;
199
+ transform: translate(-50%, -50%) scale(1.2);
200
+ }
201
+
202
+ &--hidden {
203
+ opacity: 0;
204
+ transform: translate(-50%, -50%) scale(0.5);
205
+ }
206
+
207
+ .image-editor-slider--disabled & {
208
+ &:hover {
209
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
210
+ transform: translate(-50%, -50%);
211
+ }
212
+ }
213
+
214
+ @media (max-width: 768px), (hover: none) {
215
+ width: 24px;
216
+ height: 24px;
217
+
218
+ &:hover,
219
+ &--focus {
220
+ transform: translate(-50%, -50%) scale(1.15);
221
+ }
222
+
223
+ .image-editor-slider--disabled & {
224
+ &:hover,
225
+ &--focus {
226
+ transform: translate(-50%, -50%);
227
+ }
228
+ }
229
+ }
230
+ }
231
+ }
@@ -0,0 +1,322 @@
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
+ import cn from 'classnames';
3
+ import './Slider.scss';
4
+
5
+ interface Props {
6
+ className?: string;
7
+ onChange?: (value: number) => void;
8
+ value?: number;
9
+ showValue?: boolean;
10
+ min?: number;
11
+ max?: number;
12
+ step?: number;
13
+ disabled?: boolean;
14
+ }
15
+
16
+ export const Slider: React.FC<Props> = ({
17
+ className,
18
+ onChange,
19
+ value = 0,
20
+ showValue = true,
21
+ min = -1,
22
+ max = 1,
23
+ step = 0.01,
24
+ disabled = false,
25
+ }) => {
26
+ const lineRef = useRef<HTMLDivElement>(null);
27
+ const sliderRef = useRef<HTMLDivElement>(null);
28
+ const [focus, setFocus] = useState(false);
29
+ const [width, setWidth] = useState(0);
30
+ const [isDragging, setIsDragging] = useState(false);
31
+
32
+ const displayValue = Math.round(value * 100);
33
+ const internalValueToDisplay = (val: number) => Math.round(val * 100);
34
+
35
+ const recalculateWidth = useCallback(() => {
36
+ const line = lineRef.current;
37
+ if (line) {
38
+ setWidth(line.clientWidth);
39
+ }
40
+ }, []);
41
+
42
+ const clampValue = useCallback(
43
+ (val: number) => Math.max(min, Math.min(max, val)),
44
+ [min, max],
45
+ );
46
+
47
+ const snapToStep = useCallback(
48
+ (val: number) => Math.round(val / step) * step,
49
+ [step],
50
+ );
51
+
52
+ const onDrag = useCallback(
53
+ (e: MouseEvent | TouchEvent) => {
54
+ if ((focus || isDragging) && onChange && !disabled) {
55
+ const position = 'touches' in e ? e.touches[0].clientX : e.clientX;
56
+ const line = lineRef.current;
57
+
58
+ if (line) {
59
+ const { left, width } = line.getBoundingClientRect();
60
+ const normalizedPosition = (position - left) / width;
61
+ const newValue = clampValue(
62
+ snapToStep(min + normalizedPosition * (max - min)),
63
+ );
64
+
65
+ onChange(newValue);
66
+ }
67
+ e.preventDefault?.();
68
+ }
69
+ },
70
+ [focus, isDragging, onChange, disabled, clampValue, snapToStep, min, max],
71
+ );
72
+
73
+ const onStop = useCallback(() => {
74
+ setFocus(false);
75
+ setIsDragging(false);
76
+ }, []);
77
+
78
+ const onStart = useCallback(
79
+ (e: MouseEvent | TouchEvent) => {
80
+ if (disabled) return;
81
+ setFocus(true);
82
+ setIsDragging(true);
83
+ onDrag(e);
84
+ },
85
+ [onDrag, disabled],
86
+ );
87
+
88
+ const onSliderClick = useCallback(
89
+ (e: React.MouseEvent<HTMLDivElement>) => {
90
+ if (onChange && !focus && !disabled && !isDragging) {
91
+ const line = lineRef.current;
92
+ if (line) {
93
+ const { left, width } = line.getBoundingClientRect();
94
+ const position = e.clientX;
95
+
96
+ if (position >= left && position <= left + width) {
97
+ const normalizedPosition = (position - left) / width;
98
+ const newValue = clampValue(
99
+ snapToStep(min + normalizedPosition * (max - min)),
100
+ );
101
+
102
+ onChange(newValue);
103
+ }
104
+ }
105
+ }
106
+ },
107
+ [onChange, focus, disabled, isDragging, clampValue, snapToStep, min, max],
108
+ );
109
+
110
+ const onKeyDown = useCallback(
111
+ (e: React.KeyboardEvent) => {
112
+ if (disabled || !onChange) return;
113
+
114
+ let newValue = value;
115
+ const largeStep = step * 10;
116
+
117
+ switch (e.key) {
118
+ case 'ArrowLeft':
119
+ case 'ArrowDown':
120
+ newValue = clampValue(value - step);
121
+ break;
122
+ case 'ArrowRight':
123
+ case 'ArrowUp':
124
+ newValue = clampValue(value + step);
125
+ break;
126
+ case 'PageDown':
127
+ newValue = clampValue(value - largeStep);
128
+ break;
129
+ case 'PageUp':
130
+ newValue = clampValue(value + largeStep);
131
+ break;
132
+ case 'Home':
133
+ newValue = min;
134
+ break;
135
+ case 'End':
136
+ newValue = max;
137
+ break;
138
+ case ' ':
139
+ case 'Enter':
140
+ newValue = 0;
141
+ break;
142
+ default:
143
+ return;
144
+ }
145
+
146
+ e.preventDefault();
147
+ onChange(snapToStep(newValue));
148
+ },
149
+ [disabled, onChange, value, step, clampValue, snapToStep, min, max],
150
+ );
151
+
152
+ useEffect(() => {
153
+ const handleResize = () => recalculateWidth();
154
+ window.addEventListener('resize', handleResize);
155
+ window.addEventListener('orientationchange', handleResize);
156
+
157
+ recalculateWidth();
158
+
159
+ return () => {
160
+ window.removeEventListener('resize', handleResize);
161
+ window.removeEventListener('orientationchange', handleResize);
162
+ };
163
+ }, [recalculateWidth]);
164
+
165
+ useEffect(() => {
166
+ if (!focus) return;
167
+
168
+ const handleMouseUp = () => onStop();
169
+ const handleMouseMove = (e: MouseEvent) => onDrag(e);
170
+ const handleTouchEnd = () => onStop();
171
+ const handleTouchMove = (e: TouchEvent) => onDrag(e);
172
+
173
+ window.addEventListener('mouseup', handleMouseUp);
174
+ window.addEventListener('mousemove', handleMouseMove);
175
+ window.addEventListener('touchmove', handleTouchMove);
176
+ window.addEventListener('touchend', handleTouchEnd);
177
+
178
+ return () => {
179
+ window.removeEventListener('mouseup', handleMouseUp);
180
+ window.removeEventListener('mousemove', handleMouseMove);
181
+ window.removeEventListener('touchmove', handleTouchMove);
182
+ window.removeEventListener('touchend', handleTouchEnd);
183
+ };
184
+ }, [focus, onStop, onDrag]);
185
+
186
+ useEffect(() => {
187
+ const line = lineRef.current;
188
+ if (!line) return;
189
+
190
+ const handleMouseDown = (e: MouseEvent) => onStart(e);
191
+ const handleTouchStart = (e: TouchEvent) => onStart(e);
192
+
193
+ line.addEventListener('mousedown', handleMouseDown);
194
+ line.addEventListener('touchstart', handleTouchStart);
195
+
196
+ return () => {
197
+ line.removeEventListener('mousedown', handleMouseDown);
198
+ line.removeEventListener('touchstart', handleTouchStart);
199
+ };
200
+ }, [onStart]);
201
+
202
+ const normalizedValue = (value - min) / (max - min);
203
+ const centerNormalized = (0 - min) / (max - min);
204
+ const handleInsideDot = width
205
+ ? Math.abs(normalizedValue - centerNormalized) <= 16 / width
206
+ : true;
207
+
208
+ const fillWidth = `${Math.abs(normalizedValue - centerNormalized) * 100}%`;
209
+ const fillLeft =
210
+ normalizedValue < centerNormalized
211
+ ? `${normalizedValue * 100}%`
212
+ : `${centerNormalized * 100}%`;
213
+
214
+ const formattedValue = `${displayValue > 0 ? '+' : ''}${displayValue}`;
215
+
216
+ return (
217
+ <div
218
+ className={cn(
219
+ 'image-editor-slider',
220
+ className,
221
+ disabled && 'image-editor-slider--disabled',
222
+ (focus || isDragging) && 'image-editor-slider--active',
223
+ )}
224
+ ref={sliderRef}
225
+ tabIndex={disabled ? -1 : 0}
226
+ role="slider"
227
+ aria-valuemin={internalValueToDisplay(min)}
228
+ aria-valuemax={internalValueToDisplay(max)}
229
+ aria-valuenow={displayValue}
230
+ aria-disabled={disabled}
231
+ onKeyDown={onKeyDown}
232
+ onClick={onSliderClick}
233
+ >
234
+ <div className="image-editor-slider__ticks">
235
+ {[-1, -0.5, 0, 0.5, 1].map((tickValue) => (
236
+ <div
237
+ key={tickValue}
238
+ className={cn(
239
+ 'image-editor-slider__tick',
240
+ tickValue === 0 && 'image-editor-slider__tick--center',
241
+ )}
242
+ style={{ left: `${((tickValue - min) / (max - min)) * 100}%` }}
243
+ role="button"
244
+ tabIndex={disabled ? -1 : 0}
245
+ aria-label={`Set value to ${Math.round(tickValue * 100)}`}
246
+ onClick={(e) => {
247
+ e.stopPropagation();
248
+ if (onChange && !disabled) {
249
+ onChange(snapToStep(tickValue));
250
+ }
251
+ }}
252
+ onKeyDown={(e) => {
253
+ if ((e.key === 'Enter' || e.key === ' ') && !disabled) {
254
+ e.preventDefault();
255
+ e.stopPropagation();
256
+ if (onChange) {
257
+ onChange(snapToStep(tickValue));
258
+ }
259
+ }
260
+ }}
261
+ />
262
+ ))}
263
+ </div>
264
+ <div className="image-editor-slider__line" ref={lineRef}>
265
+ <div
266
+ className="image-editor-slider__fill"
267
+ style={{
268
+ width: fillWidth,
269
+ left: fillLeft,
270
+ }}
271
+ />
272
+ <div className="image-editor-slider__dot" />
273
+ {showValue && (
274
+ <div
275
+ className={cn(
276
+ 'image-editor-slider__value',
277
+ handleInsideDot && 'image-editor-slider__value--hidden',
278
+ )}
279
+ style={{
280
+ left: `${normalizedValue * 100}%`,
281
+ }}
282
+ >
283
+ {formattedValue}
284
+ </div>
285
+ )}
286
+ <div
287
+ className={cn(
288
+ 'image-editor-slider__handler',
289
+ (focus || isDragging) && 'image-editor-slider__handler--focus',
290
+ handleInsideDot && 'image-editor-slider__handler--hidden',
291
+ )}
292
+ style={{
293
+ left: `${normalizedValue * 100}%`,
294
+ }}
295
+ role="button"
296
+ tabIndex={disabled ? -1 : 0}
297
+ aria-label={`Slider handle, current value: ${formattedValue}`}
298
+ onMouseDown={(e) => {
299
+ e.stopPropagation();
300
+ }}
301
+ onKeyDown={(e) => {
302
+ if (
303
+ e.key === 'ArrowLeft' ||
304
+ e.key === 'ArrowRight' ||
305
+ e.key === 'ArrowUp' ||
306
+ e.key === 'ArrowDown' ||
307
+ e.key === 'Home' ||
308
+ e.key === 'End' ||
309
+ e.key === ' ' ||
310
+ e.key === 'Enter'
311
+ ) {
312
+ e.stopPropagation();
313
+ if (sliderRef.current) {
314
+ sliderRef.current.focus();
315
+ }
316
+ }
317
+ }}
318
+ />
319
+ </div>
320
+ </div>
321
+ );
322
+ };
@@ -0,0 +1,23 @@
1
+ export interface ImageSettings {
2
+ // Cropper settings
3
+ aspectRatio:
4
+ | 'free'
5
+ | '1:1'
6
+ | '1:2'
7
+ | '2:1'
8
+ | '3:4'
9
+ | '4:3'
10
+ | '16:9'
11
+ | '9:16'
12
+ | '21:9';
13
+ imageRestriction: 'fit-area' | 'fill-area' | 'stencil' | 'none';
14
+ stencilType: 'rectangle' | 'circle';
15
+ minWidth: number;
16
+ minHeight: number;
17
+ maxCropWidth?: number;
18
+ maxCropHeight?: number;
19
+ scalable: boolean;
20
+ stencilGrid: boolean;
21
+ minScale: number;
22
+ maxScale: number;
23
+ }
@@ -0,0 +1,14 @@
1
+ .ui.grid.image-widget-grid {
2
+ margin: 0 !important;
3
+ .image-editor-cta {
4
+ display: flex;
5
+ align-items: center;
6
+ justify-content: center;
7
+ .image-widget {
8
+ width: 100%;
9
+ .image-widget-cropper {
10
+ margin-top: 20px;
11
+ }
12
+ }
13
+ }
14
+ }