2019-05-09 14:11:11 -03:00
|
|
|
package org.thoughtcrime.securesms.imageeditor;
|
|
|
|
|
|
|
|
import android.content.Context;
|
|
|
|
import android.graphics.Canvas;
|
|
|
|
import android.graphics.Matrix;
|
|
|
|
import android.graphics.Paint;
|
|
|
|
import android.graphics.Point;
|
|
|
|
import android.graphics.PointF;
|
|
|
|
import android.graphics.RectF;
|
|
|
|
import android.support.annotation.ColorInt;
|
|
|
|
import android.support.annotation.NonNull;
|
|
|
|
import android.support.annotation.Nullable;
|
|
|
|
import android.support.v4.view.GestureDetectorCompat;
|
|
|
|
import android.util.AttributeSet;
|
|
|
|
import android.view.GestureDetector;
|
|
|
|
import android.view.MotionEvent;
|
|
|
|
import android.widget.FrameLayout;
|
|
|
|
|
|
|
|
import org.thoughtcrime.securesms.imageeditor.model.EditorElement;
|
|
|
|
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
|
|
|
|
import org.thoughtcrime.securesms.imageeditor.model.ThumbRenderer;
|
|
|
|
import org.thoughtcrime.securesms.imageeditor.renderers.BezierDrawingRenderer;
|
|
|
|
import org.thoughtcrime.securesms.imageeditor.renderers.TextRenderer;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* ImageEditorView
|
|
|
|
* <p>
|
|
|
|
* Android {@link android.view.View} that allows manipulation of a base image, rotate/flip/crop and
|
|
|
|
* addition and manipulation of text/drawing/and other image layers that move with the base image.
|
|
|
|
* <p>
|
|
|
|
* Drawing
|
|
|
|
* <p>
|
|
|
|
* Drawing is achieved by setting the {@link #color} and putting the view in {@link Mode#Draw}.
|
|
|
|
* Touch events are then passed to a new {@link BezierDrawingRenderer} on a new {@link EditorElement}.
|
|
|
|
* <p>
|
|
|
|
* New images
|
|
|
|
* <p>
|
|
|
|
* To add new images to the base image add via the {@link EditorModel#addElementCentered(EditorElement, float)}
|
|
|
|
* which centers the new item in the current crop area.
|
|
|
|
*/
|
|
|
|
public final class ImageEditorView extends FrameLayout {
|
|
|
|
|
|
|
|
private HiddenEditText editText;
|
|
|
|
|
|
|
|
@NonNull
|
|
|
|
private Mode mode = Mode.MoveAndResize;
|
|
|
|
|
|
|
|
@ColorInt
|
|
|
|
private int color = 0xff000000;
|
|
|
|
|
|
|
|
private float thickness = 0.02f;
|
|
|
|
|
|
|
|
@NonNull
|
|
|
|
private Paint.Cap cap = Paint.Cap.ROUND;
|
|
|
|
|
|
|
|
private EditorModel model;
|
|
|
|
|
|
|
|
private GestureDetectorCompat doubleTap;
|
|
|
|
|
|
|
|
@Nullable
|
|
|
|
private DrawingChangedListener drawingChangedListener;
|
|
|
|
|
|
|
|
private final Matrix viewMatrix = new Matrix();
|
|
|
|
private final RectF viewPort = Bounds.newFullBounds();
|
|
|
|
private final RectF visibleViewPort = Bounds.newFullBounds();
|
|
|
|
private final RectF screen = new RectF();
|
|
|
|
|
|
|
|
private TapListener tapListener;
|
|
|
|
private RendererContext rendererContext;
|
|
|
|
|
|
|
|
@Nullable
|
|
|
|
private EditSession editSession;
|
2019-05-16 17:50:49 -03:00
|
|
|
private boolean moreThanOnePointerUsedInSession;
|
2019-05-09 14:11:11 -03:00
|
|
|
|
|
|
|
public ImageEditorView(Context context) {
|
|
|
|
super(context);
|
|
|
|
init();
|
|
|
|
}
|
|
|
|
|
|
|
|
public ImageEditorView(Context context, @Nullable AttributeSet attrs) {
|
|
|
|
super(context, attrs);
|
|
|
|
init();
|
|
|
|
}
|
|
|
|
|
|
|
|
public ImageEditorView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
|
|
|
super(context, attrs, defStyleAttr);
|
|
|
|
init();
|
|
|
|
}
|
|
|
|
|
|
|
|
private void init() {
|
|
|
|
setWillNotDraw(false);
|
|
|
|
setModel(new EditorModel());
|
|
|
|
|
|
|
|
editText = createAHiddenTextEntryField();
|
|
|
|
|
|
|
|
doubleTap = new GestureDetectorCompat(getContext(), new DoubleTapGestureListener());
|
|
|
|
|
|
|
|
setOnTouchListener((v, event) -> doubleTap.onTouchEvent(event));
|
|
|
|
}
|
|
|
|
|
|
|
|
private HiddenEditText createAHiddenTextEntryField() {
|
|
|
|
HiddenEditText editText = new HiddenEditText(getContext());
|
|
|
|
addView(editText);
|
|
|
|
editText.clearFocus();
|
|
|
|
editText.setOnEndEdit(this::doneTextEditing);
|
|
|
|
return editText;
|
|
|
|
}
|
|
|
|
|
|
|
|
public void startTextEditing(@NonNull EditorElement editorElement, boolean incognitoKeyboardEnabled, boolean selectAll) {
|
|
|
|
Renderer renderer = editorElement.getRenderer();
|
|
|
|
if (renderer instanceof TextRenderer) {
|
|
|
|
TextRenderer textRenderer = (TextRenderer) renderer;
|
|
|
|
|
|
|
|
editText.setIncognitoKeyboardEnabled(incognitoKeyboardEnabled);
|
|
|
|
editText.setCurrentTextEntity(textRenderer);
|
|
|
|
if (selectAll) {
|
|
|
|
editText.selectAll();
|
|
|
|
}
|
|
|
|
editText.requestFocus();
|
|
|
|
|
|
|
|
getModel().zoomTo(editorElement, Bounds.TOP / 2, true);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public boolean isTextEditing() {
|
|
|
|
return editText.getCurrentTextEntity() != null;
|
|
|
|
}
|
|
|
|
|
|
|
|
public void doneTextEditing() {
|
|
|
|
getModel().zoomOut();
|
|
|
|
if (editText.getCurrentTextEntity() != null) {
|
|
|
|
editText.setCurrentTextEntity(null);
|
|
|
|
editText.hideKeyboard();
|
|
|
|
if (tapListener != null) {
|
|
|
|
tapListener.onEntityDown(null);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
protected void onDraw(Canvas canvas) {
|
|
|
|
if (rendererContext == null || rendererContext.canvas != canvas) {
|
|
|
|
rendererContext = new RendererContext(getContext(), canvas, rendererReady, rendererInvalidate);
|
|
|
|
}
|
|
|
|
rendererContext.save();
|
|
|
|
try {
|
|
|
|
rendererContext.canvasMatrix.initial(viewMatrix);
|
|
|
|
model.draw(rendererContext);
|
|
|
|
} finally {
|
|
|
|
rendererContext.restore();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private final RendererContext.Ready rendererReady = new RendererContext.Ready() {
|
|
|
|
@Override
|
|
|
|
public void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size) {
|
|
|
|
model.onReady(renderer, cropMatrix, size);
|
|
|
|
invalidate();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
private final RendererContext.Invalidate rendererInvalidate = renderer -> invalidate();
|
|
|
|
|
|
|
|
@Override
|
|
|
|
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
|
|
|
super.onSizeChanged(w, h, oldw, oldh);
|
|
|
|
updateViewMatrix();
|
|
|
|
}
|
|
|
|
|
|
|
|
private void updateViewMatrix() {
|
|
|
|
screen.right = getWidth();
|
|
|
|
screen.bottom = getHeight();
|
|
|
|
|
|
|
|
viewMatrix.setRectToRect(viewPort, screen, Matrix.ScaleToFit.FILL);
|
|
|
|
|
|
|
|
float[] values = new float[9];
|
|
|
|
viewMatrix.getValues(values);
|
|
|
|
|
|
|
|
float scale = values[0] / values[4];
|
|
|
|
|
|
|
|
RectF tempViewPort = Bounds.newFullBounds();
|
|
|
|
if (scale < 1) {
|
|
|
|
tempViewPort.top /= scale;
|
|
|
|
tempViewPort.bottom /= scale;
|
|
|
|
} else {
|
|
|
|
tempViewPort.left *= scale;
|
|
|
|
tempViewPort.right *= scale;
|
|
|
|
}
|
|
|
|
|
|
|
|
visibleViewPort.set(tempViewPort);
|
|
|
|
|
|
|
|
viewMatrix.setRectToRect(visibleViewPort, screen, Matrix.ScaleToFit.CENTER);
|
|
|
|
|
|
|
|
model.setVisibleViewPort(visibleViewPort);
|
|
|
|
|
|
|
|
invalidate();
|
|
|
|
}
|
|
|
|
|
|
|
|
public void setModel(@NonNull EditorModel model) {
|
|
|
|
if (this.model != model) {
|
|
|
|
if (this.model != null) {
|
|
|
|
this.model.setInvalidate(null);
|
|
|
|
}
|
|
|
|
this.model = model;
|
|
|
|
this.model.setInvalidate(this::invalidate);
|
|
|
|
this.model.setVisibleViewPort(visibleViewPort);
|
|
|
|
invalidate();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public boolean onTouchEvent(MotionEvent event) {
|
|
|
|
switch (event.getActionMasked()) {
|
|
|
|
case MotionEvent.ACTION_DOWN: {
|
|
|
|
Matrix inverse = new Matrix();
|
|
|
|
PointF point = getPoint(event);
|
|
|
|
EditorElement selected = model.findElementAtPoint(point, viewMatrix, inverse);
|
|
|
|
|
2019-05-16 17:50:49 -03:00
|
|
|
moreThanOnePointerUsedInSession = false;
|
2019-05-09 14:11:11 -03:00
|
|
|
model.pushUndoPoint();
|
|
|
|
editSession = startEdit(inverse, point, selected);
|
|
|
|
|
|
|
|
if (tapListener != null && allowTaps()) {
|
|
|
|
if (editSession != null) {
|
|
|
|
tapListener.onEntityDown(editSession.getSelected());
|
|
|
|
} else {
|
|
|
|
tapListener.onEntityDown(null);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
case MotionEvent.ACTION_MOVE: {
|
|
|
|
if (editSession != null) {
|
2019-05-16 17:50:49 -03:00
|
|
|
int historySize = event.getHistorySize();
|
|
|
|
int pointerCount = Math.min(2, event.getPointerCount());
|
|
|
|
|
|
|
|
for (int h = 0; h < historySize; h++) {
|
|
|
|
for (int p = 0; p < pointerCount; p++) {
|
|
|
|
editSession.movePoint(p, getHistoricalPoint(event, p, h));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
for (int p = 0; p < pointerCount; p++) {
|
2019-05-09 14:11:11 -03:00
|
|
|
editSession.movePoint(p, getPoint(event, p));
|
|
|
|
}
|
2019-05-16 17:50:49 -03:00
|
|
|
model.moving(editSession.getSelected());
|
2019-05-09 14:11:11 -03:00
|
|
|
invalidate();
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
case MotionEvent.ACTION_POINTER_DOWN: {
|
|
|
|
if (editSession != null && event.getPointerCount() == 2) {
|
2019-05-16 17:50:49 -03:00
|
|
|
moreThanOnePointerUsedInSession = true;
|
2019-05-09 14:11:11 -03:00
|
|
|
editSession.commit();
|
|
|
|
model.pushUndoPoint();
|
|
|
|
|
|
|
|
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
|
2019-05-16 17:50:49 -03:00
|
|
|
if (newInverse != null) {
|
|
|
|
editSession = editSession.newPoint(newInverse, getPoint(event, event.getActionIndex()), event.getActionIndex());
|
|
|
|
} else {
|
|
|
|
editSession = null;
|
|
|
|
}
|
2019-05-09 14:11:11 -03:00
|
|
|
if (editSession == null) {
|
|
|
|
dragDropRelease();
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
case MotionEvent.ACTION_POINTER_UP: {
|
|
|
|
if (editSession != null && event.getActionIndex() < 2) {
|
|
|
|
editSession.commit();
|
|
|
|
model.pushUndoPoint();
|
|
|
|
dragDropRelease();
|
|
|
|
|
|
|
|
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
|
2019-05-16 17:50:49 -03:00
|
|
|
if (newInverse != null) {
|
|
|
|
editSession = editSession.removePoint(newInverse, event.getActionIndex());
|
|
|
|
} else {
|
|
|
|
editSession = null;
|
|
|
|
}
|
2019-05-09 14:11:11 -03:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
case MotionEvent.ACTION_UP: {
|
|
|
|
if (editSession != null) {
|
|
|
|
editSession.commit();
|
|
|
|
dragDropRelease();
|
|
|
|
|
|
|
|
editSession = null;
|
2019-05-16 17:50:49 -03:00
|
|
|
model.postEdit(moreThanOnePointerUsedInSession);
|
2019-05-09 14:11:11 -03:00
|
|
|
invalidate();
|
|
|
|
return true;
|
2019-05-16 17:50:49 -03:00
|
|
|
} else {
|
|
|
|
model.postEdit(moreThanOnePointerUsedInSession);
|
2019-05-09 14:11:11 -03:00
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return super.onTouchEvent(event);
|
|
|
|
}
|
|
|
|
|
|
|
|
private @Nullable EditSession startEdit(@NonNull Matrix inverse, @NonNull PointF point, @Nullable EditorElement selected) {
|
|
|
|
if (mode == Mode.Draw) {
|
|
|
|
return startADrawingSession(point);
|
|
|
|
} else {
|
|
|
|
return startAMoveAndResizeSession(inverse, point, selected);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private EditSession startADrawingSession(@NonNull PointF point) {
|
|
|
|
BezierDrawingRenderer renderer = new BezierDrawingRenderer(color, thickness * Bounds.FULL_BOUNDS.width(), cap, model.findCropRelativeToRoot());
|
|
|
|
EditorElement element = new EditorElement(renderer);
|
|
|
|
model.addElementCentered(element, 1);
|
|
|
|
|
|
|
|
Matrix elementInverseMatrix = model.findElementInverseMatrix(element, viewMatrix);
|
|
|
|
|
|
|
|
return DrawingSession.start(element, renderer, elementInverseMatrix, point);
|
|
|
|
}
|
|
|
|
|
|
|
|
private EditSession startAMoveAndResizeSession(@NonNull Matrix inverse, @NonNull PointF point, @Nullable EditorElement selected) {
|
|
|
|
Matrix elementInverseMatrix;
|
|
|
|
if (selected == null) return null;
|
|
|
|
|
|
|
|
if (selected.getRenderer() instanceof ThumbRenderer) {
|
|
|
|
ThumbRenderer thumb = (ThumbRenderer) selected.getRenderer();
|
|
|
|
|
|
|
|
selected = getModel().findById(thumb.getElementToControl());
|
|
|
|
|
|
|
|
if (selected == null) return null;
|
|
|
|
|
|
|
|
elementInverseMatrix = model.findElementInverseMatrix(selected, viewMatrix);
|
|
|
|
if (elementInverseMatrix != null) {
|
|
|
|
return ThumbDragEditSession.startDrag(selected, elementInverseMatrix, thumb.getControlPoint(), point);
|
|
|
|
} else {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return ElementDragEditSession.startDrag(selected, inverse, point);
|
|
|
|
}
|
|
|
|
|
|
|
|
public void setMode(@NonNull Mode mode) {
|
|
|
|
this.mode = mode;
|
|
|
|
}
|
|
|
|
|
|
|
|
public void startDrawing(float thickness, @NonNull Paint.Cap cap) {
|
|
|
|
this.thickness = thickness;
|
|
|
|
this.cap = cap;
|
|
|
|
setMode(Mode.Draw);
|
|
|
|
}
|
|
|
|
|
|
|
|
public void setDrawingBrushColor(int color) {
|
|
|
|
this.color = color;
|
|
|
|
}
|
|
|
|
|
|
|
|
private void dragDropRelease() {
|
|
|
|
model.dragDropRelease();
|
|
|
|
if (drawingChangedListener != null) {
|
|
|
|
drawingChangedListener.onDrawingChanged();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private static PointF getPoint(MotionEvent event) {
|
|
|
|
return getPoint(event, 0);
|
|
|
|
}
|
|
|
|
|
|
|
|
private static PointF getPoint(MotionEvent event, int p) {
|
|
|
|
return new PointF(event.getX(p), event.getY(p));
|
|
|
|
}
|
|
|
|
|
2019-05-16 17:50:49 -03:00
|
|
|
private static PointF getHistoricalPoint(MotionEvent event, int p, int historicalIndex) {
|
|
|
|
return new PointF(event.getHistoricalX(p, historicalIndex),
|
|
|
|
event.getHistoricalY(p, historicalIndex));
|
|
|
|
}
|
|
|
|
|
2019-05-09 14:11:11 -03:00
|
|
|
public EditorModel getModel() {
|
|
|
|
return model;
|
|
|
|
}
|
|
|
|
|
|
|
|
public void setDrawingChangedListener(@Nullable DrawingChangedListener drawingChangedListener) {
|
|
|
|
this.drawingChangedListener = drawingChangedListener;
|
|
|
|
}
|
|
|
|
|
|
|
|
public void setTapListener(TapListener tapListener) {
|
|
|
|
this.tapListener = tapListener;
|
|
|
|
}
|
|
|
|
|
|
|
|
public void deleteElement(@Nullable EditorElement editorElement) {
|
|
|
|
if (editorElement != null) {
|
|
|
|
model.pushUndoPoint();
|
|
|
|
model.delete(editorElement);
|
|
|
|
invalidate();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private final class DoubleTapGestureListener extends GestureDetector.SimpleOnGestureListener {
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public boolean onDoubleTap(MotionEvent e) {
|
|
|
|
if (tapListener != null && editSession != null && allowTaps()) {
|
|
|
|
tapListener.onEntityDoubleTap(editSession.getSelected());
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public void onLongPress(MotionEvent e) {}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public boolean onSingleTapUp(MotionEvent e) {
|
|
|
|
if (tapListener != null && allowTaps()) {
|
|
|
|
if (editSession != null) {
|
|
|
|
EditorElement selected = editSession.getSelected();
|
|
|
|
model.indicateSelected(selected);
|
|
|
|
tapListener.onEntitySingleTap(selected);
|
|
|
|
} else {
|
|
|
|
tapListener.onEntitySingleTap(null);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public boolean onDown(MotionEvent e) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private boolean allowTaps() {
|
|
|
|
return !model.isCropping() && mode != Mode.Draw;
|
|
|
|
}
|
|
|
|
|
|
|
|
public enum Mode {
|
|
|
|
MoveAndResize,
|
|
|
|
Draw
|
|
|
|
}
|
|
|
|
|
|
|
|
public interface DrawingChangedListener {
|
|
|
|
void onDrawingChanged();
|
|
|
|
}
|
|
|
|
|
|
|
|
public interface TapListener {
|
|
|
|
|
|
|
|
void onEntityDown(@Nullable EditorElement editorElement);
|
|
|
|
|
|
|
|
void onEntitySingleTap(@Nullable EditorElement editorElement);
|
|
|
|
|
|
|
|
void onEntityDoubleTap(@NonNull EditorElement editorElement);
|
|
|
|
}
|
|
|
|
}
|