mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-20 00:29:11 +01:00
Add new ImageEditor compose component and wire in crop and drawing tools.
This commit is contained in:
committed by
Cody Henthorne
parent
629b96dd20
commit
c2d927029a
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PointF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
import org.signal.imageeditor.core.model.EditorModel;
|
||||
import org.signal.imageeditor.core.model.ThumbRenderer;
|
||||
import org.signal.imageeditor.core.renderers.BezierDrawingRenderer;
|
||||
|
||||
/**
|
||||
* Public facade for touch handling on an {@link EditorModel}.
|
||||
* <p>
|
||||
* Encapsulates the {@link EditSession} creation and lifecycle so that callers outside
|
||||
* this package (e.g. a Compose component) can drive the editor without accessing
|
||||
* package-private classes directly.
|
||||
* <p>
|
||||
* Usage: call the on* methods in order as pointer events arrive. The handler manages
|
||||
* edit session state internally.
|
||||
*/
|
||||
public final class ImageEditorTouchHandler {
|
||||
|
||||
private boolean drawing;
|
||||
private boolean blur;
|
||||
private int drawColor = 0xff000000;
|
||||
private float drawThickness = 0.02f;
|
||||
@NonNull
|
||||
private Paint.Cap drawCap = Paint.Cap.ROUND;
|
||||
|
||||
@Nullable private EditSession editSession;
|
||||
private boolean moreThanOnePointerUsedInSession;
|
||||
|
||||
/** Configures whether the next gesture should create a drawing session if no element is hit. */
|
||||
public void setDrawing(boolean drawing, boolean blur) {
|
||||
this.drawing = drawing;
|
||||
this.blur = blur;
|
||||
}
|
||||
|
||||
/** Sets the brush parameters used when creating new drawing sessions. */
|
||||
public void setDrawingBrush(int color, float thickness, @NonNull Paint.Cap cap) {
|
||||
this.drawColor = color;
|
||||
this.drawThickness = thickness;
|
||||
this.drawCap = cap;
|
||||
}
|
||||
|
||||
/** Begins a new gesture. Creates either a move/resize, thumb drag, or drawing session. */
|
||||
@Nullable
|
||||
public EditorElement onDown(@NonNull EditorModel model, @NonNull Matrix viewMatrix, @NonNull PointF point) {
|
||||
Matrix inverse = new Matrix();
|
||||
EditorElement selected = model.findElementAtPoint(point, viewMatrix, inverse);
|
||||
|
||||
moreThanOnePointerUsedInSession = false;
|
||||
model.pushUndoPoint();
|
||||
editSession = startEdit(model, viewMatrix, inverse, point, selected);
|
||||
|
||||
return editSession != null ? editSession.getSelected() : null;
|
||||
}
|
||||
|
||||
/** Feeds pointer positions to the active session. Call for every move event. */
|
||||
public void onMove(@NonNull EditorModel model, @NonNull PointF[] pointers) {
|
||||
if (editSession == null) return;
|
||||
|
||||
int pointerCount = Math.min(2, pointers.length);
|
||||
for (int p = 0; p < pointerCount; p++) {
|
||||
editSession.movePoint(p, pointers[p]);
|
||||
}
|
||||
model.moving(editSession.getSelected());
|
||||
}
|
||||
|
||||
/** Transitions a single-finger session to a two-finger session (e.g. pinch-to-zoom). */
|
||||
public void onSecondPointerDown(@NonNull EditorModel model, @NonNull Matrix viewMatrix, @NonNull PointF newPointerPoint, int pointerIndex) {
|
||||
if (editSession == null) return;
|
||||
|
||||
moreThanOnePointerUsedInSession = true;
|
||||
editSession.commit();
|
||||
model.pushUndoPoint();
|
||||
|
||||
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
|
||||
if (newInverse != null) {
|
||||
editSession = editSession.newPoint(newInverse, newPointerPoint, pointerIndex);
|
||||
} else {
|
||||
editSession = null;
|
||||
}
|
||||
|
||||
if (editSession == null) {
|
||||
model.dragDropRelease();
|
||||
}
|
||||
}
|
||||
|
||||
/** Transitions a two-finger session back to single-finger when one pointer lifts. */
|
||||
public void onSecondPointerUp(@NonNull EditorModel model, @NonNull Matrix viewMatrix, int releasedIndex) {
|
||||
if (editSession == null) return;
|
||||
|
||||
editSession.commit();
|
||||
model.pushUndoPoint();
|
||||
model.dragDropRelease();
|
||||
|
||||
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
|
||||
if (newInverse != null) {
|
||||
editSession = editSession.removePoint(newInverse, releasedIndex);
|
||||
} else {
|
||||
editSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends the current gesture: commits the session and calls {@link EditorModel#postEdit}. */
|
||||
public void onUp(@NonNull EditorModel model) {
|
||||
if (editSession != null) {
|
||||
editSession.commit();
|
||||
model.dragDropRelease();
|
||||
editSession = null;
|
||||
}
|
||||
model.postEdit(moreThanOnePointerUsedInSession);
|
||||
}
|
||||
|
||||
public void cancel() {
|
||||
editSession = null;
|
||||
}
|
||||
|
||||
public boolean hasActiveSession() {
|
||||
return editSession != null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public EditorElement getSelected() {
|
||||
return editSession != null ? editSession.getSelected() : null;
|
||||
}
|
||||
|
||||
private @Nullable EditSession startEdit(
|
||||
@NonNull EditorModel model,
|
||||
@NonNull Matrix viewMatrix,
|
||||
@NonNull Matrix inverse,
|
||||
@NonNull PointF point,
|
||||
@Nullable EditorElement selected
|
||||
) {
|
||||
EditSession session = startMoveAndResizeSession(model, viewMatrix, inverse, point, selected);
|
||||
if (session == null && drawing) {
|
||||
return startDrawingSession(model, viewMatrix, point);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private @Nullable EditSession startDrawingSession(@NonNull EditorModel model, @NonNull Matrix viewMatrix, @NonNull PointF point) {
|
||||
BezierDrawingRenderer renderer = new BezierDrawingRenderer(drawColor, drawThickness * Bounds.FULL_BOUNDS.width(), drawCap, model.findCropRelativeToRoot());
|
||||
EditorElement element = new EditorElement(renderer, blur ? EditorModel.Z_MASK : EditorModel.Z_DRAWING);
|
||||
|
||||
model.addElementCentered(element, 1);
|
||||
|
||||
Matrix elementInverseMatrix = model.findElementInverseMatrix(element, viewMatrix);
|
||||
|
||||
return DrawingSession.start(element, renderer, elementInverseMatrix, point);
|
||||
}
|
||||
|
||||
private static @Nullable EditSession startMoveAndResizeSession(
|
||||
@NonNull EditorModel model,
|
||||
@NonNull Matrix viewMatrix,
|
||||
@NonNull Matrix inverse,
|
||||
@NonNull PointF point,
|
||||
@Nullable EditorElement selected
|
||||
) {
|
||||
if (selected == null) return null;
|
||||
|
||||
if (selected.getRenderer() instanceof ThumbRenderer) {
|
||||
ThumbRenderer thumb = (ThumbRenderer) selected.getRenderer();
|
||||
|
||||
EditorElement thumbControlledElement = model.findById(thumb.getElementToControl());
|
||||
if (thumbControlledElement == null) return null;
|
||||
|
||||
EditorElement thumbsParent = model.getRoot().findParent(selected);
|
||||
if (thumbsParent == null) return null;
|
||||
|
||||
Matrix thumbContainerRelativeMatrix = model.findRelativeMatrix(thumbsParent, thumbControlledElement);
|
||||
if (thumbContainerRelativeMatrix == null) return null;
|
||||
|
||||
selected = thumbControlledElement;
|
||||
|
||||
Matrix elementInverseMatrix = model.findElementInverseMatrix(selected, viewMatrix);
|
||||
if (elementInverseMatrix != null) {
|
||||
return ThumbDragEditSession.startDrag(selected, elementInverseMatrix, thumbContainerRelativeMatrix, thumb.getControlPoint(), point);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return ElementDragEditSession.startDrag(selected, inverse, point);
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||
public void setMainImageEditorMatrixRotation(float angle, float minScaleDown) {
|
||||
setEditorMatrixToRotationMatrixAboutParentsOrigin(editorElementHierarchy.getMainImage(), angle);
|
||||
scaleMainImageEditorMatrixToFitInsideCropBounds(minScaleDown, 2f);
|
||||
invalidate.run();
|
||||
}
|
||||
|
||||
private void scaleMainImageEditorMatrixToFitInsideCropBounds(float minScaleDown, float maxScaleUp) {
|
||||
@@ -276,6 +277,32 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||
return editorElementHierarchy.getRoot().findElement(element, viewMatrix, outInverseModelMatrix) == element;
|
||||
}
|
||||
|
||||
/** Serializes the current element tree for later restoration via {@link #restoreFromSnapshot}. */
|
||||
public byte[] createSnapshot() {
|
||||
return ElementStack.getBytes(editorElementHierarchy.getRoot());
|
||||
}
|
||||
|
||||
/** Restores the element tree from a snapshot created by {@link #createSnapshot}. */
|
||||
public void restoreFromSnapshot(@NonNull byte[] snapshot) {
|
||||
final EditorElement oldRootElement = editorElementHierarchy.getRoot();
|
||||
|
||||
Parcel parcel = Parcel.obtain();
|
||||
try {
|
||||
parcel.unmarshall(snapshot, 0, snapshot.length);
|
||||
parcel.setDataPosition(0);
|
||||
EditorElement newRoot = parcel.readParcelable(EditorElement.class.getClassLoader());
|
||||
if (newRoot != null) {
|
||||
setEditorElementHierarchy(EditorElementHierarchy.create(newRoot));
|
||||
restoreStateWithAnimations(oldRootElement, editorElementHierarchy.getRoot(), invalidate, false);
|
||||
invalidate.run();
|
||||
editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate);
|
||||
inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement());
|
||||
}
|
||||
} finally {
|
||||
parcel.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
public void pushUndoPoint() {
|
||||
boolean cropping = isCropping();
|
||||
if (cropping && !currentCropIsAcceptable()) {
|
||||
@@ -457,6 +484,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||
}
|
||||
|
||||
updateUndoRedoAvailableState(getActiveUndoRedoStacks(cropping));
|
||||
invalidate.run();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user