Implement proper text-entry component for large screen media send flow.

This commit is contained in:
Alex Hart
2026-04-15 12:51:15 -03:00
committed by jeffrey-signal
parent 2a8bd20bb0
commit b21a72153a
9 changed files with 718 additions and 256 deletions

View File

@@ -1,196 +0,0 @@
/*
* 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);
}
}

View File

@@ -0,0 +1,195 @@
/*
* 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 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 [EditorModel].
*
* Encapsulates the [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.
*
* Usage: call the on* methods in order as pointer events arrive. The handler manages
* edit session state internally.
*/
class ImageEditorTouchHandler {
private var drawing: Boolean = false
private var blur: Boolean = false
private var drawColor: Int = 0xff000000.toInt()
private var drawThickness: Float = 0.02f
private var drawCap: Paint.Cap = Paint.Cap.ROUND
private var editSession: EditSession? = null
private var moreThanOnePointerUsedInSession: Boolean = false
/** Configures whether the next gesture should create a drawing session if no element is hit. */
fun setDrawing(drawing: Boolean, blur: Boolean) {
this.drawing = drawing
this.blur = blur
}
/** Sets the brush parameters used when creating new drawing sessions. */
fun setDrawingBrush(color: Int, thickness: Float, cap: Paint.Cap) {
drawColor = color
drawThickness = thickness
drawCap = cap
}
/** Begins a new gesture. Creates either a move/resize, thumb drag, or drawing session. */
fun onDown(model: EditorModel, viewMatrix: Matrix, point: PointF): EditorElement? {
val inverse = Matrix()
val selected = model.findElementAtPoint(point, viewMatrix, inverse)
moreThanOnePointerUsedInSession = false
model.pushUndoPoint()
editSession = startEdit(model, viewMatrix, inverse, point, selected)
return editSession?.selected
}
/** Feeds pointer positions to the active session. Call for every move event. */
fun onMove(model: EditorModel, pointers: Array<PointF>) {
val currentEditSession = editSession ?: return
val pointerCount = minOf(2, pointers.size)
for (p in 0 until pointerCount) {
currentEditSession.movePoint(p, pointers[p])
}
model.moving(currentEditSession.selected)
}
/** Transitions a single-finger session to a two-finger session (e.g. pinch-to-zoom). */
fun onSecondPointerDown(model: EditorModel, viewMatrix: Matrix, newPointerPoint: PointF, pointerIndex: Int) {
val currentEditSession = editSession ?: return
moreThanOnePointerUsedInSession = true
currentEditSession.commit()
model.pushUndoPoint()
val newInverse = model.findElementInverseMatrix(currentEditSession.selected, viewMatrix)
editSession = if (newInverse != null) {
currentEditSession.newPoint(newInverse, newPointerPoint, pointerIndex)
} else {
null
}
if (editSession == null) {
model.dragDropRelease()
}
}
/** Transitions a two-finger session back to single-finger when one pointer lifts. */
fun onSecondPointerUp(model: EditorModel, viewMatrix: Matrix, releasedIndex: Int) {
val currentEditSession = editSession ?: return
currentEditSession.commit()
model.pushUndoPoint()
model.dragDropRelease()
val newInverse = model.findElementInverseMatrix(currentEditSession.selected, viewMatrix)
editSession = if (newInverse != null) {
currentEditSession.removePoint(newInverse, releasedIndex)
} else {
null
}
}
/** Ends the current gesture: commits the session and calls [EditorModel.postEdit]. */
fun onUp(model: EditorModel) {
editSession?.let {
it.commit()
model.dragDropRelease()
editSession = null
}
model.postEdit(moreThanOnePointerUsedInSession)
}
fun cancel() {
editSession = null
}
fun hasActiveSession(): Boolean {
return editSession != null
}
fun getSelected(): EditorElement? {
return editSession?.selected
}
private fun startEdit(
model: EditorModel,
viewMatrix: Matrix,
inverse: Matrix,
point: PointF,
selected: EditorElement?
): EditSession? {
val session = startMoveAndResizeSession(model, viewMatrix, inverse, point, selected)
if (session == null && drawing) {
return startDrawingSession(model, viewMatrix, point)
}
return session
}
private fun startDrawingSession(model: EditorModel, viewMatrix: Matrix, point: PointF): EditSession {
val renderer = BezierDrawingRenderer(
drawColor,
drawThickness * Bounds.FULL_BOUNDS.width(),
drawCap,
model.findCropRelativeToRoot()
)
val element = EditorElement(renderer, if (blur) EditorModel.Z_MASK else EditorModel.Z_DRAWING)
model.addElementCentered(element, 1f)
val elementInverseMatrix = model.findElementInverseMatrix(element, viewMatrix)
return DrawingSession.start(element, renderer, elementInverseMatrix, point)
}
private companion object {
fun startMoveAndResizeSession(
model: EditorModel,
viewMatrix: Matrix,
inverse: Matrix,
point: PointF,
selected: EditorElement?
): EditSession? {
if (selected == null) return null
if (selected.renderer is ThumbRenderer) {
val thumb = selected.renderer as ThumbRenderer
val thumbControlledElement = model.findById(thumb.elementToControl) ?: return null
val thumbsParent = model.root.findParent(selected) ?: return null
val thumbContainerRelativeMatrix = model.findRelativeMatrix(thumbsParent, thumbControlledElement) ?: return null
val elementInverseMatrix = model.findElementInverseMatrix(thumbControlledElement, viewMatrix)
return if (elementInverseMatrix != null) {
ThumbDragEditSession.startDrag(
thumbControlledElement,
elementInverseMatrix,
thumbContainerRelativeMatrix,
thumb.controlPoint,
point
)
} else {
null
}
}
return ElementDragEditSession.startDrag(selected, inverse, point)
}
}
}