mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
+5
-3
@@ -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
|
||||
|
||||
+7
-4
@@ -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
|
||||
|
||||
+21
-4
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+9
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+26
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user