Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
5b5f04a
feat(ImageCropper): add image cropper
Lisa18289 Aug 18, 2025
a9d7848
feat(ImageCropper): add image cropper component
Lisa18289 Aug 18, 2025
ee9d6b9
feat(ImageCropper): add image cropper component
Lisa18289 Aug 18, 2025
29851ce
feat(ImageCropper): add image cropper component
Lisa18289 Aug 18, 2025
ad43d46
feat(ImageCropper): add design tokens
Lisa18289 Sep 2, 2025
9d4d372
feat(ImageCropper): add design tokens
Lisa18289 Sep 2, 2025
d1cf384
feat(ImageCropper): add docs
Lisa18289 Sep 3, 2025
0a940f4
feat(ImageCropper): add docs
Lisa18289 Sep 3, 2025
352f77f
feat(ImageCropper): add docs
Lisa18289 Sep 3, 2025
fd61368
Merge remote-tracking branch 'origin/main' into 303-imagecropper-comp…
Lisa18289 Sep 3, 2025
4f9c491
Merge remote-tracking branch 'refs/remotes/origin/main' into 303-imag…
Lisa18289 Sep 8, 2025
6508919
fix(ImageCropper): add translation
Lisa18289 Sep 8, 2025
1ce6446
Merge remote-tracking branch 'origin/main' into 303-imagecropper-comp…
Lisa18289 Jan 13, 2026
17f32a3
feat(ImageCropper): add tests
Lisa18289 Jan 13, 2026
62af44c
feat(ImageCropper): add tests
Lisa18289 Jan 13, 2026
639a89d
test: update visual regression screenshots
github-actions[bot] Jan 13, 2026
2c49ee5
merge main
Lisa18289 Jan 28, 2026
44825f4
merge main
Lisa18289 Jan 28, 2026
cd977d6
Merge remote-tracking branch 'origin/main' into 303-imagecropper-comp…
Lisa18289 Jan 28, 2026
77fe6a3
merge main
Lisa18289 Jan 28, 2026
29b6242
Merge remote-tracking branch 'origin/main' into 303-imagecropper-comp…
Lisa18289 Jan 29, 2026
8ca78ca
update image cropper
Lisa18289 Jan 29, 2026
4ad4baf
update image cropper
Lisa18289 Jan 29, 2026
2ccce6f
update image cropper
Lisa18289 Jan 30, 2026
376a032
test: update visual regression screenshots
github-actions[bot] Jan 30, 2026
4353daa
Merge remote-tracking branch 'origin/main' into 303-imagecropper-comp…
Lisa18289 Mar 31, 2026
f3a4097
feat(ImageCropper): add image cropper
Lisa18289 Mar 31, 2026
bc6d7b9
feat(ImageCropper): add image cropper
Lisa18289 Mar 31, 2026
0685a59
update cropper
Lisa18289 Mar 31, 2026
08926a7
update cropper
Lisa18289 Apr 1, 2026
8e2912a
test: update visual regression screenshots
github-actions[bot] Apr 1, 2026
f176287
Merge branch 'main' into 303-imagecropper-component
Lisa18289 Apr 1, 2026
1768c76
update slider
Lisa18289 Apr 1, 2026
5d2a613
Merge remote-tracking branch 'origin/303-imagecropper-component' into…
Lisa18289 Apr 1, 2026
8085b59
update slider
Lisa18289 Apr 1, 2026
b31d150
remove unused disabled
Lisa18289 Apr 1, 2026
a24fb44
Merge branch 'main' into 303-imagecropper-component
Lisa18289 Apr 1, 2026
9d7b815
test: update visual regression screenshots
github-actions[bot] Apr 1, 2026
a4c491c
update cropper
Lisa18289 Apr 1, 2026
a662614
Merge remote-tracking branch 'origin/303-imagecropper-component' into…
Lisa18289 Apr 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Properties

<PropertiesTables />
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ImageCropper } from "@mittwald/flow-react-components";

<ImageCropper
image="https://mittwald.github.io/flow/assets/mittwald_logo_rgb.jpg"
aspectRatio={16 / 9}
/>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ImageCropper } from "@mittwald/flow-react-components";

<ImageCropper
image="https://mittwald.github.io/flow/assets/mittwald_logo_rgb.jpg"
cropShape="round"
aspectRatio={1}
/>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { ImageCropper } from "@mittwald/flow-react-components";

<ImageCropper image="https://mittwald.github.io/flow/assets/mittwald_logo_rgb.jpg" />;
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
Button,
Heading,
FileField,
ImageCropper,
IconImage,
Section,
FileDropZone,
Text,
Flex,
IconClose,
Action,
} from "@mittwald/flow-react-components";
import { useForm } from "react-hook-form";
import {
Form,
SubmitButton,
typedField,
} from "@mittwald/flow-react-components/react-hook-form";
import { sleep } from "@/content/04-components/actions/action/examples/lib";

export default () => {
const form = useForm<{
files: FileList | File[] | [];
}>({ defaultValues: { files: [] } });
const Field = typedField(form);

const watchedFiles = [...form.watch("files")];

return (
<Section>
<Form form={form} onSubmit={sleep}>
{watchedFiles.length === 0 && (
<FileDropZone
accept="image/png"
onChange={(f) => {
if (f) {
form.setValue("files", f);
}
}}
>
<IconImage />
<Heading>Bild ablegen</Heading>
<Text>
Bitte wähle ein Bild vom Typ PNG aus.
</Text>
<Field
name="files"
rules={{
required: "Bitte wähle ein Bild aus",
}}
>
<FileField>
<Button variant="outline" color="dark">
Bild auswählen
</Button>
</FileField>
</Field>
</FileDropZone>
)}

{watchedFiles.length > 0 && (
<Flex justify="center" align="start" gap="m">
<ImageCropper
width={200}
height={200}
image={watchedFiles[0]}
/>
<Action
onAction={() => form.setValue("files", [])}
>
<Button
variant="plain"
color="secondary"
aria-label="Bild entfernen"
>
<IconClose />
</Button>
</Action>
</Flex>
)}
<SubmitButton>Hochladen</SubmitButton>
</Form>
</Section>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ImageCropper } from "@mittwald/flow-react-components";

<ImageCropper
image="https://mittwald.github.io/flow/assets/mittwald_logo_rgb.jpg"
height={200}
width={200}
/>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
component: ImageCropper
description:
Die ImageCropper Component wird dafür verwendet ein Bild auf ein bestimmtes
Format zuzuschneiden.
---

<LiveCodeEditor editorDisabled example="size" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Playground

<LiveCodeEditor />

---

# Seitenverhältnis

Über das Property `aspect` kann das Seitenverhältnis angegeben werden.

<LiveCodeEditor example="aspect" editorCollapsed />

---

# Runder Ausschnitt

Über das Property `cropShape` kann zwischen einer rechteckigen und runden
Ansicht des Ausschnitts gewechselt werden.

<LiveCodeEditor example="crop-shape" editorCollapsed />

---

# Größe

Über die `width` und `height` Properties kann die Größe des Croppers angepasst
werden.

<LiveCodeEditor example="size" editorCollapsed />

---

# Kombiniere mit ...

## FileDropZone

<LiveCodeEditor example="file-drop-zone" editorCollapsed />
Binary file added packages/.DS_Store
Binary file not shown.
1 change: 1 addition & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"react-aria": "^3.45.0",
"react-aria-components": "^1.14.0",
"react-children-utilities": "^2.10.0",
"react-easy-crop": "^5.5.0",
"react-hotkeys-hook": "^5.2.3",
"react-markdown": "^10.1.0",
"react-stately": "^3.43.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
.imageCropper {
display: flex;
flex-direction: column;
gap: var(--image-cropper--spacing);
max-width: 100%;

.cropperContainer {
position: relative;
width: 100%;
border-radius: var(--image-cropper--corner-radius);
border-color: var(--image-cropper--border-color);
border-style: var(--image-cropper--border-style);
border-width: var(--image-cropper--border-width);
}

:global(.reactEasyCrop_CropArea) {
color: var(--image-cropper--mask-color);
border-color: var(--image-cropper--mask-border-color);
box-sizing: content-box;
}

:global(.reactEasyCrop_CropAreaGrid::before) {
border-color: var(--image-cropper--grid-color);
border-width: var(--image-cropper--grid-width);
}
}
99 changes: 99 additions & 0 deletions packages/components/src/components/ImageCropper/ImageCropper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {
type CSSProperties,
type FC,
useEffect,
useEffectEvent,
useState,
} from "react";
import Cropper, { type Area, type CropperProps } from "react-easy-crop";
import type { PropsWithClassName } from "@/lib/types/props";
import clsx from "clsx";
import styles from "./ImageCropper.module.scss";
import { Slider } from "@/components/Slider";
import { getCroppedImageFile } from "@/components/ImageCropper/lib/getCroppedImageFile";
import { useLocalizedStringFormatter } from "react-aria";
import locales from "./locales/*.locale.json";
import { useImageSrc } from "@/lib/hooks/useImageSrc";

export interface ImageCropperProps
extends PropsWithClassName, Partial<Pick<CropperProps, "cropShape">> {
/** The image file to crop. */
image?: File | string;
/** Callback on crop complete. */
onCropComplete?: (croppedImage: File) => void;
/** The width of the component. @default 300 */
width?: CSSProperties["width"];
/** The height of the component. @default 300 */
height?: CSSProperties["height"];
/** The aspect ratio of the crop shape. */
aspectRatio?: number;
}

/** @flr-generate all */
export const ImageCropper: FC<ImageCropperProps> = (props) => {
const {
image,
className,
onCropComplete,
width = 300,
height = 300,
aspectRatio,
...rest
} = props;

const imageSrc = useImageSrc(image);

const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area>();

const rootClassName = clsx(styles.imageCropper, className);

const stringFormatter = useLocalizedStringFormatter(locales);

const onCropAreaPixelsChange = useEffectEvent(async () => {
if (croppedAreaPixels) {
const croppedImageFile = await getCroppedImageFile(
imageSrc,
croppedAreaPixels,
);
if (onCropComplete) {
onCropComplete(croppedImageFile);
}
}
});

useEffect(() => {
void onCropAreaPixelsChange();
}, [croppedAreaPixels]);
Comment thread
mfal marked this conversation as resolved.
Comment thread
mfal marked this conversation as resolved.

return (
<div className={rootClassName} style={{ width }}>
<div className={styles.cropperContainer} style={{ height }}>
<Cropper
aspect={aspectRatio}
crop={crop}
image={imageSrc}
onCropChange={setCrop}
zoom={zoom}
onZoomChange={setZoom}
onCropComplete={(_, croppedAreaPixels) =>
setCroppedAreaPixels(croppedAreaPixels)
}
{...rest}
/>
</div>
<Slider
minValue={1}
maxValue={3}
step={0.01}
value={zoom}
sliderOnly
onChange={(zoom) => setZoom(zoom as number)}
aria-label={stringFormatter.format("zoom")}
/>
</div>
);
};

export default ImageCropper;
2 changes: 2 additions & 0 deletions packages/components/src/components/ImageCropper/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { type ImageCropperProps, ImageCropper } from "./ImageCropper";
export { default } from "./ImageCropper";
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Area } from "react-easy-crop";

export function getCroppedImageFile(
imageSrc: string,
pixelCrop: Area,
): Promise<File> {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = "anonymous";
image.src = imageSrc;

image.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
const ctx = canvas.getContext("2d");

if (!ctx) {
reject(new Error("Failed to get canvas context"));
return;
}

ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height,
);

canvas.toBlob((blob) => {
if (!blob) {
return;
}

const file = new File([blob], "cropped-image.png", {
type: "image/png",
});
resolve(file);
}, "image/png");
};

image.onerror = () => {
reject(new Error("Failed to load image"));
};
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"zoom": "Zoom"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"zoom": "zoom"
}
Loading
Loading