diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditor.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditor.kt index fd12e61efe..8fad8a9b2e 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditor.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditor.kt @@ -87,7 +87,9 @@ internal fun ImageEditor( .matchParentSize() .clipToBounds() .onSizeChanged { state.setCanvasSize(it.width.toFloat(), it.height.toFloat()) } - .imageEditorPointerInput(state, controller, scope) + .imageEditorPointerInput(state, controller, scope) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentTick) + } ) { state.revision @@ -151,9 +153,9 @@ private fun HiddenTextInput(controller: ImageController) { /** How far a finger travels before a touch on an element counts as a drag. */ private const val MAX_MOVE_SQUARED_BEFORE_DRAG = 10f -private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller: ImageController, scope: CoroutineScope): Modifier { +private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller: ImageController, scope: CoroutineScope, onRotationSnap: () -> Unit): Modifier { return this.pointerInput(controller, controller.textEditingElement) { - val touchHandler = ImageEditorTouchHandler() + val touchHandler = ImageEditorTouchHandler(onRotationSnap) // The tap that a second one has to land on, and beat the timeout from, to count as a double tap. var lastTapElement: EditorElement? = null diff --git a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ElementDragEditSession.java b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ElementDragEditSession.java index f1700ab8d9..103a00e2a7 100644 --- a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ElementDragEditSession.java +++ b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ElementDragEditSession.java @@ -9,14 +9,17 @@ import org.signal.imageeditor.core.model.EditorElement; final class ElementDragEditSession extends ElementEditSession { - private ElementDragEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) { + private final RotationSnapListener rotationSnapListener; + + private ElementDragEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix, @NonNull RotationSnapListener rotationSnapListener) { super(selected, inverseMatrix); + this.rotationSnapListener = rotationSnapListener; } - static ElementDragEditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull PointF point) { + static ElementDragEditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull PointF point, @NonNull RotationSnapListener rotationSnapListener) { if (!selected.getFlags().isEditable()) return null; - ElementDragEditSession elementDragEditSession = new ElementDragEditSession(selected, inverseViewModelMatrix); + ElementDragEditSession elementDragEditSession = new ElementDragEditSession(selected, inverseViewModelMatrix, rotationSnapListener); elementDragEditSession.setScreenStartPoint(0, point); elementDragEditSession.setScreenEndPoint(0, point); @@ -33,7 +36,7 @@ final class ElementDragEditSession extends ElementEditSession { @Override public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) { - return ElementScaleEditSession.startScale(this, newInverse, point, p); + return ElementScaleEditSession.startScale(this, newInverse, point, p, rotationSnapListener); } @Override diff --git a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ElementScaleEditSession.java b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ElementScaleEditSession.java index ec8d8421d3..2027377c65 100644 --- a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ElementScaleEditSession.java +++ b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ElementScaleEditSession.java @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.signal.imageeditor.core; import android.graphics.Matrix; @@ -9,13 +14,19 @@ import org.signal.imageeditor.core.model.EditorElement; final class ElementScaleEditSession extends ElementEditSession { - private ElementScaleEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) { + private final RotationSnapListener rotationSnapListener; + private final float initialRotationRadians; + private boolean wasRotationSnapped = true; // skip triggering listener on start of session + + private ElementScaleEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix, @NonNull RotationSnapListener rotationSnapListener, float initialRotationRadians) { super(selected, inverseMatrix); + this.rotationSnapListener = rotationSnapListener; + this.initialRotationRadians = initialRotationRadians; } - static ElementScaleEditSession startScale(@NonNull ElementDragEditSession session, @NonNull Matrix inverseMatrix, @NonNull PointF point, int p) { + static ElementScaleEditSession startScale(@NonNull ElementDragEditSession session, @NonNull Matrix inverseMatrix, @NonNull PointF point, int p, @NonNull RotationSnapListener rotationSnapListener) { session.commit(); - ElementScaleEditSession newSession = new ElementScaleEditSession(session.selected, inverseMatrix); + ElementScaleEditSession newSession = new ElementScaleEditSession(session.selected, inverseMatrix, rotationSnapListener, session.selected.getLocalRotationAngle()); newSession.setScreenStartPoint(1 - p, session.endPointScreen[0]); newSession.setScreenEndPoint(1 - p, session.endPointScreen[0]); newSession.setScreenStartPoint(p, point); @@ -38,9 +49,15 @@ final class ElementScaleEditSession extends ElementEditSession { editorMatrix.postScale(scale, scale); double angle = angle(endPointElement[0], endPointElement[1]) - angle(startPointElement[0], startPointElement[1]); + RotationSnapResult rotationSnapResult = RotationSnap.snapToAngle(initialRotationRadians, angle); + angle = rotationSnapResult.getAngleRadians(); if (!selected.getFlags().isRotateLocked()) { editorMatrix.postRotate((float) Math.toDegrees(angle)); + if (rotationSnapResult.getSnapped() && !wasRotationSnapped) { + rotationSnapListener.onRotationSnap(); + } + wasRotationSnapped = rotationSnapResult.getSnapped(); } editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y); @@ -71,7 +88,7 @@ final class ElementScaleEditSession extends ElementEditSession { } private ElementDragEditSession convertToDrag(int p, @NonNull Matrix inverse) { - return ElementDragEditSession.startDrag(selected, inverse, endPointScreen[1 - p]); + return ElementDragEditSession.startDrag(selected, inverse, endPointScreen[1 - p], rotationSnapListener); } /** diff --git a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.kt b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.kt index 91ed098f86..324d528924 100644 --- a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.kt +++ b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.kt @@ -24,7 +24,9 @@ import org.signal.imageeditor.core.renderers.TrashRenderer * Usage: call the on* methods in order as pointer events arrive. The handler manages * edit session state internally. */ -class ImageEditorTouchHandler { +class ImageEditorTouchHandler( + private val rotationSnapListener: RotationSnapListener +) { private var drawing: Boolean = false private var blur: Boolean = false @@ -159,7 +161,7 @@ class ImageEditorTouchHandler { point: PointF, selected: EditorElement? ): EditSession? { - val session = startMoveAndResizeSession(model, viewMatrix, inverse, point, selected) + val session = startMoveAndResizeSession(model, viewMatrix, inverse, point, selected, rotationSnapListener) if (session == null && drawing) { drawingSession = true return startDrawingSession(model, viewMatrix, point) @@ -191,7 +193,8 @@ class ImageEditorTouchHandler { viewMatrix: Matrix, inverse: Matrix, point: PointF, - selected: EditorElement? + selected: EditorElement?, + rotationSnapListener: RotationSnapListener ): EditSession? { if (selected == null) return null @@ -209,14 +212,15 @@ class ImageEditorTouchHandler { elementInverseMatrix, thumbContainerRelativeMatrix, thumb.controlPoint, - point + point, + rotationSnapListener ) } else { null } } - return ElementDragEditSession.startDrag(selected, inverse, point) + return ElementDragEditSession.startDrag(selected, inverse, point, rotationSnapListener) } } } diff --git a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorView.java b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorView.java index dcb41a293b..99a21de501 100644 --- a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorView.java +++ b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorView.java @@ -473,13 +473,13 @@ public final class ImageEditorView extends FrameLayout { elementInverseMatrix = model.findElementInverseMatrix(selected, viewMatrix); if (elementInverseMatrix != null) { - return ThumbDragEditSession.startDrag(selected, elementInverseMatrix, thumbContainerRelativeMatrix, thumb.getControlPoint(), point); + return ThumbDragEditSession.startDrag(selected, elementInverseMatrix, thumbContainerRelativeMatrix, thumb.getControlPoint(), point, () -> {}); } else { return null; } } - return ElementDragEditSession.startDrag(selected, inverse, point); + return ElementDragEditSession.startDrag(selected, inverse, point, () -> {}); } @NonNull diff --git a/lib/image-editor/src/main/java/org/signal/imageeditor/core/RotationSnap.kt b/lib/image-editor/src/main/java/org/signal/imageeditor/core/RotationSnap.kt new file mode 100644 index 0000000000..cd95206ad8 --- /dev/null +++ b/lib/image-editor/src/main/java/org/signal/imageeditor/core/RotationSnap.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.signal.imageeditor.core + +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.round + +internal data class RotationSnapResult( + val angleRadians: Double, + val snapped: Boolean +) + +internal object RotationSnap { + private const val SNAP_ANGLE_RADIANS = PI / 2.0 // 90 degrees in radians + private val SNAP_THRESHOLD_RADIANS = Math.toRadians(5.0) + + private fun snapToAngle(angleRadians: Double): RotationSnapResult { + val snappedAngle: Double = round(angleRadians / SNAP_ANGLE_RADIANS) * SNAP_ANGLE_RADIANS + + return if (isCloseEnoughToSnap(angleRadians, snappedAngle)) { + RotationSnapResult(snappedAngle, true) + } else { + RotationSnapResult(angleRadians, false) + } + } + + private fun isCloseEnoughToSnap(angleRadians: Double, snappedAngle: Double): Boolean { + return abs(angleRadians - snappedAngle) <= SNAP_THRESHOLD_RADIANS + } + + @JvmStatic + fun snapToAngle(baseAngleRadians: Double, relativeAngleRadians: Double): RotationSnapResult { + val absoluteAngle = baseAngleRadians + relativeAngleRadians + val snappedAbsoluteAngle = snapToAngle(absoluteAngle) + return RotationSnapResult(snappedAbsoluteAngle.angleRadians - baseAngleRadians, snappedAbsoluteAngle.snapped) + } +} diff --git a/lib/image-editor/src/main/java/org/signal/imageeditor/core/RotationSnapListener.kt b/lib/image-editor/src/main/java/org/signal/imageeditor/core/RotationSnapListener.kt new file mode 100644 index 0000000000..ffb4806ff5 --- /dev/null +++ b/lib/image-editor/src/main/java/org/signal/imageeditor/core/RotationSnapListener.kt @@ -0,0 +1,9 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.signal.imageeditor.core + +fun interface RotationSnapListener { + fun onRotationSnap() +} diff --git a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ThumbDragEditSession.java b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ThumbDragEditSession.java index 07a2c447ef..0d034f8de9 100644 --- a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ThumbDragEditSession.java +++ b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ThumbDragEditSession.java @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.signal.imageeditor.core; import android.graphics.Matrix; @@ -14,6 +19,10 @@ class ThumbDragEditSession extends ElementEditSession { private final float[] oppositeControlPointOnControlParent = new float[2]; private final float[] oppositeControlPointOnElement = new float[2]; + private final RotationSnapListener rotationSnapListener; + private final float initialRotationRadians; + private boolean wasRotationSnapped = true; // skip triggering listener on start of session + @NonNull private final ThumbRenderer.ControlPoint controlPoint; @NonNull private final Matrix thumbContainerRelativeMatrix; @@ -21,22 +30,27 @@ class ThumbDragEditSession extends ElementEditSession { private ThumbDragEditSession(@NonNull EditorElement selected, @NonNull ThumbRenderer.ControlPoint controlPoint, @NonNull Matrix inverseMatrix, - @NonNull Matrix thumbContainerRelativeMatrix) + @NonNull Matrix thumbContainerRelativeMatrix, + @NonNull RotationSnapListener rotationSnapListener, + float initialRotationRadians) { super(selected, inverseMatrix); this.controlPoint = controlPoint; this.thumbContainerRelativeMatrix = thumbContainerRelativeMatrix; + this.rotationSnapListener = rotationSnapListener; + this.initialRotationRadians = initialRotationRadians; } static EditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull Matrix thumbContainerRelativeMatrix, @NonNull ThumbRenderer.ControlPoint controlPoint, - @NonNull PointF point) + @NonNull PointF point, + @NonNull RotationSnapListener rotationSnapListener) { if (!selected.getFlags().isEditable()) return null; - ElementEditSession elementDragEditSession = new ThumbDragEditSession(selected, controlPoint, inverseViewModelMatrix, thumbContainerRelativeMatrix); + ElementEditSession elementDragEditSession = new ThumbDragEditSession(selected, controlPoint, inverseViewModelMatrix, thumbContainerRelativeMatrix, rotationSnapListener, selected.getLocalRotationAngle()); elementDragEditSession.setScreenStartPoint(0, point); elementDragEditSession.setScreenEndPoint(0, point); return elementDragEditSession; @@ -72,7 +86,15 @@ class ThumbDragEditSession extends ElementEditSession { editorMatrix.postTranslate(-oppositeControlPoint.x, -oppositeControlPoint.y); editorMatrix.postScale(scale, scale); double angle = angle(endPointElement[0], oppositeControlPoint) - angle(startPointElement[0], oppositeControlPoint); + + RotationSnapResult rotationSnapResult = RotationSnap.snapToAngle(initialRotationRadians, angle); + angle = rotationSnapResult.getAngleRadians(); rotate(editorMatrix, angle); + if (rotationSnapResult.getSnapped() && !wasRotationSnapped) { + rotationSnapListener.onRotationSnap(); + } + wasRotationSnapped = rotationSnapResult.getSnapped(); + editorMatrix.postTranslate(oppositeControlPoint.x, oppositeControlPoint.y); } else { // 8 point controls, where edges scale in just one dimension and corners scale in both, optionally fixed aspect ratio @@ -142,4 +164,4 @@ class ThumbDragEditSession extends ElementEditSession { float dy = a.y - b.y; return dx * dx + dy * dy; } -} \ No newline at end of file +}