mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 04:58:45 +00:00
Initial modularization of core image editor code.
This commit is contained in:
committed by
Greyson Parrelli
parent
5d5251054c
commit
95fabd7ed1
1
image-editor/lib/.gitignore
vendored
Normal file
1
image-editor/lib/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
47
image-editor/lib/build.gradle
Normal file
47
image-editor/lib/build.gradle
Normal file
@@ -0,0 +1,47 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'witness'
|
||||
id 'kotlin-android'
|
||||
id 'kotlin-kapt'
|
||||
}
|
||||
|
||||
apply from: 'witness-verifications.gradle'
|
||||
|
||||
android {
|
||||
buildToolsVersion BUILD_TOOL_VERSION
|
||||
compileSdkVersion COMPILE_SDK
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion MINIMUM_SDK
|
||||
targetSdkVersion TARGET_SDK
|
||||
multiDexEnabled true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility JAVA_VERSION
|
||||
targetCompatibility JAVA_VERSION
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
}
|
||||
|
||||
dependencyVerification {
|
||||
configuration = '(debug|release)RuntimeClasspath'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
lintChecks project(':lintchecks')
|
||||
|
||||
implementation project(':core-util')
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.5.0'
|
||||
implementation 'androidx.annotation:annotation:1.2.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.2.0'
|
||||
|
||||
kapt 'androidx.annotation:annotation:1.2.0'
|
||||
}
|
||||
0
image-editor/lib/consumer-rules.pro
Normal file
0
image-editor/lib/consumer-rules.pro
Normal file
21
image-editor/lib/proguard-rules.pro
vendored
Normal file
21
image-editor/lib/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
5
image-editor/lib/src/main/AndroidManifest.xml
Normal file
5
image-editor/lib/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="org.signal.imageeditor">
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.RectF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
|
||||
/**
|
||||
* The local extent of a {@link EditorElement}.
|
||||
* i.e. all {@link EditorElement}s have a bounding rectangle from:
|
||||
* <p>
|
||||
* {@link #LEFT} to {@link #RIGHT} and from {@link #TOP} to {@link #BOTTOM}.
|
||||
*/
|
||||
public final class Bounds {
|
||||
|
||||
public static final float LEFT = -1000f;
|
||||
public static final float RIGHT = 1000f;
|
||||
|
||||
public static final float TOP = -1000f;
|
||||
public static final float BOTTOM = 1000f;
|
||||
|
||||
public static final float CENTRE_X = (LEFT + RIGHT) / 2f;
|
||||
public static final float CENTRE_Y = (TOP + BOTTOM) / 2f;
|
||||
|
||||
public static final float[] CENTRE = new float[]{ CENTRE_X, CENTRE_Y };
|
||||
|
||||
private static final float[] POINTS = { Bounds.LEFT, Bounds.TOP,
|
||||
Bounds.RIGHT, Bounds.TOP,
|
||||
Bounds.RIGHT, Bounds.BOTTOM,
|
||||
Bounds.LEFT, Bounds.BOTTOM };
|
||||
|
||||
static RectF newFullBounds() {
|
||||
return new RectF(LEFT, TOP, RIGHT, BOTTOM);
|
||||
}
|
||||
|
||||
public static RectF FULL_BOUNDS = newFullBounds();
|
||||
|
||||
public static boolean contains(float x, float y) {
|
||||
return x >= FULL_BOUNDS.left && x <= FULL_BOUNDS.right &&
|
||||
y >= FULL_BOUNDS.top && y <= FULL_BOUNDS.bottom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps all the points of bounds with the supplied matrix and determines whether they are still in bounds.
|
||||
*
|
||||
* @param matrix matrix to transform points by, null is treated as identity.
|
||||
* @return true iff all points remain in bounds after transformation.
|
||||
*/
|
||||
public static boolean boundsRemainInBounds(@Nullable Matrix matrix) {
|
||||
if (matrix == null) return true;
|
||||
|
||||
float[] dst = new float[POINTS.length];
|
||||
|
||||
matrix.mapPoints(dst, POINTS);
|
||||
|
||||
return allWithinBounds(dst);
|
||||
}
|
||||
|
||||
private static boolean allWithinBounds(@NonNull float[] points) {
|
||||
boolean allHit = true;
|
||||
|
||||
for (int i = 0; i < points.length / 2; i++) {
|
||||
float x = points[2 * i];
|
||||
float y = points[2 * i + 1];
|
||||
|
||||
if (!Bounds.contains(x, y)) {
|
||||
allHit = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allHit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.RectF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
/**
|
||||
* Tracks the current matrix for a canvas.
|
||||
* <p>
|
||||
* This is because you cannot reliably call {@link Canvas#setMatrix(Matrix)}.
|
||||
* {@link Canvas#getMatrix()} provides this hint in its documentation:
|
||||
* "track relevant transform state outside of the canvas."
|
||||
* <p>
|
||||
* To achieve this, any changes to the canvas matrix must be done via this class, including save and
|
||||
* restore operations where the matrix was altered in between.
|
||||
*/
|
||||
public final class CanvasMatrix {
|
||||
|
||||
private final static int STACK_HEIGHT_LIMIT = 16;
|
||||
|
||||
private final Canvas canvas;
|
||||
private final Matrix canvasMatrix = new Matrix();
|
||||
private final Matrix temp = new Matrix();
|
||||
private final Matrix[] stack = new Matrix[STACK_HEIGHT_LIMIT];
|
||||
private int stackHeight;
|
||||
|
||||
CanvasMatrix(Canvas canvas) {
|
||||
this.canvas = canvas;
|
||||
for (int i = 0; i < stack.length; i++) {
|
||||
stack[i] = new Matrix();
|
||||
}
|
||||
}
|
||||
|
||||
public void concat(@NonNull Matrix matrix) {
|
||||
canvas.concat(matrix);
|
||||
canvasMatrix.preConcat(matrix);
|
||||
}
|
||||
|
||||
void save() {
|
||||
canvas.save();
|
||||
if (stackHeight == STACK_HEIGHT_LIMIT) {
|
||||
throw new AssertionError("Not enough space on stack");
|
||||
}
|
||||
stack[stackHeight++].set(canvasMatrix);
|
||||
}
|
||||
|
||||
void restore() {
|
||||
canvas.restore();
|
||||
canvasMatrix.set(stack[--stackHeight]);
|
||||
}
|
||||
|
||||
void getCurrent(@NonNull Matrix into) {
|
||||
into.set(canvasMatrix);
|
||||
}
|
||||
|
||||
public void setToIdentity() {
|
||||
if (canvasMatrix.invert(temp)) {
|
||||
concat(temp);
|
||||
}
|
||||
}
|
||||
|
||||
public void initial(Matrix viewMatrix) {
|
||||
concat(viewMatrix);
|
||||
}
|
||||
|
||||
boolean mapRect(@NonNull RectF dst, @NonNull RectF src) {
|
||||
return canvasMatrix.mapRect(dst, src);
|
||||
}
|
||||
|
||||
public void mapPoints(float[] dst, float[] src) {
|
||||
canvasMatrix.mapPoints(dst, src);
|
||||
}
|
||||
|
||||
public void copyTo(@NonNull Matrix matrix) {
|
||||
matrix.set(canvasMatrix);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
|
||||
/**
|
||||
* A renderer that can have its color changed.
|
||||
* <p>
|
||||
* For example, Lines and Text can change color.
|
||||
*/
|
||||
public interface ColorableRenderer extends Renderer {
|
||||
|
||||
@ColorInt
|
||||
int getColor();
|
||||
|
||||
void setColor(@ColorInt int color);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
import org.signal.imageeditor.core.renderers.BezierDrawingRenderer;
|
||||
|
||||
/**
|
||||
* Passes touch events into a {@link BezierDrawingRenderer}.
|
||||
*/
|
||||
class DrawingSession extends ElementEditSession {
|
||||
|
||||
private final BezierDrawingRenderer renderer;
|
||||
|
||||
private DrawingSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix, @NonNull BezierDrawingRenderer renderer) {
|
||||
super(selected, inverseMatrix);
|
||||
this.renderer = renderer;
|
||||
}
|
||||
|
||||
public static EditSession start(EditorElement element, BezierDrawingRenderer renderer, Matrix inverseMatrix, PointF point) {
|
||||
DrawingSession drawingSession = new DrawingSession(element, inverseMatrix, renderer);
|
||||
drawingSession.setScreenStartPoint(0, point);
|
||||
renderer.setFirstPoint(drawingSession.startPointElement[0]);
|
||||
return drawingSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void movePoint(int p, @NonNull PointF point) {
|
||||
if (p != 0) return;
|
||||
setScreenEndPoint(p, point);
|
||||
renderer.addNewPoint(endPointElement[0]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession removePoint(@NonNull Matrix newInverse, int p) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
|
||||
/**
|
||||
* Represents an underway edit of the image.
|
||||
* <p>
|
||||
* Accepts new touch positions, new touch points, released touch points and when complete can commit the edit.
|
||||
* <p>
|
||||
* Examples of edit session implementations are, Drag, Draw, Resize:
|
||||
* <p>
|
||||
* {@link ElementDragEditSession} for dragging with a single finger.
|
||||
* {@link ElementScaleEditSession} for resize/dragging with two fingers.
|
||||
* {@link DrawingSession} for drawing with a single finger.
|
||||
*/
|
||||
interface EditSession {
|
||||
|
||||
void movePoint(int p, @NonNull PointF point);
|
||||
|
||||
EditorElement getSelected();
|
||||
|
||||
EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p);
|
||||
|
||||
EditSession removePoint(@NonNull Matrix newInverse, int p);
|
||||
|
||||
void commit();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
|
||||
final class ElementDragEditSession extends ElementEditSession {
|
||||
|
||||
private ElementDragEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) {
|
||||
super(selected, inverseMatrix);
|
||||
}
|
||||
|
||||
static ElementDragEditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull PointF point) {
|
||||
if (!selected.getFlags().isEditable()) return null;
|
||||
|
||||
ElementDragEditSession elementDragEditSession = new ElementDragEditSession(selected, inverseViewModelMatrix);
|
||||
elementDragEditSession.setScreenStartPoint(0, point);
|
||||
elementDragEditSession.setScreenEndPoint(0, point);
|
||||
|
||||
return elementDragEditSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void movePoint(int p, @NonNull PointF point) {
|
||||
setScreenEndPoint(p, point);
|
||||
|
||||
selected.getEditorMatrix()
|
||||
.setTranslate(endPointElement[0].x - startPointElement[0].x, endPointElement[0].y - startPointElement[0].y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) {
|
||||
return ElementScaleEditSession.startScale(this, newInverse, point, p);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession removePoint(@NonNull Matrix newInverse, int p) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
|
||||
abstract class ElementEditSession implements EditSession {
|
||||
|
||||
private final Matrix inverseMatrix;
|
||||
|
||||
final EditorElement selected;
|
||||
|
||||
final PointF[] startPointElement = newTwoPointArray();
|
||||
final PointF[] endPointElement = newTwoPointArray();
|
||||
final PointF[] startPointScreen = newTwoPointArray();
|
||||
final PointF[] endPointScreen = newTwoPointArray();
|
||||
|
||||
ElementEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) {
|
||||
this.selected = selected;
|
||||
this.inverseMatrix = inverseMatrix;
|
||||
}
|
||||
|
||||
void setScreenStartPoint(int p, @NonNull PointF point) {
|
||||
startPointScreen[p] = point;
|
||||
mapPoint(startPointElement[p], inverseMatrix, point);
|
||||
}
|
||||
|
||||
void setScreenEndPoint(int p, @NonNull PointF point) {
|
||||
endPointScreen[p] = point;
|
||||
mapPoint(endPointElement[p], inverseMatrix, point);
|
||||
}
|
||||
|
||||
@Override
|
||||
public abstract void movePoint(int p, @NonNull PointF point);
|
||||
|
||||
@Override
|
||||
public void commit() {
|
||||
selected.commitEditorMatrix();
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditorElement getSelected() {
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static PointF[] newTwoPointArray() {
|
||||
PointF[] array = new PointF[2];
|
||||
for (int i = 0; i < array.length; i++) {
|
||||
array[i] = new PointF();
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map src to dst using the matrix.
|
||||
*
|
||||
* @param dst Output point.
|
||||
* @param matrix Matrix to transform point with.
|
||||
* @param src Input point.
|
||||
*/
|
||||
private static void mapPoint(@NonNull PointF dst, @NonNull Matrix matrix, @NonNull PointF src) {
|
||||
float[] in = { src.x, src.y };
|
||||
float[] out = new float[2];
|
||||
matrix.mapPoints(out, in);
|
||||
dst.set(out[0], out[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
|
||||
final class ElementScaleEditSession extends ElementEditSession {
|
||||
|
||||
private ElementScaleEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) {
|
||||
super(selected, inverseMatrix);
|
||||
}
|
||||
|
||||
static ElementScaleEditSession startScale(@NonNull ElementDragEditSession session, @NonNull Matrix inverseMatrix, @NonNull PointF point, int p) {
|
||||
session.commit();
|
||||
ElementScaleEditSession newSession = new ElementScaleEditSession(session.selected, inverseMatrix);
|
||||
newSession.setScreenStartPoint(1 - p, session.endPointScreen[0]);
|
||||
newSession.setScreenEndPoint(1 - p, session.endPointScreen[0]);
|
||||
newSession.setScreenStartPoint(p, point);
|
||||
newSession.setScreenEndPoint(p, point);
|
||||
return newSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void movePoint(int p, @NonNull PointF point) {
|
||||
setScreenEndPoint(p, point);
|
||||
Matrix editorMatrix = selected.getEditorMatrix();
|
||||
|
||||
editorMatrix.reset();
|
||||
|
||||
if (selected.getFlags().isAspectLocked()) {
|
||||
|
||||
float scale = (float) findScale(startPointElement, endPointElement);
|
||||
|
||||
editorMatrix.postTranslate(-startPointElement[0].x, -startPointElement[0].y);
|
||||
editorMatrix.postScale(scale, scale);
|
||||
|
||||
double angle = angle(endPointElement[0], endPointElement[1]) - angle(startPointElement[0], startPointElement[1]);
|
||||
|
||||
if (!selected.getFlags().isRotateLocked()) {
|
||||
editorMatrix.postRotate((float) Math.toDegrees(angle));
|
||||
}
|
||||
|
||||
editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y);
|
||||
} else {
|
||||
editorMatrix.postTranslate(-startPointElement[0].x, -startPointElement[0].y);
|
||||
|
||||
float scaleX = (endPointElement[1].x - endPointElement[0].x) / (startPointElement[1].x - startPointElement[0].x);
|
||||
float scaleY = (endPointElement[1].y - endPointElement[0].y) / (startPointElement[1].y - startPointElement[0].y);
|
||||
|
||||
editorMatrix.postScale(scaleX, scaleY);
|
||||
|
||||
editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession removePoint(@NonNull Matrix newInverse, int p) {
|
||||
return convertToDrag(p, newInverse);
|
||||
}
|
||||
|
||||
private static double angle(@NonNull PointF a, @NonNull PointF b) {
|
||||
return Math.atan2(a.y - b.y, a.x - b.x);
|
||||
}
|
||||
|
||||
private ElementDragEditSession convertToDrag(int p, @NonNull Matrix inverse) {
|
||||
return ElementDragEditSession.startDrag(selected, inverse, endPointScreen[1 - p]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find relative distance between an old and new set of Points.
|
||||
*
|
||||
* @param from Pair of points.
|
||||
* @param to New pair of points.
|
||||
* @return Scale
|
||||
*/
|
||||
private static double findScale(@NonNull PointF[] from, @NonNull PointF[] to) {
|
||||
float originalD2 = getDistanceSquared(from[0], from[1]);
|
||||
float newD2 = getDistanceSquared(to[0], to[1]);
|
||||
return Math.sqrt(newD2 / originalD2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance between two points squared.
|
||||
*/
|
||||
private static float getDistanceSquared(@NonNull PointF a, @NonNull PointF b) {
|
||||
float dx = a.x - b.x;
|
||||
float dy = a.y - b.y;
|
||||
return dx * dx + dy * dy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Rect;
|
||||
import android.text.InputType;
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
import org.signal.imageeditor.core.renderers.MultiLineTextRenderer;
|
||||
|
||||
/**
|
||||
* Invisible {@link android.widget.EditText} that is used during in-image text editing.
|
||||
*/
|
||||
public final class HiddenEditText extends androidx.appcompat.widget.AppCompatEditText {
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private static final int INCOGNITO_KEYBOARD_IME = EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING;
|
||||
|
||||
@Nullable
|
||||
private EditorElement currentTextEditorElement;
|
||||
|
||||
@Nullable
|
||||
private MultiLineTextRenderer currentTextEntity;
|
||||
|
||||
@Nullable
|
||||
private Runnable onEndEdit;
|
||||
|
||||
@Nullable
|
||||
private OnEditOrSelectionChange onEditOrSelectionChange;
|
||||
|
||||
public HiddenEditText(Context context) {
|
||||
super(context);
|
||||
setAlpha(0);
|
||||
setLayoutParams(new FrameLayout.LayoutParams(1, 1, Gravity.TOP | Gravity.START));
|
||||
setClickable(false);
|
||||
setFocusable(true);
|
||||
setFocusableInTouchMode(true);
|
||||
setBackgroundColor(Color.TRANSPARENT);
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 1);
|
||||
setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE);
|
||||
clearFocus();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) {
|
||||
super.onTextChanged(text, start, lengthBefore, lengthAfter);
|
||||
if (currentTextEntity != null) {
|
||||
currentTextEntity.setText(text.toString());
|
||||
postEditOrSelectionChange();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEditorAction(int actionCode) {
|
||||
super.onEditorAction(actionCode);
|
||||
if (actionCode == EditorInfo.IME_ACTION_DONE && currentTextEntity != null) {
|
||||
currentTextEntity.setFocused(false);
|
||||
endEdit();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) {
|
||||
super.onFocusChanged(focused, direction, previouslyFocusedRect);
|
||||
if (currentTextEntity != null) {
|
||||
currentTextEntity.setFocused(focused);
|
||||
if (!focused) {
|
||||
endEdit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void endEdit() {
|
||||
if (onEndEdit != null) {
|
||||
onEndEdit.run();
|
||||
}
|
||||
}
|
||||
|
||||
private void postEditOrSelectionChange() {
|
||||
if (currentTextEditorElement != null && currentTextEntity != null && onEditOrSelectionChange != null) {
|
||||
onEditOrSelectionChange.onChange(currentTextEditorElement, currentTextEntity);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable MultiLineTextRenderer getCurrentTextEntity() {
|
||||
return currentTextEntity;
|
||||
}
|
||||
|
||||
@Nullable EditorElement getCurrentTextEditorElement() {
|
||||
return currentTextEditorElement;
|
||||
}
|
||||
|
||||
public void setCurrentTextEditorElement(@Nullable EditorElement currentTextEditorElement) {
|
||||
if (currentTextEditorElement != null && currentTextEditorElement.getRenderer() instanceof MultiLineTextRenderer) {
|
||||
this.currentTextEditorElement = currentTextEditorElement;
|
||||
setCurrentTextEntity((MultiLineTextRenderer) currentTextEditorElement.getRenderer());
|
||||
} else {
|
||||
this.currentTextEditorElement = null;
|
||||
setCurrentTextEntity(null);
|
||||
}
|
||||
|
||||
postEditOrSelectionChange();
|
||||
}
|
||||
|
||||
private void setCurrentTextEntity(@Nullable MultiLineTextRenderer currentTextEntity) {
|
||||
if (this.currentTextEntity != currentTextEntity) {
|
||||
if (this.currentTextEntity != null) {
|
||||
this.currentTextEntity.setFocused(false);
|
||||
}
|
||||
this.currentTextEntity = currentTextEntity;
|
||||
if (currentTextEntity != null) {
|
||||
String text = currentTextEntity.getText();
|
||||
setText(text);
|
||||
setSelection(text.length());
|
||||
} else {
|
||||
setText("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSelectionChanged(int selStart, int selEnd) {
|
||||
super.onSelectionChanged(selStart, selEnd);
|
||||
if (currentTextEntity != null) {
|
||||
currentTextEntity.setSelection(selStart, selEnd);
|
||||
postEditOrSelectionChange();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requestFocus(int direction, Rect previouslyFocusedRect) {
|
||||
boolean focus = super.requestFocus(direction, previouslyFocusedRect);
|
||||
|
||||
if (currentTextEntity != null && focus) {
|
||||
currentTextEntity.setFocused(true);
|
||||
InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT);
|
||||
if (!imm.isAcceptingText()) {
|
||||
imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, InputMethodManager.HIDE_IMPLICIT_ONLY);
|
||||
}
|
||||
}
|
||||
|
||||
return focus;
|
||||
}
|
||||
|
||||
public void hideKeyboard() {
|
||||
InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
imm.hideSoftInputFromWindow(getWindowToken(), InputMethodManager.HIDE_IMPLICIT_ONLY);
|
||||
}
|
||||
|
||||
public void setIncognitoKeyboardEnabled(boolean incognitoKeyboardEnabled) {
|
||||
setImeOptions(incognitoKeyboardEnabled ? getImeOptions() | INCOGNITO_KEYBOARD_IME
|
||||
: getImeOptions() & ~INCOGNITO_KEYBOARD_IME);
|
||||
}
|
||||
|
||||
public void setOnEndEdit(@Nullable Runnable onEndEdit) {
|
||||
this.onEndEdit = onEndEdit;
|
||||
}
|
||||
|
||||
public void setOnEditOrSelectionChange(@Nullable OnEditOrSelectionChange onEditOrSelectionChange) {
|
||||
this.onEditOrSelectionChange = onEditOrSelectionChange;
|
||||
}
|
||||
|
||||
public interface OnEditOrSelectionChange {
|
||||
void onChange(@NonNull EditorElement editorElement, @NonNull MultiLineTextRenderer textRenderer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
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.util.AttributeSet;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.MotionEvent;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.view.GestureDetectorCompat;
|
||||
|
||||
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;
|
||||
import org.signal.imageeditor.core.renderers.MultiLineTextRenderer;
|
||||
import org.signal.imageeditor.core.renderers.TrashRenderer;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@Nullable
|
||||
private SizeChangedListener sizeChangedListener;
|
||||
|
||||
@Nullable
|
||||
private UndoRedoStackListener undoRedoStackListener;
|
||||
|
||||
@Nullable
|
||||
private DragListener dragListener;
|
||||
|
||||
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;
|
||||
private boolean moreThanOnePointerUsedInSession;
|
||||
|
||||
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(EditorModel.create());
|
||||
|
||||
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);
|
||||
editText.setOnEditOrSelectionChange(this::zoomToFitText);
|
||||
return editText;
|
||||
}
|
||||
|
||||
public void startTextEditing(@NonNull EditorElement editorElement) {
|
||||
getModel().addFade();
|
||||
if (editorElement.getRenderer() instanceof MultiLineTextRenderer) {
|
||||
editText.setCurrentTextEditorElement(editorElement);
|
||||
}
|
||||
}
|
||||
|
||||
public void zoomToFitText(@NonNull EditorElement editorElement, @NonNull MultiLineTextRenderer textRenderer) {
|
||||
getModel().zoomToTextElement(editorElement, textRenderer);
|
||||
}
|
||||
|
||||
public boolean isTextEditing() {
|
||||
return editText.getCurrentTextEntity() != null;
|
||||
}
|
||||
|
||||
public void doneTextEditing() {
|
||||
getModel().zoomOut();
|
||||
getModel().removeFade();
|
||||
if (editText.getCurrentTextEntity() != null) {
|
||||
editText.setCurrentTextEditorElement(null);
|
||||
editText.hideKeyboard();
|
||||
}
|
||||
}
|
||||
|
||||
@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, editText.getCurrentTextEditorElement());
|
||||
} 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();
|
||||
if (sizeChangedListener != null) {
|
||||
sizeChangedListener.onSizeChanged(w, h);
|
||||
}
|
||||
}
|
||||
|
||||
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.setUndoRedoStackListener(null);
|
||||
}
|
||||
this.model = model;
|
||||
this.model.setInvalidate(this::invalidate);
|
||||
this.model.setUndoRedoStackListener(this::onUndoRedoAvailabilityChanged);
|
||||
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);
|
||||
|
||||
moreThanOnePointerUsedInSession = false;
|
||||
model.pushUndoPoint();
|
||||
editSession = startEdit(inverse, point, selected);
|
||||
|
||||
if (editSession != null) {
|
||||
checkTrashIntersect(point);
|
||||
notifyDragStart(editSession.getSelected());
|
||||
}
|
||||
|
||||
if (tapListener != null && allowTaps()) {
|
||||
if (editSession != null) {
|
||||
tapListener.onEntityDown(editSession.getSelected());
|
||||
} else {
|
||||
tapListener.onEntityDown(null);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
case MotionEvent.ACTION_MOVE: {
|
||||
if (editSession != null) {
|
||||
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++) {
|
||||
editSession.movePoint(p, getPoint(event, p));
|
||||
}
|
||||
model.moving(editSession.getSelected());
|
||||
invalidate();
|
||||
notifyDragMove(editSession.getSelected(), checkTrashIntersect(getPoint(event)));
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MotionEvent.ACTION_POINTER_DOWN: {
|
||||
if (editSession != null && event.getPointerCount() == 2) {
|
||||
moreThanOnePointerUsedInSession = true;
|
||||
editSession.commit();
|
||||
model.pushUndoPoint();
|
||||
|
||||
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
|
||||
if (newInverse != null) {
|
||||
editSession = editSession.newPoint(newInverse, getPoint(event, event.getActionIndex()), event.getActionIndex());
|
||||
} else {
|
||||
editSession = null;
|
||||
}
|
||||
if (editSession == null) {
|
||||
dragDropRelease(false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MotionEvent.ACTION_POINTER_UP: {
|
||||
if (editSession != null && event.getActionIndex() < 2) {
|
||||
editSession.commit();
|
||||
model.pushUndoPoint();
|
||||
dragDropRelease(true);
|
||||
|
||||
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
|
||||
if (newInverse != null) {
|
||||
editSession = editSession.removePoint(newInverse, event.getActionIndex());
|
||||
} else {
|
||||
editSession = null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MotionEvent.ACTION_UP: {
|
||||
if (editSession != null) {
|
||||
editSession.commit();
|
||||
dragDropRelease(false);
|
||||
|
||||
PointF point = getPoint(event);
|
||||
boolean hittingTrash = event.getPointerCount() == 1 &&
|
||||
checkTrashIntersect(point) &&
|
||||
model.findElementAtPoint(point, viewMatrix, new Matrix()) == editSession.getSelected();
|
||||
|
||||
notifyDragEnd(editSession.getSelected(), hittingTrash);
|
||||
|
||||
editSession = null;
|
||||
model.postEdit(moreThanOnePointerUsedInSession);
|
||||
invalidate();
|
||||
return true;
|
||||
} else {
|
||||
model.postEdit(moreThanOnePointerUsedInSession);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return super.onTouchEvent(event);
|
||||
}
|
||||
|
||||
private boolean checkTrashIntersect(@NonNull PointF point) {
|
||||
if (mode == Mode.Draw || mode == Mode.Blur) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (model.checkTrashIntersectsPoint(point)) {
|
||||
if (model.getTrash().getRenderer() instanceof TrashRenderer) {
|
||||
((TrashRenderer) model.getTrash().getRenderer()).expand();
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
if (model.getTrash().getRenderer() instanceof TrashRenderer) {
|
||||
((TrashRenderer) model.getTrash().getRenderer()).shrink();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyDragStart(@Nullable EditorElement editorElement) {
|
||||
if (dragListener != null) {
|
||||
dragListener.onDragStarted(editorElement);
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyDragMove(@Nullable EditorElement editorElement, boolean isInTrashHitZone) {
|
||||
if (dragListener != null) {
|
||||
dragListener.onDragMoved(editorElement, isInTrashHitZone);
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyDragEnd(@Nullable EditorElement editorElement, boolean isInTrashHitZone) {
|
||||
if (dragListener != null) {
|
||||
dragListener.onDragEnded(editorElement, isInTrashHitZone);
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable EditSession startEdit(@NonNull Matrix inverse, @NonNull PointF point, @Nullable EditorElement selected) {
|
||||
EditSession editSession = startAMoveAndResizeSession(inverse, point, selected);
|
||||
if (editSession == null && (mode == Mode.Draw || mode == Mode.Blur)) {
|
||||
return startADrawingSession(point);
|
||||
} else {
|
||||
setMode(Mode.MoveAndResize);
|
||||
return editSession;
|
||||
}
|
||||
}
|
||||
|
||||
private EditSession startADrawingSession(@NonNull PointF point) {
|
||||
BezierDrawingRenderer renderer = new BezierDrawingRenderer(color, thickness * Bounds.FULL_BOUNDS.width(), cap, model.findCropRelativeToRoot());
|
||||
EditorElement element = new EditorElement(renderer, mode == Mode.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 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);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public Mode getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(@NonNull Mode mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public void startDrawing(float thickness, @NonNull Paint.Cap cap, boolean blur) {
|
||||
this.thickness = thickness;
|
||||
this.cap = cap;
|
||||
setMode(blur ? Mode.Blur : Mode.Draw);
|
||||
}
|
||||
|
||||
public void setDrawingBrushColor(int color) {
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
private void dragDropRelease(boolean stillTouching) {
|
||||
model.dragDropRelease();
|
||||
if (drawingChangedListener != null) {
|
||||
drawingChangedListener.onDrawingChanged(stillTouching);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
private static PointF getHistoricalPoint(MotionEvent event, int p, int historicalIndex) {
|
||||
return new PointF(event.getHistoricalX(p, historicalIndex),
|
||||
event.getHistoricalY(p, historicalIndex));
|
||||
}
|
||||
|
||||
public EditorModel getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public void setDrawingChangedListener(@Nullable DrawingChangedListener drawingChangedListener) {
|
||||
this.drawingChangedListener = drawingChangedListener;
|
||||
}
|
||||
|
||||
public void setSizeChangedListener(@Nullable SizeChangedListener sizeChangedListener) {
|
||||
this.sizeChangedListener = sizeChangedListener;
|
||||
}
|
||||
|
||||
public void setUndoRedoStackListener(@Nullable UndoRedoStackListener undoRedoStackListener) {
|
||||
this.undoRedoStackListener = undoRedoStackListener;
|
||||
}
|
||||
|
||||
public void setDragListener(@Nullable DragListener dragListener) {
|
||||
this.dragListener = dragListener;
|
||||
}
|
||||
|
||||
public void setTapListener(TapListener tapListener) {
|
||||
this.tapListener = tapListener;
|
||||
}
|
||||
|
||||
public void deleteElement(@Nullable EditorElement editorElement) {
|
||||
if (editorElement != null) {
|
||||
model.delete(editorElement);
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private void onUndoRedoAvailabilityChanged(boolean undoAvailable, boolean redoAvailable) {
|
||||
if (undoRedoStackListener != null) {
|
||||
undoRedoStackListener.onAvailabilityChanged(undoAvailable, redoAvailable);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onDown(MotionEvent e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean allowTaps() {
|
||||
return !model.isCropping() && mode != Mode.Draw && mode != Mode.Blur;
|
||||
}
|
||||
|
||||
public enum Mode {
|
||||
MoveAndResize,
|
||||
Draw,
|
||||
Blur
|
||||
}
|
||||
|
||||
public interface DrawingChangedListener {
|
||||
void onDrawingChanged(boolean stillTouching);
|
||||
}
|
||||
|
||||
public interface SizeChangedListener {
|
||||
void onSizeChanged(int newWidth, int newHeight);
|
||||
}
|
||||
|
||||
public interface DragListener {
|
||||
void onDragStarted(@Nullable EditorElement editorElement);
|
||||
void onDragMoved(@Nullable EditorElement editorElement, boolean isInTrashHitZone);
|
||||
void onDragEnded(@Nullable EditorElement editorElement, boolean isInTrashHitZone);
|
||||
}
|
||||
|
||||
public interface TapListener {
|
||||
|
||||
void onEntityDown(@Nullable EditorElement editorElement);
|
||||
|
||||
void onEntitySingleTap(@Nullable EditorElement editorElement);
|
||||
|
||||
void onEntityDoubleTap(@NonNull EditorElement editorElement);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
|
||||
/**
|
||||
* Responsible for rendering a single {@link EditorElement} to the canvas.
|
||||
* <p>
|
||||
* Because it knows the most about the whereabouts of the image it is also responsible for hit detection.
|
||||
*/
|
||||
public interface Renderer extends Parcelable {
|
||||
|
||||
/**
|
||||
* Draw self to the context.
|
||||
*
|
||||
* @param rendererContext The context to draw to.
|
||||
*/
|
||||
void render(@NonNull RendererContext rendererContext);
|
||||
|
||||
/**
|
||||
* @param x Local coordinate X
|
||||
* @param y Local coordinate Y
|
||||
* @return true iff hit.
|
||||
*/
|
||||
boolean hitTest(float x, float y);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.RectF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Contains all of the information required for a {@link Renderer} to do its job.
|
||||
* <p>
|
||||
* Includes a {@link #canvas}, preconfigured with the correct matrix.
|
||||
* <p>
|
||||
* The {@link #canvasMatrix} should further matrix manipulation be required.
|
||||
*/
|
||||
public final class RendererContext {
|
||||
|
||||
@NonNull
|
||||
public final Context context;
|
||||
|
||||
@NonNull
|
||||
public final Canvas canvas;
|
||||
|
||||
@NonNull
|
||||
public final CanvasMatrix canvasMatrix;
|
||||
|
||||
@NonNull
|
||||
public final Ready rendererReady;
|
||||
|
||||
@NonNull
|
||||
public final Invalidate invalidate;
|
||||
|
||||
private boolean blockingLoad;
|
||||
|
||||
private float fade = 1f;
|
||||
|
||||
private boolean isEditing = true;
|
||||
|
||||
private List<EditorElement> children = Collections.emptyList();
|
||||
private Paint maskPaint;
|
||||
|
||||
public RendererContext(@NonNull Context context, @NonNull Canvas canvas, @NonNull Ready rendererReady, @NonNull Invalidate invalidate) {
|
||||
this.context = context;
|
||||
this.canvas = canvas;
|
||||
this.canvasMatrix = new CanvasMatrix(canvas);
|
||||
this.rendererReady = rendererReady;
|
||||
this.invalidate = invalidate;
|
||||
}
|
||||
|
||||
public void setBlockingLoad(boolean blockingLoad) {
|
||||
this.blockingLoad = blockingLoad;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link Renderer}s generally run in the foreground but can load any data they require in the background.
|
||||
* <p>
|
||||
* If they do so, they can use the {@link #invalidate} callback when ready to inform the view it needs to be redrawn.
|
||||
* <p>
|
||||
* However, when isBlockingLoad is true, the renderer is running in the background for the final render
|
||||
* and must load the data immediately and block the render until done so.
|
||||
*/
|
||||
public boolean isBlockingLoad() {
|
||||
return blockingLoad;
|
||||
}
|
||||
|
||||
public boolean mapRect(@NonNull RectF dst, @NonNull RectF src) {
|
||||
return canvasMatrix.mapRect(dst, src);
|
||||
}
|
||||
|
||||
public void setIsEditing(boolean isEditing) {
|
||||
this.isEditing = isEditing;
|
||||
}
|
||||
|
||||
public boolean isEditing() {
|
||||
return isEditing;
|
||||
}
|
||||
|
||||
public void setFade(float fade) {
|
||||
this.fade = fade;
|
||||
}
|
||||
|
||||
public int getAlpha(int alpha) {
|
||||
return Math.max(0, Math.min(255, (int) (fade * alpha)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the current state on to a stack, must be complimented by a call to {@link #restore()}.
|
||||
*/
|
||||
public void save() {
|
||||
canvasMatrix.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the current state from the stack, must match a call to {@link #save()}.
|
||||
*/
|
||||
public void restore() {
|
||||
canvasMatrix.restore();
|
||||
}
|
||||
|
||||
public void getCurrent(@NonNull Matrix into) {
|
||||
canvasMatrix.getCurrent(into);
|
||||
}
|
||||
|
||||
public void setChildren(@NonNull List<EditorElement> children) {
|
||||
this.children = children;
|
||||
}
|
||||
|
||||
public @NonNull List<EditorElement> getChildren() {
|
||||
return children;
|
||||
}
|
||||
|
||||
public void setMaskPaint(@Nullable Paint maskPaint) {
|
||||
this.maskPaint = maskPaint;
|
||||
}
|
||||
|
||||
public @Nullable Paint getMaskPaint() {
|
||||
return maskPaint;
|
||||
}
|
||||
|
||||
public interface Ready {
|
||||
|
||||
Ready NULL = (renderer, cropMatrix, size) -> {
|
||||
};
|
||||
|
||||
void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size);
|
||||
}
|
||||
|
||||
public interface Invalidate {
|
||||
|
||||
Invalidate NULL = (renderer) -> {
|
||||
};
|
||||
|
||||
void onInvalidate(@NonNull Renderer renderer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.signal.imageeditor.core
|
||||
|
||||
/**
|
||||
* Renderer that can maintain a "selected" state
|
||||
*/
|
||||
interface SelectableRenderer : Renderer {
|
||||
fun onSelected(selected: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
import org.signal.imageeditor.core.model.ThumbRenderer;
|
||||
|
||||
class ThumbDragEditSession extends ElementEditSession {
|
||||
|
||||
@NonNull
|
||||
private final ThumbRenderer.ControlPoint controlPoint;
|
||||
|
||||
private ThumbDragEditSession(@NonNull EditorElement selected, @NonNull ThumbRenderer.ControlPoint controlPoint, @NonNull Matrix inverseMatrix) {
|
||||
super(selected, inverseMatrix);
|
||||
this.controlPoint = controlPoint;
|
||||
}
|
||||
|
||||
static EditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull ThumbRenderer.ControlPoint controlPoint, @NonNull PointF point) {
|
||||
if (!selected.getFlags().isEditable()) return null;
|
||||
|
||||
ElementEditSession elementDragEditSession = new ThumbDragEditSession(selected, controlPoint, inverseViewModelMatrix);
|
||||
elementDragEditSession.setScreenStartPoint(0, point);
|
||||
elementDragEditSession.setScreenEndPoint(0, point);
|
||||
return elementDragEditSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void movePoint(int p, @NonNull PointF point) {
|
||||
setScreenEndPoint(p, point);
|
||||
|
||||
Matrix editorMatrix = selected.getEditorMatrix();
|
||||
|
||||
editorMatrix.reset();
|
||||
|
||||
float x = controlPoint.opposite().getX();
|
||||
float y = controlPoint.opposite().getY();
|
||||
|
||||
float dx = endPointElement[0].x - startPointElement[0].x;
|
||||
float dy = endPointElement[0].y - startPointElement[0].y;
|
||||
|
||||
float xEnd = controlPoint.getX() + dx;
|
||||
float yEnd = controlPoint.getY() + dy;
|
||||
|
||||
boolean aspectLocked = selected.getFlags().isAspectLocked() && !controlPoint.isCenter();
|
||||
|
||||
float defaultScale = aspectLocked ? 2 : 1;
|
||||
|
||||
float scaleX = controlPoint.isVerticalCenter() ? defaultScale : (xEnd - x) / (controlPoint.getX() - x);
|
||||
float scaleY = controlPoint.isHorizontalCenter() ? defaultScale : (yEnd - y) / (controlPoint.getY() - y);
|
||||
|
||||
scale(editorMatrix, aspectLocked, scaleX, scaleY, controlPoint.opposite());
|
||||
}
|
||||
|
||||
private void scale(Matrix editorMatrix, boolean aspectLocked, float scaleX, float scaleY, ThumbRenderer.ControlPoint around) {
|
||||
float x = around.getX();
|
||||
float y = around.getY();
|
||||
editorMatrix.postTranslate(-x, -y);
|
||||
if (aspectLocked) {
|
||||
float minScale = Math.min(scaleX, scaleY);
|
||||
editorMatrix.postScale(minScale, minScale);
|
||||
} else {
|
||||
editorMatrix.postScale(scaleX, scaleY);
|
||||
}
|
||||
editorMatrix.postTranslate(x, y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession removePoint(@NonNull Matrix newInverse, int p) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
public interface UndoRedoStackListener {
|
||||
|
||||
void onAvailabilityChanged(boolean undoAvailable, boolean redoAvailable);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.animation.ValueAnimator;
|
||||
import android.view.animation.Interpolator;
|
||||
import android.view.animation.LinearInterpolator;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
final class AlphaAnimation {
|
||||
|
||||
private final static Interpolator interpolator = new LinearInterpolator();
|
||||
|
||||
final static AlphaAnimation NULL_1 = new AlphaAnimation(1);
|
||||
|
||||
private final float from;
|
||||
private final float to;
|
||||
private final Runnable invalidate;
|
||||
private final boolean canAnimate;
|
||||
private float animatedFraction;
|
||||
|
||||
private AlphaAnimation(float from, float to, @Nullable Runnable invalidate) {
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.invalidate = invalidate;
|
||||
this.canAnimate = invalidate != null;
|
||||
}
|
||||
|
||||
private AlphaAnimation(float fixed) {
|
||||
this(fixed, fixed, null);
|
||||
}
|
||||
|
||||
static AlphaAnimation animate(float from, float to, @Nullable Runnable invalidate) {
|
||||
if (invalidate == null) {
|
||||
return new AlphaAnimation(to);
|
||||
}
|
||||
|
||||
if (from != to) {
|
||||
AlphaAnimation animationMatrix = new AlphaAnimation(from, to, invalidate);
|
||||
animationMatrix.start();
|
||||
return animationMatrix;
|
||||
} else {
|
||||
return new AlphaAnimation(to);
|
||||
}
|
||||
}
|
||||
|
||||
private void start() {
|
||||
if (canAnimate && invalidate != null) {
|
||||
ValueAnimator animator = ValueAnimator.ofFloat(from, to);
|
||||
animator.setDuration(200);
|
||||
animator.setInterpolator(interpolator);
|
||||
animator.addUpdateListener(animation -> {
|
||||
animatedFraction = (float) animation.getAnimatedValue();
|
||||
invalidate.run();
|
||||
});
|
||||
animator.start();
|
||||
}
|
||||
}
|
||||
|
||||
float getValue() {
|
||||
if (!canAnimate) return to;
|
||||
|
||||
return animatedFraction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.animation.ValueAnimator;
|
||||
import android.graphics.Matrix;
|
||||
import android.view.animation.CycleInterpolator;
|
||||
import android.view.animation.DecelerateInterpolator;
|
||||
import android.view.animation.Interpolator;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.imageeditor.core.CanvasMatrix;
|
||||
|
||||
/**
|
||||
* Animation Matrix provides a matrix that animates over time down to the identity matrix.
|
||||
*/
|
||||
final class AnimationMatrix {
|
||||
|
||||
private final static float[] iValues = new float[9];
|
||||
private final static Interpolator interpolator = new DecelerateInterpolator();
|
||||
private final static Interpolator pulseInterpolator = inverse(new CycleInterpolator(0.5f));
|
||||
|
||||
static AnimationMatrix NULL = new AnimationMatrix();
|
||||
|
||||
static {
|
||||
new Matrix().getValues(iValues);
|
||||
}
|
||||
|
||||
private final Runnable invalidate;
|
||||
private final boolean canAnimate;
|
||||
private final float[] undoValues = new float[9];
|
||||
|
||||
private final Matrix temp = new Matrix();
|
||||
private final float[] tempValues = new float[9];
|
||||
|
||||
private ValueAnimator animator;
|
||||
private float animatedFraction;
|
||||
|
||||
private AnimationMatrix(@NonNull Matrix undo, @NonNull Runnable invalidate) {
|
||||
this.invalidate = invalidate;
|
||||
this.canAnimate = true;
|
||||
undo.getValues(undoValues);
|
||||
}
|
||||
|
||||
private AnimationMatrix() {
|
||||
canAnimate = false;
|
||||
invalidate = null;
|
||||
}
|
||||
|
||||
static @NonNull AnimationMatrix animate(@NonNull Matrix from, @NonNull Matrix to, @Nullable Runnable invalidate) {
|
||||
if (invalidate == null) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Matrix undo = new Matrix();
|
||||
boolean inverted = to.invert(undo);
|
||||
if (inverted) {
|
||||
undo.preConcat(from);
|
||||
}
|
||||
if (inverted && !undo.isIdentity()) {
|
||||
AnimationMatrix animationMatrix = new AnimationMatrix(undo, invalidate);
|
||||
animationMatrix.start(interpolator);
|
||||
return animationMatrix;
|
||||
} else {
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate applying a matrix and then animate removing.
|
||||
*/
|
||||
static @NonNull AnimationMatrix singlePulse(@NonNull Matrix pulse, @Nullable Runnable invalidate) {
|
||||
if (invalidate == null) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
AnimationMatrix animationMatrix = new AnimationMatrix(pulse, invalidate);
|
||||
animationMatrix.start(pulseInterpolator);
|
||||
|
||||
return animationMatrix;
|
||||
}
|
||||
|
||||
private void start(@NonNull Interpolator interpolator) {
|
||||
if (canAnimate) {
|
||||
animator = ValueAnimator.ofFloat(1, 0);
|
||||
animator.setDuration(250);
|
||||
animator.setInterpolator(interpolator);
|
||||
animator.addUpdateListener(animation -> {
|
||||
animatedFraction = (float) animation.getAnimatedValue();
|
||||
invalidate.run();
|
||||
});
|
||||
animator.start();
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
ValueAnimator animator = this.animator;
|
||||
if (animator != null) animator.cancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the current animation value.
|
||||
*/
|
||||
void preConcatValueTo(@NonNull Matrix onTo) {
|
||||
if (!canAnimate) return;
|
||||
|
||||
onTo.preConcat(buildTemp());
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the current animation value.
|
||||
*/
|
||||
void preConcatValueTo(@NonNull CanvasMatrix canvasMatrix) {
|
||||
if (!canAnimate) return;
|
||||
|
||||
canvasMatrix.concat(buildTemp());
|
||||
}
|
||||
|
||||
private Matrix buildTemp() {
|
||||
if (!canAnimate) {
|
||||
temp.reset();
|
||||
return temp;
|
||||
}
|
||||
|
||||
final float fractionCompliment = 1f - animatedFraction;
|
||||
for (int i = 0; i < 9; i++) {
|
||||
tempValues[i] = fractionCompliment * iValues[i] + animatedFraction * undoValues[i];
|
||||
}
|
||||
|
||||
temp.setValues(tempValues);
|
||||
return temp;
|
||||
}
|
||||
|
||||
private static Interpolator inverse(@NonNull Interpolator interpolator) {
|
||||
return input -> 1f - interpolator.getInterpolation(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
final class Bisect {
|
||||
|
||||
static final float ACCURACY = 0.001f;
|
||||
|
||||
private static final int MAX_ITERATIONS = 16;
|
||||
|
||||
interface Predicate {
|
||||
boolean test();
|
||||
}
|
||||
|
||||
interface ModifyElement {
|
||||
void applyFactor(@NonNull Matrix matrix, float factor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a predicate function, attempts to finds the boundary between predicate true and predicate false.
|
||||
* If it returns true, it will animate the element to the closest true value found to that boundary.
|
||||
*
|
||||
* @param element The element to modify.
|
||||
* @param outOfBoundsValue The current value, known to be out of bounds. 1 for a scale and 0 for a translate.
|
||||
* @param atMost A value believed to be in bounds.
|
||||
* @param predicate The out of bounds predicate.
|
||||
* @param modifyElement Apply the latest value to the element local matrix.
|
||||
* @param invalidate For animation if finds a result.
|
||||
* @return true iff finds a result.
|
||||
*/
|
||||
static boolean bisectToTest(@NonNull EditorElement element,
|
||||
float outOfBoundsValue,
|
||||
float atMost,
|
||||
@NonNull Predicate predicate,
|
||||
@NonNull ModifyElement modifyElement,
|
||||
@NonNull Runnable invalidate)
|
||||
{
|
||||
Matrix closestSuccesful = bisectToTest(element, outOfBoundsValue, atMost, predicate, modifyElement);
|
||||
|
||||
if (closestSuccesful != null) {
|
||||
element.animateLocalTo(closestSuccesful, invalidate);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a predicate function, attempts to finds the boundary between predicate true and predicate false.
|
||||
* Returns new local matrix for the element if a solution is found.
|
||||
*
|
||||
* @param element The element to modify.
|
||||
* @param outOfBoundsValue The current value, known to be out of bounds. 1 for a scale and 0 for a translate.
|
||||
* @param atMost A value believed to be in bounds.
|
||||
* @param predicate The out of bounds predicate.
|
||||
* @param modifyElement Apply the latest value to the element local matrix.
|
||||
* @return matrix to replace local matrix iff finds a result, null otherwise.
|
||||
*/
|
||||
static @Nullable Matrix bisectToTest(@NonNull EditorElement element,
|
||||
float outOfBoundsValue,
|
||||
float atMost,
|
||||
@NonNull Predicate predicate,
|
||||
@NonNull ModifyElement modifyElement)
|
||||
{
|
||||
Matrix elementMatrix = element.getLocalMatrix();
|
||||
Matrix original = new Matrix(elementMatrix);
|
||||
Matrix closestSuccessful = new Matrix();
|
||||
boolean haveResult = false;
|
||||
int attempt = 0;
|
||||
float successValue = 0;
|
||||
float inBoundsValue = atMost;
|
||||
float nextValueToTry = inBoundsValue;
|
||||
|
||||
do {
|
||||
attempt++;
|
||||
|
||||
modifyElement.applyFactor(elementMatrix, nextValueToTry);
|
||||
try {
|
||||
|
||||
if (predicate.test()) {
|
||||
inBoundsValue = nextValueToTry;
|
||||
|
||||
// if first success or closer to out of bounds than the current closest
|
||||
if (!haveResult || Math.abs(nextValueToTry - outOfBoundsValue) < Math.abs(successValue - outOfBoundsValue)) {
|
||||
haveResult = true;
|
||||
successValue = nextValueToTry;
|
||||
closestSuccessful.set(elementMatrix);
|
||||
}
|
||||
} else {
|
||||
if (attempt == 1) {
|
||||
// failure on first attempt means inBoundsValue is actually out of bounds and so no solution
|
||||
return null;
|
||||
}
|
||||
outOfBoundsValue = nextValueToTry;
|
||||
}
|
||||
} finally {
|
||||
// reset
|
||||
elementMatrix.set(original);
|
||||
}
|
||||
|
||||
nextValueToTry = (inBoundsValue + outOfBoundsValue) / 2f;
|
||||
|
||||
} while (attempt < MAX_ITERATIONS && Math.abs(inBoundsValue - outOfBoundsValue) > ACCURACY);
|
||||
|
||||
if (haveResult) {
|
||||
return closestSuccessful;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.Bounds;
|
||||
import org.signal.imageeditor.R;
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Hit tests a circle that is {@link R.dimen#crop_area_renderer_edge_size} in radius on the screen.
|
||||
* <p>
|
||||
* Does not draw anything.
|
||||
*/
|
||||
class CropThumbRenderer implements Renderer, ThumbRenderer {
|
||||
|
||||
private final ControlPoint controlPoint;
|
||||
private final UUID toControl;
|
||||
|
||||
private final float[] centreOnScreen = new float[2];
|
||||
private final Matrix matrix = new Matrix();
|
||||
private int size;
|
||||
|
||||
CropThumbRenderer(@NonNull ControlPoint controlPoint, @NonNull UUID toControl) {
|
||||
this.controlPoint = controlPoint;
|
||||
this.toControl = toControl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ControlPoint getControlPoint() {
|
||||
return controlPoint;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UUID getElementToControl() {
|
||||
return toControl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
rendererContext.canvasMatrix.mapPoints(centreOnScreen, Bounds.CENTRE);
|
||||
rendererContext.canvasMatrix.copyTo(matrix);
|
||||
size = rendererContext.context.getResources().getDimensionPixelSize(R.dimen.crop_area_renderer_edge_size);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
float[] hitPointOnScreen = new float[2];
|
||||
matrix.mapPoints(hitPointOnScreen, new float[]{ x, y });
|
||||
|
||||
float dx = centreOnScreen[0] - hitPointOnScreen[0];
|
||||
float dy = centreOnScreen[1] - hitPointOnScreen[1];
|
||||
|
||||
return dx * dx + dy * dy < size * size;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static final Creator<CropThumbRenderer> CREATOR = new Creator<CropThumbRenderer>() {
|
||||
@Override
|
||||
public CropThumbRenderer createFromParcel(Parcel in) {
|
||||
return new CropThumbRenderer(ControlPoint.values()[in.readInt()], ParcelUtils.readUUID(in));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CropThumbRenderer[] newArray(int size) {
|
||||
return new CropThumbRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(controlPoint.ordinal());
|
||||
ParcelUtils.writeUUID(dest, toControl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* An image consists of a tree of {@link EditorElement}s.
|
||||
* <p>
|
||||
* Each element has some persisted state:
|
||||
* - An optional {@link Renderer} so that it can draw itself.
|
||||
* - A list of child elements that make the tree possible.
|
||||
* - Its own transformation matrix, which applies to itself and all its children.
|
||||
* - A set of flags controlling visibility, selectablity etc.
|
||||
* <p>
|
||||
* Then some temporary state.
|
||||
* - A editor matrix for displaying as yet uncommitted edits.
|
||||
* - An animation matrix for animating from one matrix to another.
|
||||
* - Deleted children to allow them to fade out on delete.
|
||||
* - Temporary flags, for temporary visibility, selectablity etc.
|
||||
*/
|
||||
public final class EditorElement implements Parcelable {
|
||||
|
||||
private static final Comparator<EditorElement> Z_ORDER_COMPARATOR = (e1, e2) -> Integer.compare(e1.zOrder, e2.zOrder);
|
||||
|
||||
private final UUID id;
|
||||
private final EditorFlags flags;
|
||||
private final Matrix localMatrix = new Matrix();
|
||||
private final Matrix editorMatrix = new Matrix();
|
||||
private final int zOrder;
|
||||
|
||||
@Nullable
|
||||
private final Renderer renderer;
|
||||
|
||||
private final Matrix temp = new Matrix();
|
||||
|
||||
private final Matrix tempMatrix = new Matrix();
|
||||
|
||||
private final List<EditorElement> children = new LinkedList<>();
|
||||
private final List<EditorElement> deletedChildren = new LinkedList<>();
|
||||
|
||||
@NonNull
|
||||
private AnimationMatrix animationMatrix = AnimationMatrix.NULL;
|
||||
|
||||
@NonNull
|
||||
private AlphaAnimation alphaAnimation = AlphaAnimation.NULL_1;
|
||||
|
||||
public EditorElement(@Nullable Renderer renderer) {
|
||||
this(renderer, 0);
|
||||
}
|
||||
|
||||
public EditorElement(@Nullable Renderer renderer, int zOrder) {
|
||||
this.id = UUID.randomUUID();
|
||||
this.flags = new EditorFlags();
|
||||
this.renderer = renderer;
|
||||
this.zOrder = zOrder;
|
||||
}
|
||||
|
||||
private EditorElement(Parcel in) {
|
||||
id = ParcelUtils.readUUID(in);
|
||||
flags = new EditorFlags(in.readInt());
|
||||
ParcelUtils.readMatrix(localMatrix, in);
|
||||
renderer = in.readParcelable(Renderer.class.getClassLoader());
|
||||
zOrder = in.readInt();
|
||||
in.readTypedList(children, EditorElement.CREATOR);
|
||||
}
|
||||
|
||||
UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public @Nullable Renderer getRenderer() {
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iff Visible,
|
||||
* Renders tree with the following localMatrix:
|
||||
* <p>
|
||||
* viewModelMatrix * localMatrix * editorMatrix * animationMatrix
|
||||
* <p>
|
||||
* Child nodes are supplied with a viewModelMatrix' = viewModelMatrix * localMatrix * editorMatrix * animationMatrix
|
||||
*
|
||||
* @param rendererContext Canvas to draw on to.
|
||||
*/
|
||||
public void draw(@NonNull RendererContext rendererContext) {
|
||||
if (!flags.isVisible() && !flags.isChildrenVisible()) return;
|
||||
|
||||
rendererContext.save();
|
||||
|
||||
rendererContext.canvasMatrix.concat(localMatrix);
|
||||
|
||||
if (rendererContext.isEditing()) {
|
||||
rendererContext.canvasMatrix.concat(editorMatrix);
|
||||
animationMatrix.preConcatValueTo(rendererContext.canvasMatrix);
|
||||
}
|
||||
|
||||
if (flags.isVisible()) {
|
||||
float alpha = alphaAnimation.getValue();
|
||||
if (alpha > 0) {
|
||||
rendererContext.setFade(alpha);
|
||||
rendererContext.setChildren(children);
|
||||
drawSelf(rendererContext);
|
||||
rendererContext.setFade(1f);
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.isChildrenVisible()) {
|
||||
drawChildren(children, rendererContext);
|
||||
drawChildren(deletedChildren, rendererContext);
|
||||
}
|
||||
|
||||
rendererContext.restore();
|
||||
}
|
||||
|
||||
private void drawSelf(@NonNull RendererContext rendererContext) {
|
||||
if (renderer == null) return;
|
||||
renderer.render(rendererContext);
|
||||
}
|
||||
|
||||
private static void drawChildren(@NonNull List<EditorElement> children, @NonNull RendererContext rendererContext) {
|
||||
for (EditorElement element : children) {
|
||||
if (element.zOrder >= 0) {
|
||||
element.draw(rendererContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void addElement(@NonNull EditorElement element) {
|
||||
children.add(element);
|
||||
Collections.sort(children, Z_ORDER_COMPARATOR);
|
||||
}
|
||||
|
||||
public Matrix getLocalMatrix() {
|
||||
return localMatrix;
|
||||
}
|
||||
|
||||
public Matrix getEditorMatrix() {
|
||||
return editorMatrix;
|
||||
}
|
||||
|
||||
EditorElement findElement(@NonNull EditorElement toFind, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) {
|
||||
return findElement(viewMatrix, outInverseModelMatrix, (element, inverseMatrix) -> toFind == element);
|
||||
}
|
||||
|
||||
EditorElement findElementAt(float x, float y, @NonNull Matrix viewModelMatrix, @NonNull Matrix outInverseModelMatrix) {
|
||||
final float[] dst = new float[2];
|
||||
final float[] src = { x, y };
|
||||
|
||||
return findElement(viewModelMatrix, outInverseModelMatrix, (element, inverseMatrix) -> {
|
||||
Renderer renderer = element.renderer;
|
||||
if (renderer == null) return false;
|
||||
inverseMatrix.mapPoints(dst, src);
|
||||
return element.flags.isSelectable() && renderer.hitTest(dst[0], dst[1]);
|
||||
});
|
||||
}
|
||||
|
||||
public EditorElement findElement(@NonNull Matrix viewModelMatrix, @NonNull Matrix outInverseModelMatrix, @NonNull FindElementPredicate predicate) {
|
||||
temp.set(viewModelMatrix);
|
||||
|
||||
temp.preConcat(localMatrix);
|
||||
temp.preConcat(editorMatrix);
|
||||
|
||||
if (temp.invert(tempMatrix)) {
|
||||
|
||||
for (int i = children.size() - 1; i >= 0; i--) {
|
||||
EditorElement elementAt = children.get(i).findElement(temp, outInverseModelMatrix, predicate);
|
||||
if (elementAt != null) {
|
||||
return elementAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (predicate.test(this, tempMatrix)) {
|
||||
outInverseModelMatrix.set(tempMatrix);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public EditorFlags getFlags() {
|
||||
return flags;
|
||||
}
|
||||
|
||||
int getChildCount() {
|
||||
return children.size();
|
||||
}
|
||||
|
||||
EditorElement getChild(int i) {
|
||||
return children.get(i);
|
||||
}
|
||||
|
||||
void forAllInTree(@NonNull PerElementFunction function) {
|
||||
function.apply(this);
|
||||
for (EditorElement child : children) {
|
||||
child.forAllInTree(function);
|
||||
}
|
||||
}
|
||||
|
||||
void deleteChild(@NonNull EditorElement editorElement, @Nullable Runnable invalidate) {
|
||||
Iterator<EditorElement> iterator = children.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
if (iterator.next() == editorElement) {
|
||||
iterator.remove();
|
||||
addDeletedChildFadingOut(editorElement, invalidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void addDeletedChildFadingOut(@NonNull EditorElement fromElement, @Nullable Runnable invalidate) {
|
||||
deletedChildren.add(fromElement);
|
||||
fromElement.animateFadeOut(invalidate);
|
||||
}
|
||||
|
||||
void animateFadeOut(@Nullable Runnable invalidate) {
|
||||
alphaAnimation = AlphaAnimation.animate(1, 0, invalidate);
|
||||
}
|
||||
|
||||
void animateFadeIn(@Nullable Runnable invalidate) {
|
||||
alphaAnimation = AlphaAnimation.animate(0, 1, invalidate);
|
||||
}
|
||||
|
||||
public void animatePartialFadeOut(@Nullable Runnable invalidate) {
|
||||
alphaAnimation = AlphaAnimation.animate(alphaAnimation.getValue(), 0.5f, invalidate);
|
||||
}
|
||||
|
||||
public void animatePartialFadeIn(@Nullable Runnable invalidate) {
|
||||
alphaAnimation = AlphaAnimation.animate(alphaAnimation.getValue(), 1f, invalidate);
|
||||
}
|
||||
|
||||
@Nullable EditorElement parentOf(@NonNull EditorElement element) {
|
||||
if (children.contains(element)) {
|
||||
return this;
|
||||
}
|
||||
for (EditorElement child : children) {
|
||||
EditorElement parent = child.parentOf(element);
|
||||
if (parent != null) {
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void singleScalePulse(@Nullable Runnable invalidate) {
|
||||
Matrix scale = new Matrix();
|
||||
scale.setScale(1.2f, 1.2f);
|
||||
|
||||
animationMatrix = AnimationMatrix.singlePulse(scale, invalidate);
|
||||
}
|
||||
|
||||
public int getZOrder() {
|
||||
return zOrder;
|
||||
}
|
||||
|
||||
public interface PerElementFunction {
|
||||
void apply(EditorElement element);
|
||||
}
|
||||
|
||||
public interface FindElementPredicate {
|
||||
boolean test(EditorElement element, Matrix inverseMatrix);
|
||||
}
|
||||
|
||||
public void commitEditorMatrix() {
|
||||
if (flags.isEditable()) {
|
||||
localMatrix.preConcat(editorMatrix);
|
||||
editorMatrix.reset();
|
||||
} else {
|
||||
rollbackEditorMatrix(null);
|
||||
}
|
||||
}
|
||||
|
||||
void rollbackEditorMatrix(@Nullable Runnable invalidate) {
|
||||
animateEditorTo(new Matrix(), invalidate);
|
||||
}
|
||||
|
||||
void buildMap(Map<UUID, EditorElement> map) {
|
||||
map.put(id, this);
|
||||
for (EditorElement child : children) {
|
||||
child.buildMap(map);
|
||||
}
|
||||
}
|
||||
|
||||
void animateFrom(@NonNull Matrix oldMatrix, @Nullable Runnable invalidate) {
|
||||
Matrix oldMatrixCopy = new Matrix(oldMatrix);
|
||||
animationMatrix.stop();
|
||||
animationMatrix.preConcatValueTo(oldMatrixCopy);
|
||||
animationMatrix = AnimationMatrix.animate(oldMatrixCopy, localMatrix, invalidate);
|
||||
}
|
||||
|
||||
void animateEditorTo(@NonNull Matrix newEditorMatrix, @Nullable Runnable invalidate) {
|
||||
setMatrixWithAnimation(editorMatrix, newEditorMatrix, invalidate);
|
||||
}
|
||||
|
||||
void animateLocalTo(@NonNull Matrix newLocalMatrix, @Nullable Runnable invalidate) {
|
||||
setMatrixWithAnimation(localMatrix, newLocalMatrix, invalidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param destination Matrix to change
|
||||
* @param source Matrix value to set
|
||||
* @param invalidate Callback to allow animation
|
||||
*/
|
||||
private void setMatrixWithAnimation(@NonNull Matrix destination, @NonNull Matrix source, @Nullable Runnable invalidate) {
|
||||
Matrix old = new Matrix(destination);
|
||||
animationMatrix.stop();
|
||||
animationMatrix.preConcatValueTo(old);
|
||||
destination.set(source);
|
||||
animationMatrix = AnimationMatrix.animate(old, destination, invalidate);
|
||||
}
|
||||
|
||||
Matrix getLocalMatrixAnimating() {
|
||||
Matrix matrix = new Matrix(localMatrix);
|
||||
animationMatrix.preConcatValueTo(matrix);
|
||||
return matrix;
|
||||
}
|
||||
|
||||
void stopAnimation() {
|
||||
animationMatrix.stop();
|
||||
}
|
||||
|
||||
public static final Creator<EditorElement> CREATOR = new Creator<EditorElement>() {
|
||||
@Override
|
||||
public EditorElement createFromParcel(Parcel in) {
|
||||
return new EditorElement(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditorElement[] newArray(int size) {
|
||||
return new EditorElement[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
ParcelUtils.writeUUID(dest, id);
|
||||
dest.writeInt(this.flags.asInt());
|
||||
ParcelUtils.writeMatrix(dest, localMatrix);
|
||||
dest.writeParcelable(renderer, flags);
|
||||
dest.writeInt(zOrder);
|
||||
dest.writeTypedList(children);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.PointF;
|
||||
import android.graphics.RectF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.imageeditor.core.Bounds;
|
||||
import org.signal.imageeditor.R;
|
||||
import org.signal.imageeditor.core.renderers.CropAreaRenderer;
|
||||
import org.signal.imageeditor.core.renderers.FillRenderer;
|
||||
import org.signal.imageeditor.core.renderers.InverseFillRenderer;
|
||||
import org.signal.imageeditor.core.renderers.OvalGuideRenderer;
|
||||
import org.signal.imageeditor.core.renderers.TrashRenderer;
|
||||
|
||||
/**
|
||||
* Creates and handles a strict EditorElement Hierarchy.
|
||||
* <p>
|
||||
* root - always square, contains only temporary zooms for editing. e.g. when the whole editor zooms out for cropping
|
||||
* |
|
||||
* |- view - contains persisted adjustments for crops
|
||||
* | |
|
||||
* | |- flipRotate - contains persisted adjustments for flip and rotate operations, ensures operations are centered within the current view
|
||||
* | |
|
||||
* | |- imageRoot
|
||||
* | | |- mainImage
|
||||
* | | |- stickers/drawings/text
|
||||
* | |
|
||||
* | |- overlay - always square
|
||||
* | | |- imageCrop - a crop to match the aspect of the main image
|
||||
* | | | |- cropEditorElement - user crop, not always square, but upright, the area of the view
|
||||
* | | | | | All children do not move/scale or rotate.
|
||||
* | | | | |- blackout
|
||||
* | | | | |- fade
|
||||
* | | | | |- thumbs
|
||||
* | | | | | |- Center left thumb
|
||||
* | | | | | |- Center right thumb
|
||||
* | | | | | |- Top center thumb
|
||||
* | | | | | |- Bottom center thumb
|
||||
* | | | | | |- Top left thumb
|
||||
* | | | | | |- Top right thumb
|
||||
* | | | | | |- Bottom left thumb
|
||||
* | | | | | |- Bottom right thumb
|
||||
*/
|
||||
final class EditorElementHierarchy {
|
||||
|
||||
static @NonNull EditorElementHierarchy create() {
|
||||
return new EditorElementHierarchy(createRoot(CropStyle.RECTANGLE));
|
||||
}
|
||||
|
||||
static @NonNull EditorElementHierarchy createForCircleEditing() {
|
||||
return new EditorElementHierarchy(createRoot(CropStyle.CIRCLE));
|
||||
}
|
||||
|
||||
static @NonNull EditorElementHierarchy createForPinchAndPanCropping() {
|
||||
return new EditorElementHierarchy(createRoot(CropStyle.PINCH_AND_PAN));
|
||||
}
|
||||
|
||||
static @NonNull EditorElementHierarchy create(@NonNull EditorElement root) {
|
||||
return new EditorElementHierarchy(root);
|
||||
}
|
||||
|
||||
private final EditorElement root;
|
||||
private final EditorElement view;
|
||||
private final EditorElement flipRotate;
|
||||
private final EditorElement imageRoot;
|
||||
private final EditorElement overlay;
|
||||
private final EditorElement imageCrop;
|
||||
private final EditorElement cropEditorElement;
|
||||
private final EditorElement blackout;
|
||||
private final EditorElement fade;
|
||||
private final EditorElement trash;
|
||||
private final EditorElement thumbs;
|
||||
|
||||
private EditorElementHierarchy(@NonNull EditorElement root) {
|
||||
this.root = root;
|
||||
this.view = this.root.getChild(0);
|
||||
this.flipRotate = this.view.getChild(0);
|
||||
this.imageRoot = this.flipRotate.getChild(0);
|
||||
this.overlay = this.flipRotate.getChild(1);
|
||||
this.imageCrop = this.overlay.getChild(0);
|
||||
this.cropEditorElement = this.imageCrop.getChild(0);
|
||||
this.blackout = this.cropEditorElement.getChild(0);
|
||||
this.thumbs = this.cropEditorElement.getChild(1);
|
||||
this.fade = this.cropEditorElement.getChild(2);
|
||||
this.trash = this.cropEditorElement.getChild(3);
|
||||
}
|
||||
|
||||
private enum CropStyle {
|
||||
/**
|
||||
* A rectangular overlay with 8 thumbs, corners and edges.
|
||||
*/
|
||||
RECTANGLE,
|
||||
|
||||
/**
|
||||
* Cropping with a circular template overlay with Corner thumbs only.
|
||||
*/
|
||||
CIRCLE,
|
||||
|
||||
/**
|
||||
* No overlay and no thumbs. Cropping achieved through pinching and panning.
|
||||
*/
|
||||
PINCH_AND_PAN
|
||||
}
|
||||
|
||||
private static @NonNull EditorElement createRoot(@NonNull CropStyle cropStyle) {
|
||||
EditorElement root = new EditorElement(null);
|
||||
|
||||
EditorElement imageRoot = new EditorElement(null);
|
||||
root.addElement(imageRoot);
|
||||
|
||||
EditorElement flipRotate = new EditorElement(null);
|
||||
imageRoot.addElement(flipRotate);
|
||||
|
||||
EditorElement image = new EditorElement(null);
|
||||
flipRotate.addElement(image);
|
||||
|
||||
EditorElement overlay = new EditorElement(null);
|
||||
flipRotate.addElement(overlay);
|
||||
|
||||
EditorElement imageCrop = new EditorElement(null);
|
||||
overlay.addElement(imageCrop);
|
||||
|
||||
boolean renderCenterThumbs = cropStyle == CropStyle.RECTANGLE;
|
||||
EditorElement cropEditorElement = new EditorElement(new CropAreaRenderer(R.color.crop_area_renderer_outer_color, renderCenterThumbs));
|
||||
|
||||
cropEditorElement.getFlags()
|
||||
.setRotateLocked(true)
|
||||
.setAspectLocked(true)
|
||||
.setSelectable(false)
|
||||
.setVisible(false)
|
||||
.persist();
|
||||
|
||||
imageCrop.addElement(cropEditorElement);
|
||||
|
||||
EditorElement fade = new EditorElement(new FillRenderer(0x66000000), EditorModel.Z_FADE);
|
||||
fade.getFlags()
|
||||
.setSelectable(false)
|
||||
.setEditable(false)
|
||||
.setVisible(false)
|
||||
.persist();
|
||||
cropEditorElement.addElement(fade);
|
||||
|
||||
EditorElement trash = new EditorElement(new TrashRenderer(), EditorModel.Z_TRASH);
|
||||
trash.getFlags()
|
||||
.setSelectable(false)
|
||||
.setEditable(false)
|
||||
.setVisible(false)
|
||||
.persist();
|
||||
cropEditorElement.addElement(trash);
|
||||
|
||||
EditorElement blackout = new EditorElement(new InverseFillRenderer(0xff000000));
|
||||
|
||||
blackout.getFlags()
|
||||
.setSelectable(false)
|
||||
.setEditable(false)
|
||||
.persist();
|
||||
|
||||
cropEditorElement.addElement(blackout);
|
||||
|
||||
if (cropStyle == CropStyle.PINCH_AND_PAN) {
|
||||
cropEditorElement.addElement(new EditorElement(null));
|
||||
} else {
|
||||
cropEditorElement.addElement(createThumbs(cropEditorElement, renderCenterThumbs));
|
||||
|
||||
if (cropStyle == CropStyle.CIRCLE) {
|
||||
EditorElement circle = new EditorElement(new OvalGuideRenderer(R.color.crop_circle_guide_color));
|
||||
circle.getFlags().setSelectable(false)
|
||||
.persist();
|
||||
|
||||
cropEditorElement.addElement(circle);
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static @NonNull EditorElement createThumbs(EditorElement cropEditorElement, boolean centerThumbs) {
|
||||
EditorElement thumbs = new EditorElement(null);
|
||||
|
||||
thumbs.getFlags()
|
||||
.setChildrenVisible(false)
|
||||
.setSelectable(false)
|
||||
.setVisible(false)
|
||||
.persist();
|
||||
|
||||
if (centerThumbs) {
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.CENTER_LEFT));
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.CENTER_RIGHT));
|
||||
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_CENTER));
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_CENTER));
|
||||
}
|
||||
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_LEFT));
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_RIGHT));
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_LEFT));
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_RIGHT));
|
||||
|
||||
return thumbs;
|
||||
}
|
||||
|
||||
private static @NonNull EditorElement newThumb(@NonNull EditorElement toControl, @NonNull ThumbRenderer.ControlPoint controlPoint) {
|
||||
EditorElement element = new EditorElement(new CropThumbRenderer(controlPoint, toControl.getId()));
|
||||
|
||||
element.getFlags()
|
||||
.setSelectable(false)
|
||||
.persist();
|
||||
|
||||
element.getLocalMatrix().preTranslate(controlPoint.getX(), controlPoint.getY());
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
EditorElement getRoot() {
|
||||
return root;
|
||||
}
|
||||
|
||||
EditorElement getImageRoot() {
|
||||
return imageRoot;
|
||||
}
|
||||
|
||||
EditorElement getTrash() {
|
||||
return trash;
|
||||
}
|
||||
|
||||
/**
|
||||
* The main image, null if not yet set.
|
||||
*/
|
||||
@Nullable EditorElement getMainImage() {
|
||||
return imageRoot.getChildCount() > 0 ? imageRoot.getChild(0) : null;
|
||||
}
|
||||
|
||||
EditorElement getCropEditorElement() {
|
||||
return cropEditorElement;
|
||||
}
|
||||
|
||||
EditorElement getImageCrop() {
|
||||
return imageCrop;
|
||||
}
|
||||
|
||||
EditorElement getOverlay() {
|
||||
return overlay;
|
||||
}
|
||||
|
||||
EditorElement getFlipRotate() {
|
||||
return flipRotate;
|
||||
}
|
||||
|
||||
void addFade(@NonNull Runnable invalidate) {
|
||||
fade.getFlags()
|
||||
.setVisible(true)
|
||||
.persist();
|
||||
|
||||
invalidate.run();
|
||||
}
|
||||
|
||||
void removeFade(@NonNull Runnable invalidate) {
|
||||
fade.getFlags()
|
||||
.setVisible(false)
|
||||
.persist();
|
||||
|
||||
invalidate.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param scaleIn Use 1 for no scale in, use less than 1 and it will zoom the image out
|
||||
* so user can see more of the surrounding image while cropping.
|
||||
*/
|
||||
void startCrop(@NonNull Runnable invalidate, float scaleIn) {
|
||||
Matrix editor = new Matrix();
|
||||
|
||||
editor.postScale(scaleIn, scaleIn);
|
||||
root.animateEditorTo(editor, invalidate);
|
||||
|
||||
cropEditorElement.getFlags()
|
||||
.setVisible(true);
|
||||
|
||||
blackout.getFlags()
|
||||
.setVisible(false);
|
||||
|
||||
thumbs.getFlags()
|
||||
.setChildrenVisible(true);
|
||||
|
||||
thumbs.forAllInTree(element -> element.getFlags().setSelectable(true));
|
||||
|
||||
imageRoot.forAllInTree(element -> element.getFlags().setSelectable(false));
|
||||
|
||||
EditorElement mainImage = getMainImage();
|
||||
if (mainImage != null) {
|
||||
mainImage.getFlags().setSelectable(true);
|
||||
}
|
||||
|
||||
invalidate.run();
|
||||
}
|
||||
|
||||
void doneCrop(@NonNull RectF visibleViewPort, @Nullable Runnable invalidate) {
|
||||
updateViewToCrop(visibleViewPort, invalidate);
|
||||
|
||||
root.rollbackEditorMatrix(invalidate);
|
||||
|
||||
root.forAllInTree(element -> element.getFlags().reset());
|
||||
}
|
||||
|
||||
void updateViewToCrop(@NonNull RectF visibleViewPort, @Nullable Runnable invalidate) {
|
||||
RectF dst = new RectF();
|
||||
|
||||
getCropFinalMatrix().mapRect(dst, Bounds.FULL_BOUNDS);
|
||||
|
||||
Matrix temp = new Matrix();
|
||||
temp.setRectToRect(dst, visibleViewPort, Matrix.ScaleToFit.CENTER);
|
||||
view.animateLocalTo(temp, invalidate);
|
||||
}
|
||||
|
||||
private @NonNull Matrix getCropFinalMatrix() {
|
||||
Matrix matrix = new Matrix(flipRotate.getLocalMatrix());
|
||||
matrix.preConcat(imageCrop.getLocalMatrix());
|
||||
matrix.preConcat(cropEditorElement.getLocalMatrix());
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a matrix that maps points from the crop on to the visible image.
|
||||
* <p>
|
||||
* i.e. if a mapped point is in bounds, then the point is on the visible image.
|
||||
*/
|
||||
@Nullable Matrix imageMatrixRelativeToCrop() {
|
||||
EditorElement mainImage = getMainImage();
|
||||
if (mainImage == null) return null;
|
||||
|
||||
Matrix matrix1 = new Matrix(imageCrop.getLocalMatrix());
|
||||
matrix1.preConcat(cropEditorElement.getLocalMatrix());
|
||||
matrix1.preConcat(cropEditorElement.getEditorMatrix());
|
||||
|
||||
Matrix matrix2 = new Matrix(mainImage.getLocalMatrix());
|
||||
matrix2.preConcat(mainImage.getEditorMatrix());
|
||||
matrix2.preConcat(imageCrop.getLocalMatrix());
|
||||
|
||||
Matrix inverse = new Matrix();
|
||||
matrix2.invert(inverse);
|
||||
inverse.preConcat(matrix1);
|
||||
|
||||
return inverse;
|
||||
}
|
||||
|
||||
void dragDropRelease(@NonNull RectF visibleViewPort, @NonNull Runnable invalidate) {
|
||||
if (cropEditorElement.getFlags().isVisible()) {
|
||||
updateViewToCrop(visibleViewPort, invalidate);
|
||||
}
|
||||
}
|
||||
|
||||
RectF getCropRect() {
|
||||
RectF dst = new RectF();
|
||||
getCropFinalMatrix().mapRect(dst, Bounds.FULL_BOUNDS);
|
||||
return dst;
|
||||
}
|
||||
|
||||
void flipRotate(int degrees, int scaleX, int scaleY, @NonNull RectF visibleViewPort, @Nullable Runnable invalidate) {
|
||||
Matrix newLocal = new Matrix(flipRotate.getLocalMatrix());
|
||||
if (degrees != 0) {
|
||||
newLocal.postRotate(degrees);
|
||||
}
|
||||
newLocal.postScale(scaleX, scaleY);
|
||||
flipRotate.animateLocalTo(newLocal, invalidate);
|
||||
updateViewToCrop(visibleViewPort, invalidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* The full matrix for the {@link #getMainImage()} from {@link #root} down.
|
||||
*/
|
||||
Matrix getMainImageFullMatrix() {
|
||||
Matrix matrix = new Matrix();
|
||||
|
||||
matrix.preConcat(view.getLocalMatrix());
|
||||
matrix.preConcat(getMainImageFullMatrixFromFlipRotate());
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full matrix for the {@link #getMainImage()} from {@link #flipRotate} down.
|
||||
*/
|
||||
Matrix getMainImageFullMatrixFromFlipRotate() {
|
||||
Matrix matrix = new Matrix();
|
||||
|
||||
matrix.preConcat(flipRotate.getLocalMatrix());
|
||||
matrix.preConcat(imageRoot.getLocalMatrix());
|
||||
|
||||
EditorElement mainImage = getMainImage();
|
||||
if (mainImage != null) {
|
||||
matrix.preConcat(mainImage.getLocalMatrix());
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the exact output size based upon the crops/rotates and zooms in the hierarchy.
|
||||
*
|
||||
* @param inputSize Main image size
|
||||
* @return Size after applying all zooms/rotates and crops
|
||||
*/
|
||||
PointF getOutputSize(@NonNull Point inputSize) {
|
||||
Matrix matrix = new Matrix();
|
||||
|
||||
matrix.preConcat(flipRotate.getLocalMatrix());
|
||||
matrix.preConcat(cropEditorElement.getLocalMatrix());
|
||||
matrix.preConcat(cropEditorElement.getEditorMatrix());
|
||||
EditorElement mainImage = getMainImage();
|
||||
if (mainImage != null) {
|
||||
float xScale = 1f / (xScale(mainImage.getLocalMatrix()) * xScale(mainImage.getEditorMatrix()));
|
||||
matrix.preScale(xScale, xScale);
|
||||
}
|
||||
|
||||
float[] dst = new float[4];
|
||||
matrix.mapPoints(dst, new float[]{ 0, 0, inputSize.x, inputSize.y });
|
||||
|
||||
float widthF = Math.abs(dst[0] - dst[2]);
|
||||
float heightF = Math.abs(dst[1] - dst[3]);
|
||||
|
||||
return new PointF(widthF, heightF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the x scale from a matrix, which is the length of the first column.
|
||||
*/
|
||||
static float xScale(@NonNull Matrix matrix) {
|
||||
float[] values = new float[9];
|
||||
matrix.getValues(values);
|
||||
return (float) Math.sqrt(values[0] * values[0] + values[3] * values[3]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
/**
|
||||
* Flags for an {@link EditorElement}.
|
||||
* <p>
|
||||
* Values you set are not persisted unless you call {@link #persist()}.
|
||||
* <p>
|
||||
* This allows temporary state for editing and an easy way to revert to the persisted state via {@link #reset()}.
|
||||
*/
|
||||
public final class EditorFlags {
|
||||
|
||||
private static final int ASPECT_LOCK = 1;
|
||||
private static final int ROTATE_LOCK = 2;
|
||||
private static final int SELECTABLE = 4;
|
||||
private static final int VISIBLE = 8;
|
||||
private static final int CHILDREN_VISIBLE = 16;
|
||||
private static final int EDITABLE = 32;
|
||||
|
||||
private int flags;
|
||||
private int markedFlags;
|
||||
private int persistedFlags;
|
||||
|
||||
EditorFlags() {
|
||||
this(ASPECT_LOCK | SELECTABLE | VISIBLE | CHILDREN_VISIBLE | EDITABLE);
|
||||
}
|
||||
|
||||
EditorFlags(int flags) {
|
||||
this.flags = flags;
|
||||
this.persistedFlags = flags;
|
||||
}
|
||||
|
||||
public EditorFlags setRotateLocked(boolean rotateLocked) {
|
||||
setFlag(ROTATE_LOCK, rotateLocked);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isRotateLocked() {
|
||||
return isFlagSet(ROTATE_LOCK);
|
||||
}
|
||||
|
||||
public EditorFlags setAspectLocked(boolean aspectLocked) {
|
||||
setFlag(ASPECT_LOCK, aspectLocked);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isAspectLocked() {
|
||||
return isFlagSet(ASPECT_LOCK);
|
||||
}
|
||||
|
||||
public EditorFlags setSelectable(boolean selectable) {
|
||||
setFlag(SELECTABLE, selectable);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isSelectable() {
|
||||
return isFlagSet(SELECTABLE);
|
||||
}
|
||||
|
||||
public EditorFlags setEditable(boolean canEdit) {
|
||||
setFlag(EDITABLE, canEdit);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isEditable() {
|
||||
return isFlagSet(EDITABLE);
|
||||
}
|
||||
|
||||
public EditorFlags setVisible(boolean visible) {
|
||||
setFlag(VISIBLE, visible);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isVisible() {
|
||||
return isFlagSet(VISIBLE);
|
||||
}
|
||||
|
||||
public EditorFlags setChildrenVisible(boolean childrenVisible) {
|
||||
setFlag(CHILDREN_VISIBLE, childrenVisible);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isChildrenVisible() {
|
||||
return isFlagSet(CHILDREN_VISIBLE);
|
||||
}
|
||||
|
||||
private void setFlag(int flag, boolean set) {
|
||||
if (set) {
|
||||
this.flags |= flag;
|
||||
} else {
|
||||
this.flags &= ~flag;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isFlagSet(int flag) {
|
||||
return (flags & flag) != 0;
|
||||
}
|
||||
|
||||
int asInt() {
|
||||
return persistedFlags;
|
||||
}
|
||||
|
||||
int getCurrentState() {
|
||||
return flags;
|
||||
}
|
||||
|
||||
public void persist() {
|
||||
persistedFlags = flags;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
restoreState(persistedFlags);
|
||||
}
|
||||
|
||||
void restoreState(int flags) {
|
||||
this.flags = flags;
|
||||
}
|
||||
|
||||
void mark() {
|
||||
markedFlags = flags;
|
||||
}
|
||||
|
||||
void restore() {
|
||||
flags = markedFlags;
|
||||
}
|
||||
|
||||
public void set(@NonNull EditorFlags from) {
|
||||
this.persistedFlags = from.persistedFlags;
|
||||
this.flags = from.flags;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,963 @@
|
||||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.PointF;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.imageeditor.core.Bounds;
|
||||
import org.signal.imageeditor.core.ColorableRenderer;
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
import org.signal.imageeditor.core.UndoRedoStackListener;
|
||||
import org.signal.imageeditor.core.renderers.FaceBlurRenderer;
|
||||
import org.signal.imageeditor.core.renderers.MultiLineTextRenderer;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Contains a reference to the root {@link EditorElement}, maintains undo and redo stacks and has a
|
||||
* reference to the {@link EditorElementHierarchy}.
|
||||
* <p>
|
||||
* As such it is the entry point for all operations that change the image.
|
||||
*/
|
||||
public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||
|
||||
public static final int Z_MASK = -1;
|
||||
public static final int Z_DRAWING = 0;
|
||||
public static final int Z_STICKERS = 0;
|
||||
public static final int Z_FADE = 1;
|
||||
public static final int Z_TEXT = 2;
|
||||
public static final int Z_TRASH = 3;
|
||||
|
||||
private static final Runnable NULL_RUNNABLE = () -> {
|
||||
};
|
||||
|
||||
private static final int MINIMUM_OUTPUT_WIDTH = 1024;
|
||||
|
||||
private static final int MINIMUM_CROP_PIXEL_COUNT = 100;
|
||||
private static final Point MINIMUM_RATIO = new Point(15, 1);
|
||||
|
||||
@NonNull
|
||||
private Runnable invalidate = NULL_RUNNABLE;
|
||||
|
||||
private UndoRedoStackListener undoRedoStackListener;
|
||||
|
||||
private final UndoRedoStacks undoRedoStacks;
|
||||
private final UndoRedoStacks cropUndoRedoStacks;
|
||||
private final InBoundsMemory inBoundsMemory = new InBoundsMemory();
|
||||
|
||||
private EditorElementHierarchy editorElementHierarchy;
|
||||
|
||||
private final RectF visibleViewPort = new RectF();
|
||||
private final Point size;
|
||||
private final EditingPurpose editingPurpose;
|
||||
private float fixedRatio;
|
||||
|
||||
private enum EditingPurpose {
|
||||
IMAGE,
|
||||
AVATAR_CAPTURE,
|
||||
AVATAR_EDIT,
|
||||
WALLPAPER
|
||||
}
|
||||
|
||||
private EditorModel(@NonNull Parcel in) {
|
||||
ClassLoader classLoader = getClass().getClassLoader();
|
||||
this.editingPurpose = EditingPurpose.values()[in.readInt()];
|
||||
this.fixedRatio = in.readFloat();
|
||||
this.size = new Point(in.readInt(), in.readInt());
|
||||
//noinspection ConstantConditions
|
||||
this.editorElementHierarchy = EditorElementHierarchy.create(in.readParcelable(classLoader));
|
||||
this.undoRedoStacks = in.readParcelable(classLoader);
|
||||
this.cropUndoRedoStacks = in.readParcelable(classLoader);
|
||||
}
|
||||
|
||||
public EditorModel(@NonNull EditingPurpose editingPurpose, float fixedRatio, @NonNull EditorElementHierarchy editorElementHierarchy) {
|
||||
this.editingPurpose = editingPurpose;
|
||||
this.fixedRatio = fixedRatio;
|
||||
this.size = new Point(1024, 1024);
|
||||
this.editorElementHierarchy = editorElementHierarchy;
|
||||
this.undoRedoStacks = new UndoRedoStacks(50);
|
||||
this.cropUndoRedoStacks = new UndoRedoStacks(50);
|
||||
}
|
||||
|
||||
public static EditorModel create() {
|
||||
EditorModel model = new EditorModel(EditingPurpose.IMAGE, 0, EditorElementHierarchy.create());
|
||||
model.setCropAspectLock(false);
|
||||
return model;
|
||||
}
|
||||
|
||||
public static EditorModel createForAvatarCapture() {
|
||||
EditorModel editorModel = new EditorModel(EditingPurpose.AVATAR_CAPTURE, 1, EditorElementHierarchy.createForCircleEditing());
|
||||
editorModel.setCropAspectLock(true);
|
||||
return editorModel;
|
||||
}
|
||||
|
||||
public static EditorModel createForAvatarEdit() {
|
||||
EditorModel editorModel = new EditorModel(EditingPurpose.AVATAR_EDIT, 1, EditorElementHierarchy.createForCircleEditing());
|
||||
editorModel.setCropAspectLock(true);
|
||||
return editorModel;
|
||||
}
|
||||
|
||||
public static EditorModel createForWallpaperEditing(float fixedRatio) {
|
||||
EditorModel editorModel = new EditorModel(EditingPurpose.WALLPAPER, fixedRatio, EditorElementHierarchy.createForPinchAndPanCropping());
|
||||
editorModel.setCropAspectLock(true);
|
||||
return editorModel;
|
||||
}
|
||||
|
||||
public void setInvalidate(@Nullable Runnable invalidate) {
|
||||
this.invalidate = invalidate != null ? invalidate : NULL_RUNNABLE;
|
||||
}
|
||||
|
||||
public void setUndoRedoStackListener(UndoRedoStackListener undoRedoStackListener) {
|
||||
this.undoRedoStackListener = undoRedoStackListener;
|
||||
|
||||
updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders tree with the following matrix:
|
||||
* <p>
|
||||
* viewModelMatrix * matrix * editorMatrix
|
||||
* <p>
|
||||
* Child nodes are supplied with a viewModelMatrix' = viewModelMatrix * matrix * editorMatrix
|
||||
*
|
||||
* @param rendererContext Canvas to draw on to.
|
||||
* @param renderOnTop This element will appear on top of the overlay.
|
||||
*/
|
||||
public void draw(@NonNull RendererContext rendererContext, @Nullable EditorElement renderOnTop) {
|
||||
EditorElement root = editorElementHierarchy.getRoot();
|
||||
if (renderOnTop != null) {
|
||||
root.forAllInTree(element -> element.getFlags().mark());
|
||||
|
||||
renderOnTop.getFlags().setVisible(false);
|
||||
}
|
||||
|
||||
// pass 1
|
||||
root.draw(rendererContext);
|
||||
|
||||
if (renderOnTop != null) {
|
||||
// hide all
|
||||
try {
|
||||
root.forAllInTree(element -> element.getFlags().setVisible(renderOnTop == element));
|
||||
|
||||
// pass 2
|
||||
root.draw(rendererContext);
|
||||
} finally {
|
||||
root.forAllInTree(element -> element.getFlags().restore());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable Matrix findElementInverseMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) {
|
||||
Matrix inverse = new Matrix();
|
||||
if (findElement(element, viewMatrix, inverse)) {
|
||||
return inverse;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private @Nullable Matrix findElementMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) {
|
||||
Matrix inverse = findElementInverseMatrix(element, viewMatrix);
|
||||
if (inverse != null) {
|
||||
Matrix regular = new Matrix();
|
||||
inverse.invert(regular);
|
||||
return regular;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public EditorElement findElementAtPoint(@NonNull PointF point, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) {
|
||||
return editorElementHierarchy.getRoot().findElementAt(point.x, point.y, viewMatrix, outInverseModelMatrix);
|
||||
}
|
||||
|
||||
public boolean checkTrashIntersectsPoint(@NonNull PointF point) {
|
||||
EditorElement trash = editorElementHierarchy.getTrash();
|
||||
if (trash.getFlags().isVisible()) {
|
||||
trash.getFlags()
|
||||
.setSelectable(true)
|
||||
.persist();
|
||||
|
||||
boolean isIntersecting = trash.findElementAt(point.x, point.y, new Matrix(), new Matrix()) != null;
|
||||
|
||||
trash.getFlags()
|
||||
.setSelectable(false)
|
||||
.persist();
|
||||
|
||||
return isIntersecting;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean findElement(@NonNull EditorElement element, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) {
|
||||
return editorElementHierarchy.getRoot().findElement(element, viewMatrix, outInverseModelMatrix) == element;
|
||||
}
|
||||
|
||||
public void pushUndoPoint() {
|
||||
boolean cropping = isCropping();
|
||||
if (cropping && !currentCropIsAcceptable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
getActiveUndoRedoStacks(cropping).pushState(editorElementHierarchy.getRoot());
|
||||
}
|
||||
|
||||
public void clearUndoStack() {
|
||||
EditorElement root = editorElementHierarchy.getRoot();
|
||||
EditorElement original = root;
|
||||
boolean cropping = isCropping();
|
||||
UndoRedoStacks stacks = getActiveUndoRedoStacks(cropping);
|
||||
boolean didPop = false;
|
||||
|
||||
while (stacks.canUndo(root)) {
|
||||
final EditorElement oldRootElement = root;
|
||||
final EditorElement popped = stacks.getUndoStack().pop(oldRootElement);
|
||||
|
||||
if (popped != null) {
|
||||
didPop = true;
|
||||
editorElementHierarchy = EditorElementHierarchy.create(popped);
|
||||
stacks.getRedoStack().tryPush(oldRootElement);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
root = editorElementHierarchy.getRoot();
|
||||
}
|
||||
|
||||
if (didPop) {
|
||||
restoreStateWithAnimations(original, editorElementHierarchy.getRoot(), invalidate, cropping);
|
||||
invalidate.run();
|
||||
editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate);
|
||||
inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement());
|
||||
}
|
||||
|
||||
updateUndoRedoAvailableState(stacks);
|
||||
}
|
||||
|
||||
public void undo() {
|
||||
boolean cropping = isCropping();
|
||||
UndoRedoStacks stacks = getActiveUndoRedoStacks(cropping);
|
||||
|
||||
undoRedo(stacks.getUndoStack(), stacks.getRedoStack(), cropping);
|
||||
|
||||
updateUndoRedoAvailableState(stacks);
|
||||
}
|
||||
|
||||
public void redo() {
|
||||
boolean cropping = isCropping();
|
||||
UndoRedoStacks stacks = getActiveUndoRedoStacks(cropping);
|
||||
|
||||
undoRedo(stacks.getRedoStack(), stacks.getUndoStack(), cropping);
|
||||
|
||||
updateUndoRedoAvailableState(stacks);
|
||||
}
|
||||
|
||||
private void undoRedo(@NonNull ElementStack fromStack, @NonNull ElementStack toStack, boolean keepEditorState) {
|
||||
final EditorElement oldRootElement = editorElementHierarchy.getRoot();
|
||||
final EditorElement popped = fromStack.pop(oldRootElement);
|
||||
|
||||
if (popped != null) {
|
||||
editorElementHierarchy = EditorElementHierarchy.create(popped);
|
||||
toStack.tryPush(oldRootElement);
|
||||
|
||||
restoreStateWithAnimations(oldRootElement, editorElementHierarchy.getRoot(), invalidate, keepEditorState);
|
||||
invalidate.run();
|
||||
|
||||
// re-zoom image root as the view port might be different now
|
||||
editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate);
|
||||
|
||||
inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement());
|
||||
}
|
||||
}
|
||||
|
||||
private static void restoreStateWithAnimations(@NonNull EditorElement fromRootElement, @NonNull EditorElement toRootElement, @NonNull Runnable onInvalidate, boolean keepEditorState) {
|
||||
Map<UUID, EditorElement> fromMap = getElementMap(fromRootElement);
|
||||
Map<UUID, EditorElement> toMap = getElementMap(toRootElement);
|
||||
|
||||
for (EditorElement fromElement : fromMap.values()) {
|
||||
fromElement.stopAnimation();
|
||||
EditorElement toElement = toMap.get(fromElement.getId());
|
||||
if (toElement != null) {
|
||||
toElement.animateFrom(fromElement.getLocalMatrixAnimating(), onInvalidate);
|
||||
|
||||
if (keepEditorState) {
|
||||
toElement.getEditorMatrix().set(fromElement.getEditorMatrix());
|
||||
toElement.getFlags().set(fromElement.getFlags());
|
||||
}
|
||||
} else {
|
||||
// element is removed
|
||||
EditorElement parentFrom = fromRootElement.parentOf(fromElement);
|
||||
if (parentFrom != null) {
|
||||
EditorElement toParent = toMap.get(parentFrom.getId());
|
||||
if (toParent != null) {
|
||||
toParent.addDeletedChildFadingOut(fromElement, onInvalidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (EditorElement toElement : toMap.values()) {
|
||||
if (!fromMap.containsKey(toElement.getId())) {
|
||||
// new item
|
||||
toElement.animateFadeIn(onInvalidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateUndoRedoAvailableState(UndoRedoStacks currentStack) {
|
||||
if (undoRedoStackListener == null) return;
|
||||
|
||||
EditorElement root = editorElementHierarchy.getRoot();
|
||||
|
||||
undoRedoStackListener.onAvailabilityChanged(currentStack.canUndo(root), currentStack.canRedo(root));
|
||||
}
|
||||
|
||||
private static Map<UUID, EditorElement> getElementMap(@NonNull EditorElement element) {
|
||||
final Map<UUID, EditorElement> result = new HashMap<>();
|
||||
element.buildMap(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public void addFade() {
|
||||
editorElementHierarchy.addFade(invalidate);
|
||||
}
|
||||
|
||||
public void removeFade() {
|
||||
editorElementHierarchy.removeFade(invalidate);
|
||||
}
|
||||
|
||||
public void startCrop() {
|
||||
float scaleIn = editingPurpose == EditingPurpose.WALLPAPER ? 1 : 0.8f;
|
||||
|
||||
pushUndoPoint();
|
||||
cropUndoRedoStacks.clear(editorElementHierarchy.getRoot());
|
||||
editorElementHierarchy.startCrop(invalidate, scaleIn);
|
||||
inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement());
|
||||
updateUndoRedoAvailableState(cropUndoRedoStacks);
|
||||
}
|
||||
|
||||
public void doneCrop() {
|
||||
editorElementHierarchy.doneCrop(visibleViewPort, invalidate);
|
||||
updateUndoRedoAvailableState(undoRedoStacks);
|
||||
}
|
||||
|
||||
public void setCropAspectLock(boolean locked) {
|
||||
EditorFlags flags = editorElementHierarchy.getCropEditorElement().getFlags();
|
||||
int currentState = flags.setAspectLocked(locked).getCurrentState();
|
||||
|
||||
flags.reset();
|
||||
flags.setAspectLocked(locked)
|
||||
.persist();
|
||||
flags.restoreState(currentState);
|
||||
}
|
||||
|
||||
public boolean isCropAspectLocked() {
|
||||
return editorElementHierarchy.getCropEditorElement().getFlags().isAspectLocked();
|
||||
}
|
||||
|
||||
public void postEdit(boolean allowScaleToRepairCrop) {
|
||||
boolean cropping = isCropping();
|
||||
if (cropping) {
|
||||
ensureFitsBounds(allowScaleToRepairCrop);
|
||||
}
|
||||
|
||||
updateUndoRedoAvailableState(getActiveUndoRedoStacks(cropping));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param cropping Set to true if cropping is underway.
|
||||
* @return The correct stack for the mode of operation.
|
||||
*/
|
||||
private UndoRedoStacks getActiveUndoRedoStacks(boolean cropping) {
|
||||
return cropping ? cropUndoRedoStacks : undoRedoStacks;
|
||||
}
|
||||
|
||||
private void ensureFitsBounds(boolean allowScaleToRepairCrop) {
|
||||
EditorElement mainImage = editorElementHierarchy.getMainImage();
|
||||
if (mainImage == null) return;
|
||||
|
||||
EditorElement cropEditorElement = editorElementHierarchy.getCropEditorElement();
|
||||
|
||||
if (!currentCropIsAcceptable()) {
|
||||
if (allowScaleToRepairCrop) {
|
||||
if (!tryToScaleToFit(cropEditorElement, 0.9f)) {
|
||||
tryToScaleToFit(mainImage, 2f);
|
||||
}
|
||||
} else {
|
||||
tryToFixTranslationOutOfBounds(mainImage, inBoundsMemory.getLastKnownGoodMainImageMatrix());
|
||||
}
|
||||
|
||||
if (!currentCropIsAcceptable()) {
|
||||
inBoundsMemory.restore(mainImage, cropEditorElement, invalidate);
|
||||
} else {
|
||||
inBoundsMemory.push(mainImage, cropEditorElement);
|
||||
}
|
||||
}
|
||||
|
||||
editorElementHierarchy.dragDropRelease(visibleViewPort, invalidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to scale the supplied element such that {@link #cropIsWithinMainImageBounds} is true.
|
||||
* <p>
|
||||
* Does not respect minimum scale, so does need a further check to {@link #currentCropIsAcceptable} afterwards.
|
||||
*
|
||||
* @param element The element to be scaled. If successful, it will be animated to the correct position.
|
||||
* @param scaleAtMost The amount of scale to apply at most. Use < 1 for the crop, and > 1 for the image.
|
||||
* @return true if successfully scaled the element. false if the element was left unchanged.
|
||||
*/
|
||||
private boolean tryToScaleToFit(@NonNull EditorElement element, float scaleAtMost) {
|
||||
return Bisect.bisectToTest(element,
|
||||
1,
|
||||
scaleAtMost,
|
||||
this::cropIsWithinMainImageBounds,
|
||||
(matrix, scale) -> matrix.preScale(scale, scale),
|
||||
invalidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to translate the supplied element such that {@link #cropIsWithinMainImageBounds} is true.
|
||||
* If you supply both x and y, it will attempt to find a fit on the diagonal with vector x, y.
|
||||
*
|
||||
* @param element The element to be translated. If successful, it will be animated to the correct position.
|
||||
* @param translateXAtMost The maximum translation to apply in the x axis.
|
||||
* @param translateYAtMost The maximum translation to apply in the y axis.
|
||||
* @return a matrix if successfully translated the element. null if the element unable to be translated to fit.
|
||||
*/
|
||||
private Matrix tryToTranslateToFit(@NonNull EditorElement element, float translateXAtMost, float translateYAtMost) {
|
||||
return Bisect.bisectToTest(element,
|
||||
0,
|
||||
1,
|
||||
this::cropIsWithinMainImageBounds,
|
||||
(matrix, factor) -> matrix.postTranslate(factor * translateXAtMost, factor * translateYAtMost));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to fix an element that is out of bounds by adjusting it's translation.
|
||||
*
|
||||
* @param element Element to move.
|
||||
* @param lastKnownGoodPosition Last known good position of element.
|
||||
* @return true iff fixed the element.
|
||||
*/
|
||||
private boolean tryToFixTranslationOutOfBounds(@NonNull EditorElement element, @NonNull Matrix lastKnownGoodPosition) {
|
||||
final Matrix elementMatrix = element.getLocalMatrix();
|
||||
final Matrix original = new Matrix(elementMatrix);
|
||||
final float[] current = new float[9];
|
||||
final float[] lastGood = new float[9];
|
||||
Matrix matrix;
|
||||
|
||||
elementMatrix.getValues(current);
|
||||
lastKnownGoodPosition.getValues(lastGood);
|
||||
|
||||
final float xTranslate = current[2] - lastGood[2];
|
||||
final float yTranslate = current[5] - lastGood[5];
|
||||
|
||||
if (Math.abs(xTranslate) < Bisect.ACCURACY && Math.abs(yTranslate) < Bisect.ACCURACY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
float pass1X;
|
||||
float pass1Y;
|
||||
|
||||
float pass2X;
|
||||
float pass2Y;
|
||||
|
||||
// try the fix by the smallest user translation first
|
||||
if (Math.abs(xTranslate) < Math.abs(yTranslate)) {
|
||||
// try to bisect along x
|
||||
pass1X = -xTranslate;
|
||||
pass1Y = 0;
|
||||
|
||||
// then y
|
||||
pass2X = 0;
|
||||
pass2Y = -yTranslate;
|
||||
} else {
|
||||
// try to bisect along y
|
||||
pass1X = 0;
|
||||
pass1Y = -yTranslate;
|
||||
|
||||
// then x
|
||||
pass2X = -xTranslate;
|
||||
pass2Y = 0;
|
||||
}
|
||||
|
||||
matrix = tryToTranslateToFit(element, pass1X, pass1Y);
|
||||
if (matrix != null) {
|
||||
element.animateLocalTo(matrix, invalidate);
|
||||
return true;
|
||||
}
|
||||
|
||||
matrix = tryToTranslateToFit(element, pass2X, pass2Y);
|
||||
if (matrix != null) {
|
||||
element.animateLocalTo(matrix, invalidate);
|
||||
return true;
|
||||
}
|
||||
|
||||
// apply pass 1 fully
|
||||
elementMatrix.postTranslate(pass1X, pass1Y);
|
||||
|
||||
matrix = tryToTranslateToFit(element, pass2X, pass2Y);
|
||||
elementMatrix.set(original);
|
||||
|
||||
if (matrix != null) {
|
||||
element.animateLocalTo(matrix, invalidate);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void dragDropRelease() {
|
||||
editorElementHierarchy.dragDropRelease(visibleViewPort, invalidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pixel count must be no smaller than {@link #MINIMUM_CROP_PIXEL_COUNT} (unless its original size was less than that)
|
||||
* and all points must be within the bounds.
|
||||
*/
|
||||
private boolean currentCropIsAcceptable() {
|
||||
Point outputSize = getOutputSize();
|
||||
int outputPixelCount = outputSize.x * outputSize.y;
|
||||
int minimumPixelCount = Math.min(size.x * size.y, MINIMUM_CROP_PIXEL_COUNT);
|
||||
|
||||
Point thinnestRatio = MINIMUM_RATIO;
|
||||
|
||||
if (compareRatios(size, thinnestRatio) < 0) {
|
||||
// original is narrower than the thinnestRatio
|
||||
thinnestRatio = size;
|
||||
}
|
||||
|
||||
return compareRatios(outputSize, thinnestRatio) >= 0 &&
|
||||
outputPixelCount >= minimumPixelCount &&
|
||||
cropIsWithinMainImageBounds();
|
||||
}
|
||||
|
||||
/**
|
||||
* -1 iff a is a narrower ratio than b.
|
||||
* +1 iff a is a squarer ratio than b.
|
||||
* 0 if the ratios are the same.
|
||||
*/
|
||||
private static int compareRatios(@NonNull Point a, @NonNull Point b) {
|
||||
int smallA = Math.min(a.x, a.y);
|
||||
int largeA = Math.max(a.x, a.y);
|
||||
|
||||
int smallB = Math.min(b.x, b.y);
|
||||
int largeB = Math.max(b.x, b.y);
|
||||
|
||||
return Integer.compare(smallA * largeB, smallB * largeA);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true if and only if the current crop rect is fully in the bounds.
|
||||
*/
|
||||
private boolean cropIsWithinMainImageBounds() {
|
||||
return Bounds.boundsRemainInBounds(editorElementHierarchy.imageMatrixRelativeToCrop());
|
||||
}
|
||||
|
||||
/**
|
||||
* Called as edits are underway.
|
||||
*/
|
||||
public void moving(@NonNull EditorElement editorElement) {
|
||||
if (!isCropping()) return;
|
||||
|
||||
EditorElement mainImage = editorElementHierarchy.getMainImage();
|
||||
EditorElement cropEditorElement = editorElementHierarchy.getCropEditorElement();
|
||||
|
||||
if (editorElement == mainImage || editorElement == cropEditorElement) {
|
||||
if (currentCropIsAcceptable()) {
|
||||
inBoundsMemory.push(mainImage, cropEditorElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setVisibleViewPort(@NonNull RectF visibleViewPort) {
|
||||
this.visibleViewPort.set(visibleViewPort);
|
||||
this.editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate);
|
||||
}
|
||||
|
||||
public Set<Integer> getUniqueColorsIgnoringAlpha() {
|
||||
final Set<Integer> colors = new LinkedHashSet<>();
|
||||
|
||||
editorElementHierarchy.getRoot().forAllInTree(element -> {
|
||||
Renderer renderer = element.getRenderer();
|
||||
if (renderer instanceof ColorableRenderer) {
|
||||
colors.add(((ColorableRenderer) renderer).getColor() | 0xff000000);
|
||||
}
|
||||
});
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
public static final Creator<EditorModel> CREATOR = new Creator<EditorModel>() {
|
||||
@Override
|
||||
public EditorModel createFromParcel(Parcel in) {
|
||||
return new EditorModel(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditorModel[] newArray(int size) {
|
||||
return new EditorModel[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(editingPurpose.ordinal());
|
||||
dest.writeFloat(fixedRatio);
|
||||
dest.writeInt(size.x);
|
||||
dest.writeInt(size.y);
|
||||
dest.writeParcelable(editorElementHierarchy.getRoot(), flags);
|
||||
dest.writeParcelable(undoRedoStacks, flags);
|
||||
dest.writeParcelable(cropUndoRedoStacks, flags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking render of the model.
|
||||
*/
|
||||
@WorkerThread
|
||||
public @NonNull Bitmap render(@NonNull Context context) {
|
||||
return render(context, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking render of the model.
|
||||
*/
|
||||
@WorkerThread
|
||||
public @NonNull Bitmap render(@NonNull Context context, @Nullable Point size) {
|
||||
EditorElement image = editorElementHierarchy.getFlipRotate();
|
||||
RectF cropRect = editorElementHierarchy.getCropRect();
|
||||
Point outputSize = size != null ? size : getOutputSize();
|
||||
|
||||
Bitmap bitmap = Bitmap.createBitmap(outputSize.x, outputSize.y, Bitmap.Config.ARGB_8888);
|
||||
try {
|
||||
Canvas canvas = new Canvas(bitmap);
|
||||
RendererContext rendererContext = new RendererContext(context, canvas, RendererContext.Ready.NULL, RendererContext.Invalidate.NULL);
|
||||
|
||||
RectF bitmapArea = new RectF();
|
||||
bitmapArea.right = bitmap.getWidth();
|
||||
bitmapArea.bottom = bitmap.getHeight();
|
||||
|
||||
Matrix viewMatrix = new Matrix();
|
||||
viewMatrix.setRectToRect(cropRect, bitmapArea, Matrix.ScaleToFit.FILL);
|
||||
|
||||
rendererContext.setIsEditing(false);
|
||||
rendererContext.setBlockingLoad(true);
|
||||
|
||||
EditorElement overlay = editorElementHierarchy.getOverlay();
|
||||
overlay.getFlags().setVisible(false).setChildrenVisible(false);
|
||||
|
||||
try {
|
||||
rendererContext.canvasMatrix.initial(viewMatrix);
|
||||
image.draw(rendererContext);
|
||||
} finally {
|
||||
overlay.getFlags().reset();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
bitmap.recycle();
|
||||
throw e;
|
||||
}
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private Point getOutputSize() {
|
||||
PointF outputSize = editorElementHierarchy.getOutputSize(size);
|
||||
|
||||
int width = (int) Math.max(MINIMUM_OUTPUT_WIDTH, outputSize.x);
|
||||
int height = (int) (width * outputSize.y / outputSize.x);
|
||||
|
||||
return new Point(width, height);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public Point getOutputSizeMaxWidth(int maxDimension) {
|
||||
PointF outputSize = editorElementHierarchy.getOutputSize(size);
|
||||
|
||||
int width = Math.min(maxDimension, (int) Math.max(MINIMUM_OUTPUT_WIDTH, outputSize.x));
|
||||
int height = (int) (width * outputSize.y / outputSize.x);
|
||||
|
||||
if (height > maxDimension) {
|
||||
height = maxDimension;
|
||||
width = (int) (height * outputSize.x / outputSize.y);
|
||||
}
|
||||
|
||||
return new Point(width, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size) {
|
||||
if (cropMatrix != null && size != null && isRendererOfMainImage(renderer)) {
|
||||
boolean changedBefore = isChanged();
|
||||
Matrix imageCropMatrix = editorElementHierarchy.getImageCrop().getLocalMatrix();
|
||||
this.size.set(size.x, size.y);
|
||||
if (imageCropMatrix.isIdentity()) {
|
||||
imageCropMatrix.set(cropMatrix);
|
||||
|
||||
if (editingPurpose == EditingPurpose.AVATAR_CAPTURE || editingPurpose == EditingPurpose.WALLPAPER || editingPurpose == EditingPurpose.AVATAR_EDIT) {
|
||||
Matrix userCropMatrix = editorElementHierarchy.getCropEditorElement().getLocalMatrix();
|
||||
if (size.x > size.y) {
|
||||
userCropMatrix.setScale(fixedRatio * size.y / (float) size.x, 1f);
|
||||
} else {
|
||||
userCropMatrix.setScale(1f, size.x / (float) size.y);
|
||||
}
|
||||
}
|
||||
|
||||
editorElementHierarchy.doneCrop(visibleViewPort, null);
|
||||
|
||||
if (!changedBefore) {
|
||||
undoRedoStacks.clear(editorElementHierarchy.getRoot());
|
||||
}
|
||||
|
||||
switch (editingPurpose) {
|
||||
case AVATAR_CAPTURE: {
|
||||
startCrop();
|
||||
break;
|
||||
}
|
||||
case WALLPAPER: {
|
||||
setFixedRatio(fixedRatio);
|
||||
startCrop();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setFixedRatio(float r) {
|
||||
fixedRatio = r;
|
||||
Matrix userCropMatrix = editorElementHierarchy.getCropEditorElement().getLocalMatrix();
|
||||
float w = size.x;
|
||||
float h = size.y;
|
||||
float imageRatio = w / h;
|
||||
if (imageRatio > r) {
|
||||
userCropMatrix.setScale(r / imageRatio, 1f);
|
||||
} else {
|
||||
userCropMatrix.setScale(1f, imageRatio / r);
|
||||
}
|
||||
|
||||
editorElementHierarchy.doneCrop(visibleViewPort, null);
|
||||
startCrop();
|
||||
}
|
||||
|
||||
private boolean isRendererOfMainImage(@NonNull Renderer renderer) {
|
||||
EditorElement mainImage = editorElementHierarchy.getMainImage();
|
||||
Renderer mainImageRenderer = mainImage != null ? mainImage.getRenderer() : null;
|
||||
return mainImageRenderer == renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new {@link EditorElement} centered in the current visible crop area.
|
||||
*
|
||||
* @param element New element to add.
|
||||
* @param scale Initial scale for new element.
|
||||
*/
|
||||
public void addElementCentered(@NonNull EditorElement element, float scale) {
|
||||
Matrix localMatrix = element.getLocalMatrix();
|
||||
|
||||
editorElementHierarchy.getMainImageFullMatrix().invert(localMatrix);
|
||||
|
||||
localMatrix.preScale(scale, scale);
|
||||
addElement(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an element to the main image, or if there is no main image, make the new element the main image.
|
||||
*
|
||||
* @param element New element to add.
|
||||
*/
|
||||
public void addElement(@NonNull EditorElement element) {
|
||||
pushUndoPoint();
|
||||
addElementWithoutPushUndo(element);
|
||||
}
|
||||
|
||||
public void addElementWithoutPushUndo(@NonNull EditorElement element) {
|
||||
EditorElement mainImage = editorElementHierarchy.getMainImage();
|
||||
EditorElement parent = mainImage != null ? mainImage : editorElementHierarchy.getImageRoot();
|
||||
|
||||
parent.addElement(element);
|
||||
|
||||
if (parent != mainImage) {
|
||||
undoRedoStacks.clear(editorElementHierarchy.getRoot());
|
||||
}
|
||||
|
||||
updateUndoRedoAvailableState(undoRedoStacks);
|
||||
}
|
||||
|
||||
public void clearFaceRenderers() {
|
||||
EditorElement mainImage = editorElementHierarchy.getMainImage();
|
||||
if (mainImage != null) {
|
||||
boolean hasPushedUndo = false;
|
||||
for (int i = mainImage.getChildCount() - 1; i >= 0; i--) {
|
||||
if (mainImage.getChild(i).getRenderer() instanceof FaceBlurRenderer) {
|
||||
if (!hasPushedUndo) {
|
||||
pushUndoPoint();
|
||||
hasPushedUndo = true;
|
||||
}
|
||||
|
||||
mainImage.deleteChild(mainImage.getChild(i), invalidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasFaceRenderer() {
|
||||
EditorElement mainImage = editorElementHierarchy.getMainImage();
|
||||
if (mainImage != null) {
|
||||
for (int i = mainImage.getChildCount() - 1; i >= 0; i--) {
|
||||
if (mainImage.getChild(i).getRenderer() instanceof FaceBlurRenderer) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isChanged() {
|
||||
return undoRedoStacks.isChanged(editorElementHierarchy.getRoot());
|
||||
}
|
||||
|
||||
public RectF findCropRelativeToRoot() {
|
||||
return findCropRelativeTo(editorElementHierarchy.getRoot());
|
||||
}
|
||||
|
||||
RectF findCropRelativeTo(EditorElement element) {
|
||||
return findRelativeBounds(editorElementHierarchy.getCropEditorElement(), element);
|
||||
}
|
||||
|
||||
RectF findRelativeBounds(EditorElement from, EditorElement to) {
|
||||
Matrix relative = findRelativeMatrix(from, to);
|
||||
|
||||
RectF dst = new RectF(Bounds.FULL_BOUNDS);
|
||||
if (relative != null) {
|
||||
relative.mapRect(dst, Bounds.FULL_BOUNDS);
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a matrix that maps points in the {@param from} element in to points in the {@param to} element.
|
||||
*
|
||||
* @param from
|
||||
* @param to
|
||||
* @return
|
||||
*/
|
||||
@Nullable Matrix findRelativeMatrix(@NonNull EditorElement from, @NonNull EditorElement to) {
|
||||
Matrix matrix = findElementInverseMatrix(to, new Matrix());
|
||||
Matrix outOf = findElementMatrix(from, new Matrix());
|
||||
|
||||
if (outOf != null && matrix != null) {
|
||||
matrix.preConcat(outOf);
|
||||
return matrix;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void rotate90clockwise() {
|
||||
flipRotate(90, 1, 1);
|
||||
}
|
||||
|
||||
public void rotate90anticlockwise() {
|
||||
flipRotate(-90, 1, 1);
|
||||
}
|
||||
|
||||
public void flipHorizontal() {
|
||||
flipRotate(0, -1, 1);
|
||||
}
|
||||
|
||||
public void flipVertical() {
|
||||
flipRotate(0, 1, -1);
|
||||
}
|
||||
|
||||
private void flipRotate(int degrees, int scaleX, int scaleY) {
|
||||
pushUndoPoint();
|
||||
editorElementHierarchy.flipRotate(degrees, scaleX, scaleY, visibleViewPort, invalidate);
|
||||
updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping()));
|
||||
}
|
||||
|
||||
public EditorElement getRoot() {
|
||||
return editorElementHierarchy.getRoot();
|
||||
}
|
||||
|
||||
public EditorElement getTrash() {
|
||||
return editorElementHierarchy.getTrash();
|
||||
}
|
||||
|
||||
public @Nullable EditorElement getMainImage() {
|
||||
return editorElementHierarchy.getMainImage();
|
||||
}
|
||||
|
||||
public void delete(@NonNull EditorElement editorElement) {
|
||||
editorElementHierarchy.getImageRoot().forAllInTree(element -> element.deleteChild(editorElement, invalidate));
|
||||
}
|
||||
|
||||
public @Nullable EditorElement findById(@NonNull UUID uuid) {
|
||||
return getElementMap(getRoot()).get(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the temporary view so that the text element is centered in it.
|
||||
*
|
||||
* @param entity Entity to center on.
|
||||
* @param textRenderer The text renderer, which can make additional adjustments to the zoom matrix
|
||||
* to leave space for the keyboard for example.
|
||||
*/
|
||||
public void zoomToTextElement(@NonNull EditorElement entity, @NonNull MultiLineTextRenderer textRenderer) {
|
||||
Matrix elementInverseMatrix = findElementInverseMatrix(entity, new Matrix());
|
||||
if (elementInverseMatrix != null) {
|
||||
EditorElement root = editorElementHierarchy.getRoot();
|
||||
|
||||
elementInverseMatrix.preConcat(root.getEditorMatrix());
|
||||
|
||||
textRenderer.applyRecommendedEditorMatrix(elementInverseMatrix);
|
||||
|
||||
root.animateEditorTo(elementInverseMatrix, invalidate);
|
||||
}
|
||||
}
|
||||
|
||||
public void zoomOut() {
|
||||
editorElementHierarchy.getRoot().rollbackEditorMatrix(invalidate);
|
||||
}
|
||||
|
||||
public void indicateSelected(@NonNull EditorElement selected) {
|
||||
selected.singleScalePulse(invalidate);
|
||||
}
|
||||
|
||||
public boolean isCropping() {
|
||||
return editorElementHierarchy.getCropEditorElement().getFlags().isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a matrix that maps bounds to the crop area.
|
||||
*/
|
||||
public Matrix getInverseCropPosition() {
|
||||
Matrix matrix = new Matrix();
|
||||
matrix.set(findRelativeMatrix(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement()));
|
||||
matrix.postConcat(editorElementHierarchy.getFlipRotate().getLocalMatrix());
|
||||
|
||||
Matrix positionRelativeToCrop = new Matrix();
|
||||
matrix.invert(positionRelativeToCrop);
|
||||
return positionRelativeToCrop;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Stack;
|
||||
|
||||
/**
|
||||
* Contains a stack of elements for undo and redo stacks.
|
||||
* <p>
|
||||
* Elements are mutable, so this stack serializes the element and keeps a stack of serialized data.
|
||||
* <p>
|
||||
* The stack has a {@link #limit} and if it exceeds that limit during a push the second to earliest item
|
||||
* is removed so that it can always go back to the first state. Effectively collapsing the history for
|
||||
* the start of the stack.
|
||||
*/
|
||||
final class ElementStack implements Parcelable {
|
||||
|
||||
private final int limit;
|
||||
private final Stack<byte[]> stack = new Stack<>();
|
||||
|
||||
ElementStack(int limit) {
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
private ElementStack(@NonNull Parcel in) {
|
||||
this(in.readInt());
|
||||
final int count = in.readInt();
|
||||
for (int i = 0; i < count; i++) {
|
||||
stack.add(i, in.createByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes an element to the stack iff the element's serialized value is different to any found at
|
||||
* the top of the stack.
|
||||
* <p>
|
||||
* Removes the second to earliest item if it is overflowing.
|
||||
*
|
||||
* @param element new editor element state.
|
||||
* @return true iff the pushed item was different to the top item.
|
||||
*/
|
||||
boolean tryPush(@NonNull EditorElement element) {
|
||||
byte[] bytes = getBytes(element);
|
||||
boolean push = stack.isEmpty() || !Arrays.equals(bytes, stack.peek());
|
||||
|
||||
if (push) {
|
||||
stack.push(bytes);
|
||||
if (stack.size() > limit) {
|
||||
stack.remove(1);
|
||||
}
|
||||
}
|
||||
return push;
|
||||
}
|
||||
|
||||
static byte[] getBytes(@NonNull Parcelable parcelable) {
|
||||
Parcel parcel = Parcel.obtain();
|
||||
byte[] bytes;
|
||||
try {
|
||||
parcel.writeParcelable(parcelable, 0);
|
||||
bytes = parcel.marshall();
|
||||
} finally {
|
||||
parcel.recycle();
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pops the first different state from the supplied element.
|
||||
*/
|
||||
@Nullable EditorElement pop(@NonNull EditorElement element) {
|
||||
if (stack.empty()) return null;
|
||||
|
||||
byte[] elementBytes = getBytes(element);
|
||||
byte[] stackData = null;
|
||||
|
||||
while (!stack.empty() && stackData == null) {
|
||||
byte[] topData = stack.pop();
|
||||
|
||||
if (!Arrays.equals(topData, elementBytes)) {
|
||||
stackData = topData;
|
||||
}
|
||||
}
|
||||
|
||||
if (stackData == null) return null;
|
||||
|
||||
Parcel parcel = Parcel.obtain();
|
||||
try {
|
||||
parcel.unmarshall(stackData, 0, stackData.length);
|
||||
parcel.setDataPosition(0);
|
||||
return parcel.readParcelable(EditorElement.class.getClassLoader());
|
||||
} finally {
|
||||
parcel.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
stack.clear();
|
||||
}
|
||||
|
||||
public static final Creator<ElementStack> CREATOR = new Creator<ElementStack>() {
|
||||
@Override
|
||||
public ElementStack createFromParcel(Parcel in) {
|
||||
return new ElementStack(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ElementStack[] newArray(int size) {
|
||||
return new ElementStack[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(limit);
|
||||
final int count = stack.size();
|
||||
dest.writeInt(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
dest.writeByteArray(stack.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
boolean stackContainsStateDifferentFrom(@NonNull EditorElement element) {
|
||||
if (stack.isEmpty()) return false;
|
||||
|
||||
byte[] currentStateBytes = getBytes(element);
|
||||
|
||||
for (byte[] item : stack) {
|
||||
if (!Arrays.equals(item, currentStateBytes)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
final class InBoundsMemory {
|
||||
|
||||
private final Matrix lastGoodUserCrop = new Matrix();
|
||||
private final Matrix lastGoodMainImage = new Matrix();
|
||||
|
||||
void push(@Nullable EditorElement mainImage, @NonNull EditorElement userCrop) {
|
||||
if (mainImage == null) {
|
||||
lastGoodMainImage.reset();
|
||||
} else {
|
||||
lastGoodMainImage.set(mainImage.getLocalMatrix());
|
||||
lastGoodMainImage.preConcat(mainImage.getEditorMatrix());
|
||||
}
|
||||
|
||||
lastGoodUserCrop.set(userCrop.getLocalMatrix());
|
||||
lastGoodUserCrop.preConcat(userCrop.getEditorMatrix());
|
||||
}
|
||||
|
||||
void restore(@Nullable EditorElement mainImage, @NonNull EditorElement cropEditorElement, @Nullable Runnable invalidate) {
|
||||
if (mainImage != null) {
|
||||
mainImage.animateLocalTo(lastGoodMainImage, invalidate);
|
||||
}
|
||||
cropEditorElement.animateLocalTo(lastGoodUserCrop, invalidate);
|
||||
}
|
||||
|
||||
Matrix getLastKnownGoodMainImageMatrix() {
|
||||
return new Matrix(lastGoodMainImage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public final class ParcelUtils {
|
||||
|
||||
private ParcelUtils() {
|
||||
}
|
||||
|
||||
public static void writeMatrix(@NonNull Parcel dest, @NonNull Matrix matrix) {
|
||||
float[] values = new float[9];
|
||||
matrix.getValues(values);
|
||||
dest.writeFloatArray(values);
|
||||
}
|
||||
|
||||
public static void readMatrix(@NonNull Matrix matrix, @NonNull Parcel in) {
|
||||
float[] values = new float[9];
|
||||
in.readFloatArray(values);
|
||||
matrix.setValues(values);
|
||||
}
|
||||
|
||||
public static @NonNull Matrix readMatrix(@NonNull Parcel in) {
|
||||
Matrix matrix = new Matrix();
|
||||
readMatrix(matrix, in);
|
||||
return matrix;
|
||||
}
|
||||
|
||||
public static void writeRect(@NonNull Parcel dest, @NonNull RectF rect) {
|
||||
dest.writeFloat(rect.left);
|
||||
dest.writeFloat(rect.top);
|
||||
dest.writeFloat(rect.right);
|
||||
dest.writeFloat(rect.bottom);
|
||||
}
|
||||
|
||||
public static @NonNull RectF readRectF(@NonNull Parcel in) {
|
||||
float left = in.readFloat();
|
||||
float top = in.readFloat();
|
||||
float right = in.readFloat();
|
||||
float bottom = in.readFloat();
|
||||
return new RectF(left, top, right, bottom);
|
||||
}
|
||||
|
||||
static UUID readUUID(@NonNull Parcel in) {
|
||||
return new UUID(in.readLong(), in.readLong());
|
||||
}
|
||||
|
||||
static void writeUUID(@NonNull Parcel dest, @NonNull UUID uuid) {
|
||||
dest.writeLong(uuid.getMostSignificantBits());
|
||||
dest.writeLong(uuid.getLeastSignificantBits());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import org.signal.imageeditor.core.Bounds;
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A special {@link Renderer} that controls another {@link EditorElement}.
|
||||
* <p>
|
||||
* It has a reference to the {@link EditorElement#getId()} and a {@link ControlPoint} which it is in control of.
|
||||
* <p>
|
||||
* The presence of this interface on the selected element is used to launch a ThumbDragEditSession.
|
||||
*/
|
||||
public interface ThumbRenderer extends Renderer {
|
||||
|
||||
enum ControlPoint {
|
||||
|
||||
CENTER_LEFT (Bounds.LEFT, Bounds.CENTRE_Y),
|
||||
CENTER_RIGHT (Bounds.RIGHT, Bounds.CENTRE_Y),
|
||||
|
||||
TOP_CENTER (Bounds.CENTRE_X, Bounds.TOP),
|
||||
BOTTOM_CENTER (Bounds.CENTRE_X, Bounds.BOTTOM),
|
||||
|
||||
TOP_LEFT (Bounds.LEFT, Bounds.TOP),
|
||||
TOP_RIGHT (Bounds.RIGHT, Bounds.TOP),
|
||||
BOTTOM_LEFT (Bounds.LEFT, Bounds.BOTTOM),
|
||||
BOTTOM_RIGHT (Bounds.RIGHT, Bounds.BOTTOM);
|
||||
|
||||
private final float x;
|
||||
private final float y;
|
||||
|
||||
ControlPoint(float x, float y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
public float getX() {
|
||||
return x;
|
||||
}
|
||||
|
||||
public float getY() {
|
||||
return y;
|
||||
}
|
||||
|
||||
public ControlPoint opposite() {
|
||||
switch (this) {
|
||||
case CENTER_LEFT: return CENTER_RIGHT;
|
||||
case CENTER_RIGHT: return CENTER_LEFT;
|
||||
case TOP_CENTER: return BOTTOM_CENTER;
|
||||
case BOTTOM_CENTER: return TOP_CENTER;
|
||||
case TOP_LEFT: return BOTTOM_RIGHT;
|
||||
case TOP_RIGHT: return BOTTOM_LEFT;
|
||||
case BOTTOM_LEFT: return TOP_RIGHT;
|
||||
case BOTTOM_RIGHT: return TOP_LEFT;
|
||||
default:
|
||||
throw new RuntimeException();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isHorizontalCenter() {
|
||||
return this == ControlPoint.CENTER_LEFT || this == ControlPoint.CENTER_RIGHT;
|
||||
}
|
||||
|
||||
public boolean isVerticalCenter() {
|
||||
return this == ControlPoint.TOP_CENTER || this == ControlPoint.BOTTOM_CENTER;
|
||||
}
|
||||
|
||||
public boolean isCenter() {
|
||||
return isHorizontalCenter() || isVerticalCenter();
|
||||
}
|
||||
}
|
||||
|
||||
ControlPoint getControlPoint();
|
||||
|
||||
UUID getElementToControl();
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
final class UndoRedoStacks implements Parcelable {
|
||||
|
||||
private final ElementStack undoStack;
|
||||
private final ElementStack redoStack;
|
||||
|
||||
@NonNull
|
||||
private byte[] unchangedState;
|
||||
|
||||
UndoRedoStacks(int limit) {
|
||||
this(new ElementStack(limit), new ElementStack(limit), null);
|
||||
}
|
||||
|
||||
private UndoRedoStacks(ElementStack undoStack, ElementStack redoStack, @Nullable byte[] unchangedState) {
|
||||
this.undoStack = undoStack;
|
||||
this.redoStack = redoStack;
|
||||
this.unchangedState = unchangedState != null ? unchangedState : new byte[0];
|
||||
}
|
||||
|
||||
public static final Creator<UndoRedoStacks> CREATOR = new Creator<UndoRedoStacks>() {
|
||||
@Override
|
||||
public UndoRedoStacks createFromParcel(Parcel in) {
|
||||
return new UndoRedoStacks(
|
||||
in.readParcelable(ElementStack.class.getClassLoader()),
|
||||
in.readParcelable(ElementStack.class.getClassLoader()),
|
||||
in.createByteArray()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UndoRedoStacks[] newArray(int size) {
|
||||
return new UndoRedoStacks[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeParcelable(undoStack, flags);
|
||||
dest.writeParcelable(redoStack, flags);
|
||||
dest.writeByteArray(unchangedState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
ElementStack getUndoStack() {
|
||||
return undoStack;
|
||||
}
|
||||
|
||||
ElementStack getRedoStack() {
|
||||
return redoStack;
|
||||
}
|
||||
|
||||
void pushState(@NonNull EditorElement element) {
|
||||
if (undoStack.tryPush(element)) {
|
||||
redoStack.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void clear(@NonNull EditorElement element) {
|
||||
undoStack.clear();
|
||||
redoStack.clear();
|
||||
unchangedState = ElementStack.getBytes(element);
|
||||
}
|
||||
|
||||
boolean isChanged(@NonNull EditorElement element) {
|
||||
return !Arrays.equals(ElementStack.getBytes(element), unchangedState);
|
||||
}
|
||||
|
||||
/**
|
||||
* As long as there is something different in the stack somewhere, then we can undo.
|
||||
*/
|
||||
boolean canUndo(@NonNull EditorElement currentState) {
|
||||
return undoStack.stackContainsStateDifferentFrom(currentState);
|
||||
}
|
||||
|
||||
/**
|
||||
* As long as there is something different in the stack somewhere, then we can redo.
|
||||
*/
|
||||
boolean canRedo(@NonNull EditorElement currentState) {
|
||||
return redoStack.stackContainsStateDifferentFrom(currentState);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Given points for a line to go though, automatically finds control points.
|
||||
* <p>
|
||||
* Based on http://www.particleincell.com/2012/bezier-splines/
|
||||
* <p>
|
||||
* Can then draw that line to a {@link Canvas} given a {@link Paint}.
|
||||
* <p>
|
||||
* Allocation efficient so that adding new points does not result in lots of array allocations.
|
||||
*/
|
||||
final class AutomaticControlPointBezierLine implements Parcelable {
|
||||
|
||||
private static final int INITIAL_CAPACITY = 256;
|
||||
|
||||
private float[] x;
|
||||
private float[] y;
|
||||
|
||||
// control points
|
||||
private float[] p1x;
|
||||
private float[] p1y;
|
||||
private float[] p2x;
|
||||
private float[] p2y;
|
||||
|
||||
private int count;
|
||||
|
||||
private final Path path = new Path();
|
||||
|
||||
private AutomaticControlPointBezierLine(@Nullable float[] x, @Nullable float[] y, int count) {
|
||||
this.count = count;
|
||||
this.x = x != null ? x : new float[INITIAL_CAPACITY];
|
||||
this.y = y != null ? y : new float[INITIAL_CAPACITY];
|
||||
allocControlPointsAndWorkingMemory(this.x.length);
|
||||
recalculateControlPoints();
|
||||
}
|
||||
|
||||
AutomaticControlPointBezierLine() {
|
||||
this(null, null, 0);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
count = 0;
|
||||
path.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new point to the end of the line but ignores points that are too close to the last.
|
||||
*
|
||||
* @param x new x point
|
||||
* @param y new y point
|
||||
* @param thickness the maximum distance to allow, line thickness is recommended.
|
||||
*/
|
||||
void addPointFiltered(float x, float y, float thickness) {
|
||||
if (count > 0) {
|
||||
float dx = this.x[count - 1] - x;
|
||||
float dy = this.y[count - 1] - y;
|
||||
if (dx * dx + dy * dy < thickness * thickness) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
addPoint(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new point to the end of the line.
|
||||
*
|
||||
* @param x new x point
|
||||
* @param y new y point
|
||||
*/
|
||||
void addPoint(float x, float y) {
|
||||
if (this.x == null || count == this.x.length) {
|
||||
resize(this.x != null ? this.x.length << 1 : INITIAL_CAPACITY);
|
||||
}
|
||||
|
||||
this.x[count] = x;
|
||||
this.y[count] = y;
|
||||
count++;
|
||||
|
||||
recalculateControlPoints();
|
||||
}
|
||||
|
||||
private void resize(int newCapacity) {
|
||||
x = Arrays.copyOf(x, newCapacity);
|
||||
y = Arrays.copyOf(y, newCapacity);
|
||||
allocControlPointsAndWorkingMemory(newCapacity - 1);
|
||||
}
|
||||
|
||||
private void allocControlPointsAndWorkingMemory(int max) {
|
||||
p1x = new float[max];
|
||||
p1y = new float[max];
|
||||
p2x = new float[max];
|
||||
p2y = new float[max];
|
||||
|
||||
a = new float[max];
|
||||
b = new float[max];
|
||||
c = new float[max];
|
||||
r = new float[max];
|
||||
}
|
||||
|
||||
private void recalculateControlPoints() {
|
||||
path.reset();
|
||||
|
||||
if (count > 2) {
|
||||
computeControlPoints(x, p1x, p2x, count);
|
||||
computeControlPoints(y, p1y, p2y, count);
|
||||
}
|
||||
|
||||
path.moveTo(x[0], y[0]);
|
||||
switch (count) {
|
||||
case 1:
|
||||
path.lineTo(x[0], y[0]);
|
||||
break;
|
||||
case 2:
|
||||
path.lineTo(x[1], y[1]);
|
||||
break;
|
||||
default:
|
||||
for (int i = 1; i < count - 1; i++) {
|
||||
path.cubicTo(p1x[i], p1y[i], p2x[i], p2y[i], x[i + 1], y[i + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the line.
|
||||
*
|
||||
* @param canvas The canvas to draw on.
|
||||
* @param paint The paint to use.
|
||||
*/
|
||||
void draw(@NonNull Canvas canvas, @NonNull Paint paint) {
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
// rhs vector for computeControlPoints method
|
||||
private float[] a;
|
||||
private float[] b;
|
||||
private float[] c;
|
||||
private float[] r;
|
||||
|
||||
/**
|
||||
* Based on http://www.particleincell.com/2012/bezier-splines/
|
||||
*
|
||||
* @param k knots x or y, must be at least 2 entries
|
||||
* @param p1 corresponding first control point x or y
|
||||
* @param p2 corresponding second control point x or y
|
||||
* @param count number of k to process
|
||||
*/
|
||||
private void computeControlPoints(float[] k, float[] p1, float[] p2, int count) {
|
||||
final int n = count - 1;
|
||||
|
||||
// left most segment
|
||||
a[0] = 0;
|
||||
b[0] = 2;
|
||||
c[0] = 1;
|
||||
r[0] = k[0] + 2 * k[1];
|
||||
|
||||
// internal segments
|
||||
for (int i = 1; i < n - 1; i++) {
|
||||
a[i] = 1;
|
||||
b[i] = 4;
|
||||
c[i] = 1;
|
||||
r[i] = 4 * k[i] + 2 * k[i + 1];
|
||||
}
|
||||
|
||||
// right segment
|
||||
a[n - 1] = 2;
|
||||
b[n - 1] = 7;
|
||||
c[n - 1] = 0;
|
||||
r[n - 1] = 8 * k[n - 1] + k[n];
|
||||
|
||||
// solves Ax=b with the Thomas algorithm
|
||||
for (int i = 1; i < n; i++) {
|
||||
float m = a[i] / b[i - 1];
|
||||
b[i] = b[i] - m * c[i - 1];
|
||||
r[i] = r[i] - m * r[i - 1];
|
||||
}
|
||||
|
||||
p1[n - 1] = r[n - 1] / b[n - 1];
|
||||
for (int i = n - 2; i >= 0; --i) {
|
||||
p1[i] = (r[i] - c[i] * p1[i + 1]) / b[i];
|
||||
}
|
||||
|
||||
// we have p1, now compute p2
|
||||
for (int i = 0; i < n - 1; i++) {
|
||||
p2[i] = 2 * k[i + 1] - p1[i + 1];
|
||||
}
|
||||
|
||||
p2[n - 1] = 0.5f * (k[n] + p1[n - 1]);
|
||||
}
|
||||
|
||||
public static final Creator<AutomaticControlPointBezierLine> CREATOR = new Creator<AutomaticControlPointBezierLine>() {
|
||||
@Override
|
||||
public AutomaticControlPointBezierLine createFromParcel(Parcel in) {
|
||||
float[] x = in.createFloatArray();
|
||||
float[] y = in.createFloatArray();
|
||||
return new AutomaticControlPointBezierLine(x, y, x != null ? x.length : 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AutomaticControlPointBezierLine[] newArray(int size) {
|
||||
return new AutomaticControlPointBezierLine[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeFloatArray(Arrays.copyOfRange(x, 0, count));
|
||||
dest.writeFloatArray(Arrays.copyOfRange(y, 0, count));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PointF;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.imageeditor.core.ColorableRenderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
/**
|
||||
* Renders a {@link AutomaticControlPointBezierLine} with {@link #thickness}, {@link #color} and {@link #cap} end type.
|
||||
*/
|
||||
public final class BezierDrawingRenderer extends InvalidateableRenderer implements ColorableRenderer {
|
||||
|
||||
private final Paint paint;
|
||||
private final AutomaticControlPointBezierLine bezierLine;
|
||||
private final Paint.Cap cap;
|
||||
|
||||
@Nullable
|
||||
private final RectF clipRect;
|
||||
|
||||
private int color;
|
||||
private float thickness;
|
||||
|
||||
private BezierDrawingRenderer(int color, float thickness, @NonNull Paint.Cap cap, @Nullable AutomaticControlPointBezierLine bezierLine, @Nullable RectF clipRect) {
|
||||
this.paint = new Paint();
|
||||
this.color = color;
|
||||
this.thickness = thickness;
|
||||
this.cap = cap;
|
||||
this.clipRect = clipRect;
|
||||
this.bezierLine = bezierLine != null ? bezierLine : new AutomaticControlPointBezierLine();
|
||||
|
||||
updatePaint();
|
||||
}
|
||||
|
||||
public BezierDrawingRenderer(int color, float thickness, @NonNull Paint.Cap cap, @Nullable RectF clipRect) {
|
||||
this(color, thickness, cap,null, clipRect != null ? new RectF(clipRect) : null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setColor(int color) {
|
||||
if (this.color != color) {
|
||||
this.color = color;
|
||||
updatePaint();
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public void setThickness(float thickness) {
|
||||
if (this.thickness != thickness) {
|
||||
this.thickness = thickness;
|
||||
updatePaint();
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updatePaint() {
|
||||
paint.setColor(color);
|
||||
paint.setStrokeWidth(thickness);
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
paint.setAntiAlias(true);
|
||||
paint.setStrokeCap(cap);
|
||||
}
|
||||
|
||||
public void setFirstPoint(PointF point) {
|
||||
bezierLine.reset();
|
||||
bezierLine.addPoint(point.x, point.y);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void addNewPoint(PointF point) {
|
||||
if (cap != Paint.Cap.ROUND) {
|
||||
bezierLine.addPointFiltered(point.x, point.y, thickness * 0.5f);
|
||||
} else {
|
||||
bezierLine.addPoint(point.x, point.y);
|
||||
}
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
super.render(rendererContext);
|
||||
Canvas canvas = rendererContext.canvas;
|
||||
canvas.save();
|
||||
if (clipRect != null) {
|
||||
canvas.clipRect(clipRect);
|
||||
}
|
||||
|
||||
int alpha = paint.getAlpha();
|
||||
paint.setAlpha(rendererContext.getAlpha(alpha));
|
||||
|
||||
paint.setXfermode(rendererContext.getMaskPaint() != null ? rendererContext.getMaskPaint().getXfermode() : null);
|
||||
|
||||
bezierLine.draw(canvas, paint);
|
||||
|
||||
paint.setAlpha(alpha);
|
||||
rendererContext.canvas.restore();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static final Creator<BezierDrawingRenderer> CREATOR = new Creator<BezierDrawingRenderer>() {
|
||||
@Override
|
||||
public BezierDrawingRenderer createFromParcel(Parcel in) {
|
||||
int color = in.readInt();
|
||||
float thickness = in.readFloat();
|
||||
Paint.Cap cap = Paint.Cap.values()[in.readInt()];
|
||||
AutomaticControlPointBezierLine bezierLine = in.readParcelable(AutomaticControlPointBezierLine.class.getClassLoader());
|
||||
RectF clipRect = in.readParcelable(RectF.class.getClassLoader());
|
||||
|
||||
return new BezierDrawingRenderer(color, thickness, cap, bezierLine, clipRect);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BezierDrawingRenderer[] newArray(int size) {
|
||||
return new BezierDrawingRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(color);
|
||||
dest.writeFloat(thickness);
|
||||
dest.writeInt(cap.ordinal());
|
||||
dest.writeParcelable(bezierLine, flags);
|
||||
dest.writeParcelable(clipRect, flags);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import android.content.res.Resources;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.ColorRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.res.ResourcesCompat;
|
||||
|
||||
import org.signal.imageeditor.R;
|
||||
import org.signal.imageeditor.core.Bounds;
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
/**
|
||||
* Renders a box outside of the current crop area using {@link R.color#crop_area_renderer_outer_color}
|
||||
* and around the edge it renders the markers for the thumbs using {@link R.color#crop_area_renderer_edge_color},
|
||||
* {@link R.dimen#crop_area_renderer_edge_thickness} and {@link R.dimen#crop_area_renderer_edge_size}.
|
||||
* <p>
|
||||
* Hit tests outside of the bounds.
|
||||
*/
|
||||
public final class CropAreaRenderer implements Renderer {
|
||||
|
||||
@ColorRes
|
||||
private final int color;
|
||||
private final boolean renderCenterThumbs;
|
||||
|
||||
private final Path cropClipPath = new Path();
|
||||
private final Path screenClipPath = new Path();
|
||||
|
||||
private final RectF dst = new RectF();
|
||||
private final Paint paint = new Paint();
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
rendererContext.save();
|
||||
|
||||
Canvas canvas = rendererContext.canvas;
|
||||
Resources resources = rendererContext.context.getResources();
|
||||
|
||||
canvas.clipPath(cropClipPath);
|
||||
canvas.drawColor(ResourcesCompat.getColor(resources, color, null));
|
||||
|
||||
rendererContext.mapRect(dst, Bounds.FULL_BOUNDS);
|
||||
|
||||
final int thickness = resources.getDimensionPixelSize(R.dimen.crop_area_renderer_edge_thickness);
|
||||
final int size = (int) Math.min(resources.getDimensionPixelSize(R.dimen.crop_area_renderer_edge_size), Math.min(dst.width(), dst.height()) / 3f - 10);
|
||||
|
||||
paint.setColor(ResourcesCompat.getColor(resources, R.color.crop_area_renderer_edge_color, null));
|
||||
|
||||
rendererContext.canvasMatrix.setToIdentity();
|
||||
screenClipPath.reset();
|
||||
screenClipPath.moveTo(dst.left, dst.top);
|
||||
screenClipPath.lineTo(dst.right, dst.top);
|
||||
screenClipPath.lineTo(dst.right, dst.bottom);
|
||||
screenClipPath.lineTo(dst.left, dst.bottom);
|
||||
screenClipPath.close();
|
||||
canvas.clipPath(screenClipPath);
|
||||
canvas.translate(dst.left, dst.top);
|
||||
|
||||
float halfDx = (dst.right - dst.left - size + thickness) / 2;
|
||||
float halfDy = (dst.bottom - dst.top - size + thickness) / 2;
|
||||
|
||||
canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
canvas.translate(0, halfDy);
|
||||
if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
canvas.translate(0, halfDy);
|
||||
canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
canvas.translate(halfDx, 0);
|
||||
if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
canvas.translate(halfDx, 0);
|
||||
canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
canvas.translate(0, -halfDy);
|
||||
if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
canvas.translate(0, -halfDy);
|
||||
canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
canvas.translate(-halfDx, 0);
|
||||
if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
rendererContext.restore();
|
||||
}
|
||||
|
||||
public CropAreaRenderer(@ColorRes int color, boolean renderCenterThumbs) {
|
||||
this.color = color;
|
||||
this.renderCenterThumbs = renderCenterThumbs;
|
||||
|
||||
cropClipPath.toggleInverseFillType();
|
||||
cropClipPath.moveTo(Bounds.LEFT, Bounds.TOP);
|
||||
cropClipPath.lineTo(Bounds.RIGHT, Bounds.TOP);
|
||||
cropClipPath.lineTo(Bounds.RIGHT, Bounds.BOTTOM);
|
||||
cropClipPath.lineTo(Bounds.LEFT, Bounds.BOTTOM);
|
||||
cropClipPath.close();
|
||||
screenClipPath.toggleInverseFillType();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
return !Bounds.contains(x, y);
|
||||
}
|
||||
|
||||
public static final Creator<CropAreaRenderer> CREATOR = new Creator<CropAreaRenderer>() {
|
||||
@Override
|
||||
public @NonNull CropAreaRenderer createFromParcel(@NonNull Parcel in) {
|
||||
return new CropAreaRenderer(in.readInt(),
|
||||
in.readByte() == 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull CropAreaRenderer[] newArray(int size) {
|
||||
return new CropAreaRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(color);
|
||||
dest.writeByte((byte) (renderCenterThumbs ? 1 : 0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.Bounds;
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
/**
|
||||
* A rectangle that will be rendered on the blur mask layer. Intended for blurring faces.
|
||||
*/
|
||||
public final class FaceBlurRenderer implements Renderer {
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
rendererContext.canvas.drawRect(Bounds.FULL_BOUNDS, rendererContext.getMaskPaint());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
return Bounds.FULL_BOUNDS.contains(x, y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
}
|
||||
|
||||
public static final Creator<FaceBlurRenderer> CREATOR = new Creator<FaceBlurRenderer>() {
|
||||
@Override
|
||||
public FaceBlurRenderer createFromParcel(Parcel in) {
|
||||
return new FaceBlurRenderer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public FaceBlurRenderer[] newArray(int size) {
|
||||
return new FaceBlurRenderer[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.imageeditor.core.Bounds;
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
/**
|
||||
* Renders the {@link color} outside of the {@link Bounds}.
|
||||
* <p>
|
||||
* Hit tests outside of the bounds.
|
||||
*/
|
||||
public final class FillRenderer implements Renderer {
|
||||
|
||||
private final int color;
|
||||
|
||||
private final RectF dst = new RectF();
|
||||
private final Path path = new Path();
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
rendererContext.canvas.save();
|
||||
|
||||
rendererContext.mapRect(dst, Bounds.FULL_BOUNDS);
|
||||
rendererContext.canvasMatrix.setToIdentity();
|
||||
|
||||
path.reset();
|
||||
path.addRoundRect(dst, DimensionUnit.DP.toPixels(18), DimensionUnit.DP.toPixels(18), Path.Direction.CW);
|
||||
|
||||
rendererContext.canvas.clipPath(path);
|
||||
rendererContext.canvas.drawColor(color);
|
||||
rendererContext.canvas.restore();
|
||||
}
|
||||
|
||||
public FillRenderer(@ColorInt int color) {
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
private FillRenderer(Parcel in) {
|
||||
this(in.readInt());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
return !Bounds.contains(x, y);
|
||||
}
|
||||
|
||||
public static final Creator<FillRenderer> CREATOR = new Creator<FillRenderer>() {
|
||||
@Override
|
||||
public FillRenderer createFromParcel(Parcel in) {
|
||||
return new FillRenderer(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FillRenderer[] newArray(int size) {
|
||||
return new FillRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
/**
|
||||
* Maintains a weak reference to the an invalidate callback allowing future invalidation without memory leak risk.
|
||||
*/
|
||||
abstract class InvalidateableRenderer implements Renderer {
|
||||
|
||||
private WeakReference<RendererContext.Invalidate> invalidate = new WeakReference<>(null);
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
setInvalidate(rendererContext.invalidate);
|
||||
}
|
||||
|
||||
private void setInvalidate(RendererContext.Invalidate invalidate) {
|
||||
if (invalidate != this.invalidate.get()) {
|
||||
this.invalidate = new WeakReference<>(invalidate);
|
||||
}
|
||||
}
|
||||
|
||||
protected void invalidate() {
|
||||
RendererContext.Invalidate invalidate = this.invalidate.get();
|
||||
if (invalidate != null) {
|
||||
invalidate.onInvalidate(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.imageeditor.core.Bounds;
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
/**
|
||||
* Renders the {@link color} outside of the {@link Bounds}.
|
||||
* <p>
|
||||
* Hit tests outside of the bounds.
|
||||
*/
|
||||
public final class InverseFillRenderer implements Renderer {
|
||||
|
||||
private final int color;
|
||||
|
||||
private final RectF dst = new RectF();
|
||||
private final Path path = new Path();
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
rendererContext.canvas.save();
|
||||
|
||||
rendererContext.mapRect(dst, Bounds.FULL_BOUNDS);
|
||||
rendererContext.canvasMatrix.setToIdentity();
|
||||
|
||||
path.reset();
|
||||
path.addRoundRect(dst, DimensionUnit.DP.toPixels(18), DimensionUnit.DP.toPixels(18), Path.Direction.CW);
|
||||
|
||||
rendererContext.canvas.clipPath(path);
|
||||
rendererContext.canvas.drawColor(color);
|
||||
rendererContext.canvas.restore();
|
||||
}
|
||||
|
||||
public InverseFillRenderer(@ColorInt int color) {
|
||||
this.color = color;
|
||||
path.toggleInverseFillType();
|
||||
}
|
||||
|
||||
private InverseFillRenderer(Parcel in) {
|
||||
this(in.readInt());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
return !Bounds.contains(x, y);
|
||||
}
|
||||
|
||||
public static final Creator<InverseFillRenderer> CREATOR = new Creator<InverseFillRenderer>() {
|
||||
@Override
|
||||
public InverseFillRenderer createFromParcel(Parcel in) {
|
||||
return new InverseFillRenderer(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InverseFillRenderer[] newArray(int size) {
|
||||
return new InverseFillRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import android.animation.ValueAnimator;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Typeface;
|
||||
import android.os.Build;
|
||||
import android.os.Parcel;
|
||||
import android.view.animation.Interpolator;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.imageeditor.core.Bounds;
|
||||
import org.signal.imageeditor.core.ColorableRenderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
import org.signal.imageeditor.core.SelectableRenderer;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static java.util.Collections.emptyList;
|
||||
|
||||
/**
|
||||
* Renders multiple lines of {@link #text} in ths specified {@link #color}.
|
||||
* <p>
|
||||
* Scales down the text size of long lines to fit inside the {@link Bounds} width.
|
||||
*/
|
||||
public final class MultiLineTextRenderer extends InvalidateableRenderer implements ColorableRenderer, SelectableRenderer {
|
||||
|
||||
private static final float HIT_PADDING = DimensionUnit.DP.toPixels(30);
|
||||
private static final float HIGHLIGHT_HORIZONTAL_PADDING = DimensionUnit.DP.toPixels(8);
|
||||
private static final float HIGHLIGHT_TOP_PADDING = DimensionUnit.DP.toPixels(10);
|
||||
private static final float HIGHLIGHT_BOTTOM_PADDING = DimensionUnit.DP.toPixels(6);
|
||||
private static final float HIGHLIGHT_CORNER_RADIUS = DimensionUnit.DP.toPixels(4);
|
||||
|
||||
@NonNull
|
||||
private String text = "";
|
||||
|
||||
@ColorInt
|
||||
private int color;
|
||||
|
||||
private final Paint paint = new Paint();
|
||||
private final Paint selectionPaint = new Paint();
|
||||
private final Paint modePaint = new Paint();
|
||||
|
||||
private final float textScale;
|
||||
|
||||
private int selStart;
|
||||
private int selEnd;
|
||||
private boolean hasFocus;
|
||||
private boolean selected;
|
||||
private Mode mode;
|
||||
|
||||
private List<Line> lines = emptyList();
|
||||
|
||||
private ValueAnimator cursorAnimator;
|
||||
private float cursorAnimatedValue;
|
||||
|
||||
private final Matrix recommendedEditorMatrix = new Matrix();
|
||||
|
||||
private final SelectedElementGuideRenderer selectedElementGuideRenderer = new SelectedElementGuideRenderer();
|
||||
private final RectF textBounds = new RectF();
|
||||
|
||||
public MultiLineTextRenderer(@Nullable String text, @ColorInt int color, @NonNull Mode mode) {
|
||||
this.mode = mode;
|
||||
|
||||
Typeface typeface = getTypeface();
|
||||
|
||||
modePaint.setAntiAlias(true);
|
||||
modePaint.setTextSize(100);
|
||||
modePaint.setTypeface(typeface);
|
||||
|
||||
setColorInternal(color);
|
||||
|
||||
float regularTextSize = paint.getTextSize();
|
||||
|
||||
paint.setAntiAlias(true);
|
||||
paint.setTextSize(100);
|
||||
paint.setTypeface(typeface);
|
||||
|
||||
textScale = paint.getTextSize() / regularTextSize;
|
||||
|
||||
selectionPaint.setAntiAlias(true);
|
||||
|
||||
setText(text != null ? text : "");
|
||||
createLinesForText();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
super.render(rendererContext);
|
||||
|
||||
float height = 0;
|
||||
float width = 0;
|
||||
for (Line line : lines) {
|
||||
line.render(rendererContext);
|
||||
height += line.heightInBounds - line.ascentInBounds + line.descentInBounds;
|
||||
width = Math.max(line.textBounds.width(), width);
|
||||
}
|
||||
|
||||
if (selected && rendererContext.isEditing()) {
|
||||
textBounds.set(-width, -height / 2f, width, 0f);
|
||||
selectedElementGuideRenderer.render(rendererContext, textBounds);
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
public void setText(@NonNull String text) {
|
||||
if (!this.text.equals(text)) {
|
||||
this.text = text;
|
||||
createLinesForText();
|
||||
}
|
||||
}
|
||||
|
||||
public void nextMode() {
|
||||
setMode(Mode.fromCode(mode.code + 1));
|
||||
}
|
||||
|
||||
public @NonNull Mode getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post concats an additional matrix to the supplied matrix that scales and positions the editor
|
||||
* so that all the text is visible.
|
||||
*
|
||||
* @param matrix editor matrix, already zoomed and positioned to fit the regular bounds.
|
||||
*/
|
||||
public void applyRecommendedEditorMatrix(@NonNull Matrix matrix) {
|
||||
recommendedEditorMatrix.reset();
|
||||
|
||||
float scale = 1f;
|
||||
for (Line line : lines) {
|
||||
if (line.scale < scale) {
|
||||
scale = line.scale;
|
||||
}
|
||||
}
|
||||
|
||||
float yOff = 0;
|
||||
for (Line line : lines) {
|
||||
if (line.containsSelectionEnd()) {
|
||||
break;
|
||||
} else {
|
||||
yOff -= line.heightInBounds;
|
||||
}
|
||||
}
|
||||
|
||||
recommendedEditorMatrix.postTranslate(0, Bounds.TOP / 1.5f + yOff);
|
||||
|
||||
recommendedEditorMatrix.postScale(scale, scale);
|
||||
|
||||
matrix.postConcat(recommendedEditorMatrix);
|
||||
}
|
||||
|
||||
private void createLinesForText() {
|
||||
String[] split = text.split("\n", -1);
|
||||
|
||||
if (split.length == lines.size()) {
|
||||
for (int i = 0; i < split.length; i++) {
|
||||
lines.get(i).setText(split[i]);
|
||||
}
|
||||
} else {
|
||||
lines = new ArrayList<>(split.length);
|
||||
for (String s : split) {
|
||||
lines.add(new Line(s));
|
||||
}
|
||||
}
|
||||
setSelection(selStart, selEnd);
|
||||
}
|
||||
|
||||
private class Line {
|
||||
private final Matrix accentMatrix = new Matrix();
|
||||
private final Matrix decentMatrix = new Matrix();
|
||||
private final Matrix projectionMatrix = new Matrix();
|
||||
private final Matrix inverseProjectionMatrix = new Matrix();
|
||||
private final RectF selectionBounds = new RectF();
|
||||
private final RectF textBounds = new RectF();
|
||||
private final RectF hitBounds = new RectF();
|
||||
private final RectF modeBounds = new RectF();
|
||||
|
||||
private String text;
|
||||
private int selStart;
|
||||
private int selEnd;
|
||||
private float ascentInBounds;
|
||||
private float descentInBounds;
|
||||
private float scale = 1f;
|
||||
private float heightInBounds;
|
||||
|
||||
Line(String text) {
|
||||
this.text = text;
|
||||
recalculate();
|
||||
}
|
||||
|
||||
private void recalculate() {
|
||||
RectF maxTextBounds = new RectF();
|
||||
Rect temp = new Rect();
|
||||
|
||||
getTextBoundsWithoutTrim(text, 0, text.length(), temp);
|
||||
textBounds.set(temp);
|
||||
hitBounds.set(textBounds);
|
||||
|
||||
hitBounds.left -= HIT_PADDING;
|
||||
hitBounds.right += HIT_PADDING;
|
||||
hitBounds.top -= HIT_PADDING;
|
||||
hitBounds.bottom += HIT_PADDING;
|
||||
|
||||
maxTextBounds.set(textBounds);
|
||||
float widthLimit = 150 * textScale;
|
||||
|
||||
scale = 1f / Math.max(1, maxTextBounds.right / widthLimit);
|
||||
|
||||
maxTextBounds.right = widthLimit;
|
||||
|
||||
if (showSelectionOrCursor()) {
|
||||
Rect startTemp = new Rect();
|
||||
int startInString = Math.min(text.length(), Math.max(0, selStart));
|
||||
int endInString = Math.min(text.length(), Math.max(0, selEnd));
|
||||
String startText = this.text.substring(0, startInString);
|
||||
|
||||
getTextBoundsWithoutTrim(startText, 0, startInString, startTemp);
|
||||
|
||||
if (selStart != selEnd) {
|
||||
// selection
|
||||
getTextBoundsWithoutTrim(text, startInString, endInString, temp);
|
||||
} else {
|
||||
// cursor
|
||||
paint.getTextBounds("|", 0, 1, temp);
|
||||
int width = temp.width();
|
||||
|
||||
temp.left -= width;
|
||||
temp.right -= width;
|
||||
}
|
||||
|
||||
temp.left += startTemp.right;
|
||||
temp.right += startTemp.right;
|
||||
selectionBounds.set(temp);
|
||||
}
|
||||
|
||||
projectionMatrix.setRectToRect(new RectF(maxTextBounds), Bounds.FULL_BOUNDS, Matrix.ScaleToFit.CENTER);
|
||||
removeTranslate(projectionMatrix);
|
||||
|
||||
float[] pts = { 0, paint.ascent(), 0, paint.descent() };
|
||||
projectionMatrix.mapPoints(pts);
|
||||
ascentInBounds = pts[1];
|
||||
descentInBounds = pts[3];
|
||||
heightInBounds = descentInBounds - ascentInBounds;
|
||||
|
||||
projectionMatrix.preTranslate(-textBounds.centerX(), 0);
|
||||
projectionMatrix.invert(inverseProjectionMatrix);
|
||||
|
||||
accentMatrix.setTranslate(0, -ascentInBounds);
|
||||
decentMatrix.setTranslate(0, descentInBounds);
|
||||
|
||||
invalidate();
|
||||
}
|
||||
|
||||
private void removeTranslate(Matrix matrix) {
|
||||
float[] values = new float[9];
|
||||
|
||||
matrix.getValues(values);
|
||||
values[2] = 0;
|
||||
values[5] = 0;
|
||||
matrix.setValues(values);
|
||||
}
|
||||
|
||||
private boolean showSelectionOrCursor() {
|
||||
return (selStart >= 0 || selEnd >= 0) &&
|
||||
(selStart <= text.length() || selEnd <= text.length());
|
||||
}
|
||||
|
||||
private boolean containsSelectionEnd() {
|
||||
return (selEnd >= 0) &&
|
||||
(selEnd <= text.length());
|
||||
}
|
||||
|
||||
private void getTextBoundsWithoutTrim(String text, int start, int end, Rect result) {
|
||||
Rect extra = new Rect();
|
||||
Rect xBounds = new Rect();
|
||||
|
||||
String cannotBeTrimmed = "x" + text.substring(Math.max(0, start), Math.min(text.length(), end)) + "x";
|
||||
|
||||
paint.getTextBounds(cannotBeTrimmed, 0, cannotBeTrimmed.length(), extra);
|
||||
paint.getTextBounds("x", 0, 1, xBounds);
|
||||
result.set(extra);
|
||||
result.right -= 2 * xBounds.width();
|
||||
|
||||
int temp = result.left;
|
||||
result.left -= temp;
|
||||
result.right -= temp;
|
||||
}
|
||||
|
||||
public boolean contains(float x, float y) {
|
||||
float[] dst = new float[2];
|
||||
|
||||
inverseProjectionMatrix.mapPoints(dst, new float[]{ x, y });
|
||||
|
||||
return hitBounds.contains(dst[0], dst[1]);
|
||||
}
|
||||
|
||||
void setText(String text) {
|
||||
if (!this.text.equals(text)) {
|
||||
this.text = text;
|
||||
recalculate();
|
||||
}
|
||||
}
|
||||
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
// add our ascent for ourselves and the next lines
|
||||
rendererContext.canvasMatrix.concat(accentMatrix);
|
||||
|
||||
rendererContext.save();
|
||||
|
||||
rendererContext.canvasMatrix.concat(projectionMatrix);
|
||||
|
||||
if (mode == Mode.HIGHLIGHT) {
|
||||
modeBounds.set(textBounds.left - HIGHLIGHT_HORIZONTAL_PADDING,
|
||||
selectionBounds.top - HIGHLIGHT_TOP_PADDING,
|
||||
textBounds.right + HIGHLIGHT_HORIZONTAL_PADDING,
|
||||
selectionBounds.bottom + HIGHLIGHT_BOTTOM_PADDING);
|
||||
|
||||
int alpha = modePaint.getAlpha();
|
||||
modePaint.setAlpha(rendererContext.getAlpha(alpha));
|
||||
rendererContext.canvas.drawRoundRect(modeBounds, HIGHLIGHT_CORNER_RADIUS, HIGHLIGHT_CORNER_RADIUS, modePaint);
|
||||
modePaint.setAlpha(alpha);
|
||||
} else if (mode == Mode.UNDERLINE) {
|
||||
modeBounds.set(textBounds.left, selectionBounds.top, textBounds.right, selectionBounds.bottom);
|
||||
modeBounds.inset(-DimensionUnit.DP.toPixels(2), -DimensionUnit.DP.toPixels(2));
|
||||
|
||||
modeBounds.set(modeBounds.left,
|
||||
Math.max(modeBounds.top, modeBounds.bottom - DimensionUnit.DP.toPixels(6)),
|
||||
modeBounds.right,
|
||||
modeBounds.bottom - DimensionUnit.DP.toPixels(2));
|
||||
|
||||
int alpha = modePaint.getAlpha();
|
||||
modePaint.setAlpha(rendererContext.getAlpha(alpha));
|
||||
rendererContext.canvas.drawRect(modeBounds, modePaint);
|
||||
modePaint.setAlpha(alpha);
|
||||
}
|
||||
|
||||
if (hasFocus && showSelectionOrCursor()) {
|
||||
if (selStart == selEnd) {
|
||||
selectionPaint.setAlpha((int) (cursorAnimatedValue * 128));
|
||||
} else {
|
||||
selectionPaint.setAlpha(128);
|
||||
}
|
||||
rendererContext.canvas.drawRect(selectionBounds, selectionPaint);
|
||||
}
|
||||
|
||||
int alpha = paint.getAlpha();
|
||||
paint.setAlpha(rendererContext.getAlpha(alpha));
|
||||
|
||||
rendererContext.canvas.drawText(text, 0, 0, paint);
|
||||
|
||||
paint.setAlpha(alpha);
|
||||
|
||||
if (mode == Mode.OUTLINE) {
|
||||
int modeAlpha = modePaint.getAlpha();
|
||||
modePaint.setAlpha(rendererContext.getAlpha(alpha));
|
||||
rendererContext.canvas.drawText(text, 0, 0, modePaint);
|
||||
modePaint.setAlpha(modeAlpha);
|
||||
}
|
||||
|
||||
rendererContext.restore();
|
||||
|
||||
// add our descent for the next lines
|
||||
rendererContext.canvasMatrix.concat(decentMatrix);
|
||||
}
|
||||
|
||||
void setSelection(int selStart, int selEnd) {
|
||||
if (selStart != this.selStart || selEnd != this.selEnd) {
|
||||
this.selStart = selStart;
|
||||
this.selEnd = selEnd;
|
||||
recalculate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setColor(@ColorInt int color) {
|
||||
if (this.color != color) {
|
||||
setColorInternal(color);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSelected(boolean selected) {
|
||||
if (this.selected != selected) {
|
||||
this.selected = selected;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
for (Line line : lines) {
|
||||
y += line.ascentInBounds;
|
||||
if (line.contains(x, y)) return true;
|
||||
y -= line.descentInBounds;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void setSelection(int selStart, int selEnd) {
|
||||
this.selStart = selStart;
|
||||
this.selEnd = selEnd;
|
||||
for (Line line : lines) {
|
||||
line.setSelection(selStart, selEnd);
|
||||
|
||||
int length = line.text.length() + 1; // one for new line
|
||||
|
||||
selStart -= length;
|
||||
selEnd -= length;
|
||||
}
|
||||
}
|
||||
|
||||
public void setFocused(boolean hasFocus) {
|
||||
if (this.hasFocus != hasFocus) {
|
||||
this.hasFocus = hasFocus;
|
||||
if (cursorAnimator != null) {
|
||||
cursorAnimator.cancel();
|
||||
cursorAnimator = null;
|
||||
}
|
||||
if (hasFocus) {
|
||||
cursorAnimator = ValueAnimator.ofFloat(0, 1);
|
||||
cursorAnimator.setInterpolator(pulseInterpolator());
|
||||
cursorAnimator.setRepeatCount(ValueAnimator.INFINITE);
|
||||
cursorAnimator.setDuration(1000);
|
||||
cursorAnimator.addUpdateListener(animation -> {
|
||||
cursorAnimatedValue = (float) animation.getAnimatedValue();
|
||||
invalidate();
|
||||
});
|
||||
cursorAnimator.start();
|
||||
} else {
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setMode(@NonNull Mode mode) {
|
||||
if (this.mode != mode) {
|
||||
this.mode = mode;
|
||||
setColorInternal(color);
|
||||
}
|
||||
}
|
||||
|
||||
private void setColorInternal(@ColorInt int color) {
|
||||
this.color = color;
|
||||
|
||||
if (mode == Mode.REGULAR) {
|
||||
paint.setColor(color);
|
||||
selectionPaint.setColor(color);
|
||||
} else {
|
||||
paint.setColor(Color.WHITE);
|
||||
selectionPaint.setColor(Color.WHITE);
|
||||
}
|
||||
|
||||
if (mode == Mode.OUTLINE) {
|
||||
modePaint.setStrokeWidth(DimensionUnit.DP.toPixels(15) / 10f);
|
||||
modePaint.setStyle(Paint.Style.STROKE);
|
||||
} else {
|
||||
modePaint.setStyle(Paint.Style.FILL);
|
||||
}
|
||||
|
||||
modePaint.setColor(color);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public static final Creator<MultiLineTextRenderer> CREATOR = new Creator<MultiLineTextRenderer>() {
|
||||
@Override
|
||||
public MultiLineTextRenderer createFromParcel(Parcel in) {
|
||||
return new MultiLineTextRenderer(in.readString(), in.readInt(), Mode.fromCode(in.readInt()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public MultiLineTextRenderer[] newArray(int size) {
|
||||
return new MultiLineTextRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeString(text);
|
||||
dest.writeInt(color);
|
||||
dest.writeInt(mode.code);
|
||||
}
|
||||
|
||||
private static Interpolator pulseInterpolator() {
|
||||
return input -> {
|
||||
input *= 5;
|
||||
if (input > 1) {
|
||||
input = 4 - input;
|
||||
}
|
||||
return Math.max(0, Math.min(1, input));
|
||||
};
|
||||
}
|
||||
|
||||
private static @NonNull Typeface getTypeface() {
|
||||
if (Build.VERSION.SDK_INT < 26) {
|
||||
return Typeface.create(Typeface.DEFAULT, Typeface.BOLD);
|
||||
} else {
|
||||
return new Typeface.Builder("")
|
||||
.setFallback("sans-serif")
|
||||
.setWeight(900)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
public enum Mode {
|
||||
REGULAR(0),
|
||||
HIGHLIGHT(1),
|
||||
UNDERLINE(2),
|
||||
OUTLINE(3);
|
||||
|
||||
private final int code;
|
||||
|
||||
Mode(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
private static Mode fromCode(int code) {
|
||||
for (final Mode value : Mode.values()) {
|
||||
if (value.code == code) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return REGULAR;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.ColorRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.signal.imageeditor.R;
|
||||
import org.signal.imageeditor.core.Bounds;
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
/**
|
||||
* Renders an oval inside of the {@link Bounds}.
|
||||
* <p>
|
||||
* Hit tests outside of the bounds.
|
||||
*/
|
||||
public final class OvalGuideRenderer implements Renderer {
|
||||
|
||||
private final @ColorRes int ovalGuideColor;
|
||||
|
||||
private final Paint paint;
|
||||
|
||||
private final RectF dst = new RectF();
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
rendererContext.save();
|
||||
|
||||
Canvas canvas = rendererContext.canvas;
|
||||
Context context = rendererContext.context;
|
||||
int stroke = context.getResources().getDimensionPixelSize(R.dimen.oval_guide_stroke_width);
|
||||
float halfStroke = stroke / 2f;
|
||||
|
||||
this.paint.setStrokeWidth(stroke);
|
||||
paint.setColor(ContextCompat.getColor(context, ovalGuideColor));
|
||||
|
||||
rendererContext.mapRect(dst, Bounds.FULL_BOUNDS);
|
||||
dst.set(dst.left + halfStroke, dst.top + halfStroke, dst.right - halfStroke, dst.bottom - halfStroke);
|
||||
|
||||
rendererContext.canvasMatrix.setToIdentity();
|
||||
canvas.drawOval(dst, paint);
|
||||
|
||||
rendererContext.restore();
|
||||
}
|
||||
|
||||
public OvalGuideRenderer(@ColorRes int color) {
|
||||
this.ovalGuideColor = color;
|
||||
|
||||
this.paint = new Paint();
|
||||
this.paint.setStyle(Paint.Style.STROKE);
|
||||
this.paint.setAntiAlias(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
return !Bounds.contains(x, y);
|
||||
}
|
||||
|
||||
public static final Creator<OvalGuideRenderer> CREATOR = new Creator<OvalGuideRenderer>() {
|
||||
@Override
|
||||
public @NonNull OvalGuideRenderer createFromParcel(@NonNull Parcel in) {
|
||||
return new OvalGuideRenderer(in.readInt());
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull OvalGuideRenderer[] newArray(int size) {
|
||||
return new OvalGuideRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||
dest.writeInt(ovalGuideColor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package org.signal.imageeditor.core.renderers
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.DashPathEffect
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.imageeditor.core.Bounds
|
||||
import org.signal.imageeditor.core.RendererContext
|
||||
|
||||
class SelectedElementGuideRenderer {
|
||||
|
||||
companion object {
|
||||
private const val PADDING: Int = 10
|
||||
}
|
||||
|
||||
private val allPointsOnScreen = FloatArray(8)
|
||||
private val allPointsInLocalCords = floatArrayOf(
|
||||
Bounds.LEFT, Bounds.TOP,
|
||||
Bounds.RIGHT, Bounds.TOP,
|
||||
Bounds.RIGHT, Bounds.BOTTOM,
|
||||
Bounds.LEFT, Bounds.BOTTOM
|
||||
)
|
||||
|
||||
private val circleRadius = DimensionUnit.DP.toPixels(5f)
|
||||
|
||||
private val guidePaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
strokeWidth = DimensionUnit.DP.toPixels(1.5f)
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.STROKE
|
||||
pathEffect = DashPathEffect(floatArrayOf(15f, 15f), 0f)
|
||||
}
|
||||
|
||||
private val circlePaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
private val path = Path()
|
||||
|
||||
/**
|
||||
* Draw self to the context.
|
||||
*
|
||||
* @param rendererContext The context to draw to.
|
||||
*/
|
||||
fun render(rendererContext: RendererContext) {
|
||||
rendererContext.canvasMatrix.mapPoints(allPointsOnScreen, allPointsInLocalCords)
|
||||
performRender(rendererContext)
|
||||
}
|
||||
|
||||
fun render(rendererContext: RendererContext, contentBounds: RectF) {
|
||||
rendererContext.canvasMatrix.mapPoints(
|
||||
allPointsOnScreen,
|
||||
floatArrayOf(
|
||||
contentBounds.left - PADDING,
|
||||
contentBounds.top - PADDING,
|
||||
contentBounds.right + PADDING,
|
||||
contentBounds.top - PADDING,
|
||||
contentBounds.right + PADDING,
|
||||
contentBounds.bottom + PADDING,
|
||||
contentBounds.left - PADDING,
|
||||
contentBounds.bottom + PADDING
|
||||
)
|
||||
)
|
||||
|
||||
performRender(rendererContext)
|
||||
}
|
||||
|
||||
private fun performRender(rendererContext: RendererContext) {
|
||||
rendererContext.save()
|
||||
|
||||
rendererContext.canvasMatrix.setToIdentity()
|
||||
|
||||
path.reset()
|
||||
path.moveTo(allPointsOnScreen[0], allPointsOnScreen[1])
|
||||
path.lineTo(allPointsOnScreen[2], allPointsOnScreen[3])
|
||||
path.lineTo(allPointsOnScreen[4], allPointsOnScreen[5])
|
||||
path.lineTo(allPointsOnScreen[6], allPointsOnScreen[7])
|
||||
path.close()
|
||||
|
||||
rendererContext.canvas.drawPath(path, guidePaint)
|
||||
// TODO: Implement scaling
|
||||
// rendererContext.canvas.drawCircle(
|
||||
// (allPointsOnScreen[6] + allPointsOnScreen[0]) / 2f,
|
||||
// (allPointsOnScreen[7] + allPointsOnScreen[1]) / 2f,
|
||||
// circleRadius,
|
||||
// circlePaint
|
||||
// )
|
||||
// rendererContext.canvas.drawCircle(
|
||||
// (allPointsOnScreen[4] + allPointsOnScreen[2]) / 2f,
|
||||
// (allPointsOnScreen[5] + allPointsOnScreen[3]) / 2f,
|
||||
// circleRadius,
|
||||
// circlePaint
|
||||
// )
|
||||
|
||||
rendererContext.restore()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package org.signal.imageeditor.core.renderers
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.view.animation.Interpolator
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.imageeditor.R
|
||||
import org.signal.imageeditor.core.Bounds
|
||||
import org.signal.imageeditor.core.Renderer
|
||||
import org.signal.imageeditor.core.RendererContext
|
||||
|
||||
internal class TrashRenderer : InvalidateableRenderer, Renderer, Parcelable {
|
||||
|
||||
private val outlinePaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = DimensionUnit.DP.toPixels(1.5f)
|
||||
}
|
||||
|
||||
private val shadePaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = 0x99000000.toInt()
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
private val bounds = RectF()
|
||||
|
||||
private val diameterSmall = DimensionUnit.DP.toPixels(41f)
|
||||
private val diameterLarge = DimensionUnit.DP.toPixels(54f)
|
||||
private val trashSize: Int = DimensionUnit.DP.toPixels(24f).toInt()
|
||||
private val padBottom = DimensionUnit.DP.toPixels(16f)
|
||||
private val interpolator: Interpolator = FastOutSlowInInterpolator()
|
||||
|
||||
private var startTime = 0L
|
||||
|
||||
private var isExpanding = false
|
||||
|
||||
private val buttonCenter = FloatArray(2)
|
||||
|
||||
constructor()
|
||||
|
||||
override fun render(rendererContext: RendererContext) {
|
||||
super.render(rendererContext)
|
||||
|
||||
val frameRenderTime = System.currentTimeMillis()
|
||||
|
||||
val trash: Drawable = requireNotNull(AppCompatResources.getDrawable(rendererContext.context, R.drawable.ic_trash_white_24))
|
||||
trash.setBounds(0, 0, trashSize, trashSize)
|
||||
|
||||
val diameter = getInterpolatedDiameter(frameRenderTime - startTime)
|
||||
|
||||
rendererContext.canvas.save()
|
||||
rendererContext.mapRect(bounds, Bounds.FULL_BOUNDS)
|
||||
|
||||
buttonCenter[0] = bounds.centerX()
|
||||
buttonCenter[1] = bounds.bottom - diameterLarge / 2f - padBottom
|
||||
|
||||
rendererContext.canvasMatrix.setToIdentity()
|
||||
|
||||
rendererContext.canvas.drawCircle(buttonCenter[0], buttonCenter[1], diameter / 2f, shadePaint)
|
||||
rendererContext.canvas.drawCircle(buttonCenter[0], buttonCenter[1], diameter / 2f, outlinePaint)
|
||||
rendererContext.canvas.translate(bounds.centerX(), bounds.bottom - diameterLarge / 2f - padBottom)
|
||||
rendererContext.canvas.translate(- (trashSize / 2f), - (trashSize / 2f))
|
||||
trash.draw(rendererContext.canvas)
|
||||
rendererContext.canvas.restore()
|
||||
|
||||
if (frameRenderTime - DURATION < startTime) {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getInterpolatedDiameter(timeElapsed: Long): Float {
|
||||
return if (timeElapsed >= DURATION) {
|
||||
if (isExpanding) {
|
||||
diameterLarge
|
||||
} else {
|
||||
diameterSmall
|
||||
}
|
||||
} else {
|
||||
val interpolatedFraction = interpolator.getInterpolation(timeElapsed / DURATION.toFloat())
|
||||
if (isExpanding) {
|
||||
interpolateFromFraction(interpolatedFraction)
|
||||
} else {
|
||||
interpolateFromFraction(1 - interpolatedFraction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun interpolateFromFraction(fraction: Float): Float {
|
||||
return diameterSmall + (diameterLarge - diameterSmall) * fraction
|
||||
}
|
||||
|
||||
fun expand() {
|
||||
if (isExpanding) {
|
||||
return
|
||||
}
|
||||
|
||||
isExpanding = true
|
||||
startTime = System.currentTimeMillis()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun shrink() {
|
||||
if (!isExpanding) {
|
||||
return
|
||||
}
|
||||
|
||||
isExpanding = false
|
||||
startTime = System.currentTimeMillis()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private constructor(inParcel: Parcel?)
|
||||
|
||||
override fun hitTest(x: Float, y: Float): Boolean {
|
||||
val dx = x - buttonCenter[0]
|
||||
val dy = y - buttonCenter[1]
|
||||
val radius = diameterLarge / 2
|
||||
|
||||
return dx * dx + dy * dy < radius * radius
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DURATION = 150L
|
||||
|
||||
@JvmField
|
||||
val CREATOR: Parcelable.Creator<TrashRenderer> = object : Parcelable.Creator<TrashRenderer> {
|
||||
override fun createFromParcel(`in`: Parcel): TrashRenderer {
|
||||
return TrashRenderer(`in`)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<TrashRenderer?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
image-editor/lib/src/main/res/values/crop_area_renderer.xml
Normal file
12
image-editor/lib/src/main/res/values/crop_area_renderer.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<dimen name="crop_area_renderer_edge_size">32dp</dimen>
|
||||
<dimen name="crop_area_renderer_edge_thickness">2dp</dimen>
|
||||
<dimen name="oval_guide_stroke_width">1dp</dimen>
|
||||
|
||||
<color name="crop_area_renderer_edge_color">#ffffffff</color>
|
||||
<color name="crop_area_renderer_outer_color">#7f000000</color>
|
||||
<color name="crop_circle_guide_color">#66FFFFFF</color>
|
||||
|
||||
</resources>
|
||||
93
image-editor/lib/witness-verifications.gradle
Normal file
93
image-editor/lib/witness-verifications.gradle
Normal file
@@ -0,0 +1,93 @@
|
||||
// Auto-generated, use ./gradlew calculateChecksums to regenerate
|
||||
|
||||
dependencyVerification {
|
||||
verify = [
|
||||
|
||||
['androidx.activity:activity:1.0.0',
|
||||
'd1bc9842455c2e534415d88c44df4d52413b478db9093a1ba36324f705f44c3d'],
|
||||
|
||||
['androidx.annotation:annotation:1.2.0',
|
||||
'9029262bddce116e6d02be499e4afdba21f24c239087b76b3b57d7e98b490a36'],
|
||||
|
||||
['androidx.appcompat:appcompat-resources:1.2.0',
|
||||
'c470297c03ff3de1c3d15dacf0be0cae63abc10b52f021dd07ae28daa3100fe5'],
|
||||
|
||||
['androidx.appcompat:appcompat:1.2.0',
|
||||
'3d2131a55a61a777322e2126e0018011efa6339e53b44153eb651b16020cca70'],
|
||||
|
||||
['androidx.arch.core:core-common:2.1.0',
|
||||
'fe1237bf029d063e7f29fe39aeaf73ef74c8b0a3658486fc29d3c54326653889'],
|
||||
|
||||
['androidx.arch.core:core-runtime:2.0.0',
|
||||
'87e65fc767c712b437649c7cee2431ebb4bed6daef82e501d4125b3ed3f65f8e'],
|
||||
|
||||
['androidx.collection:collection:1.1.0',
|
||||
'632a0e5407461de774409352940e292a291037724207a787820c77daf7d33b72'],
|
||||
|
||||
['androidx.core:core-ktx:1.5.0',
|
||||
'5964cfe7a4882da2a00fb6ca3d3a072d04139208186f7bc4b3cb66022764fc42'],
|
||||
|
||||
['androidx.core:core:1.5.0',
|
||||
'2b279712795689069cfb63e48b3ab63c32a5649bdda44c482eb8f81ca1a72161'],
|
||||
|
||||
['androidx.cursoradapter:cursoradapter:1.0.0',
|
||||
'a81c8fe78815fa47df5b749deb52727ad11f9397da58b16017f4eb2c11e28564'],
|
||||
|
||||
['androidx.customview:customview:1.0.0',
|
||||
'20e5b8f6526a34595a604f56718da81167c0b40a7a94a57daa355663f2594df2'],
|
||||
|
||||
['androidx.drawerlayout:drawerlayout:1.0.0',
|
||||
'9402442cdc5a43cf62fb14f8cf98c63342d4d9d9b805c8033c6cf7e802749ac1'],
|
||||
|
||||
['androidx.fragment:fragment:1.1.0',
|
||||
'a14c8b8f2153f128e800fbd266a6beab1c283982a29ec570d2cc05d307d81496'],
|
||||
|
||||
['androidx.interpolator:interpolator:1.0.0',
|
||||
'33193135a64fe21fa2c35eec6688f1a76e512606c0fc83dc1b689e37add7732a'],
|
||||
|
||||
['androidx.lifecycle:lifecycle-common:2.1.0',
|
||||
'76db6be533bd730fb361c2feb12a2c26d9952824746847da82601ef81f082643'],
|
||||
|
||||
['androidx.lifecycle:lifecycle-livedata-core:2.0.0',
|
||||
'fde334ec7e22744c0f5bfe7caf1a84c9d717327044400577bdf9bd921ec4f7bc'],
|
||||
|
||||
['androidx.lifecycle:lifecycle-livedata:2.0.0',
|
||||
'c82609ced8c498f0a701a30fb6771bb7480860daee84d82e0a81ee86edf7ba39'],
|
||||
|
||||
['androidx.lifecycle:lifecycle-runtime:2.1.0',
|
||||
'e5173897b965e870651e83d9d5af1742d3f532d58863223a390ce3a194c8312b'],
|
||||
|
||||
['androidx.lifecycle:lifecycle-viewmodel:2.1.0',
|
||||
'ba55fb7ac1b2828d5327cda8acf7085d990b2b4c43ef336caa67686249b8523d'],
|
||||
|
||||
['androidx.loader:loader:1.0.0',
|
||||
'11f735cb3b55c458d470bed9e25254375b518b4b1bad6926783a7026db0f5025'],
|
||||
|
||||
['androidx.savedstate:savedstate:1.0.0',
|
||||
'2510a5619c37579c9ce1a04574faaf323cd0ffe2fc4e20fa8f8f01e5bb402e83'],
|
||||
|
||||
['androidx.vectordrawable:vectordrawable-animated:1.1.0',
|
||||
'76da2c502371d9c38054df5e2b248d00da87809ed058f3363eae87ce5e2403f8'],
|
||||
|
||||
['androidx.vectordrawable:vectordrawable:1.1.0',
|
||||
'46fd633ac01b49b7fcabc263bf098c5a8b9e9a69774d234edcca04fb02df8e26'],
|
||||
|
||||
['androidx.versionedparcelable:versionedparcelable:1.1.1',
|
||||
'57e8d93260d18d5b9007c9eed3c64ad159de90c8609ebfc74a347cbd514535a4'],
|
||||
|
||||
['androidx.viewpager:viewpager:1.0.0',
|
||||
'147af4e14a1984010d8f155e5e19d781f03c1d70dfed02a8e0d18428b8fc8682'],
|
||||
|
||||
['com.google.protobuf:protobuf-javalite:3.10.0',
|
||||
'215a94dbe100130295906b531bb72a26965c7ac8fcd9a75bf8054a8ac2abf4b4'],
|
||||
|
||||
['org.jetbrains.kotlin:kotlin-stdlib-common:1.4.32',
|
||||
'e1ff6f55ee9e7591dcc633f7757bac25a7edb1cc7f738b37ec652f10f66a4145'],
|
||||
|
||||
['org.jetbrains.kotlin:kotlin-stdlib:1.4.32',
|
||||
'13e9fd3e69dc7230ce0fc873a92a4e5d521d179bcf1bef75a6705baac3bfecba'],
|
||||
|
||||
['org.jetbrains:annotations:13.0',
|
||||
'ace2a10dc8e2d5fd34925ecac03e4988b2c0f851650c94b8cef49ba1bd111478'],
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user