Skip to content

Commit 93ef2ff

Browse files
committed
Add option for gravity to Image editor
1 parent 5645b5a commit 93ef2ff

File tree

4 files changed

+86
-35
lines changed

4 files changed

+86
-35
lines changed

client/components/editor/Image.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, {Fragment, useEffect, useRef, useState} from 'react';
22
import ReactCrop, {Crop} from 'react-image-crop';
3+
import DropDownMenu from '../../common/DropDownMenu';
34
import FileDetails from '../../admin/FileDetails';
45
import ClearCropIcon from '../../icons/clear-crop.svg';
56

@@ -13,8 +14,15 @@ export default function Image(props) {
1314
width: document.getElementById('id_width') as HTMLInputElement,
1415
height: document.getElementById('id_height') as HTMLInputElement,
1516
};
17+
const gravityField = document.getElementById('id_gravity') as HTMLInputElement;
1618
const [crop, setCrop] = useState<Crop>(null);
19+
const [gravity, setGravity] = useState<string>(gravityField.value);
1720
const ref = useRef(null);
21+
const gravityOptions = {
22+
'': gettext("Center"), 'n': gettext("North"), 'ne': gettext("Northeast"),
23+
'e': gettext("East"), 'se': gettext("Southeast"), 's': gettext("South"),
24+
'sw': gettext("Southwest"), 'w': gettext("West"), 'nw': gettext("Northwest"),
25+
};
1826

1927
useEffect(() => {
2028
const crop = () => {
@@ -52,9 +60,23 @@ export default function Image(props) {
5260
}
5361
}
5462

63+
function getItemProps(value: string) {
64+
return {
65+
role: 'option',
66+
'aria-selected': gravity === value,
67+
onClick: () => {
68+
setGravity(value);
69+
gravityField.value = value;
70+
},
71+
};
72+
}
73+
5574
const controlButtons = [
5675
<Fragment key="clear-crop">
5776
<button type="button" onClick={() => handleChange(null)}><ClearCropIcon/>{gettext("Clear selection")}</button>
77+
<DropDownMenu className="with-caret" wrapperElement="div" label={gettext("Gravity") + ": " + gravityOptions[gravity]} tooltip={gettext("Align image before cropping")}>
78+
{Object.entries(gravityOptions).map(([value, label]) => (<li {...getItemProps(value)}>{label}</li>))}
79+
</DropDownMenu>
5880
</Fragment>
5981
];
6082

finder/contrib/image/forms.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,16 @@ class ImageFileForm(EntangledModelFormMixin, FileForm):
2222
)
2323
width = IntegerField(widget=HiddenInput())
2424
height = IntegerField(widget=HiddenInput())
25+
gravity = CharField(
26+
widget=HiddenInput(),
27+
required=False,
28+
)
2529
alt_text = CharField(
2630
widget=TextInput(attrs={'size': 100}),
2731
required=False,
2832
)
2933

3034
class Meta:
3135
model = ImageFileModel
32-
entangled_fields = {'meta_data': ['crop_x', 'crop_y', 'crop_size', 'alt_text']}
36+
entangled_fields = {'meta_data': ['crop_x', 'crop_y', 'crop_size', 'gravity', 'alt_text']}
3337
untangled_fields = ['name', 'labels', 'width', 'height']

finder/contrib/image/models.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,22 @@ class Meta:
2929
def summary(self):
3030
return "{width}×{height}px ({size})".format(size=super().summary, width=self.width, height=self.height)
3131

32-
def get_thumbnail_path(self, crop_x=None, crop_y=None, crop_size=None):
32+
def get_thumbnail_path(self, crop_x=None, crop_y=None, crop_size=None, gravity=None):
3333
id = str(self.id)
3434
thumbnail_folder = self.filer_public_thumbnails / f'{id[0:2]}/{id[2:4]}/{id}'
3535
thumbnail_path = Path(self.file_name)
3636
if crop_x is None or crop_y is None or crop_size is None:
3737
thumbnail_path_template = '{stem}__{width}x{height}{suffix}'
3838
else:
3939
crop_x, crop_y, crop_size = int(crop_x), int(crop_y), int(crop_size)
40-
thumbnail_path_template = '{stem}__{width}x{height}__{crop_x}_{crop_y}_{crop_size}{suffix}'
40+
thumbnail_path_template = '{stem}__{width}x{height}__{crop_x}_{crop_y}_{crop_size}{gravity}{suffix}'
4141
return thumbnail_folder / thumbnail_path_template.format(
4242
stem=thumbnail_path.stem,
4343
width=self.thumbnail_size,
4444
height=self.thumbnail_size,
4545
crop_x=crop_x,
4646
crop_y=crop_y,
4747
crop_size=crop_size,
48+
gravity=gravity or '',
4849
suffix=thumbnail_path.suffix,
4950
)

finder/contrib/image/pil/models.py

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -44,46 +44,19 @@ def save(self, **kwargs):
4444
super().save(**kwargs)
4545

4646
def get_thumbnail_url(self):
47-
crop_x, crop_y, crop_size = (
48-
self.meta_data.get('crop_x'), self.meta_data.get('crop_y'), self.meta_data.get('crop_size')
47+
crop_x, crop_y, crop_size, gravity = (
48+
self.meta_data.get('crop_x'), self.meta_data.get('crop_y'), self.meta_data.get('crop_size'),
49+
self.meta_data.get('gravity')
4950
)
50-
thumbnail_path = self.get_thumbnail_path(crop_x, crop_y, crop_size)
51+
thumbnail_path = self.get_thumbnail_path(crop_x, crop_y, crop_size, gravity)
5152
if not default_storage.exists(thumbnail_path):
5253
try:
5354
image = Image.open(default_storage.open(self.file_path))
5455
image = self.orientate_top(image)
5556
if crop_x is None or crop_y is None or crop_size is None:
5657
image = self.crop_centered(image)
5758
else:
58-
# with cropping, the expected resizing could be done using:
59-
# `image = image.crop((crop_x, crop_y, crop_x + crop_size, crop_y + crop_size))`
60-
# however, for small selections or images in low resolution this might result
61-
# in blurry preview images. We therefore want to use at least `thumbnail_size`
62-
# pixels from the original image
63-
min_size = max(crop_size, self.thumbnail_size)
64-
off_size = min_size / 2 - crop_size / 2
65-
min_x = max(crop_x - off_size, 0)
66-
min_y = max(crop_y - off_size, 0)
67-
if min_x + min_size > image.width:
68-
min_x = max(image.width - min_size, 0)
69-
max_x = image.width
70-
else:
71-
max_x = min_x + min_size
72-
if min_y + min_size > image.height:
73-
min_y = max(image.height - min_size, 0)
74-
max_y = image.height
75-
else:
76-
max_y = min_y + min_size
77-
# correct thumbnailing for low resolution images with an aspect ratio unequal to 1
78-
if round(max_x - min_x) > round(max_y - min_y):
79-
off_size = (max_x - min_x - max_y + min_y) / 2
80-
min_x += off_size
81-
max_x -= off_size
82-
elif round(max_x - min_x) < round(max_y - min_y):
83-
off_size = (max_y - min_y - max_x + min_x) / 2
84-
min_y += off_size
85-
max_y -= off_size
86-
image = image.crop((min_x, min_y, max_x, max_y))
59+
image = self.crop_eccentric(image, crop_x, crop_y, crop_size, gravity)
8760
image.thumbnail((self.thumbnail_size, self.thumbnail_size))
8861
(default_storage.base_location / thumbnail_path.parent).mkdir(parents=True, exist_ok=True)
8962
image.save(default_storage.open(thumbnail_path, 'wb'), image.format)
@@ -124,3 +97,54 @@ def crop_centered(self, image):
12497
right = width
12598
bottom = (height + width) / 2
12699
return image.crop((left, top, right, bottom))
100+
101+
def crop_eccentric(self, image, crop_x, crop_y, crop_size, gravity):
102+
"""
103+
with cropping, the expected resizing could be done using:
104+
`image = image.crop((crop_x, crop_y, crop_x + crop_size, crop_y + crop_size))`
105+
however, for small selections or images in low resolution this might result
106+
in blurry preview images. We therefore want to use at least `thumbnail_size`
107+
pixels from the original image
108+
"""
109+
min_size = max(crop_size, self.thumbnail_size)
110+
off_size = min_size - crop_size
111+
112+
# horizontal thumbnailing
113+
if gravity in ('e', 'ne', 'se'):
114+
max_x = min(crop_x + min_size, image.width)
115+
min_x = max(max_x - min_size, 0)
116+
elif gravity in ('w', 'nw', 'sw'):
117+
min_x = max(crop_x - off_size, 0)
118+
else:
119+
min_x = max(crop_x - off_size / 2, 0)
120+
if min_x + min_size > image.width:
121+
min_x = max(image.width - min_size, 0)
122+
max_x = image.width
123+
else:
124+
max_x = min_x + min_size
125+
126+
# vertical thumbnailing
127+
if gravity in ('s', 'se', 'sw'):
128+
max_y = min(crop_y + min_size, image.height)
129+
min_y = max(max_y - min_size, 0)
130+
elif gravity in ('n', 'ne', 'nw'):
131+
min_y = max(crop_y - off_size, 0)
132+
else:
133+
min_y = max(crop_y - off_size / 2, 0)
134+
if min_y + min_size > image.height:
135+
min_y = max(image.height - min_size, 0)
136+
max_y = image.height
137+
else:
138+
max_y = min_y + min_size
139+
140+
# correct thumbnailing for low resolution images with an aspect ratio unequal to 1
141+
if round(max_x - min_x) > round(max_y - min_y):
142+
off_size = (max_x - min_x - max_y + min_y) / 2
143+
min_x += off_size
144+
max_x -= off_size
145+
elif round(max_x - min_x) < round(max_y - min_y):
146+
off_size = (max_y - min_y - max_x + min_x) / 2
147+
min_y += off_size
148+
max_y -= off_size
149+
150+
return image.crop((min_x, min_y, max_x, max_y))

0 commit comments

Comments
 (0)