Skip to content

Commit bff2b89

Browse files
committed
feat(upload): add focal point picker for images
This feature allows users to set a focal point on images directly from the media gallery. The focal point coordinates are stored as percentage values (x, y) and can be used by frontend applications to intelligently crop images while preserving the most important part of the image. Changes: - Add focalPoint field to file schema (JSON type with x, y coordinates) - Add FocalPointActions component for confirm/cancel actions - Add styled components for focal point UI (aim marker, halo) - Update PreviewBox to handle focal point selection via click - Update EditAssetContent to manage focal point state and display - Add validation schemas for focal point (0-100 range) - Update upload service to persist focal point data - Add translations for focal point UI elements Based on RFC: strapi/rfcs#58
1 parent 9cb5ee9 commit bff2b89

13 files changed

Lines changed: 280 additions & 16 deletions

File tree

packages/core/upload/admin/src/components/EditAssetDialog/EditAssetContent.tsx

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,30 @@ import { DialogHeader } from './DialogHeader';
3232
import { PreviewBox } from './PreviewBox/PreviewBox';
3333
import { ReplaceMediaButton } from './ReplaceMediaButton';
3434

35-
import type { File as FileDefinition, RawFile } from '../../../../shared/contracts/files';
35+
import type {
36+
File as FileDefinition,
37+
RawFile,
38+
FocalPoint,
39+
} from '../../../../shared/contracts/files';
3640

3741
const LoadingBody = styled(Flex)`
3842
/* 80px are coming from the Tabs component that is not included in the ModalBody */
3943
min-height: ${() => `calc(60dvh + 8rem)`};
4044
`;
4145

46+
const focalPointSchema = yup
47+
.object({
48+
x: yup.number().min(0).max(100).required(),
49+
y: yup.number().min(0).max(100).required(),
50+
})
51+
.nullable()
52+
.default(null);
53+
4254
const fileInfoSchema = yup.object({
4355
name: yup.string().required(),
4456
alternativeText: yup.string(),
4557
caption: yup.string(),
58+
focalPoint: focalPointSchema,
4659
folder: yup.number(),
4760
});
4861

@@ -67,6 +80,7 @@ interface FormInitialData {
6780
name?: string;
6881
alternativeText?: string;
6982
caption?: string;
83+
focalPoint?: FocalPoint | null;
7084
parent?: {
7185
value?: number;
7286
label: string;
@@ -87,6 +101,7 @@ export const EditAssetContent = ({
87101
const { trackUsage } = useTracking();
88102
const submitButtonRef = React.useRef<HTMLButtonElement>(null);
89103
const [isCropping, setIsCropping] = React.useState(false);
104+
const [isFocalPointMode, setIsFocalPointMode] = React.useState(false);
90105
const [replacementFile, setReplacementFile] = React.useState<File | undefined>();
91106
const { editAsset, isLoading } = useEditAsset();
92107

@@ -132,7 +147,15 @@ export const EditAssetContent = ({
132147
onClose();
133148
};
134149

135-
const formDisabled = !canUpdate || isCropping;
150+
const handleFocalPointStart = () => {
151+
setIsFocalPointMode(true);
152+
};
153+
154+
const handleFocalPointCancel = () => {
155+
setIsFocalPointMode(false);
156+
};
157+
158+
const formDisabled = !canUpdate || isCropping || isFocalPointMode;
136159

137160
const handleConfirmClose = () => {
138161
// eslint-disable-next-line no-alert
@@ -153,6 +176,7 @@ export const EditAssetContent = ({
153176
name: asset?.name,
154177
alternativeText: asset?.alternativeText ?? undefined,
155178
caption: asset?.caption ?? undefined,
179+
focalPoint: asset?.focalPoint ?? null,
156180
parent: {
157181
value: activeFolderId ?? undefined,
158182
label:
@@ -214,6 +238,13 @@ export const EditAssetContent = ({
214238
onCropCancel={handleCancelCropping}
215239
replacementFile={replacementFile}
216240
trackedLocation={trackedLocation}
241+
formFocalPoint={values.focalPoint}
242+
onFocalPointStart={handleFocalPointStart}
243+
onFocalPointFinish={(focalPoint) => {
244+
setIsFocalPointMode(false);
245+
setFieldValue('focalPoint', focalPoint);
246+
}}
247+
onFocalPointCancel={handleFocalPointCancel}
217248
/>
218249
</Grid.Item>
219250
<Grid.Item xs={12} col={6} direction="column" alignItems="stretch">
@@ -261,6 +292,18 @@ export const EditAssetContent = ({
261292
}),
262293
value: asset?.id ? asset.id : null,
263294
},
295+
296+
...(values.focalPoint
297+
? [
298+
{
299+
label: formatMessage({
300+
id: getTrad('modal.file-details.focal-point'),
301+
defaultMessage: 'Focal point',
302+
}),
303+
value: `x: ${values.focalPoint.x}% - y: ${values.focalPoint.y}%`,
304+
},
305+
]
306+
: []),
264307
]}
265308
/>
266309
<Field.Root name="name" error={errors.name}>

packages/core/upload/admin/src/components/EditAssetDialog/PreviewBox/AssetPreview.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ interface AssetPreviewProps {
2525
name: string;
2626
url: string;
2727
onLoad?: () => void;
28+
onClick?: (e: React.MouseEvent<HTMLElement>) => void;
29+
style?: React.CSSProperties;
2830
}
2931

3032
export const AssetPreview = React.forwardRef<
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Flex, FocusTrap, IconButton } from '@strapi/design-system';
2+
import { Check, Cross } from '@strapi/icons';
3+
import { useIntl } from 'react-intl';
4+
5+
import { getTrad } from '../../../utils';
6+
7+
import { FocalPointActionRow } from './PreviewComponents';
8+
9+
interface FocalPointActionsProps {
10+
onCancel: () => void;
11+
onValidate: () => void;
12+
}
13+
14+
export const FocalPointActions = ({ onCancel, onValidate }: FocalPointActionsProps) => {
15+
const { formatMessage } = useIntl();
16+
17+
return (
18+
<FocusTrap onEscape={onCancel}>
19+
<FocalPointActionRow justifyContent="flex-end" paddingLeft={3} paddingRight={3}>
20+
<Flex gap={1}>
21+
<IconButton
22+
label={formatMessage({
23+
id: getTrad('control-card.stop-focal-point'),
24+
defaultMessage: 'Cancel focal point selection',
25+
})}
26+
onClick={onCancel}
27+
>
28+
<Cross />
29+
</IconButton>
30+
31+
<IconButton
32+
label={formatMessage({
33+
id: getTrad('control-card.save-focal-point'),
34+
defaultMessage: 'Save focal point',
35+
})}
36+
onClick={onValidate}
37+
>
38+
<Check />
39+
</IconButton>
40+
</Flex>
41+
</FocalPointActionRow>
42+
</FocusTrap>
43+
);
44+
};

packages/core/upload/admin/src/components/EditAssetDialog/PreviewBox/PreviewBox.tsx

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import * as React from 'react';
33

44
import { Flex, IconButton } from '@strapi/design-system';
5-
import { Crop as Resize, Download as DownloadIcon, Trash } from '@strapi/icons';
5+
import { Crop as Resize, Download as DownloadIcon, Trash, PinMap } from '@strapi/icons';
66
import cropperjscss from 'cropperjs/dist/cropper.css?raw';
77
import { useIntl } from 'react-intl';
88
import { createGlobalStyle } from 'styled-components';
@@ -19,15 +19,23 @@ import { RemoveAssetDialog } from '../RemoveAssetDialog';
1919

2020
import { AssetPreview } from './AssetPreview';
2121
import { CroppingActions } from './CroppingActions';
22+
import { FocalPointActions } from './FocalPointActions';
2223
import {
2324
ActionRow,
2425
BadgeOverride,
2526
RelativeBox,
2627
UploadProgressWrapper,
2728
Wrapper,
29+
FocalPointImageWrapper,
30+
FocalPointAim,
31+
FocalPointHalo,
2832
} from './PreviewComponents';
2933

30-
import type { File as FileDefinition, RawFile } from '../../../../../shared/contracts/files';
34+
import type {
35+
File as FileDefinition,
36+
RawFile,
37+
FocalPoint,
38+
} from '../../../../../shared/contracts/files';
3139

3240
interface Asset extends Omit<FileDefinition, 'folder'> {
3341
isLocal?: boolean;
@@ -46,6 +54,10 @@ interface PreviewBoxProps {
4654
onCropStart: () => void;
4755
onCropCancel: () => void;
4856
trackedLocation?: string;
57+
formFocalPoint?: FocalPoint | null;
58+
onFocalPointStart: () => void;
59+
onFocalPointFinish: (focalPoint: FocalPoint) => void;
60+
onFocalPointCancel: () => void;
4961
}
5062

5163
export const PreviewBox = ({
@@ -59,6 +71,10 @@ export const PreviewBox = ({
5971
onCropCancel,
6072
replacementFile,
6173
trackedLocation,
74+
formFocalPoint,
75+
onFocalPointStart,
76+
onFocalPointFinish,
77+
onFocalPointCancel,
6278
}: PreviewBoxProps) => {
6379
const CropperjsStyle = createGlobalStyle`${cropperjscss}`;
6480
const { trackUsage } = useTracking();
@@ -72,6 +88,10 @@ export const PreviewBox = ({
7288
const { crop, produceFile, stopCropping, isCropping, isCropperReady, width, height } =
7389
useCropImg();
7490
const { editAsset, error, isLoading, progress, cancel } = useEditAsset();
91+
const [hasFocalPointIntent, setHasFocalPointIntent] = React.useState<boolean | null>(null);
92+
const [focalPoint, setFocalPoint] = React.useState<FocalPoint>(
93+
formFocalPoint ?? { x: 50, y: 50 }
94+
);
7595

7696
const {
7797
upload,
@@ -165,6 +185,38 @@ export const PreviewBox = ({
165185
setHasCropIntent(true);
166186
};
167187

188+
const handleFocalPointClick = (e: React.MouseEvent<HTMLElement>) => {
189+
if (!hasFocalPointIntent) return;
190+
191+
const { clientX, clientY } = e;
192+
const rect = e.currentTarget.getBoundingClientRect();
193+
const posX = clientX - rect.left;
194+
const posY = clientY - rect.top;
195+
196+
setFocalPoint({
197+
x: Number(((posX / rect.width) * 100).toFixed(2)),
198+
y: Number(((posY / rect.height) * 100).toFixed(2)),
199+
});
200+
};
201+
202+
const handleFocalPointCancel = () => {
203+
setHasFocalPointIntent(false);
204+
setFocalPoint(formFocalPoint ?? { x: 50, y: 50 });
205+
onFocalPointCancel();
206+
};
207+
208+
const handleFocalPointStart = () => {
209+
onFocalPointStart();
210+
setHasFocalPointIntent(true);
211+
};
212+
213+
const handleFocalPointValidate = () => {
214+
setHasFocalPointIntent(false);
215+
onFocalPointFinish(focalPoint);
216+
};
217+
218+
const isInFocalPointMode = hasFocalPointIntent === true;
219+
168220
return (
169221
<>
170222
<CropperjsStyle />
@@ -177,6 +229,13 @@ export const PreviewBox = ({
177229
/>
178230
)}
179231

232+
{isInFocalPointMode && (
233+
<FocalPointActions
234+
onValidate={handleFocalPointValidate}
235+
onCancel={handleFocalPointCancel}
236+
/>
237+
)}
238+
180239
<ActionRow paddingLeft={3} paddingRight={3} justifyContent="flex-end">
181240
<Flex gap={1}>
182241
{canUpdate && !asset.isLocal && (
@@ -213,6 +272,18 @@ export const PreviewBox = ({
213272
<Resize />
214273
</IconButton>
215274
)}
275+
276+
{canUpdate && asset.mime?.includes(AssetType.Image) && (
277+
<IconButton
278+
label={formatMessage({
279+
id: getTrad('control-card.set-focal-point'),
280+
defaultMessage: 'Set focal point',
281+
})}
282+
onClick={handleFocalPointStart}
283+
>
284+
<PinMap />
285+
</IconButton>
286+
)}
216287
</Flex>
217288
</ActionRow>
218289

@@ -235,17 +306,27 @@ export const PreviewBox = ({
235306
</UploadProgressWrapper>
236307
)}
237308

238-
<AssetPreview
239-
ref={previewRef}
240-
mime={asset.mime!}
241-
name={asset.name}
242-
url={hasCropIntent ? assetUrl! : thumbnailUrl!}
243-
onLoad={() => {
244-
if (asset.isLocal || hasCropIntent) {
245-
setIsCropImageReady(true);
246-
}
247-
}}
248-
/>
309+
<FocalPointImageWrapper>
310+
<AssetPreview
311+
ref={previewRef}
312+
mime={asset.mime!}
313+
name={asset.name}
314+
url={hasCropIntent ? assetUrl! : thumbnailUrl!}
315+
onLoad={() => {
316+
if (asset.isLocal || hasCropIntent) {
317+
setIsCropImageReady(true);
318+
}
319+
}}
320+
onClick={handleFocalPointClick}
321+
style={{ cursor: isInFocalPointMode ? 'crosshair' : undefined }}
322+
/>
323+
324+
{isInFocalPointMode && (
325+
<FocalPointAim $focalPoint={focalPoint}>
326+
<FocalPointHalo />
327+
</FocalPointAim>
328+
)}
329+
</FocalPointImageWrapper>
249330
</Wrapper>
250331

251332
<ActionRow

packages/core/upload/admin/src/components/EditAssetDialog/PreviewBox/PreviewComponents.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,54 @@ export const UploadProgressWrapper = styled.div`
6868
height: 100%;
6969
width: 100%;
7070
`;
71+
72+
export const FocalPointActionRow = styled(Flex)`
73+
z-index: 1;
74+
height: 5.2rem;
75+
position: absolute;
76+
background-color: rgba(33, 33, 52, 0.4);
77+
width: 100%;
78+
`;
79+
80+
export const FocalPointImageWrapper = styled.div`
81+
display: inline-block;
82+
position: relative;
83+
`;
84+
85+
interface FocalPointAimProps {
86+
$focalPoint: { x: number; y: number };
87+
}
88+
89+
export const FocalPointAim = styled.div<FocalPointAimProps>`
90+
position: absolute;
91+
pointer-events: none;
92+
left: ${({ $focalPoint }) => $focalPoint.x}%;
93+
top: ${({ $focalPoint }) => $focalPoint.y}%;
94+
95+
&:before {
96+
content: '';
97+
position: absolute;
98+
width: 10px;
99+
height: 10px;
100+
border: 2px solid ${({ theme }) => theme.colors.primary700};
101+
border-radius: 50%;
102+
background-color: ${({ theme }) => theme.colors.primary500};
103+
left: 50%;
104+
top: 50%;
105+
transform: translate(-50%, -50%);
106+
}
107+
`;
108+
109+
export const FocalPointHalo = styled.div`
110+
&:before {
111+
content: '';
112+
position: absolute;
113+
width: 50px;
114+
height: 50px;
115+
border: 1px solid ${({ theme }) => theme.colors.neutral500};
116+
border-radius: 50%;
117+
left: 50%;
118+
top: 50%;
119+
transform: translate(-50%, -50%);
120+
}
121+
`;

0 commit comments

Comments
 (0)