Source added

This commit is contained in:
Fr4nz D13trich 2025-11-20 09:26:33 +01:00
parent b2864b500e
commit ba28ca859e
8352 changed files with 1487182 additions and 1 deletions

1
image-editor/app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,20 @@
plugins {
id("signal-sample-app")
id("com.google.devtools.ksp")
alias(libs.plugins.compose.compiler)
}
android {
namespace = "org.signal.imageeditor.app"
defaultConfig {
applicationId = "org.signal.imageeditor.app"
}
}
dependencies {
implementation(project(":image-editor"))
implementation(libs.glide.glide)
ksp(libs.glide.ksp)
}

21
image-editor/app/proguard-rules.pro vendored Normal file
View 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

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Signal">
<activity
android:name="org.signal.imageeditor.app.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,272 @@
package org.signal.imageeditor.app;
import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Typeface;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.provider.MediaStore;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import org.signal.imageeditor.app.renderers.UriRenderer;
import org.signal.imageeditor.app.renderers.UrlRenderer;
import org.signal.imageeditor.core.ImageEditorView;
import org.signal.imageeditor.core.RendererContext;
import org.signal.imageeditor.core.model.EditorElement;
import org.signal.imageeditor.core.model.EditorModel;
import org.signal.imageeditor.core.renderers.MultiLineTextRenderer;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Locale;
public final class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
private ImageEditorView imageEditorView;
private Menu menu;
private final RendererContext.TypefaceProvider typefaceProvider = (context, renderer, invalidate) -> {
if (Build.VERSION.SDK_INT < 26) {
return Typeface.create(Typeface.DEFAULT, Typeface.BOLD);
} else {
return new Typeface.Builder("")
.setFallback("sans-serif")
.setWeight(900)
.build();
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main_activity);
Toolbar toolbar = findViewById(R.id.toolbar);
toolbar.setTitle(R.string.app_name_short);
setSupportActionBar(toolbar);
imageEditorView = findViewById(R.id.image_editor);
imageEditorView.setTypefaceProvider(typefaceProvider);
imageEditorView.setUndoRedoStackListener((undoAvailable, redoAvailable) -> {
Log.d("ALAN", String.format("Undo/Redo available: %s, %s", undoAvailable ? "Y" : "N", redoAvailable ? "Y" : "N"));
if (menu == null) return;
MenuItem undo = menu.findItem(R.id.action_undo);
MenuItem redo = menu.findItem(R.id.action_redo);
if (undo != null) undo.setVisible(undoAvailable);
if (redo != null) redo.setVisible(redoAvailable);
});
EditorModel model = null;
if (savedInstanceState != null) {
model = savedInstanceState.getParcelable("MODEL");
Log.d("ALAN", "Restoring instance " + (model != null ? model.hashCode() : 0));
}
if (model == null) {
model = initialModel();
Log.d("ALAN", "New instance created " + model.hashCode());
}
imageEditorView.setModel(model);
imageEditorView.setTapListener(new ImageEditorView.TapListener() {
@Override
public void onEntityDown(@Nullable EditorElement editorElement) {
Log.d("ALAN", "Entity down " + editorElement);
}
@Override
public void onEntitySingleTap(@Nullable EditorElement editorElement) {
Log.d("ALAN", "Entity single tapped " + editorElement);
}
@Override
public void onEntityDoubleTap(@NonNull EditorElement editorElement) {
Log.d("ALAN", "Entity double tapped " + editorElement);
if (editorElement.getRenderer() instanceof MultiLineTextRenderer) {
imageEditorView.startTextEditing(editorElement);
} else {
imageEditorView.deleteElement(editorElement);
}
}
});
}
private static EditorModel initialModel() {
EditorModel model = EditorModel.create(0xFF000000);
EditorElement image = new EditorElement(new UrlRenderer("https://cdn.aarp.net/content/dam/aarp/home-and-family/your-home/2018/06/1140-house-inheriting.imgcache.rev68c065601779c5d76b913cf9ec3a977e.jpg"));
image.getFlags().setSelectable(false).persist();
model.addElement(image);
EditorElement elementC = new EditorElement(new UrlRenderer("https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/SNice.svg/220px-SNice.svg.png"));
elementC.getLocalMatrix().postScale(0.2f, 0.2f);
//elementC.getLocalMatrix().postRotate(30);
model.addElement(elementC);
EditorElement elementE = new EditorElement(new UrlRenderer("https://www.vitalessentialsraw.com/assets/images/background-images/laying-grey-cat.png"));
elementE.getLocalMatrix().postScale(0.2f, 0.2f);
//elementE.getLocalMatrix().postRotate(60);
model.addElement(elementE);
EditorElement elementD = new EditorElement(new UrlRenderer("https://petspluslubbocktx.com/files/2016/11/DC-Cat-Weight-Management.png"));
elementD.getLocalMatrix().postScale(0.2f, 0.2f);
//elementD.getLocalMatrix().postRotate(60);
model.addElement(elementD);
EditorElement elementF = new EditorElement(new UrlRenderer("https://purepng.com/public/uploads/large/purepng.com-black-top-hathatsstandard-sizeblacktop-14215263591972x0zh.png"));
elementF.getLocalMatrix().postScale(0.2f, 0.2f);
//elementF.getLocalMatrix().postRotatF(60);
model.addElement(elementF);
EditorElement elementG = new EditorElement(new UriRenderer(Uri.parse("file:///android_asset/food/apple.png")));
elementG.getLocalMatrix().postScale(0.2f, 0.2f);
//elementG.getLocalMatrix().postRotatG(60);
model.addElement(elementG);
EditorElement elementH = new EditorElement(new MultiLineTextRenderer("Hello, World!", 0xff0000ff, MultiLineTextRenderer.Mode.REGULAR));
//elementH.getLocalMatrix().postScale(0.2f, 0.2f);
model.addElement(elementH);
EditorElement elementH2 = new EditorElement(new MultiLineTextRenderer("Hello, World 2!", 0xff0000ff, MultiLineTextRenderer.Mode.REGULAR));
//elementH.getLocalMatrix().postScale(0.2f, 0.2f);
model.addElement(elementH2);
return model;
}
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putParcelable("MODEL", imageEditorView.getModel());
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.action_menu, menu);
this.menu = menu;
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int itemId = item.getItemId();
if (itemId == R.id.action_undo) {
imageEditorView.getModel().undo();
Log.d(TAG, String.format("Model is %s", imageEditorView.getModel().isChanged() ? "changed" : "unchanged"));
return true;
} else if (itemId == R.id.action_redo) {
imageEditorView.getModel().redo();
return true;
} else if (itemId == R.id.action_crop) {
imageEditorView.setMode(ImageEditorView.Mode.MoveAndResize);
imageEditorView.getModel().startCrop();
return true;
} else if (itemId == R.id.action_done) {
imageEditorView.setMode(ImageEditorView.Mode.MoveAndResize);
imageEditorView.getModel().doneCrop();
return true;
} else if (itemId == R.id.action_draw) {
imageEditorView.setDrawingBrushColor(0xffffff00);
imageEditorView.startDrawing(0.02f, Paint.Cap.ROUND, false);
return true;
} else if (itemId == R.id.action_rotate_left_90) {
imageEditorView.getModel().rotate90anticlockwise();
return true;
} else if (itemId == R.id.action_flip_horizontal) {
imageEditorView.getModel().flipHorizontal();
return true;
} else if (itemId == R.id.action_edit_text) {
editText();
return true;
} else if (itemId == R.id.action_lock_crop_aspect) {
imageEditorView.getModel().setCropAspectLock(!imageEditorView.getModel().isCropAspectLocked());
return true;
} else if (itemId == R.id.action_save) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED)
{
ActivityCompat.requestPermissions(this,
new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE },
0);
} else {
Bitmap bitmap = imageEditorView.getModel().render(this, typefaceProvider);
try {
Uri uri = saveBmp(bitmap);
Intent intent = new Intent();
intent.setAction(Intent.ACTION_VIEW);
intent.setDataAndType(uri, "image/*");
startActivity(intent);
} finally {
bitmap.recycle();
}
}
return true;
}
return super.onOptionsItemSelected(item);
}
private void editText() {
imageEditorView.getModel().getRoot().findElement(new Matrix(), new Matrix(), (element, inverseMatrix) -> {
if (element.getRenderer() instanceof MultiLineTextRenderer) {
imageEditorView.startTextEditing(element);
return true;
}
return false;
}
);
}
private Uri saveBmp(Bitmap bitmap) {
String path = Environment.getExternalStorageDirectory().toString();
File filePath = new File(path);
File imageEditor = new File(filePath, "ImageEditor");
if (!imageEditor.exists()) {
imageEditor.mkdir();
}
int counter = 0;
File file;
do {
counter++;
file = new File(imageEditor, String.format(Locale.US, "ImageEditor_%03d.jpg", counter));
} while (file.exists());
try {
try (OutputStream stream = new FileOutputStream(file)) {
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
}
return Uri.parse(MediaStore.Images.Media.insertImage(getContentResolver(), file.getAbsolutePath(), file.getName(), file.getName()));
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

View file

@ -0,0 +1,10 @@
package org.signal.imageeditor.app;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.AppGlideModule;
@GlideModule
public class TheAppGlideModule extends AppGlideModule {
}

View file

@ -0,0 +1,12 @@
package org.signal.imageeditor.app.renderers;
import org.signal.imageeditor.core.Bounds;
import org.signal.imageeditor.core.Renderer;
public abstract class StandardHitTestRenderer implements Renderer {
@Override
public boolean hitTest(float x, float y) {
return Bounds.contains(x, y);
}
}

View file

@ -0,0 +1,138 @@
package org.signal.imageeditor.app.renderers;
import android.graphics.Bitmap;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Point;
import android.graphics.RectF;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.request.target.SimpleTarget;
import com.bumptech.glide.request.transition.Transition;
import org.signal.imageeditor.core.Bounds;
import org.signal.imageeditor.core.Renderer;
import org.signal.imageeditor.core.RendererContext;
public final class UriRenderer implements Renderer, Parcelable {
private final Uri imageUri;
private final Paint paint = new Paint();
private final Matrix temp1 = new Matrix();
private final Matrix temp2 = new Matrix();
@Nullable
private Bitmap bitmap;
public UriRenderer(Uri imageUri) {
this.imageUri = imageUri;
paint.setAntiAlias(true);
}
private UriRenderer(Parcel in) {
this(Uri.parse(in.readString()));
}
@Override
public void render(@NonNull RendererContext rendererContext) {
if (bitmap != null && bitmap.isRecycled()) bitmap = null;
if (bitmap == null) {
Glide.with(rendererContext.context)
.asBitmap()
.load(imageUri)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.into(new SimpleTarget<Bitmap>() {
@Override
public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
setBitmap(resource);
rendererContext.rendererReady.onReady(UriRenderer.this, cropMatrix(resource), new Point(resource.getWidth(), resource.getHeight()));
}
});
}
if (bitmap != null) {
rendererContext.save();
rendererContext.canvasMatrix.concat(temp1);
// FYI units are pixels at this point.
paint.setAlpha(rendererContext.getAlpha(255));
rendererContext.canvas.drawBitmap(bitmap, 0, 0, paint);
rendererContext.restore();
} else {
rendererContext.canvas.drawRect(-0.5f, -0.5f, 0.5f, 0.5f, paint);
}
}
@Override
public boolean hitTest(float x, float y) {
return pixelNotAlpha(x, y);
}
private boolean pixelNotAlpha(float x, float y) {
if (bitmap == null) return false;
temp1.invert(temp2);
float[] onBmp = new float[2];
temp2.mapPoints(onBmp, new float[]{ x, y });
int xInt = (int) onBmp[0];
int yInt = (int) onBmp[1];
if (xInt >= 0 && xInt < bitmap.getWidth() && yInt >= 0 && yInt < bitmap.getHeight()) {
return (bitmap.getPixel(xInt, yInt) & 0xff000000) != 0;
} else {
return false;
}
}
private void setBitmap(Bitmap bitmap) {
if (bitmap != null) {
this.bitmap = bitmap;
RectF from = new RectF(0, 0, bitmap.getWidth(), bitmap.getHeight());
temp1.setRectToRect(from, Bounds.FULL_BOUNDS, Matrix.ScaleToFit.CENTER);
}
}
private static Matrix cropMatrix(Bitmap bitmap) {
Matrix matrix = new Matrix();
if (bitmap.getWidth() > bitmap.getHeight()) {
matrix.preScale(1, ((float) bitmap.getHeight()) / bitmap.getWidth());
} else {
matrix.preScale(((float) bitmap.getWidth()) / bitmap.getHeight(), 1);
}
return matrix;
}
public static final Creator<UriRenderer> CREATOR = new Creator<UriRenderer>() {
@Override
public UriRenderer createFromParcel(Parcel in) {
return new UriRenderer(in);
}
@Override
public UriRenderer[] newArray(int size) {
return new UriRenderer[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(imageUri.toString());
}
}

View file

@ -0,0 +1,263 @@
package org.signal.imageeditor.app.renderers;
import android.graphics.Bitmap;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Point;
import android.graphics.RectF;
import android.os.Parcel;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.request.target.SimpleTarget;
import com.bumptech.glide.request.transition.Transition;
import org.signal.imageeditor.core.Bounds;
import org.signal.imageeditor.core.RendererContext;
import java.util.concurrent.ExecutionException;
public final class UrlRenderer extends StandardHitTestRenderer {
private static final String TAG = "UrlRenderer";
private final String url;
private final Paint paint = new Paint();
private final Matrix temp1 = new Matrix();
private final Matrix temp2 = new Matrix();
private Bitmap bitmap;
public UrlRenderer(@Nullable String url) {
this.url = url;
paint.setAntiAlias(true);
}
private UrlRenderer(Parcel in) {
this(in.readString());
}
public static final Creator<UrlRenderer> CREATOR = new Creator<UrlRenderer>() {
@Override
public UrlRenderer createFromParcel(Parcel in) {
return new UrlRenderer(in);
}
@Override
public UrlRenderer[] newArray(int size) {
return new UrlRenderer[size];
}
};
@Override
public void render(@NonNull RendererContext rendererContext) {
if (bitmap != null && bitmap.isRecycled()) bitmap = null;
if (bitmap == null) {
if (rendererContext.isBlockingLoad()) {
try {
setBitmap(rendererContext, Glide.with(rendererContext.context)
.asBitmap()
.load(url)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.submit()
.get());
} catch (ExecutionException e) {
throw new RuntimeException(e);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
} else {
Glide.with(rendererContext.context)
.asBitmap()
.load(url)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.into(new SimpleTarget<Bitmap>() {
@Override
public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
setBitmap(rendererContext, resource);
}
});
}
}
if (bitmap != null) {
rendererContext.save();
rendererContext.getCurrent(temp2);
temp2.preConcat(temp1);
rendererContext.canvas.concat(temp1);
// FYI units are pixels at this point.
paint.setAlpha(rendererContext.getAlpha(255));
rendererContext.canvas.drawBitmap(bitmap, 0, 0, paint);
rendererContext.restore();
} else {
if (rendererContext.isBlockingLoad()) {
Log.e(TAG, "blocking but drawing null :(");
}
rendererContext.canvas.drawRect(Bounds.FULL_BOUNDS, paint);
}
drawDebugInfo(rendererContext);
}
private void drawDebugInfo(RendererContext rendererContext) {
// float width = bitmap.getWidth();
// float height = bitmap.getWidth();
//RectF bounds = new RectF(Bounds.LEFT, Bounds.TOP/2f, Bounds.RIGHT,Bounds.BOTTOM/2f );//Bounds.FULL_BOUNDS;
RectF bounds = Bounds.FULL_BOUNDS;
Paint paint = new Paint();
paint.setStyle(Paint.Style.STROKE);
paint.setColor(0xffffff00);
rendererContext.canvas.drawRect(bounds, paint);
RectF fullBounds = new RectF();
rendererContext.mapRect(fullBounds, bounds);
rendererContext.save();
RectF dst = new RectF();
rendererContext.mapRect(dst, bounds);
paint.setColor(0xffff00ff);
rendererContext.canvasMatrix.setToIdentity();
rendererContext.canvas.drawRect(dst, paint);
rendererContext.restore();
rendererContext.save();
Matrix unrotated = new Matrix();
rendererContext.getCurrent(unrotated);
findUnrotateMatrix(unrotated);
Matrix rotated = new Matrix();
rendererContext.getCurrent(rotated);
findRotateMatrix(rotated);
RectF dst2 = new RectF();
unrotated.mapRect(dst2, Bounds.FULL_BOUNDS); // works because square, do we need rotated here?
float scaleX = Bounds.FULL_BOUNDS.width() / dst2.width();
float scaleY = Bounds.FULL_BOUNDS.height() / dst2.height();
rendererContext.canvasMatrix.concat(unrotated);
Matrix matrix = new Matrix();
matrix.setScale(scaleX, scaleY);
rendererContext.canvasMatrix.concat(matrix);
paint.setColor(0xff0000ff);
rendererContext.canvas.drawRect(bounds, paint);
rendererContext.restore();
}
/**
* Given a scaled/rotated and transformed matrix, extract just the rotate and reverse it.
*/
private void findUnrotateMatrix(@NonNull Matrix matrix) {
float[] values = new float[9];
matrix.getValues(values);
float xScale = (float) Math.sqrt(values[0] * values[0] + values[3] * values[3]);
float yScale = (float) Math.sqrt(values[1] * values[1] + values[4] * values[4]);
values[0] /= xScale;
values[1] /= -yScale;
values[2] = 0;
values[3] /= -xScale;
values[4] /= yScale;
values[5] = 0;
matrix.setValues(values);
}
/**
* Given a scaled/rotated and transformed matrix, extract just the rotate and reverse it.
*/
private void findRotateMatrix(@NonNull Matrix matrix) {
float[] values = new float[9];
matrix.getValues(values);
float xScale = (float) Math.sqrt(values[0] * values[0] + values[3] * values[3]);
float yScale = (float) Math.sqrt(values[1] * values[1] + values[4] * values[4]);
values[0] /= xScale;
values[1] /= yScale;
values[2] = 0;
values[3] /= xScale;
values[4] /= yScale;
values[5] = 0;
matrix.setValues(values);
}
@Override
public boolean hitTest(float x, float y) {
return super.hitTest(x, y) && pixelNotAlpha(x, y);
}
private boolean pixelNotAlpha(float x, float y) {
if (bitmap == null) return false;
temp1.invert(temp2);
float[] onBmp = new float[2];
temp2.mapPoints(onBmp, new float[]{ x, y });
int xInt = (int) onBmp[0];
int yInt = (int) onBmp[1];
if (xInt >= 0 && xInt < bitmap.getWidth() && yInt >= 0 && yInt < bitmap.getHeight()) {
return (bitmap.getPixel(xInt, yInt) & 0xff000000) != 0;
} else {
return xInt >= 0 && xInt <= bitmap.getWidth() && yInt >= 0 && yInt <= bitmap.getHeight();
}
}
private void setBitmap(@NonNull RendererContext rendererContext, @Nullable Bitmap bitmap) {
this.bitmap = bitmap;
if (bitmap != null) {
RectF from = new RectF(0, 0, bitmap.getWidth(), bitmap.getHeight());
temp1.setRectToRect(from, Bounds.FULL_BOUNDS, Matrix.ScaleToFit.CENTER);
rendererContext.rendererReady.onReady(this, cropMatrix(bitmap), new Point(bitmap.getWidth(), bitmap.getHeight()));
}
}
private void setBitmap(Bitmap bitmap) {
if (bitmap != null) {
this.bitmap = bitmap;
RectF from = new RectF(0, 0, bitmap.getWidth(), bitmap.getHeight());
temp1.setRectToRect(from, Bounds.FULL_BOUNDS, Matrix.ScaleToFit.CENTER);
}
}
private static Matrix cropMatrix(Bitmap bitmap) {
Matrix matrix = new Matrix();
if (bitmap.getWidth() > bitmap.getHeight()) {
matrix.preScale(1, ((float) bitmap.getHeight()) / bitmap.getWidth());
} else {
matrix.preScale(((float) bitmap.getWidth()) / bitmap.getHeight(), 1);
}
return matrix;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(url);
}
}

View file

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M17,15h2V7c0,-1.1 -0.9,-2 -2,-2H9v2h8v8zM7,17V1H5v4H1v2h4v10c0,1.1 0.9,2 2,2h10v4h2v-4h4v-2H7z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M15,21h2v-2h-2v2zM19,9h2L21,7h-2v2zM3,5v14c0,1.1 0.9,2 2,2h4v-2L5,19L5,5h4L9,3L5,3c-1.1,0 -2,0.9 -2,2zM19,3v2h2c0,-1.1 -0.9,-2 -2,-2zM11,23h2L13,1h-2v22zM19,17h2v-2h-2v2zM15,5h2L17,3h-2v2zM19,13h2v-2h-2v2zM19,21c1.1,0 2,-0.9 2,-2h-2v2z"/>
</vector>

View file

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M18.4,10.6C16.55,8.99 14.15,8 11.5,8c-4.65,0 -8.58,3.03 -9.96,7.22L3.9,16c1.05,-3.19 4.05,-5.5 7.6,-5.5 1.95,0 3.73,0.72 5.12,1.88L13,16h9V7l-3.6,3.6z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M7.11,8.53L5.7,7.11C4.8,8.27 4.24,9.61 4.07,11h2.02c0.14,-0.87 0.49,-1.72 1.02,-2.47zM6.09,13L4.07,13c0.17,1.39 0.72,2.73 1.62,3.89l1.41,-1.42c-0.52,-0.75 -0.87,-1.59 -1.01,-2.47zM7.1,18.32c1.16,0.9 2.51,1.44 3.9,1.61L11,17.9c-0.87,-0.15 -1.71,-0.49 -2.46,-1.03L7.1,18.32zM13,4.07L13,1L8.45,5.55 13,10L13,6.09c2.84,0.48 5,2.94 5,5.91s-2.16,5.43 -5,5.91v2.02c3.95,-0.49 7,-3.85 7,-7.93s-3.05,-7.44 -7,-7.93z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M15.55,5.55L11,1v3.07C7.06,4.56 4,7.92 4,12s3.05,7.44 7,7.93v-2.02c-2.84,-0.48 -5,-2.94 -5,-5.91s2.16,-5.43 5,-5.91L11,10l4.55,-4.45zM19.93,11c-0.17,-1.39 -0.72,-2.73 -1.62,-3.89l-1.42,1.42c0.54,0.75 0.88,1.6 1.02,2.47h2.02zM13,17.9v2.02c1.39,-0.17 2.74,-0.71 3.9,-1.61l-1.44,-1.44c-0.75,0.54 -1.59,0.89 -2.46,1.03zM16.89,15.48l1.42,1.41c0.9,-1.16 1.45,-2.5 1.62,-3.89h-2.02c-0.14,0.87 -0.48,1.72 -1.02,2.48z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12.5,8c-2.65,0 -5.05,0.99 -6.9,2.6L2,7v9h9l-3.62,-3.62c1.39,-1.16 3.16,-1.88 5.12,-1.88 3.54,0 6.55,2.31 7.6,5.5l2.37,-0.78C21.08,11.03 17.15,8 12.5,8z"/>
</vector>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:elevation="4dp"
android:theme="@style/ThemeOverlay.AppCompat.ActionBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
<org.signal.imageeditor.core.ImageEditorView
android:id="@+id/image_editor"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_crop"
android:icon="@drawable/ic_crop_black_24dp"
android:title="@string/crop"
app:showAsAction="always" />
<item
android:id="@+id/action_done"
android:icon="@drawable/ic_check_black_24dp"
android:title="@string/done"
app:showAsAction="always" />
<item
android:id="@+id/action_undo"
android:icon="@drawable/ic_undo_black_24dp"
android:title="@string/undo"
app:showAsAction="always" />
<item
android:id="@+id/action_redo"
android:icon="@drawable/ic_redo_black_24dp"
android:title="@string/redo"
app:showAsAction="always" />
<item
android:id="@+id/action_save"
android:icon="@drawable/ic_save_black_24dp"
android:title="@string/save"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_draw"
android:title="@string/draw"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_rotate_left_90"
android:icon="@drawable/ic_rotate_left_black_24dp"
android:title="@string/rotate_90_left"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_flip_horizontal"
android:icon="@drawable/ic_flip_black_24dp"
android:title="@string/flip_horizontal"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_edit_text"
android:title="@string/edit_text"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_lock_crop_aspect"
android:title="@string/lock_crop_aspect"
app:showAsAction="ifRoom" />
</menu>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#008577</color>
<color name="colorPrimaryDark">#00574B</color>
<color name="colorAccent">#D81B60</color>
</resources>

View file

@ -0,0 +1,17 @@
<resources>
<string name="app_name">Image Editor Sample App</string>
<string name="app_name_short">Image Editor</string>
<string name="undo">Undo</string>
<string name="redo">Redo</string>
<string name="crop">Crop</string>
<string name="done">Done</string>
<string name="save">Save</string>
<string name="draw">Draw</string>
<string name="rotate_90_right">Rotate 90 right</string>
<string name="rotate_90_left">Rotate 90 left</string>
<string name="flip_horizontal">Flip horizontal</string>
<string name="flip_vertical">Flip vertical</string>
<string name="edit_text">Edit Text</string>
<string name="lock_crop_aspect">Lock crop aspect</string>
</resources>

View file

@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Signal" parent="Theme.MaterialComponents.NoActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
</resources>

View file

@ -0,0 +1,16 @@
package com.example.imageeditor.app
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

1
image-editor/lib/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,11 @@
plugins {
id("signal-library")
}
android {
namespace = "org.signal.imageeditor"
}
dependencies {
implementation(project(":core-util"))
}

View file

21
image-editor/lib/proguard-rules.pro vendored Normal file
View 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

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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();
}

View file

@ -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;
}
}

View file

@ -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]);
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,205 @@
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;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;
/**
* 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;
private List<TextFilter> textFilters = new LinkedList<>();
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) {
String filtered = text.toString();
for (TextFilter filter : textFilters) {
filtered = filter.filter(filtered);
}
currentTextEntity.setText(filtered);
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();
}
}
}
public void addTextFilter(@NonNull TextFilter filter) {
textFilters.add(filter);
}
public void addTextFilters(@NonNull Collection<TextFilter> filters) {
textFilters.addAll(filters);
}
public void removeTextFilter(@NonNull TextFilter filter) {
textFilters.remove(filter);
}
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);
}
public interface TextFilter {
/**
* Given an input string, return a filtered version.
*/
String filter(@NonNull String text);
}
}

View file

@ -0,0 +1,634 @@
package org.signal.imageeditor.core;
import android.content.Context;
import android.content.res.TypedArray;
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.R;
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;
import java.util.LinkedList;
import java.util.List;
/**
* 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 static final int DEFAULT_BLACKOUT_COLOR = 0xFF000000;
/** Maximum distance squared a user can move the pointer before we consider a drag starting */
private static final int MAX_MOVE_SQUARED_BEFORE_DRAG = 10;
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 List<HiddenEditText.TextFilter> textFilters = new LinkedList<>();
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;
private RendererContext.TypefaceProvider typefaceProvider;
@Nullable
private EditSession editSession;
private boolean moreThanOnePointerUsedInSession;
private PointF touchDownStart;
private boolean inDrag;
public ImageEditorView(Context context) {
super(context);
init(null);
}
public ImageEditorView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
public ImageEditorView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(attrs);
}
private void init(@Nullable AttributeSet attributeSet) {
setWillNotDraw(false);
final int blackoutColor;
if (attributeSet != null) {
TypedArray typedArray = getContext().obtainStyledAttributes(attributeSet, R.styleable.ImageEditorView);
blackoutColor = typedArray.getColor(R.styleable.ImageEditorView_imageEditorView_blackoutColor, DEFAULT_BLACKOUT_COLOR);
typedArray.recycle();
} else {
blackoutColor = DEFAULT_BLACKOUT_COLOR;
}
setModel(EditorModel.create(blackoutColor));
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);
editText.addTextFilters(textFilters);
return editText;
}
public void startTextEditing(@NonNull EditorElement editorElement) {
getModel().addFade();
if (editorElement.getRenderer() instanceof MultiLineTextRenderer) {
getModel().setSelectionVisible(false);
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();
getModel().setSelectionVisible(true);
if (editText.getCurrentTextEntity() != null) {
getModel().setSelected(null);
editText.setCurrentTextEditorElement(null);
editText.hideKeyboard();
}
}
public void setTypefaceProvider(@NonNull RendererContext.TypefaceProvider typefaceProvider) {
this.typefaceProvider = typefaceProvider;
}
public void addTextInputFilter(@NonNull HiddenEditText.TextFilter inputFilter) {
textFilters.add(inputFilter);
editText = createAHiddenTextEntryField();
}
public void removeTextInputFilter(@NonNull HiddenEditText.TextFilter inputFilter) {
textFilters.remove(inputFilter);
editText = createAHiddenTextEntryField();
}
@Override
protected void onDraw(Canvas canvas) {
if (rendererContext == null || rendererContext.canvas != canvas || rendererContext.typefaceProvider != typefaceProvider) {
rendererContext = new RendererContext(getContext(), canvas, rendererReady, rendererInvalidate, typefaceProvider);
}
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);
inDrag = false;
moreThanOnePointerUsedInSession = false;
touchDownStart = point;
model.pushUndoPoint();
editSession = startEdit(inverse, point, selected);
if (editSession != null) {
checkTrashIntersect(point);
}
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();
if (inDrag) {
notifyDragMove(editSession.getSelected(), checkTrashIntersect(getPoint(event)));
} else if (pointerCount == 1) {
checkDragStart(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();
if (inDrag) {
notifyDragEnd(editSession.getSelected(), hittingTrash);
inDrag = false;
}
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 checkDragStart(MotionEvent moveEvent) {
if (inDrag || editSession == null) {
return;
}
float dX = touchDownStart.x - moveEvent.getX();
float dY = touchDownStart.y - moveEvent.getY();
float distSquared = dX * dX + dY * dY;
if (distSquared > MAX_MOVE_SQUARED_BEFORE_DRAG) {
inDrag = true;
notifyDragStart(editSession.getSelected());
}
}
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();
EditorElement thumbControlledElement = getModel().findById(thumb.getElementToControl());
if (thumbControlledElement == null) return null;
EditorElement thumbsParent = getModel().getRoot().findParent(selected);
if (thumbsParent == null) return null;
Matrix thumbContainerRelativeMatrix = model.findRelativeMatrix(thumbsParent, thumbControlledElement);
if (thumbContainerRelativeMatrix == null) return null;
selected = thumbControlledElement;
elementInverseMatrix = model.findElementInverseMatrix(selected, viewMatrix);
if (elementInverseMatrix != null) {
return ThumbDragEditSession.startDrag(selected, elementInverseMatrix, thumbContainerRelativeMatrix, thumb.getControlPoint(), point);
} else {
return null;
}
}
return ElementDragEditSession.startDrag(selected, inverse, point);
}
@NonNull
public Mode getMode() {
return mode;
}
public void setMode(@NonNull Mode mode) {
this.mode = mode;
}
public void setMainImageEditorMatrixRotation(float angle, float minScaleDown) {
model.setMainImageEditorMatrixRotation(angle, minScaleDown);
invalidate();
}
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);
model.setSelected(selected);
tapListener.onEntitySingleTap(selected);
} else {
tapListener.onEntitySingleTap(null);
model.setSelected(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);
}
}

View file

@ -0,0 +1,37 @@
package org.signal.imageeditor.core;
import android.graphics.Matrix;
import androidx.annotation.NonNull;
public final class MatrixUtils {
private static final ThreadLocal<float[]> tempMatrixValues = new ThreadLocal<>();
protected static @NonNull float[] getTempMatrixValues() {
float[] floats = tempMatrixValues.get();
if(floats == null) {
floats = new float[9];
tempMatrixValues.set(floats);
}
return floats;
}
/**
* Extracts the angle from a matrix in radians.
*/
public static float getRotationAngle(@NonNull Matrix matrix) {
float[] matrixValues = getTempMatrixValues();
matrix.getValues(matrixValues);
return (float) -Math.atan2(matrixValues[Matrix.MSKEW_X], matrixValues[Matrix.MSCALE_X]);
}
/** Gets the scale on the X axis */
public static float getScaleX(@NonNull Matrix matrix) {
float[] matrixValues = getTempMatrixValues();
matrix.getValues(matrixValues);
float scaleX = matrixValues[Matrix.MSCALE_X];
float skewX = matrixValues[Matrix.MSKEW_X];
return (float) Math.sqrt(scaleX * scaleX + skewX * skewX);
}
}

View file

@ -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);
}

View file

@ -0,0 +1,157 @@
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 android.graphics.Typeface;
import android.os.Build;
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;
@NonNull
public final TypefaceProvider typefaceProvider;
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, @NonNull TypefaceProvider typefaceProvider) {
this.context = context;
this.canvas = canvas;
this.canvasMatrix = new CanvasMatrix(canvas);
this.rendererReady = rendererReady;
this.invalidate = invalidate;
this.typefaceProvider = typefaceProvider;
}
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;
}
/**
* Allows a RenderContext creator to specify which font to use for text on the fly.
*/
public interface TypefaceProvider {
@NonNull Typeface getSelectedTypeface(@NonNull Context context, @NonNull Renderer renderer, @NonNull Invalidate invalidate);
}
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);
}
}

View file

@ -0,0 +1,15 @@
package org.signal.imageeditor.core
import android.graphics.RectF
/**
* Renderer that can maintain a "selected" state
*/
interface SelectableRenderer : Renderer {
fun onSelected(selected: Boolean)
/**
* Get the sub bounds in local coordinates in case the selection should be shown smaller than full bounds
*/
fun getSelectionBounds(bounds: RectF)
}

View file

@ -0,0 +1,145 @@
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 {
private final PointF oppositeControlPoint = new PointF();
private final float[] oppositeControlPointOnControlParent = new float[2];
private final float[] oppositeControlPointOnElement = new float[2];
@NonNull
private final ThumbRenderer.ControlPoint controlPoint;
@NonNull private final Matrix thumbContainerRelativeMatrix;
private ThumbDragEditSession(@NonNull EditorElement selected,
@NonNull ThumbRenderer.ControlPoint controlPoint,
@NonNull Matrix inverseMatrix,
@NonNull Matrix thumbContainerRelativeMatrix)
{
super(selected, inverseMatrix);
this.controlPoint = controlPoint;
this.thumbContainerRelativeMatrix = thumbContainerRelativeMatrix;
}
static EditSession startDrag(@NonNull EditorElement selected,
@NonNull Matrix inverseViewModelMatrix,
@NonNull Matrix thumbContainerRelativeMatrix,
@NonNull ThumbRenderer.ControlPoint controlPoint,
@NonNull PointF point)
{
if (!selected.getFlags().isEditable()) return null;
ElementEditSession elementDragEditSession = new ThumbDragEditSession(selected, controlPoint, inverseViewModelMatrix, thumbContainerRelativeMatrix);
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();
// Think of this process as a pinch to zoom/rotate, one finger being on the control point being manipulated, and the other on its opposite.
// Even if the opposite thumb doesn't exist on the tree, the position it would be at gives the virtual second finger position for the pinch.
// The opposite control point needs an additional mapping to put it in to the same coordinate system as the dragged thumb
oppositeControlPointOnControlParent[0] = controlPoint.opposite().getX();
oppositeControlPointOnControlParent[1] = controlPoint.opposite().getY();
thumbContainerRelativeMatrix.mapPoints(oppositeControlPointOnElement, oppositeControlPointOnControlParent);
float x = oppositeControlPointOnElement[0];
float y = oppositeControlPointOnElement[1];
oppositeControlPoint.set(x, y);
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;
if (controlPoint.isScaleAndRotateThumb()) {
float scale = findScale(oppositeControlPoint, startPointElement[0], endPointElement[0]);
editorMatrix.postTranslate(-oppositeControlPoint.x, -oppositeControlPoint.y);
editorMatrix.postScale(scale, scale);
double angle = angle(endPointElement[0], oppositeControlPoint) - angle(startPointElement[0], oppositeControlPoint);
rotate(editorMatrix, angle);
editorMatrix.postTranslate(oppositeControlPoint.x, oppositeControlPoint.y);
} else {
// 8 point controls, where edges scale in just one dimension and corners scale in both, optionally fixed aspect ratio
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 static void scale(Matrix editorMatrix, boolean aspectLocked, float scaleX, float scaleY, @NonNull 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);
}
private static void rotate(Matrix editorMatrix, double angle) {
editorMatrix.postRotate((float) Math.toDegrees(angle));
}
private static double angle(@NonNull PointF a, @NonNull PointF b) {
return Math.atan2(a.y - b.y, a.x - b.x);
}
@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;
}
/**
* Find relative distance between an old and new Point relative to an anchor.
* <p>
* <pre>
* |to - anchor| / |from - anchor|
* </pre>
*
* @param anchor Fixed point.
* @param from Starting point.
* @param to Ending point.
* @return Scale required to scale a line anchor->from to reach the to point from anchor.
*/
private static float findScale(@NonNull PointF anchor, @NonNull PointF from, @NonNull PointF to) {
float originalD2 = getDistanceSquared(from, anchor);
float newD2 = getDistanceSquared(to, anchor);
return (float) 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;
}
}

View file

@ -0,0 +1,6 @@
package org.signal.imageeditor.core;
public interface UndoRedoStackListener {
void onAvailabilityChanged(boolean undoAvailable, boolean redoAvailable);
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,403 @@
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.MatrixUtils;
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);
}
}
public @Nullable EditorElement findParent(@NonNull EditorElement editorElement) {
for (EditorElement child : children) {
if (child == editorElement) {
return this;
} else {
EditorElement element = child.findParent(editorElement);
if (element != null) {
return element;
}
}
}
return null;
}
public @Nullable EditorElement findElementWithId(@NonNull UUID id) {
for (EditorElement child : children) {
if (id.equals(child.id)) {
return child;
} else {
EditorElement element = child.findElementWithId(id);
if (element != null) {
return element;
}
}
}
return null;
}
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 void deleteAllChildren() {
children.clear();
}
public float getLocalRotationAngle() {
return MatrixUtils.getRotationAngle(localMatrix);
}
public float getLocalScaleX() {
return MatrixUtils.getScaleX(localMatrix);
}
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);
}
}

View file

@ -0,0 +1,519 @@
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.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.graphics.ColorUtils;
import org.signal.imageeditor.core.Bounds;
import org.signal.imageeditor.R;
import org.signal.imageeditor.core.SelectableRenderer;
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.SelectedElementGuideRenderer;
import org.signal.imageeditor.core.renderers.TrashRenderer;
/**
* Creates and handles a strict EditorElement Hierarchy.
* <p>
* <pre>
* 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
* | | |- selection - matches the aspect and overall matrix of the selected item's selectedBounds
* | | | |- Selection thumbs
* </pre>
*/
final class EditorElementHierarchy {
static @NonNull EditorElementHierarchy create(@ColorInt int blackoutColor) {
return new EditorElementHierarchy(createRoot(CropStyle.RECTANGLE, blackoutColor));
}
static @NonNull EditorElementHierarchy createForCircleEditing(@ColorInt int blackoutColor) {
return new EditorElementHierarchy(createRoot(CropStyle.CIRCLE, blackoutColor));
}
static @NonNull EditorElementHierarchy createForPinchAndPanCropping(@ColorInt int blackoutColor) {
return new EditorElementHierarchy(createRoot(CropStyle.PINCH_AND_PAN, blackoutColor));
}
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 final EditorElement selection;
private EditorElement selectedElement;
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.selection = this.overlay.getChild(1);
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, @ColorInt int blackoutColor) {
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);
EditorElement selection = new EditorElement(null);
overlay.addElement(selection);
boolean renderCenterThumbs = cropStyle == CropStyle.RECTANGLE;
EditorElement cropEditorElement = new EditorElement(new CropAreaRenderer(ColorUtils.setAlphaComponent(blackoutColor, 0x7F), renderCenterThumbs));
cropEditorElement.getFlags()
.setRotateLocked(true)
.setAspectLocked(true)
.setSelectable(false)
.setVisible(false)
.persist();
imageCrop.addElement(cropEditorElement);
EditorElement fade = new EditorElement(new FillRenderer(ColorUtils.setAlphaComponent(blackoutColor, 0x66)), 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(ColorUtils.setAlphaComponent(blackoutColor, 0xFF)));
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), EditorModel.Z_CIRCLE);
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;
}
void removeAllSelectionArtifacts() {
selection.deleteAllChildren();
selectedElement = null;
}
void updateSelectionThumbsForElement(@NonNull EditorElement element, @Nullable Matrix overlayMappingMatrix) {
if (element == selectedElement) {
setOrUpdateSelectionThumbsForElement(element, overlayMappingMatrix);
}
}
void setOrUpdateSelectionThumbsForElement(@NonNull EditorElement element, @Nullable Matrix overlayMappingMatrix) {
if (selectedElement != element) {
removeAllSelectionArtifacts();
if (element.getRenderer() instanceof SelectableRenderer) {
selectedElement = element;
} else {
selectedElement = null;
}
if (selectedElement == null) return;
selection.addElement(createSelectionBox());
selection.addElement(createScaleControlThumb(element));
selection.addElement(createRotateControlThumb(element));
}
if (overlayMappingMatrix != null) {
Matrix selectionMatrix = selection.getLocalMatrix();
if (selectedElement.getRenderer() instanceof SelectableRenderer) {
SelectableRenderer renderer = (SelectableRenderer) selectedElement.getRenderer();
RectF bounds = new RectF();
renderer.getSelectionBounds(bounds);
selectionMatrix.setRectToRect(Bounds.FULL_BOUNDS, bounds, Matrix.ScaleToFit.FILL);
}
selectionMatrix.postConcat(overlayMappingMatrix);
}
}
private static @NonNull EditorElement createSelectionBox() {
return new EditorElement(new SelectedElementGuideRenderer());
}
private static @NonNull EditorElement createScaleControlThumb(@NonNull EditorElement element) {
ThumbRenderer.ControlPoint controlPoint = ThumbRenderer.ControlPoint.SCALE_ROT_RIGHT;
EditorElement thumbElement = new EditorElement(new CropThumbRenderer(controlPoint, element.getId()));
thumbElement.getLocalMatrix().preTranslate(controlPoint.getX(), controlPoint.getY());
return thumbElement;
}
private static @NonNull EditorElement createRotateControlThumb(@NonNull EditorElement element) {
ThumbRenderer.ControlPoint controlPoint = ThumbRenderer.ControlPoint.SCALE_ROT_LEFT;
EditorElement rotateThumbElement = new EditorElement(new CropThumbRenderer(controlPoint, element.getId()));
rotateThumbElement.getLocalMatrix().preTranslate(controlPoint.getX(), controlPoint.getY());
return rotateThumbElement;
}
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 getSelection() {
return selection;
}
public @Nullable EditorElement getSelectedElement() {
return selectedElement;
}
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(float 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]);
}
}

View file

@ -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;
}
}

File diff suppressed because it is too large Load diff

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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());
}
}

View file

@ -0,0 +1,89 @@
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 {
// 8 point controls
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),
// 2 point controls
SCALE_ROT_LEFT (Bounds.LEFT, Bounds.CENTRE_Y),
SCALE_ROT_RIGHT (Bounds.RIGHT, Bounds.CENTRE_Y),
ORIGIN (0, 0);
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;
case SCALE_ROT_LEFT:
case SCALE_ROT_RIGHT: return ORIGIN;
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();
}
public boolean isScaleAndRotateThumb() {
return this == SCALE_ROT_LEFT || this == SCALE_ROT_RIGHT;
}
}
ControlPoint getControlPoint();
UUID getElementToControl();
}

View file

@ -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);
}
}

View file

@ -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));
}
}

View file

@ -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);
}
}

View file

@ -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.ColorInt;
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 {
@ColorInt
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(color);
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(@ColorInt 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;
}
}

View file

@ -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];
}
};
}

View file

@ -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);
}
}

View file

@ -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.
*/
public 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);
}
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,547 @@
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.Path;
import android.graphics.Rect;
import android.graphics.RectF;
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 = "";
private static final int PADDING = 10;
@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 Mode mode;
private List<Line> lines = emptyList();
private ValueAnimator cursorAnimator;
private float cursorAnimatedValue;
private final Matrix recommendedEditorMatrix = new Matrix();
private final RectF textBounds = new RectF();
public MultiLineTextRenderer(@Nullable String text, @ColorInt int color, @NonNull Mode mode) {
this.mode = mode;
modePaint.setAntiAlias(true);
modePaint.setTextSize(100);
setColorInternal(color);
float regularTextSize = paint.getTextSize();
paint.setAntiAlias(true);
paint.setTextSize(100);
textScale = paint.getTextSize() / regularTextSize;
selectionPaint.setAntiAlias(true);
setText(text != null ? text : "");
createLinesForText();
}
@Override
public void render(@NonNull RendererContext rendererContext) {
super.render(rendererContext);
paint.setTypeface(rendererContext.typefaceProvider.getSelectedTypeface(rendererContext.context, this, rendererContext.invalidate));
modePaint.setTypeface(rendererContext.typefaceProvider.getSelectedTypeface(rendererContext.context, this, rendererContext.invalidate));
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);
}
textBounds.set(-width - PADDING, -PADDING, width + PADDING, height / 2f + PADDING);
}
@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 ascentMatrix = new Matrix();
private final Matrix descentMatrix = 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 final Path outlinerPath = new Path();
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);
ascentMatrix.setTranslate(0, -ascentInBounds);
descentMatrix.setTranslate(0, descentInBounds + HIGHLIGHT_TOP_PADDING + HIGHLIGHT_BOTTOM_PADDING);
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(ascentMatrix);
rendererContext.save();
rendererContext.canvasMatrix.concat(projectionMatrix);
if (mode == Mode.HIGHLIGHT) {
if (text.isEmpty()) {
modeBounds.setEmpty();
} else {
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) {
if (text.isEmpty()) {
modeBounds.setEmpty();
} else {
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));
if (Build.VERSION.SDK_INT >= 31) {
outlinerPath.reset();
modePaint.getTextPath(text, 0, text.length(), 0, 0, outlinerPath);
outlinerPath.op(outlinerPath, Path.Op.INTERSECT);
rendererContext.canvas.drawPath(outlinerPath, modePaint);
} else {
rendererContext.canvas.drawText(text, 0, 0, modePaint);
}
modePaint.setAlpha(modeAlpha);
}
rendererContext.restore();
// add our descent for the next lines
rendererContext.canvasMatrix.concat(descentMatrix);
}
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) {
}
@Override
public void getSelectionBounds(@NonNull RectF bounds) {
bounds.set(textBounds);
}
@Override
public boolean hitTest(float x, float y) {
return textBounds.contains(x, y);
}
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));
};
}
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;
}
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,103 @@
package org.signal.imageeditor.core.renderers
import android.graphics.Color
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.graphics.Path
import android.os.Parcel
import android.os.Parcelable
import org.signal.core.util.DimensionUnit
import org.signal.imageeditor.core.Bounds
import org.signal.imageeditor.core.Renderer
import org.signal.imageeditor.core.RendererContext
class SelectedElementGuideRenderer : Renderer {
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.
*/
override fun render(rendererContext: RendererContext) {
rendererContext.canvasMatrix.mapPoints(allPointsOnScreen, allPointsInLocalCords)
performRender(rendererContext)
}
override fun hitTest(x: Float, y: Float): Boolean = false
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)
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()
}
override fun writeToParcel(parcel: Parcel, flags: Int) {
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<SelectedElementGuideRenderer> {
override fun createFromParcel(parcel: Parcel): SelectedElementGuideRenderer {
return SelectedElementGuideRenderer()
}
override fun newArray(size: Int): Array<SelectedElementGuideRenderer?> {
return arrayOfNulls(size)
}
}
}

View file

@ -0,0 +1,147 @@
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.core.Bounds
import org.signal.imageeditor.core.Renderer
import org.signal.imageeditor.core.RendererContext
import org.signal.core.util.R as CoreUtilR
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)
override fun render(rendererContext: RendererContext) {
super.render(rendererContext)
val frameRenderTime = System.currentTimeMillis()
val trash: Drawable = requireNotNull(AppCompatResources.getDrawable(rendererContext.context, CoreUtilR.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()
}
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()
}
override fun newArray(size: Int): Array<TrashRenderer?> {
return arrayOfNulls(size)
}
}
}
}

View 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>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="ImageEditorView">
<attr name="imageEditorView_blackoutColor" format="color" />
</declare-styleable>
</resources>