Skip to content

Commit a44bea3

Browse files
committed
Merge branch 'file-upload-image-crop' into avatar-file-processor
# Conflicts: # ts/WoltLabSuite/Core/Component/Image/Cropper.ts # wcfsetup/install/files/js/WoltLabSuite/Core/Component/Image/Cropper.js
2 parents 8705964 + 50bed09 commit a44bea3

File tree

6 files changed

+251
-308
lines changed

6 files changed

+251
-308
lines changed

ts/WoltLabSuite/Core/Component/Image/Cropper.ts

Lines changed: 139 additions & 174 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ abstract class ImageCropper {
4747
protected dialog?: WoltlabCoreDialogElement;
4848
protected exif?: ExifUtil.Exif;
4949
protected orientation?: number;
50+
protected cropperCanvasRect?: DOMRect;
5051
#cropper?: Cropper;
5152

5253
constructor(element: WoltlabCoreFileUploadElement, file: File, configuration: CropperConfiguration) {
@@ -76,6 +77,31 @@ abstract class ImageCropper {
7677
}
7778
}
7879

80+
abstract get minSize(): { width: number; height: number };
81+
82+
abstract get maxSize(): { width: number; height: number };
83+
84+
public async loadImage() {
85+
const { image, exif } = await this.resizer.loadFile(this.file);
86+
this.image = image;
87+
this.exif = exif;
88+
const tags = await ExifReader.load(this.file);
89+
if (tags.Orientation) {
90+
switch (tags.Orientation.value) {
91+
case 3:
92+
this.orientation = 180;
93+
break;
94+
case 6:
95+
this.orientation = 90;
96+
break;
97+
case 8:
98+
this.orientation = 270;
99+
break;
100+
// Any other rotation is unsupported.
101+
}
102+
}
103+
}
104+
79105
public async showDialog(): Promise<File> {
80106
this.dialog = dialogFactory().fromElement(this.image!).asPrompt({
81107
extra: this.getDialogExtra(),
@@ -126,47 +152,23 @@ abstract class ImageCropper {
126152
});
127153
}
128154

129-
protected getCanvas(): Promise<HTMLCanvasElement> {
130-
return this.cropperSelection!.$toCanvas();
131-
}
132-
133-
public async loadImage() {
134-
const { image, exif } = await this.resizer.loadFile(this.file);
135-
this.image = image;
136-
this.exif = exif;
137-
const tags = await ExifReader.load(this.file);
138-
if (tags.Orientation) {
139-
switch (tags.Orientation.value) {
140-
case 3:
141-
this.orientation = 180;
142-
break;
143-
case 6:
144-
this.orientation = 90;
145-
break;
146-
case 8:
147-
this.orientation = 270;
148-
break;
149-
// Any other rotation is unsupported.
150-
}
151-
}
152-
}
153-
154-
protected abstract getCropperTemplate(): string;
155-
156155
protected getDialogExtra(): string | undefined {
157156
return undefined;
158157
}
159158

160-
protected setCropperStyle() {
161-
this.cropperCanvas!.style.aspectRatio = `${this.width}/${this.height}`;
162-
163-
if (this.width >= this.height) {
164-
this.cropperCanvas!.style.maxHeight = "100%";
165-
} else {
166-
this.cropperCanvas!.style.maxWidth = "100%";
167-
}
159+
protected getCanvas(): Promise<HTMLCanvasElement> {
160+
// Calculate the size of the image in relation to the window size
161+
const selectionRatio = Math.min(
162+
this.cropperCanvasRect!.width / this.width,
163+
this.cropperCanvasRect!.height / this.height,
164+
);
165+
const width = this.cropperSelection!.width / selectionRatio;
166+
const height = width / this.configuration.aspectRatio;
168167

169-
this.cropperSelection!.aspectRatio = this.configuration.aspectRatio;
168+
return this.cropperSelection!.$toCanvas({
169+
width: Math.max(Math.min(Math.floor(width), this.maxSize.width), this.minSize.width),
170+
height: Math.max(Math.min(Math.ceil(height), this.maxSize.height), this.minSize.height),
171+
});
170172
}
171173

172174
protected createCropper() {
@@ -203,23 +205,108 @@ abstract class ImageCropper {
203205
event.preventDefault();
204206
}
205207
});
208+
209+
// Limit the selection to the min/max size
210+
this.cropperSelection!.addEventListener("change", (event: CustomEvent) => {
211+
const selection = event.detail as Selection;
212+
this.cropperCanvasRect = this.cropperCanvas!.getBoundingClientRect();
213+
214+
const selectionRatio = Math.min(
215+
this.cropperCanvasRect.width / this.width,
216+
this.cropperCanvasRect.height / this.height,
217+
);
218+
219+
const minWidth = this.minSize.width * selectionRatio;
220+
const maxWidth = this.cropperCanvasRect.width;
221+
const minHeight = minWidth / this.configuration.aspectRatio;
222+
const maxHeight = maxWidth / this.configuration.aspectRatio;
223+
224+
if (
225+
Math.round(selection.width) < minWidth ||
226+
Math.round(selection.height) < minHeight ||
227+
Math.round(selection.width) > maxWidth ||
228+
Math.round(selection.height) > maxHeight
229+
) {
230+
event.preventDefault();
231+
}
232+
});
233+
}
234+
235+
protected setCropperStyle() {
236+
this.cropperCanvas!.style.aspectRatio = `${this.width}/${this.height}`;
237+
238+
this.cropperSelection!.aspectRatio = this.configuration.aspectRatio;
206239
}
207240

208241
protected centerSelection(): void {
242+
// Set to the maximum size
243+
this.cropperCanvas!.style.width = `${this.width}px`;
244+
this.cropperCanvas!.style.height = `${this.height}px`;
245+
246+
const dimension = DomUtil.innerDimensions(this.cropperCanvas!.parentElement!);
247+
const ratio = Math.min(dimension.width / this.width, dimension.height / this.height);
248+
249+
this.cropperCanvas!.style.height = `${this.height * ratio}px`;
250+
this.cropperCanvas!.style.width = `${this.width * ratio}px`;
251+
209252
this.cropperImage!.$center("contain");
253+
this.cropperCanvasRect = this.cropperImage!.getBoundingClientRect();
254+
255+
const selectionRatio = Math.min(
256+
this.cropperCanvasRect.width / this.maxSize.width,
257+
this.cropperCanvasRect.height / this.maxSize.height,
258+
);
259+
260+
this.cropperSelection!.$change(
261+
0,
262+
0,
263+
this.maxSize.width * selectionRatio,
264+
this.maxSize.height * selectionRatio,
265+
this.configuration.aspectRatio,
266+
true,
267+
);
268+
210269
this.cropperSelection!.$center();
211270
this.cropperSelection!.scrollIntoView({ block: "center", inline: "center" });
212271
}
272+
273+
protected getCropperTemplate(): string {
274+
return `<cropper-canvas background scale-step="0.0">
275+
<cropper-image skewable scalable translatable rotatable></cropper-image>
276+
<cropper-shade hidden></cropper-shade>
277+
<cropper-handle action="scale" hidden disabled></cropper-handle>
278+
<cropper-selection precise movable resizable outlined>
279+
<cropper-grid role="grid" bordered covered></cropper-grid>
280+
<cropper-crosshair centered></cropper-crosshair>
281+
<cropper-handle action="move" theme-color="rgba(255, 255, 255, 0.35)"></cropper-handle>
282+
<cropper-handle action="n-resize"></cropper-handle>
283+
<cropper-handle action="e-resize"></cropper-handle>
284+
<cropper-handle action="s-resize"></cropper-handle>
285+
<cropper-handle action="w-resize"></cropper-handle>
286+
<cropper-handle action="ne-resize"></cropper-handle>
287+
<cropper-handle action="nw-resize"></cropper-handle>
288+
<cropper-handle action="se-resize"></cropper-handle>
289+
<cropper-handle action="sw-resize"></cropper-handle>
290+
</cropper-selection>
291+
</cropper-canvas>`;
292+
}
213293
}
214294

215295
class ExactImageCropper extends ImageCropper {
216-
#size?: { width: number; height: number };
296+
get minSize() {
297+
return this.configuration.sizes[0];
298+
}
299+
300+
get maxSize() {
301+
return this.configuration.sizes[this.configuration.sizes.length - 1];
302+
}
217303

218304
public async showDialog(): Promise<File> {
219305
// The image already has the correct size, cropping is not necessary
220306
if (
221-
this.width == this.#size!.width &&
222-
this.height == this.#size!.height &&
307+
this.configuration.sizes.filter((size) => {
308+
return size.width == this.width && size.height == this.height;
309+
}).length > 0 &&
223310
this.image instanceof HTMLCanvasElement
224311
) {
225312
return this.resizer.saveFile(
@@ -235,14 +322,17 @@ class ExactImageCropper extends ImageCropper {
235322
public async loadImage(): Promise<void> {
236323
await super.loadImage();
237324

238-
const timeout = new Promise<File>((resolve) => {
239-
window.setTimeout(() => resolve(this.file), 10_000);
240-
});
241-
242-
// resize image to the largest possible size
243-
const sizes = this.configuration.sizes.filter((size) => {
244-
return size.width <= this.width && size.height <= this.height;
245-
});
325+
const sizes = this.configuration.sizes
326+
.filter((size) => {
327+
return size.width <= this.width && size.height <= this.height;
328+
})
329+
.sort((a, b) => {
330+
if (this.configuration.aspectRatio >= 1) {
331+
return a.width - b.width;
332+
} else {
333+
return a.height - b.height;
334+
}
335+
});
246336

247337
if (sizes.length === 0) {
248338
const smallestSize =
@@ -255,43 +345,11 @@ class ExactImageCropper extends ImageCropper {
255345
);
256346
}
257347

258-
this.#size = sizes[sizes.length - 1];
259-
this.image = await this.resizer.resize(
260-
this.image as HTMLImageElement,
261-
this.width >= this.height ? this.width : this.#size.width,
262-
this.height > this.width ? this.height : this.#size.height,
263-
this.resizer.quality,
264-
true,
265-
timeout,
266-
);
267-
}
268-
269-
protected getCropperTemplate(): string {
270-
return `<cropper-canvas background>
271-
<cropper-image rotatable></cropper-image>
272-
<cropper-shade hidden></cropper-shade>
273-
<cropper-selection movable outlined keyboard>
274-
<cropper-grid role="grid" bordered covered></cropper-grid>
275-
<cropper-crosshair centered></cropper-crosshair>
276-
<cropper-handle action="move" theme-color="rgba(255, 255, 255, 0.35)"></cropper-handle>
277-
</cropper-selection>
278-
</cropper-canvas>`;
279-
}
280-
281-
protected setCropperStyle() {
282-
super.setCropperStyle();
283-
284-
this.cropperSelection!.width = this.#size!.width;
285-
this.cropperSelection!.height = this.#size!.height;
286-
287-
this.cropperCanvas!.style.width = `${this.width}px`;
288-
this.cropperCanvas!.style.height = `${this.height}px`;
289-
this.cropperSelection!.style.removeProperty("aspectRatio");
348+
this.configuration.sizes = sizes;
290349
}
291350
}
292351

293352
class MinMaxImageCropper extends ImageCropper {
294-
#cropperCanvasRect?: DOMRect;
295353
constructor(element: WoltlabCoreFileUploadElement, file: File, configuration: CropperConfiguration) {
296354
super(element, file, configuration);
297355
if (configuration.sizes.length !== 2) {
@@ -314,7 +372,7 @@ class MinMaxImageCropper extends ImageCropper {
314372
public async loadImage(): Promise<void> {
315373
await super.loadImage();
316374

317-
if (this.image!.width < this.minSize.width || this.image!.height < this.minSize.height) {
375+
if (this.width < this.minSize.width || this.height < this.minSize.height) {
318376
throw new Error(
319377
getPhrase("wcf.upload.error.image.tooSmall", {
320378
width: this.minSize.width,
@@ -324,105 +382,12 @@ class MinMaxImageCropper extends ImageCropper {
324382
}
325383
}
326384

327-
protected getCropperTemplate(): string {
328-
return `<cropper-canvas background scale-step="0.0">
329-
<cropper-image skewable scalable translatable rotatable></cropper-image>
330-
<cropper-shade hidden></cropper-shade>
331-
<cropper-handle action="scale" hidden disabled></cropper-handle>
332-
<cropper-selection precise movable resizable outlined>
333-
<cropper-grid role="grid" bordered covered></cropper-grid>
334-
<cropper-crosshair centered></cropper-crosshair>
335-
<cropper-handle action="move" theme-color="rgba(255, 255, 255, 0.35)"></cropper-handle>
336-
<cropper-handle action="n-resize"></cropper-handle>
337-
<cropper-handle action="e-resize"></cropper-handle>
338-
<cropper-handle action="s-resize"></cropper-handle>
339-
<cropper-handle action="w-resize"></cropper-handle>
340-
<cropper-handle action="ne-resize"></cropper-handle>
341-
<cropper-handle action="nw-resize"></cropper-handle>
342-
<cropper-handle action="se-resize"></cropper-handle>
343-
<cropper-handle action="sw-resize"></cropper-handle>
344-
</cropper-selection>
345-
</cropper-canvas>`;
346-
}
347-
348385
protected createCropper() {
349386
super.createCropper();
350387

351388
this.dialog!.addEventListener("extra", () => {
352389
this.centerSelection();
353390
});
354-
355-
// Limit the selection to the min/max size
356-
this.cropperSelection!.addEventListener("change", (event: CustomEvent) => {
357-
const selection = event.detail as Selection;
358-
this.#cropperCanvasRect = this.cropperCanvas!.getBoundingClientRect();
359-
360-
const maxImageWidth = Math.min(this.image!.width, this.maxSize.width);
361-
const maxImageHeight = Math.min(this.image!.height, this.maxSize.height);
362-
const selectionRatio = Math.min(
363-
this.#cropperCanvasRect.width / maxImageWidth,
364-
this.#cropperCanvasRect.height / maxImageHeight,
365-
);
366-
367-
const minWidth = this.minSize.width * selectionRatio;
368-
const maxWidth = this.maxSize.width * selectionRatio;
369-
const minHeight = minWidth / this.configuration.aspectRatio;
370-
const maxHeight = maxWidth / this.configuration.aspectRatio;
371-
372-
if (
373-
Math.round(selection.width) < minWidth ||
374-
Math.round(selection.height) < minHeight ||
375-
Math.round(selection.width) > maxWidth ||
376-
Math.round(selection.height) > maxHeight
377-
) {
378-
event.preventDefault();
379-
}
380-
});
381-
}
382-
383-
protected getCanvas(): Promise<HTMLCanvasElement> {
384-
// Calculate the size of the image in relation to the window size
385-
const maxImageWidth = Math.min(this.image!.width, this.maxSize.width);
386-
const widthRatio = this.#cropperCanvasRect!.width / maxImageWidth;
387-
const width = this.cropperSelection!.width / widthRatio;
388-
const height = width / this.configuration.aspectRatio;
389-
390-
return this.cropperSelection!.$toCanvas({
391-
width: Math.max(Math.min(Math.ceil(width), this.maxSize.width), this.minSize.width),
392-
height: Math.max(Math.min(Math.ceil(height), this.maxSize.height), this.minSize.height),
393-
});
394-
}
395-
396-
protected centerSelection(): void {
397-
// Reset to get the maximum available height and width
398-
this.cropperCanvas!.style.height = "";
399-
this.cropperCanvas!.style.width = "";
400-
401-
const dimension = DomUtil.innerDimensions(this.cropperCanvas!.parentElement!);
402-
const ratio = Math.min(dimension.width / this.image!.width, dimension.height / this.image!.height);
403-
404-
this.cropperCanvas!.style.height = `${this.image!.height * ratio}px`;
405-
this.cropperCanvas!.style.width = `${this.image!.width * ratio}px`;
406-
407-
this.cropperImage!.$center("contain");
408-
this.#cropperCanvasRect = this.cropperImage!.getBoundingClientRect();
409-
410-
const selectionRatio = Math.min(
411-
this.#cropperCanvasRect.width / this.maxSize.width,
412-
this.#cropperCanvasRect.height / this.maxSize.height,
413-
);
414-
415-
this.cropperSelection!.$change(
416-
0,
417-
0,
418-
this.maxSize.width * selectionRatio,
419-
this.maxSize.height * selectionRatio,
420-
this.configuration.aspectRatio,
421-
true,
422-
);
423-
424-
this.cropperSelection!.$center();
425-
this.cropperSelection!.scrollIntoView({ block: "center", inline: "center" });
426391
}
427392
}
428393

0 commit comments

Comments
 (0)