diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index 00c5884e9..ff15b2d23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -95,6 +95,9 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu private int imageMaxWidth; private final ThrottledDebouncer deleteFadeDebouncer = new ThrottledDebouncer(500); + private float initialDialImageDegrees; + private float initialDialScale; + private float minDialScaleDown; public static class Data { private final Bundle bundle; @@ -133,7 +136,6 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu private boolean hasMadeAnEditThisSession; private boolean wasInTrashHitZone; - public static ImageEditorFragment newInstanceForAvatarCapture(@NonNull Uri imageUri) { ImageEditorFragment fragment = newInstance(imageUri); fragment.requireArguments().putString(KEY_MODE, Mode.AVATAR_CAPTURE.code); @@ -422,6 +424,8 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu .setVisible(mode == ImageEditorHudV2.Mode.DELETE) .persist(); + updateHudDialRotation(); + switch (mode) { case CROP: { imageEditorView.getModel().startCrop(); @@ -561,6 +565,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu @Override public void onClearAll() { imageEditorView.getModel().clearUndoStack(); + updateHudDialRotation(); } @Override @@ -586,6 +591,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu public void onUndo() { imageEditorView.getModel().undo(); imageEditorHud.setBlurFacesToggleEnabled(imageEditorView.getModel().hasFaceRenderer()); + updateHudDialRotation(); } @Override @@ -641,6 +647,32 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu controller.onDoneEditing(); } + @Override + public void onDialRotationGestureStarted() { + float localScaleX = imageEditorView.getModel().getMainImage().getLocalScaleX(); + minDialScaleDown = initialDialScale / localScaleX; + imageEditorView.getModel().pushUndoPoint(); + imageEditorView.getModel().updateUndoRedoAvailabilityState(); + initialDialImageDegrees = (float) Math.toDegrees(imageEditorView.getModel().getMainImage().getLocalRotationAngle()); + } + + @Override + public void onDialRotationGestureFinished() { + imageEditorView.getModel().getMainImage().commitEditorMatrix(); + imageEditorView.getModel().postEdit(true); + imageEditorView.invalidate(); + } + + @Override + public void onDialRotationChanged(float degrees) { + imageEditorView.setMainImageEditorMatrixRotation(degrees - initialDialImageDegrees, minDialScaleDown); + } + + private void updateHudDialRotation() { + imageEditorHud.setDialRotation(getRotationDegreesRounded(imageEditorView.getModel().getMainImage())); + initialDialScale = imageEditorView.getModel().getMainImage().getLocalScaleX(); + } + private ResizeAnimation resizeAnimation; private void scaleViewPortForDrawing(int orientation) { @@ -738,6 +770,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu private void onDrawingChanged(boolean stillTouching, boolean isUserEdit) { if (isUserEdit) { hasMadeAnEditThisSession = true; + } } @@ -832,10 +865,18 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu } }; + public float getRotationDegreesRounded(@Nullable EditorElement editorElement) { + if (editorElement == null) { + return 0f; + } + return Math.round(Math.toDegrees(editorElement.getLocalRotationAngle())); + } + private final ImageEditorView.DragListener dragListener = new ImageEditorView.DragListener() { @Override public void onDragStarted(@Nullable EditorElement editorElement) { if (imageEditorHud.getMode() == ImageEditorHudV2.Mode.CROP) { + updateHudDialRotation(); return; } @@ -855,6 +896,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu @Override public void onDragMoved(@Nullable EditorElement editorElement, boolean isInTrashHitZone) { if (imageEditorHud.getMode() == ImageEditorHudV2.Mode.CROP || editorElement == null) { + updateHudDialRotation(); return; } @@ -882,6 +924,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu wasInTrashHitZone = false; imageEditorHud.animate().alpha(1f); if (imageEditorHud.getMode() == ImageEditorHudV2.Mode.CROP) { + updateHudDialRotation(); return; } @@ -961,6 +1004,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu if (editorElement != null && editorElement.getRenderer() instanceof SelectableRenderer) { ((SelectableRenderer) editorElement.getRenderer()).onSelected(selected); } + imageEditorView.getModel().setSelected(selected ? editorElement : null); } private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHudV2.kt b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHudV2.kt index eff052eb0..0c0f6c19d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHudV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHudV2.kt @@ -65,16 +65,18 @@ class ImageEditorHudV2 @JvmOverloads constructor( private val bottomGuideline: Guideline = findViewById(R.id.image_editor_bottom_guide) private val brushPreview: BrushWidthPreviewView = findViewById(R.id.image_editor_hud_brush_preview) private val textStyleToggle: ImageView = findViewById(R.id.image_editor_hud_text_style_button) + private val rotationDial: RotationDialView = findViewById(R.id.image_editor_hud_crop_rotation_dial) private val selectableSet: Set = setOf(drawButton, textButton, stickerButton, blurButton) private val undoTools: Set = setOf(undoButton, clearAllButton) private val drawTools: Set = setOf(brushToggle, drawSeekBar, widthSeekBar) private val blurTools: Set = setOf(blurToggleContainer, blurHelpText, widthSeekBar) + private val cropTools: Set = setOf(rotationDial) private val drawButtonRow: Set = setOf(cancelButton, doneButton, drawButton, textButton, stickerButton, blurButton) private val cropButtonRow: Set = setOf(cancelButton, doneButton, cropRotateButton, cropFlipButton, cropAspectLockButton) - private val allModeTools: Set = drawTools + blurTools + drawButtonRow + cropButtonRow + textStyleToggle + private val allModeTools: Set = drawTools + blurTools + drawButtonRow + cropButtonRow + textStyleToggle + cropTools private val viewsToSlide: Set = drawButtonRow + cropButtonRow @@ -150,6 +152,24 @@ class ImageEditorHudV2 @JvmOverloads constructor( blurToggle.setOnCheckedChangeListener { _, enabled -> listener?.onBlurFacesToggled(enabled) } setupWidthSeekBar() + + rotationDial.listener = object : RotationDialView.Listener { + override fun onDegreeChanged(degrees: Float) { + listener?.onDialRotationChanged(degrees) + } + + override fun onGestureStart() { + listener?.onDialRotationGestureStarted() + } + + override fun onGestureEnd() { + listener?.onDialRotationGestureFinished() + } + } + } + + fun setDialRotation(degrees: Float) { + rotationDial.setDegrees(degrees) } fun setBottomOfImageEditorView(bottom: Int) { @@ -326,7 +346,7 @@ class ImageEditorHudV2 @JvmOverloads constructor( private fun presentModeCrop() { animateModeChange( - inSet = cropButtonRow - if (isAvatarEdit) setOf(cropAspectLockButton) else setOf(), + inSet = cropTools + cropButtonRow - if (isAvatarEdit) setOf(cropAspectLockButton) else setOf(), outSet = allModeTools ) animateInUndoTools() @@ -523,6 +543,9 @@ class ImageEditorHudV2 @JvmOverloads constructor( fun onRotate90AntiClockwise() fun onCropAspectLock() fun onTextStyleToggle() + fun onDialRotationGestureStarted() + fun onDialRotationChanged(degrees: Float) + fun onDialRotationGestureFinished() val isCropAspectLocked: Boolean fun onRequestFullScreen(fullScreen: Boolean, hideKeyboard: Boolean) diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/RotationDialView.kt b/app/src/main/java/org/thoughtcrime/securesms/scribbles/RotationDialView.kt new file mode 100644 index 000000000..d3be567bb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/RotationDialView.kt @@ -0,0 +1,297 @@ +package org.thoughtcrime.securesms.scribbles + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Typeface +import android.util.AttributeSet +import android.view.GestureDetector +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import androidx.annotation.ColorInt +import androidx.annotation.Dimension +import androidx.annotation.Px +import org.thoughtcrime.securesms.util.ViewUtil +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.roundToInt + +class RotationDialView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private val canvasBounds = Rect() + private val centerMostIndicatorRect = RectF() + private val indicatorRect = RectF() + private val dimensions = Dimensions() + + private var snapDegrees: Float = 0f + private var degrees: Float = 0f + private var isInGesture: Boolean = false + + private val gestureDetector: GestureDetector = GestureDetector(context, GestureListener()) + + var listener: Listener? = null + + private val textPaint = Paint().apply { + isAntiAlias = true + textSize = ViewUtil.spToPx(15f).toFloat() + typeface = Typeface.DEFAULT + color = Colors.textColor + style = Paint.Style.FILL + textAlign = Paint.Align.CENTER + } + + private val angleIndicatorPaint = Paint().apply { + isAntiAlias = true + color = Color.WHITE + style = Paint.Style.FILL + } + + fun setDegrees(degrees: Float) { + if (degrees != this.degrees) { + this.degrees = degrees + this.snapDegrees = calculateSnapDegrees() + + if (isInGesture) { + listener?.onDegreeChanged(snapDegrees) + } + + invalidate() + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.actionIndex != 0) return false + + isInGesture = gestureDetector.onTouchEvent(event) + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> listener?.onGestureStart() + MotionEvent.ACTION_UP -> listener?.onGestureEnd() + } + + return isInGesture + } + + override fun onDraw(canvas: Canvas) { + if (isInEditMode) { + canvas.drawColor(Color.BLACK) + } + + canvas.getClipBounds(canvasBounds) + + val dialDegrees = getDialDegrees(snapDegrees) + val bottom = canvasBounds.bottom + val approximateCenterDegree = dialDegrees.roundToInt() + var currentDegree = approximateCenterDegree + val fractionalOffset = dialDegrees - approximateCenterDegree + val dialOffset = dimensions.spaceBetweenAngleIndicators * fractionalOffset + + val centerX = width / 2f + centerMostIndicatorRect.set( + centerX - dimensions.angleIndicatorWidth / 2f, + bottom.toFloat() - dimensions.majorAngleIndicatorHeight, + centerX + dimensions.angleIndicatorWidth / 2f, + bottom.toFloat() + ) + centerMostIndicatorRect.offset(-dialOffset, 0f) + + indicatorRect.set(centerMostIndicatorRect) + + angleIndicatorPaint.color = Colors.colorForOtherDegree(currentDegree) + indicatorRect.top = bottom.toFloat() - dimensions.getHeightForDegree(currentDegree) + canvas.drawRect(indicatorRect, angleIndicatorPaint) + indicatorRect.offset(dimensions.spaceBetweenAngleIndicators.toFloat(), 0f) + currentDegree += 1 + + while (indicatorRect.left < width && currentDegree <= ceil(MAX_DEGREES)) { + angleIndicatorPaint.color = Colors.colorForOtherDegree(currentDegree) + indicatorRect.top = bottom.toFloat() - dimensions.getHeightForDegree(currentDegree) + canvas.drawRect(indicatorRect, angleIndicatorPaint) + indicatorRect.offset(dimensions.spaceBetweenAngleIndicators.toFloat(), 0f) + currentDegree += 1 + } + + currentDegree = approximateCenterDegree + indicatorRect.set(centerMostIndicatorRect) + indicatorRect.offset(-dimensions.spaceBetweenAngleIndicators.toFloat(), 0f) + currentDegree -= 1 + + while (indicatorRect.left >= 0 && currentDegree >= floor(MIN_DEGRESS)) { + angleIndicatorPaint.color = Colors.colorForOtherDegree(currentDegree) + indicatorRect.top = bottom.toFloat() - dimensions.getHeightForDegree(currentDegree) + canvas.drawRect(indicatorRect, angleIndicatorPaint) + indicatorRect.offset(-dimensions.spaceBetweenAngleIndicators.toFloat(), 0f) + currentDegree -= 1 + } + + centerMostIndicatorRect.offset(dialOffset, 0f) + angleIndicatorPaint.color = Colors.colorForCenterDegree(approximateCenterDegree) + canvas.drawRect(centerMostIndicatorRect, angleIndicatorPaint) + + drawText(canvas) + } + + private fun drawText(canvas: Canvas) { + val approximateDegrees = getDialDegrees(snapDegrees).roundToInt() + canvas.drawText( + "$approximateDegrees", + width / 2f, + canvasBounds.bottom - textPaint.descent() - dimensions.majorAngleIndicatorHeight - dimensions.textPaddingBottom, + textPaint + ) + } + + private fun getDialDegrees(degrees: Float): Float { + val alpha: Float = degrees % 360f + + if (alpha % 90 == 0f) { + return 0f + } + + val beta: Float = floor(alpha / 90f) + val offset: Float = alpha - beta * 90f + + return if (offset > 45f) { + offset - 90f + } else { + offset + } + } + + private fun calculateSnapDegrees(): Float { + return if (isInGesture) { + val dialDegrees = getDialDegrees(degrees) + if (dialDegrees.roundToInt() == 0) { + degrees - dialDegrees + } else { + degrees + } + } else { + degrees + } + } + + private inner class GestureListener : GestureDetector.SimpleOnGestureListener() { + override fun onDown(e: MotionEvent?): Boolean { + return true + } + + override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean { + val degreeIncrement: Float = distanceX / dimensions.spaceBetweenAngleIndicators + val prevDialDegrees = getDialDegrees(degrees) + val newDialDegrees = getDialDegrees(degrees + degreeIncrement) + + val offEndOfMax = prevDialDegrees >= MAX_DEGREES / 2f && newDialDegrees <= MIN_DEGRESS / 2f + val offEndOfMin = newDialDegrees >= MAX_DEGREES / 2f && prevDialDegrees <= MIN_DEGRESS / 2f + + if (prevDialDegrees.roundToInt() != newDialDegrees.roundToInt() && isHapticFeedbackEnabled) { + performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + } + + when { + offEndOfMax -> { + val newIncrement = MAX_DEGREES - prevDialDegrees + setDegrees(degrees + newIncrement) + } + offEndOfMin -> { + val newIncrement = MAX_DEGREES - abs(prevDialDegrees) + setDegrees(degrees - newIncrement) + } + else -> { + setDegrees(degrees + degreeIncrement) + } + } + + return true + } + } + + private class Dimensions { + + @Px + val spaceBetweenAngleIndicators: Int = ViewUtil.dpToPx(Dimensions.spaceBetweenAngleIndicators) + + @Px + val angleIndicatorWidth: Int = ViewUtil.dpToPx(Dimensions.angleIndicatorWidth) + + @Px + val minorAngleIndicatorHeight: Int = ViewUtil.dpToPx(Dimensions.minorAngleIndicatorHeight) + + @Px + val majorAngleIndicatorHeight: Int = ViewUtil.dpToPx(Dimensions.majorAngleIndicatorHeight) + + @Px + val textPaddingBottom: Int = ViewUtil.dpToPx(Dimensions.textPaddingBottom) + + fun getHeightForDegree(degree: Int): Int { + return if (degree == 0) { + majorAngleIndicatorHeight + } else { + minorAngleIndicatorHeight + } + } + + companion object { + + @Dimension(unit = Dimension.DP) + private val spaceBetweenAngleIndicators: Int = 12 + + @Dimension(unit = Dimension.DP) + private val angleIndicatorWidth: Int = 1 + + @Dimension(unit = Dimension.DP) + private val minorAngleIndicatorHeight: Int = 12 + + @Dimension(unit = Dimension.DP) + private val majorAngleIndicatorHeight: Int = 32 + + @Dimension(unit = Dimension.DP) + private val textPaddingBottom: Int = 8 + } + } + + private object Colors { + @ColorInt + val textColor: Int = Color.WHITE + + @ColorInt + val majorAngleIndicatorColor: Int = 0xFF62E87A.toInt() + + @ColorInt + val modFiveIndicatorColor: Int = Color.WHITE + + @ColorInt + val minorAngleIndicatorColor: Int = 0x80FFFFFF.toInt() + + fun colorForCenterDegree(degree: Int) = if (degree == 0) modFiveIndicatorColor else majorAngleIndicatorColor + + fun colorForOtherDegree(degree: Int): Int { + return when { + degree % 5 == 0 -> modFiveIndicatorColor + else -> minorAngleIndicatorColor + } + } + } + + companion object { + private const val MAX_DEGREES: Float = 44.99999f + private const val MIN_DEGRESS: Float = -44.99999f + } + + interface Listener { + fun onDegreeChanged(degrees: Float) + fun onGestureStart() + fun onGestureEnd() + } +} diff --git a/app/src/main/res/layout/v2_media_image_editor_hud.xml b/app/src/main/res/layout/v2_media_image_editor_hud.xml index 582089711..7d9ed3663 100644 --- a/app/src/main/res/layout/v2_media_image_editor_hud.xml +++ b/app/src/main/res/layout/v2_media_image_editor_hud.xml @@ -331,6 +331,16 @@ tools:translationY="0dp" tools:visibility="visible" /> + + diff --git a/image-editor/lib/src/main/java/org/signal/imageeditor/core/ImageEditorView.java b/image-editor/lib/src/main/java/org/signal/imageeditor/core/ImageEditorView.java index 11f1235ee..54201d1ad 100644 --- a/image-editor/lib/src/main/java/org/signal/imageeditor/core/ImageEditorView.java +++ b/image-editor/lib/src/main/java/org/signal/imageeditor/core/ImageEditorView.java @@ -427,6 +427,11 @@ public final class ImageEditorView extends FrameLayout { this.mode = mode; } + public void setMainImageEditorMatrixRotation(float angle, float minScaleDown) { + model.setMainImageEditorMatrixRotation(angle, minScaleDown); + invalidate(); + } + public void startDrawing(float thickness, @NonNull Paint.Cap cap, boolean blur) { this.thickness = thickness; this.cap = cap; diff --git a/image-editor/lib/src/main/java/org/signal/imageeditor/core/MatrixUtils.java b/image-editor/lib/src/main/java/org/signal/imageeditor/core/MatrixUtils.java new file mode 100644 index 000000000..1b22adf19 --- /dev/null +++ b/image-editor/lib/src/main/java/org/signal/imageeditor/core/MatrixUtils.java @@ -0,0 +1,37 @@ +package org.signal.imageeditor.core; + +import android.graphics.Matrix; + +import androidx.annotation.NonNull; + +public final class MatrixUtils { + + private static final ThreadLocal tempMatrixValues = new ThreadLocal<>(); + + protected static @NonNull float[] getTempMatrixValues() { + float[] floats = tempMatrixValues.get(); + if(floats == null) { + floats = new float[9]; + tempMatrixValues.set(floats); + } + return floats; + } + + /** + * Extracts the angle from a matrix in radians. + */ + public static float getRotationAngle(@NonNull Matrix matrix) { + float[] matrixValues = getTempMatrixValues(); + matrix.getValues(matrixValues); + return (float) -Math.atan2(matrixValues[Matrix.MSKEW_X], matrixValues[Matrix.MSCALE_X]); + } + + /** Gets the scale on the X axis */ + public static float getScaleX(@NonNull Matrix matrix) { + float[] matrixValues = getTempMatrixValues(); + matrix.getValues(matrixValues); + float scaleX = matrixValues[Matrix.MSCALE_X]; + float skewX = matrixValues[Matrix.MSKEW_X]; + return (float) Math.sqrt(scaleX * scaleX + skewX * skewX); + } +} diff --git a/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorElement.java b/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorElement.java index 5d1f1356b..6c8015b42 100644 --- a/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorElement.java +++ b/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorElement.java @@ -7,6 +7,7 @@ import android.os.Parcelable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.signal.imageeditor.core.MatrixUtils; import org.signal.imageeditor.core.Renderer; import org.signal.imageeditor.core.RendererContext; @@ -299,6 +300,14 @@ public final class EditorElement implements Parcelable { children.clear(); } + public float getLocalRotationAngle() { + return MatrixUtils.getRotationAngle(localMatrix); + } + + public float getLocalScaleX() { + return MatrixUtils.getScaleX(localMatrix); + } + public interface PerElementFunction { void apply(EditorElement element); } diff --git a/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorElementHierarchy.java b/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorElementHierarchy.java index e274d3150..84716a4b2 100644 --- a/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorElementHierarchy.java +++ b/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorElementHierarchy.java @@ -221,6 +221,12 @@ final class EditorElementHierarchy { selectedElement = null; } + void updateSelectionThumbsForElement(@NonNull EditorElement element, @Nullable Matrix overlayMappingMatrix) { + if (element == selectedElement) { + setOrUpdateSelectionThumbsForElement(element, overlayMappingMatrix); + } + } + void setOrUpdateSelectionThumbsForElement(@NonNull EditorElement element, @Nullable Matrix overlayMappingMatrix) { if (selectedElement != element) { removeAllSelectionArtifacts(); @@ -433,7 +439,7 @@ final class EditorElementHierarchy { return dst; } - void flipRotate(int degrees, int scaleX, int scaleY, @NonNull RectF visibleViewPort, @Nullable Runnable invalidate) { + void flipRotate(float degrees, int scaleX, int scaleY, @NonNull RectF visibleViewPort, @Nullable Runnable invalidate) { Matrix newLocal = new Matrix(flipRotate.getLocalMatrix()); if (degrees != 0) { newLocal.postRotate(degrees); diff --git a/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorModel.java b/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorModel.java index 310881ab9..af5c22194 100644 --- a/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorModel.java +++ b/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorModel.java @@ -76,6 +76,11 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { } } + public void updateSelectionThumbsIfSelected(@NonNull EditorElement editorElement) { + Matrix overlayMappingMatrix = findRelativeMatrix(editorElement, editorElementHierarchy.getOverlay()); + editorElementHierarchy.updateSelectionThumbsForElement(editorElement, overlayMappingMatrix); + } + public void setSelectionVisible(boolean visible) { editorElementHierarchy.getSelection() .getFlags() @@ -145,6 +150,51 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping())); } + /** Keeps the image within the crop bounds as it rotates */ + public void setMainImageEditorMatrixRotation(float angle, float minScaleDown) { + setEditorMatrixToRotationMatrixAboutParentsOrigin(editorElementHierarchy.getMainImage(), angle); + scaleMainImageEditorMatrixToFitInsideCropBounds(minScaleDown, 2f); + } + + private void scaleMainImageEditorMatrixToFitInsideCropBounds(float minScaleDown, float maxScaleUp) { + EditorElement mainImage = editorElementHierarchy.getMainImage(); + Matrix mainImageLocalBackup = new Matrix(mainImage.getLocalMatrix()); + Matrix mainImageEditorBackup = new Matrix(mainImage.getEditorMatrix()); + + mainImage.commitEditorMatrix(); + Matrix combinedLocal = new Matrix(mainImage.getLocalMatrix()); + Matrix newLocal = Bisect.bisectToTest(mainImage, + minScaleDown, + maxScaleUp, + this::cropIsWithinMainImageBounds, + (matrix, scale) -> matrix.preScale(scale, scale)); + + Matrix invertLocal = new Matrix(); + if (newLocal != null && combinedLocal.invert(invertLocal)) { + invertLocal.preConcat(newLocal); // L^-1 (L * Scale) -> Scale + mainImageEditorBackup.preConcat(invertLocal); // add the scale to editor matrix to keep this image within crop + } + mainImage.getLocalMatrix().set(mainImageLocalBackup); + mainImage.getEditorMatrix().set(mainImageEditorBackup); + } + + /** + * Sets the editor matrix for the element to a rotation of the degrees but does so that we are rotating around the + * parents elements origin. + */ + private void setEditorMatrixToRotationMatrixAboutParentsOrigin(@NonNull EditorElement element, float degrees) { + Matrix localMatrix = element.getLocalMatrix(); + Matrix editorMatrix = element.getEditorMatrix(); + localMatrix.invert(editorMatrix); + editorMatrix.preRotate(degrees); + editorMatrix.preConcat(localMatrix); + // Editor Matrix is then: Local^-1 * Rotate(degrees) * Local + // So you end up with this overall for the element: Local * Local^-1 * Rotate(degrees) * Local + // Meaning the rotate applies after existing effects of the local matrix + // Where as simply setting the editor matrix rotate gives this: Local * Rotate(degrees) + // which rotates around local origin first + } + /** * Renders tree with the following matrix: *

@@ -233,6 +283,10 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { getActiveUndoRedoStacks(cropping).pushState(editorElementHierarchy.getRoot()); } + public void updateUndoRedoAvailabilityState() { + updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping())); + } + public void clearUndoStack() { EditorElement root = editorElementHierarchy.getRoot(); EditorElement original = root; @@ -598,7 +652,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { */ public void moving(@NonNull EditorElement editorElement) { if (!isCropping()) { - setSelected(editorElement); + updateSelectionThumbsIfSelected(editorElement); return; } @@ -902,10 +956,6 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { return null; } - public void rotate90clockwise() { - flipRotate(90, 1, 1); - } - public void rotate90anticlockwise() { flipRotate(-90, 1, 1); } @@ -914,11 +964,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { flipRotate(0, -1, 1); } - public void flipVertical() { - flipRotate(0, 1, -1); - } - - private void flipRotate(int degrees, int scaleX, int scaleY) { + private void flipRotate(float degrees, int scaleX, int scaleY) { pushUndoPoint(); editorElementHierarchy.flipRotate(degrees, scaleX, scaleY, visibleViewPort, invalidate); updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping()));