1/*
2 * Copyright (c) 2022 Huawei Device Co., Ltd.
3 * Licensed under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License.
5 * You may obtain a copy of the License at
6 *
7 *     http://www.apache.org/licenses/LICENSE-2.0
8 *
9 * Unless required by applicable law or agreed to in writing, software
10 * distributed under the License is distributed on an "AS IS" BASIS,
11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 * See the License for the specific language governing permissions and
13 * limitations under the License.
14 */
15
16import { Constants, Log } from '@ohos/common';
17import { PhotoEditBase } from '../base/PhotoEditBase';
18import { PhotoEditMode } from '../base/PhotoEditType';
19import { Point } from '../base/Point';
20import { RectF } from '../base/Rect';
21import { CropAngle, CropRatioType, CropTouchState } from './CropType';
22import { ImageFilterCrop } from './ImageFilterCrop';
23import { CropShow } from './CropShow';
24import { MathUtils } from './MathUtils';
25import { DrawingUtils } from './DrawingUtils';
26import type { ImageFilterBase } from '../base/ImageFilterBase';
27import type { PixelMapWrapper } from '../base/PixelMapWrapper';
28
29const TAG: string = 'editor_PhotoEditCrop';
30
31export class PhotoEditCrop extends PhotoEditBase {
32  private static readonly BASE_SCALE_VALUE: number = 1.0;
33  private static readonly DEFAULT_MAX_SCALE_VALUE: number = 3.0;
34  private static readonly DEFAULT_IMAGE_RATIO: number = 1.0;
35  private static readonly DEFAULT_MIN_SIDE_LENGTH: number = 32;
36  private static readonly DEFAULT_MARGIN_LENGTH: number = 20;
37  private static readonly DEFAULT_TIMEOUT_MILLISECOND_1000: number = 1000;
38  private static readonly DEFAULT_SPLIT_FRACTION: number = 3;
39  private filter: ImageFilterCrop = undefined;
40  private input: PixelMapWrapper = undefined;
41  private isFlipHorizontal: boolean = false;
42  private isFlipVertically: boolean = false;
43  private rotationAngle: number = 0;
44  private sliderAngle: number = 0;
45  private cropRatio: CropRatioType = CropRatioType.RATIO_TYPE_FREE;
46  private cropShow: CropShow = undefined;
47  private isCropShowInitialized: boolean = false;
48  private ctx: CanvasRenderingContext2D = undefined;
49  private displayWidth: number = 0;
50  private displayHeight: number = 0;
51  private marginW: number = PhotoEditCrop.DEFAULT_MARGIN_LENGTH;
52  private marginH: number = PhotoEditCrop.DEFAULT_MARGIN_LENGTH;
53  private imageRatio: number = PhotoEditCrop.DEFAULT_IMAGE_RATIO;
54  private scale: number = PhotoEditCrop.BASE_SCALE_VALUE;
55  private timeoutId: number = 0;
56  private timeout: number = PhotoEditCrop.DEFAULT_TIMEOUT_MILLISECOND_1000;
57  private isWaitingRefresh: boolean = false;
58  private touchPoint: Point = undefined;
59  private pinchPoint: Point = undefined;
60  private state: CropTouchState = CropTouchState.NONE;
61  private splitFraction: number = PhotoEditCrop.DEFAULT_SPLIT_FRACTION;
62  private canvasReady: boolean = false;
63
64  constructor() {
65    super(PhotoEditMode.EDIT_MODE_CROP);
66    this.cropShow = new CropShow();
67    this.touchPoint = new Point(0, 0);
68    this.pinchPoint = new Point(0, 0);
69  }
70
71  entry(pixelMap: PixelMapWrapper): void {
72    Log.info(TAG, `entry pixelMap: ${JSON.stringify(pixelMap)}`);
73    this.input = pixelMap;
74    this.filter = new ImageFilterCrop();
75    this.initialize();
76    if (this.isCropShowInitialized) {
77      let limit = this.calcNewLimit();
78      this.cropShow.init(limit, this.imageRatio);
79    }
80    this.refresh();
81  }
82
83  exit(): ImageFilterBase {
84    Log.info(TAG, 'exit');
85    this.saveFinalOperation();
86    this.isCropShowInitialized = false;
87    this.input = undefined;
88    if (this.couldReset()) {
89      this.clear();
90    } else {
91      this.filter = undefined;
92    }
93    return this.filter;
94  }
95
96  setCanvasContext(context: CanvasRenderingContext2D): void {
97    Log.info(TAG, 'setCanvasContext');
98    this.ctx = context;
99    this.refresh();
100  }
101
102  setCanvasReady(state: boolean): void {
103    this.canvasReady = state;
104  }
105
106  setCanvasSize(width: number, height: number): void {
107    Log.info(TAG, `setCanvasSize: width[${width}], height[${height}]`);
108    this.displayWidth = width;
109    this.displayHeight = height;
110    let limit = this.calcNewLimit();
111    if (this.isCropShowInitialized) {
112      this.cropShow.syncLimitRect(limit);
113      this.determineMaxScaleFactor();
114    } else {
115      this.cropShow.init(limit, this.imageRatio);
116      this.isCropShowInitialized = true;
117    }
118    this.refresh();
119  }
120
121  clearCanvas(): void {
122    if (this.ctx != undefined) {
123      this.ctx.clearRect(0, 0, this.displayWidth, this.displayHeight);
124    }
125  }
126
127  onMirrorChange(): void {
128    Log.debug(TAG, 'onMirrorChange');
129    if (this.isWaitingRefresh) {
130      this.clearDelayRefresh();
131      this.cropShow.enlargeCropArea();
132    } else {
133      if (MathUtils.isOddRotation(this.rotationAngle)) {
134        this.isFlipVertically = !this.isFlipVertically;
135      } else {
136        this.isFlipHorizontal = !this.isFlipHorizontal;
137      }
138      this.cropShow.setFlip(this.isFlipHorizontal, this.isFlipVertically);
139    }
140    this.refresh();
141  }
142
143  onRotationAngleChange(): void {
144    Log.debug(TAG, 'onRotationAngleChange');
145    if (this.isWaitingRefresh) {
146      this.clearDelayRefresh();
147      this.cropShow.enlargeCropArea();
148    } else {
149      this.rotationAngle = (this.rotationAngle - CropAngle.ONE_QUARTER_CIRCLE_ANGLE) % CropAngle.CIRCLE_ANGLE;
150      this.cropShow.syncRotationAngle(this.rotationAngle);
151    }
152    this.refresh();
153  }
154
155  onSliderAngleChange(angle: number): void {
156    Log.debug(TAG, `onSliderAngleChange: angle[${angle}]`);
157    if (this.isWaitingRefresh) {
158      this.clearDelayRefresh();
159      this.cropShow.enlargeCropArea();
160      this.refresh();
161    }
162    this.sliderAngle = angle;
163    this.cropShow.syncHorizontalAngle(this.sliderAngle);
164    this.refresh();
165  }
166
167  onFixedRatioChange(ratio: CropRatioType): void {
168    Log.debug(TAG, `onFixedRatioChange: ratio[${ratio}]`);
169    if (this.isWaitingRefresh) {
170      this.clearDelayRefresh();
171      this.cropShow.enlargeCropArea();
172    }
173    this.cropRatio = ratio;
174    this.cropShow.setRatio(ratio);
175    this.endImageDrag();
176    this.refresh();
177  }
178
179  onTouchStart(x: number, y: number): void {
180    if (this.state !== CropTouchState.NONE) {
181      Log.debug(TAG, 'onTouchStart: touch state is not none!');
182      return;
183    }
184
185    if (this.isWaitingRefresh) {
186      this.clearDelayRefresh();
187    }
188
189    Log.debug(TAG, `onTouchStart: [x: ${x}, y: ${y}]`);
190    if (this.cropShow.isCropRectTouch(x, y)) {
191      this.state = CropTouchState.CROP_MOVE;
192    } else {
193      this.state = CropTouchState.IMAGE_DRAG;
194    }
195    this.touchPoint.set(x, y);
196  }
197
198  onTouchMove(x: number, y: number): void {
199    Log.debug(TAG, `onTouchMove: [state: ${this.state}] [x: ${x}, y: ${y}]`);
200    let offsetX = x - this.touchPoint.x;
201    let offsetY = y - this.touchPoint.y;
202    if (this.state === CropTouchState.CROP_MOVE) {
203      this.cropShow.moveCropRect(offsetX, offsetY);
204    } else if (this.state === CropTouchState.IMAGE_DRAG) {
205      this.onImageDrag(offsetX, offsetY);
206    } else {
207      return;
208    }
209    this.refresh();
210    this.touchPoint.set(x, y);
211  }
212
213  onTouchEnd(): void {
214    Log.debug(TAG, `onTouchEnd: [state: ${this.state}]`);
215    if (this.state === CropTouchState.CROP_MOVE) {
216      this.cropShow.endCropRectMove();
217    } else if (this.state === CropTouchState.IMAGE_DRAG) {
218      this.endImageDrag();
219      this.refresh();
220    } else {
221      return;
222    }
223    this.state = CropTouchState.NONE;
224    if (this.isWaitingRefresh) {
225      this.clearDelayRefresh();
226    }
227    this.delayRefresh(this.timeout);
228  }
229
230  onPinchStart(x: number, y: number, scale: number): void {
231    Log.debug(TAG, `onPinchStart: event[x: ${x}, y: ${y}]`);
232    this.state = CropTouchState.IMAGE_SCALE;
233    this.pinchPoint.set(x, y);
234    this.scale = scale;
235  }
236
237  onPinchUpdate(scale: number): void {
238    Log.debug(TAG, `onPinchUpdate: scale[${scale}]`);
239    if (this.state === CropTouchState.IMAGE_SCALE) {
240      let factor = scale / this.scale;
241      if (!this.cropShow.couldEnlargeImage()) {
242        factor = factor > PhotoEditCrop.BASE_SCALE_VALUE ? PhotoEditCrop.BASE_SCALE_VALUE : factor;
243      }
244      let image = this.cropShow.getImageRect();
245      MathUtils.scaleRectBasedOnPoint(image, this.pinchPoint, factor);
246      this.cropShow.setImageRect(image);
247      this.refresh();
248      this.scale *= factor;
249    }
250  }
251
252  onPinchEnd(): void {
253    Log.debug(TAG, 'onPinchEnd');
254    let crop = this.cropShow.getCropRect();
255    let points = MathUtils.rectToPoints(crop);
256    let tX = this.isFlipHorizontal ? -1 : 1;
257    let tY = this.isFlipVertically ? -1 : 1;
258    let angle = -(this.rotationAngle * tX * tY + this.sliderAngle);
259    let displayCenter = new Point(this.displayWidth / Constants.NUMBER_2, this.displayHeight / Constants.NUMBER_2);
260    let rotated = MathUtils.rotatePoints(points, angle, displayCenter);
261
262    let flipImage = this.cropShow.getCurrentFlipImage();
263    let origin = new Point(crop.getCenterX(), crop.getCenterY());
264    let centerOffsetX = origin.x - flipImage.getCenterX();
265    let centerOffsetY = origin.y - flipImage.getCenterY();
266    flipImage.move(centerOffsetX, centerOffsetY);
267    let scale = MathUtils.findSuitableScale(rotated, flipImage, origin);
268    flipImage.move(-centerOffsetX, -centerOffsetY);
269
270    MathUtils.scaleRectBasedOnPoint(flipImage, origin, scale);
271    let offsets = MathUtils.fixImageMove(rotated, flipImage);
272
273    let image = this.cropShow.getImageRect();
274    MathUtils.scaleRectBasedOnPoint(image, origin, scale);
275    image.move(offsets[0] * tX, offsets[1] * tY);
276    this.cropShow.setImageRect(image);
277    this.refresh();
278    this.state = CropTouchState.NONE;
279    this.delayRefresh(this.timeout);
280    this.scale = PhotoEditCrop.BASE_SCALE_VALUE;
281  }
282
283  couldReset(): boolean {
284    let crop = this.cropShow.getCropRect();
285    MathUtils.roundOutRect(crop);
286    let image = this.cropShow.getImageRect();
287    MathUtils.roundOutRect(image);
288    if (this.isFlipHorizontal && this.isFlipVertically && MathUtils.areRectsSame(crop, image)) {
289      return false;
290    }
291    if (
292      this.isFlipHorizontal !== false ||
293      this.isFlipVertically !== false ||
294      this.rotationAngle !== 0 || this.sliderAngle !== 0 ||
295      this.cropRatio !== CropRatioType.RATIO_TYPE_FREE ||
296      !MathUtils.areRectsSame(crop, image)
297    ) {
298      return true;
299    }
300    return false;
301  }
302
303  reset(): void {
304    Log.debug(TAG, 'reset');
305    let limit = this.calcNewLimit();
306    this.cropShow.init(limit, this.imageRatio);
307    this.initialize();
308    this.refresh();
309  }
310
311  private initialize(): void {
312    this.imageRatio = this.input.width / this.input.height;
313    this.determineMaxScaleFactor();
314    this.clear();
315  }
316
317  private calcNewLimit(): RectF {
318    let limit = new RectF();
319    limit.set(this.marginW, this.marginH, this.displayWidth - this.marginW, this.displayHeight - this.marginH);
320    return limit;
321  }
322
323  private determineMaxScaleFactor(): void {
324    if (this.input == null) {
325      return;
326    }
327    let scaleFactorW = this.input.width / px2vp(PhotoEditCrop.DEFAULT_MIN_SIDE_LENGTH);
328    let scaleFactorH = this.input.height / px2vp(PhotoEditCrop.DEFAULT_MIN_SIDE_LENGTH);
329    this.cropShow.setMaxScaleFactor(scaleFactorW, scaleFactorH);
330  }
331
332  private saveFinalOperation(): void {
333    let crop = this.cropShow.getCropRect();
334    let image = this.cropShow.getImageRect();
335    crop.move(-image.left, -image.top);
336    MathUtils.normalizeRect(crop, image.getWidth(), image.getHeight());
337    this.filter.setCropRect(crop);
338    this.filter.setRotationAngle(this.rotationAngle);
339    this.filter.setHorizontalAngle(this.sliderAngle);
340    this.filter.setFlipHorizontal(this.isFlipHorizontal);
341    this.filter.setFlipVertically(this.isFlipVertically);
342  }
343
344  private clear(): void {
345    this.cropRatio = CropRatioType.RATIO_TYPE_FREE;
346    this.isFlipHorizontal = false;
347    this.isFlipVertically = false;
348    this.rotationAngle = 0;
349    this.sliderAngle = 0;
350  }
351
352  private refresh(): void {
353    if (this.ctx !== undefined && this.input !== undefined && this.canvasReady) {
354      this.drawImage();
355      this.drawCrop();
356    }
357  }
358
359  private delayRefresh(delay: number): void {
360    this.isWaitingRefresh = true;
361    this.timeoutId = setTimeout(() => {
362      this.cropShow.enlargeCropArea();
363      this.refresh();
364      this.isWaitingRefresh = false;
365    }, delay);
366  }
367
368  private clearDelayRefresh(): void {
369    clearTimeout(this.timeoutId);
370    this.isWaitingRefresh = false;
371  }
372
373  private drawImage(): void {
374    this.ctx.save();
375    this.clearCanvas();
376
377    let x = this.displayWidth / Constants.NUMBER_2;
378    let y = this.displayHeight / Constants.NUMBER_2;
379    this.ctx.translate(this.isFlipHorizontal ? Constants.NUMBER_2 * x : 0, this.isFlipVertically ? Constants.NUMBER_2 * y : 0);
380
381    let tX = this.isFlipHorizontal ? -1 : 1;
382    let tY = this.isFlipVertically ? -1 : 1;
383    this.ctx.scale(tX, tY);
384
385    this.ctx.translate(x, y);
386    this.ctx.rotate(MathUtils.formulaAngle(this.rotationAngle * tX * tY + this.sliderAngle));
387    this.ctx.translate(-x, -y);
388
389    let image = this.cropShow.getImageRect();
390    MathUtils.roundOutRect(image);
391    this.ctx.drawImage(this.input.pixelMap, image.left, image.top, image.getWidth(), image.getHeight());
392    this.ctx.restore();
393  }
394
395  private drawCrop(): void {
396    let crop = this.cropShow.getCropRect();
397    MathUtils.roundOutRect(crop);
398    let display = new RectF();
399    display.set(0, 0, this.displayWidth, this.displayHeight);
400    DrawingUtils.drawMask(this.ctx, display, crop);
401    DrawingUtils.drawSplitLine(this.ctx, crop, this.splitFraction);
402    DrawingUtils.drawRect(this.ctx, crop);
403    DrawingUtils.drawCropButton(this.ctx, crop);
404  }
405
406  private onImageDrag(offsetX: number, offsetY: number): void {
407    let tX = this.isFlipHorizontal ? -1 : 1;
408    let tY = this.isFlipVertically ? -1 : 1;
409    let alpha = MathUtils.formulaAngle(this.rotationAngle * tX * tY + this.sliderAngle);
410    let x = Math.cos(alpha) * offsetX * tX + Math.sin(alpha) * offsetY * tY;
411    let y = -Math.sin(alpha) * offsetX * tX + Math.cos(alpha) * offsetY * tY;
412    let image = this.cropShow.getImageRect();
413    image.move(x, y);
414    this.cropShow.setImageRect(image);
415  }
416
417  private endImageDrag(): void {
418    let crop = this.cropShow.getCropRect();
419    let points = MathUtils.rectToPoints(crop);
420    let tX = this.isFlipHorizontal ? -1 : 1;
421    let tY = this.isFlipVertically ? -1 : 1;
422    let angle = -(this.rotationAngle * tX * tY + this.sliderAngle);
423    let displayCenter = new Point(this.displayWidth / Constants.NUMBER_2, this.displayHeight / Constants.NUMBER_2);
424    let rotated = MathUtils.rotatePoints(points, angle, displayCenter);
425
426    let flipImage = this.cropShow.getCurrentFlipImage();
427    let offsets = MathUtils.fixImageMove(rotated, flipImage);
428    let image = this.cropShow.getImageRect();
429    image.move(offsets[0] * tX, offsets[1] * tY);
430    this.cropShow.setImageRect(image);
431  }
432}