Source added
1
image-editor/app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
20
image-editor/app/build.gradle
Normal 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
|
|
@ -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
|
||||
27
image-editor/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
26
image-editor/app/src/main/res/layout/main_activity.xml
Normal 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>
|
||||
62
image-editor/app/src/main/res/menu/action_menu.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
BIN
image-editor/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
image-editor/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
image-editor/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
image-editor/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
image-editor/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
BIN
image-editor/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
BIN
image-editor/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
6
image-editor/app/src/main/res/values/colors.xml
Normal 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>
|
||||
17
image-editor/app/src/main/res/values/strings.xml
Normal 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>
|
||||
9
image-editor/app/src/main/res/values/themes.xml
Normal 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>
|
||||
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
11
image-editor/lib/build.gradle
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
plugins {
|
||||
id("signal-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.signal.imageeditor"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core-util"))
|
||||
}
|
||||
0
image-editor/lib/consumer-rules.pro
Normal file
21
image-editor/lib/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
4
image-editor/lib/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.RectF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
|
||||
/**
|
||||
* The local extent of a {@link EditorElement}.
|
||||
* i.e. all {@link EditorElement}s have a bounding rectangle from:
|
||||
* <p>
|
||||
* {@link #LEFT} to {@link #RIGHT} and from {@link #TOP} to {@link #BOTTOM}.
|
||||
*/
|
||||
public final class Bounds {
|
||||
|
||||
public static final float LEFT = -1000f;
|
||||
public static final float RIGHT = 1000f;
|
||||
|
||||
public static final float TOP = -1000f;
|
||||
public static final float BOTTOM = 1000f;
|
||||
|
||||
public static final float CENTRE_X = (LEFT + RIGHT) / 2f;
|
||||
public static final float CENTRE_Y = (TOP + BOTTOM) / 2f;
|
||||
|
||||
public static final float[] CENTRE = new float[]{ CENTRE_X, CENTRE_Y };
|
||||
|
||||
private static final float[] POINTS = { Bounds.LEFT, Bounds.TOP,
|
||||
Bounds.RIGHT, Bounds.TOP,
|
||||
Bounds.RIGHT, Bounds.BOTTOM,
|
||||
Bounds.LEFT, Bounds.BOTTOM };
|
||||
|
||||
static RectF newFullBounds() {
|
||||
return new RectF(LEFT, TOP, RIGHT, BOTTOM);
|
||||
}
|
||||
|
||||
public static RectF FULL_BOUNDS = newFullBounds();
|
||||
|
||||
public static boolean contains(float x, float y) {
|
||||
return x >= FULL_BOUNDS.left && x <= FULL_BOUNDS.right &&
|
||||
y >= FULL_BOUNDS.top && y <= FULL_BOUNDS.bottom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps all the points of bounds with the supplied matrix and determines whether they are still in bounds.
|
||||
*
|
||||
* @param matrix matrix to transform points by, null is treated as identity.
|
||||
* @return true iff all points remain in bounds after transformation.
|
||||
*/
|
||||
public static boolean boundsRemainInBounds(@Nullable Matrix matrix) {
|
||||
if (matrix == null) return true;
|
||||
|
||||
float[] dst = new float[POINTS.length];
|
||||
|
||||
matrix.mapPoints(dst, POINTS);
|
||||
|
||||
return allWithinBounds(dst);
|
||||
}
|
||||
|
||||
private static boolean allWithinBounds(@NonNull float[] points) {
|
||||
boolean allHit = true;
|
||||
|
||||
for (int i = 0; i < points.length / 2; i++) {
|
||||
float x = points[2 * i];
|
||||
float y = points[2 * i + 1];
|
||||
|
||||
if (!Bounds.contains(x, y)) {
|
||||
allHit = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allHit;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.RectF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
/**
|
||||
* Tracks the current matrix for a canvas.
|
||||
* <p>
|
||||
* This is because you cannot reliably call {@link Canvas#setMatrix(Matrix)}.
|
||||
* {@link Canvas#getMatrix()} provides this hint in its documentation:
|
||||
* "track relevant transform state outside of the canvas."
|
||||
* <p>
|
||||
* To achieve this, any changes to the canvas matrix must be done via this class, including save and
|
||||
* restore operations where the matrix was altered in between.
|
||||
*/
|
||||
public final class CanvasMatrix {
|
||||
|
||||
private final static int STACK_HEIGHT_LIMIT = 16;
|
||||
|
||||
private final Canvas canvas;
|
||||
private final Matrix canvasMatrix = new Matrix();
|
||||
private final Matrix temp = new Matrix();
|
||||
private final Matrix[] stack = new Matrix[STACK_HEIGHT_LIMIT];
|
||||
private int stackHeight;
|
||||
|
||||
CanvasMatrix(Canvas canvas) {
|
||||
this.canvas = canvas;
|
||||
for (int i = 0; i < stack.length; i++) {
|
||||
stack[i] = new Matrix();
|
||||
}
|
||||
}
|
||||
|
||||
public void concat(@NonNull Matrix matrix) {
|
||||
canvas.concat(matrix);
|
||||
canvasMatrix.preConcat(matrix);
|
||||
}
|
||||
|
||||
void save() {
|
||||
canvas.save();
|
||||
if (stackHeight == STACK_HEIGHT_LIMIT) {
|
||||
throw new AssertionError("Not enough space on stack");
|
||||
}
|
||||
stack[stackHeight++].set(canvasMatrix);
|
||||
}
|
||||
|
||||
void restore() {
|
||||
canvas.restore();
|
||||
canvasMatrix.set(stack[--stackHeight]);
|
||||
}
|
||||
|
||||
void getCurrent(@NonNull Matrix into) {
|
||||
into.set(canvasMatrix);
|
||||
}
|
||||
|
||||
public void setToIdentity() {
|
||||
if (canvasMatrix.invert(temp)) {
|
||||
concat(temp);
|
||||
}
|
||||
}
|
||||
|
||||
public void initial(Matrix viewMatrix) {
|
||||
concat(viewMatrix);
|
||||
}
|
||||
|
||||
boolean mapRect(@NonNull RectF dst, @NonNull RectF src) {
|
||||
return canvasMatrix.mapRect(dst, src);
|
||||
}
|
||||
|
||||
public void mapPoints(float[] dst, float[] src) {
|
||||
canvasMatrix.mapPoints(dst, src);
|
||||
}
|
||||
|
||||
public void copyTo(@NonNull Matrix matrix) {
|
||||
matrix.set(canvasMatrix);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package org.signal.imageeditor.core;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
|
||||
/**
|
||||
* A renderer that can have its color changed.
|
||||
* <p>
|
||||
* For example, Lines and Text can change color.
|
||||
*/
|
||||
public interface ColorableRenderer extends Renderer {
|
||||
|
||||
@ColorInt
|
||||
int getColor();
|
||||
|
||||
void setColor(@ColorInt int color);
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
import org.signal.imageeditor.core.renderers.BezierDrawingRenderer;
|
||||
|
||||
/**
|
||||
* Passes touch events into a {@link BezierDrawingRenderer}.
|
||||
*/
|
||||
class DrawingSession extends ElementEditSession {
|
||||
|
||||
private final BezierDrawingRenderer renderer;
|
||||
|
||||
private DrawingSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix, @NonNull BezierDrawingRenderer renderer) {
|
||||
super(selected, inverseMatrix);
|
||||
this.renderer = renderer;
|
||||
}
|
||||
|
||||
public static EditSession start(EditorElement element, BezierDrawingRenderer renderer, Matrix inverseMatrix, PointF point) {
|
||||
DrawingSession drawingSession = new DrawingSession(element, inverseMatrix, renderer);
|
||||
drawingSession.setScreenStartPoint(0, point);
|
||||
renderer.setFirstPoint(drawingSession.startPointElement[0]);
|
||||
return drawingSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void movePoint(int p, @NonNull PointF point) {
|
||||
if (p != 0) return;
|
||||
setScreenEndPoint(p, point);
|
||||
renderer.addNewPoint(endPointElement[0]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession removePoint(@NonNull Matrix newInverse, int p) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
|
||||
/**
|
||||
* Represents an underway edit of the image.
|
||||
* <p>
|
||||
* Accepts new touch positions, new touch points, released touch points and when complete can commit the edit.
|
||||
* <p>
|
||||
* Examples of edit session implementations are, Drag, Draw, Resize:
|
||||
* <p>
|
||||
* {@link ElementDragEditSession} for dragging with a single finger.
|
||||
* {@link ElementScaleEditSession} for resize/dragging with two fingers.
|
||||
* {@link DrawingSession} for drawing with a single finger.
|
||||
*/
|
||||
interface EditSession {
|
||||
|
||||
void movePoint(int p, @NonNull PointF point);
|
||||
|
||||
EditorElement getSelected();
|
||||
|
||||
EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p);
|
||||
|
||||
EditSession removePoint(@NonNull Matrix newInverse, int p);
|
||||
|
||||
void commit();
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
|
||||
final class ElementDragEditSession extends ElementEditSession {
|
||||
|
||||
private ElementDragEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) {
|
||||
super(selected, inverseMatrix);
|
||||
}
|
||||
|
||||
static ElementDragEditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull PointF point) {
|
||||
if (!selected.getFlags().isEditable()) return null;
|
||||
|
||||
ElementDragEditSession elementDragEditSession = new ElementDragEditSession(selected, inverseViewModelMatrix);
|
||||
elementDragEditSession.setScreenStartPoint(0, point);
|
||||
elementDragEditSession.setScreenEndPoint(0, point);
|
||||
|
||||
return elementDragEditSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void movePoint(int p, @NonNull PointF point) {
|
||||
setScreenEndPoint(p, point);
|
||||
|
||||
selected.getEditorMatrix()
|
||||
.setTranslate(endPointElement[0].x - startPointElement[0].x, endPointElement[0].y - startPointElement[0].y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) {
|
||||
return ElementScaleEditSession.startScale(this, newInverse, point, p);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession removePoint(@NonNull Matrix newInverse, int p) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
|
||||
abstract class ElementEditSession implements EditSession {
|
||||
|
||||
private final Matrix inverseMatrix;
|
||||
|
||||
final EditorElement selected;
|
||||
|
||||
final PointF[] startPointElement = newTwoPointArray();
|
||||
final PointF[] endPointElement = newTwoPointArray();
|
||||
final PointF[] startPointScreen = newTwoPointArray();
|
||||
final PointF[] endPointScreen = newTwoPointArray();
|
||||
|
||||
ElementEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) {
|
||||
this.selected = selected;
|
||||
this.inverseMatrix = inverseMatrix;
|
||||
}
|
||||
|
||||
void setScreenStartPoint(int p, @NonNull PointF point) {
|
||||
startPointScreen[p] = point;
|
||||
mapPoint(startPointElement[p], inverseMatrix, point);
|
||||
}
|
||||
|
||||
void setScreenEndPoint(int p, @NonNull PointF point) {
|
||||
endPointScreen[p] = point;
|
||||
mapPoint(endPointElement[p], inverseMatrix, point);
|
||||
}
|
||||
|
||||
@Override
|
||||
public abstract void movePoint(int p, @NonNull PointF point);
|
||||
|
||||
@Override
|
||||
public void commit() {
|
||||
selected.commitEditorMatrix();
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditorElement getSelected() {
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static PointF[] newTwoPointArray() {
|
||||
PointF[] array = new PointF[2];
|
||||
for (int i = 0; i < array.length; i++) {
|
||||
array[i] = new PointF();
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map src to dst using the matrix.
|
||||
*
|
||||
* @param dst Output point.
|
||||
* @param matrix Matrix to transform point with.
|
||||
* @param src Input point.
|
||||
*/
|
||||
private static void mapPoint(@NonNull PointF dst, @NonNull Matrix matrix, @NonNull PointF src) {
|
||||
float[] in = { src.x, src.y };
|
||||
float[] out = new float[2];
|
||||
matrix.mapPoints(out, in);
|
||||
dst.set(out[0], out[1]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
|
||||
final class ElementScaleEditSession extends ElementEditSession {
|
||||
|
||||
private ElementScaleEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) {
|
||||
super(selected, inverseMatrix);
|
||||
}
|
||||
|
||||
static ElementScaleEditSession startScale(@NonNull ElementDragEditSession session, @NonNull Matrix inverseMatrix, @NonNull PointF point, int p) {
|
||||
session.commit();
|
||||
ElementScaleEditSession newSession = new ElementScaleEditSession(session.selected, inverseMatrix);
|
||||
newSession.setScreenStartPoint(1 - p, session.endPointScreen[0]);
|
||||
newSession.setScreenEndPoint(1 - p, session.endPointScreen[0]);
|
||||
newSession.setScreenStartPoint(p, point);
|
||||
newSession.setScreenEndPoint(p, point);
|
||||
return newSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void movePoint(int p, @NonNull PointF point) {
|
||||
setScreenEndPoint(p, point);
|
||||
Matrix editorMatrix = selected.getEditorMatrix();
|
||||
|
||||
editorMatrix.reset();
|
||||
|
||||
if (selected.getFlags().isAspectLocked()) {
|
||||
|
||||
float scale = (float) findScale(startPointElement, endPointElement);
|
||||
|
||||
editorMatrix.postTranslate(-startPointElement[0].x, -startPointElement[0].y);
|
||||
editorMatrix.postScale(scale, scale);
|
||||
|
||||
double angle = angle(endPointElement[0], endPointElement[1]) - angle(startPointElement[0], startPointElement[1]);
|
||||
|
||||
if (!selected.getFlags().isRotateLocked()) {
|
||||
editorMatrix.postRotate((float) Math.toDegrees(angle));
|
||||
}
|
||||
|
||||
editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y);
|
||||
} else {
|
||||
editorMatrix.postTranslate(-startPointElement[0].x, -startPointElement[0].y);
|
||||
|
||||
float scaleX = (endPointElement[1].x - endPointElement[0].x) / (startPointElement[1].x - startPointElement[0].x);
|
||||
float scaleY = (endPointElement[1].y - endPointElement[0].y) / (startPointElement[1].y - startPointElement[0].y);
|
||||
|
||||
editorMatrix.postScale(scaleX, scaleY);
|
||||
|
||||
editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession removePoint(@NonNull Matrix newInverse, int p) {
|
||||
return convertToDrag(p, newInverse);
|
||||
}
|
||||
|
||||
private static double angle(@NonNull PointF a, @NonNull PointF b) {
|
||||
return Math.atan2(a.y - b.y, a.x - b.x);
|
||||
}
|
||||
|
||||
private ElementDragEditSession convertToDrag(int p, @NonNull Matrix inverse) {
|
||||
return ElementDragEditSession.startDrag(selected, inverse, endPointScreen[1 - p]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find relative distance between an old and new set of Points.
|
||||
*
|
||||
* @param from Pair of points.
|
||||
* @param to New pair of points.
|
||||
* @return Scale
|
||||
*/
|
||||
private static double findScale(@NonNull PointF[] from, @NonNull PointF[] to) {
|
||||
float originalD2 = getDistanceSquared(from[0], from[1]);
|
||||
float newD2 = getDistanceSquared(to[0], to[1]);
|
||||
return Math.sqrt(newD2 / originalD2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance between two points squared.
|
||||
*/
|
||||
private static float getDistanceSquared(@NonNull PointF a, @NonNull PointF b) {
|
||||
float dx = a.x - b.x;
|
||||
float dy = a.y - b.y;
|
||||
return dx * dx + dy * dy;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.model.EditorElement;
|
||||
|
||||
/**
|
||||
* Responsible for rendering a single {@link EditorElement} to the canvas.
|
||||
* <p>
|
||||
* Because it knows the most about the whereabouts of the image it is also responsible for hit detection.
|
||||
*/
|
||||
public interface Renderer extends Parcelable {
|
||||
|
||||
/**
|
||||
* Draw self to the context.
|
||||
*
|
||||
* @param rendererContext The context to draw to.
|
||||
*/
|
||||
void render(@NonNull RendererContext rendererContext);
|
||||
|
||||
/**
|
||||
* @param x Local coordinate X
|
||||
* @param y Local coordinate Y
|
||||
* @return true iff hit.
|
||||
*/
|
||||
boolean hitTest(float x, float y);
|
||||
}
|
||||
|
|
@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package org.signal.imageeditor.core;
|
||||
|
||||
public interface UndoRedoStackListener {
|
||||
|
||||
void onAvailabilityChanged(boolean undoAvailable, boolean redoAvailable);
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.animation.ValueAnimator;
|
||||
import android.view.animation.Interpolator;
|
||||
import android.view.animation.LinearInterpolator;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
final class AlphaAnimation {
|
||||
|
||||
private final static Interpolator interpolator = new LinearInterpolator();
|
||||
|
||||
final static AlphaAnimation NULL_1 = new AlphaAnimation(1);
|
||||
|
||||
private final float from;
|
||||
private final float to;
|
||||
private final Runnable invalidate;
|
||||
private final boolean canAnimate;
|
||||
private float animatedFraction;
|
||||
|
||||
private AlphaAnimation(float from, float to, @Nullable Runnable invalidate) {
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.invalidate = invalidate;
|
||||
this.canAnimate = invalidate != null;
|
||||
}
|
||||
|
||||
private AlphaAnimation(float fixed) {
|
||||
this(fixed, fixed, null);
|
||||
}
|
||||
|
||||
static AlphaAnimation animate(float from, float to, @Nullable Runnable invalidate) {
|
||||
if (invalidate == null) {
|
||||
return new AlphaAnimation(to);
|
||||
}
|
||||
|
||||
if (from != to) {
|
||||
AlphaAnimation animationMatrix = new AlphaAnimation(from, to, invalidate);
|
||||
animationMatrix.start();
|
||||
return animationMatrix;
|
||||
} else {
|
||||
return new AlphaAnimation(to);
|
||||
}
|
||||
}
|
||||
|
||||
private void start() {
|
||||
if (canAnimate && invalidate != null) {
|
||||
ValueAnimator animator = ValueAnimator.ofFloat(from, to);
|
||||
animator.setDuration(200);
|
||||
animator.setInterpolator(interpolator);
|
||||
animator.addUpdateListener(animation -> {
|
||||
animatedFraction = (float) animation.getAnimatedValue();
|
||||
invalidate.run();
|
||||
});
|
||||
animator.start();
|
||||
}
|
||||
}
|
||||
|
||||
float getValue() {
|
||||
if (!canAnimate) return to;
|
||||
|
||||
return animatedFraction;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.animation.ValueAnimator;
|
||||
import android.graphics.Matrix;
|
||||
import android.view.animation.CycleInterpolator;
|
||||
import android.view.animation.DecelerateInterpolator;
|
||||
import android.view.animation.Interpolator;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.imageeditor.core.CanvasMatrix;
|
||||
|
||||
/**
|
||||
* Animation Matrix provides a matrix that animates over time down to the identity matrix.
|
||||
*/
|
||||
final class AnimationMatrix {
|
||||
|
||||
private final static float[] iValues = new float[9];
|
||||
private final static Interpolator interpolator = new DecelerateInterpolator();
|
||||
private final static Interpolator pulseInterpolator = inverse(new CycleInterpolator(0.5f));
|
||||
|
||||
static AnimationMatrix NULL = new AnimationMatrix();
|
||||
|
||||
static {
|
||||
new Matrix().getValues(iValues);
|
||||
}
|
||||
|
||||
private final Runnable invalidate;
|
||||
private final boolean canAnimate;
|
||||
private final float[] undoValues = new float[9];
|
||||
|
||||
private final Matrix temp = new Matrix();
|
||||
private final float[] tempValues = new float[9];
|
||||
|
||||
private ValueAnimator animator;
|
||||
private float animatedFraction;
|
||||
|
||||
private AnimationMatrix(@NonNull Matrix undo, @NonNull Runnable invalidate) {
|
||||
this.invalidate = invalidate;
|
||||
this.canAnimate = true;
|
||||
undo.getValues(undoValues);
|
||||
}
|
||||
|
||||
private AnimationMatrix() {
|
||||
canAnimate = false;
|
||||
invalidate = null;
|
||||
}
|
||||
|
||||
static @NonNull AnimationMatrix animate(@NonNull Matrix from, @NonNull Matrix to, @Nullable Runnable invalidate) {
|
||||
if (invalidate == null) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Matrix undo = new Matrix();
|
||||
boolean inverted = to.invert(undo);
|
||||
if (inverted) {
|
||||
undo.preConcat(from);
|
||||
}
|
||||
if (inverted && !undo.isIdentity()) {
|
||||
AnimationMatrix animationMatrix = new AnimationMatrix(undo, invalidate);
|
||||
animationMatrix.start(interpolator);
|
||||
return animationMatrix;
|
||||
} else {
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate applying a matrix and then animate removing.
|
||||
*/
|
||||
static @NonNull AnimationMatrix singlePulse(@NonNull Matrix pulse, @Nullable Runnable invalidate) {
|
||||
if (invalidate == null) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
AnimationMatrix animationMatrix = new AnimationMatrix(pulse, invalidate);
|
||||
animationMatrix.start(pulseInterpolator);
|
||||
|
||||
return animationMatrix;
|
||||
}
|
||||
|
||||
private void start(@NonNull Interpolator interpolator) {
|
||||
if (canAnimate) {
|
||||
animator = ValueAnimator.ofFloat(1, 0);
|
||||
animator.setDuration(250);
|
||||
animator.setInterpolator(interpolator);
|
||||
animator.addUpdateListener(animation -> {
|
||||
animatedFraction = (float) animation.getAnimatedValue();
|
||||
invalidate.run();
|
||||
});
|
||||
animator.start();
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
ValueAnimator animator = this.animator;
|
||||
if (animator != null) animator.cancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the current animation value.
|
||||
*/
|
||||
void preConcatValueTo(@NonNull Matrix onTo) {
|
||||
if (!canAnimate) return;
|
||||
|
||||
onTo.preConcat(buildTemp());
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the current animation value.
|
||||
*/
|
||||
void preConcatValueTo(@NonNull CanvasMatrix canvasMatrix) {
|
||||
if (!canAnimate) return;
|
||||
|
||||
canvasMatrix.concat(buildTemp());
|
||||
}
|
||||
|
||||
private Matrix buildTemp() {
|
||||
if (!canAnimate) {
|
||||
temp.reset();
|
||||
return temp;
|
||||
}
|
||||
|
||||
final float fractionCompliment = 1f - animatedFraction;
|
||||
for (int i = 0; i < 9; i++) {
|
||||
tempValues[i] = fractionCompliment * iValues[i] + animatedFraction * undoValues[i];
|
||||
}
|
||||
|
||||
temp.setValues(tempValues);
|
||||
return temp;
|
||||
}
|
||||
|
||||
private static Interpolator inverse(@NonNull Interpolator interpolator) {
|
||||
return input -> 1f - interpolator.getInterpolation(input);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
final class Bisect {
|
||||
|
||||
static final float ACCURACY = 0.001f;
|
||||
|
||||
private static final int MAX_ITERATIONS = 16;
|
||||
|
||||
interface Predicate {
|
||||
boolean test();
|
||||
}
|
||||
|
||||
interface ModifyElement {
|
||||
void applyFactor(@NonNull Matrix matrix, float factor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a predicate function, attempts to finds the boundary between predicate true and predicate false.
|
||||
* If it returns true, it will animate the element to the closest true value found to that boundary.
|
||||
*
|
||||
* @param element The element to modify.
|
||||
* @param outOfBoundsValue The current value, known to be out of bounds. 1 for a scale and 0 for a translate.
|
||||
* @param atMost A value believed to be in bounds.
|
||||
* @param predicate The out of bounds predicate.
|
||||
* @param modifyElement Apply the latest value to the element local matrix.
|
||||
* @param invalidate For animation if finds a result.
|
||||
* @return true iff finds a result.
|
||||
*/
|
||||
static boolean bisectToTest(@NonNull EditorElement element,
|
||||
float outOfBoundsValue,
|
||||
float atMost,
|
||||
@NonNull Predicate predicate,
|
||||
@NonNull ModifyElement modifyElement,
|
||||
@NonNull Runnable invalidate)
|
||||
{
|
||||
Matrix closestSuccesful = bisectToTest(element, outOfBoundsValue, atMost, predicate, modifyElement);
|
||||
|
||||
if (closestSuccesful != null) {
|
||||
element.animateLocalTo(closestSuccesful, invalidate);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a predicate function, attempts to finds the boundary between predicate true and predicate false.
|
||||
* Returns new local matrix for the element if a solution is found.
|
||||
*
|
||||
* @param element The element to modify.
|
||||
* @param outOfBoundsValue The current value, known to be out of bounds. 1 for a scale and 0 for a translate.
|
||||
* @param atMost A value believed to be in bounds.
|
||||
* @param predicate The out of bounds predicate.
|
||||
* @param modifyElement Apply the latest value to the element local matrix.
|
||||
* @return matrix to replace local matrix iff finds a result, null otherwise.
|
||||
*/
|
||||
static @Nullable Matrix bisectToTest(@NonNull EditorElement element,
|
||||
float outOfBoundsValue,
|
||||
float atMost,
|
||||
@NonNull Predicate predicate,
|
||||
@NonNull ModifyElement modifyElement)
|
||||
{
|
||||
Matrix elementMatrix = element.getLocalMatrix();
|
||||
Matrix original = new Matrix(elementMatrix);
|
||||
Matrix closestSuccessful = new Matrix();
|
||||
boolean haveResult = false;
|
||||
int attempt = 0;
|
||||
float successValue = 0;
|
||||
float inBoundsValue = atMost;
|
||||
float nextValueToTry = inBoundsValue;
|
||||
|
||||
do {
|
||||
attempt++;
|
||||
|
||||
modifyElement.applyFactor(elementMatrix, nextValueToTry);
|
||||
try {
|
||||
|
||||
if (predicate.test()) {
|
||||
inBoundsValue = nextValueToTry;
|
||||
|
||||
// if first success or closer to out of bounds than the current closest
|
||||
if (!haveResult || Math.abs(nextValueToTry - outOfBoundsValue) < Math.abs(successValue - outOfBoundsValue)) {
|
||||
haveResult = true;
|
||||
successValue = nextValueToTry;
|
||||
closestSuccessful.set(elementMatrix);
|
||||
}
|
||||
} else {
|
||||
if (attempt == 1) {
|
||||
// failure on first attempt means inBoundsValue is actually out of bounds and so no solution
|
||||
return null;
|
||||
}
|
||||
outOfBoundsValue = nextValueToTry;
|
||||
}
|
||||
} finally {
|
||||
// reset
|
||||
elementMatrix.set(original);
|
||||
}
|
||||
|
||||
nextValueToTry = (inBoundsValue + outOfBoundsValue) / 2f;
|
||||
|
||||
} while (attempt < MAX_ITERATIONS && Math.abs(inBoundsValue - outOfBoundsValue) > ACCURACY);
|
||||
|
||||
if (haveResult) {
|
||||
return closestSuccessful;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.Bounds;
|
||||
import org.signal.imageeditor.R;
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Hit tests a circle that is {@link R.dimen#crop_area_renderer_edge_size} in radius on the screen.
|
||||
* <p>
|
||||
* Does not draw anything.
|
||||
*/
|
||||
class CropThumbRenderer implements Renderer, ThumbRenderer {
|
||||
|
||||
private final ControlPoint controlPoint;
|
||||
private final UUID toControl;
|
||||
|
||||
private final float[] centreOnScreen = new float[2];
|
||||
private final Matrix matrix = new Matrix();
|
||||
private int size;
|
||||
|
||||
CropThumbRenderer(@NonNull ControlPoint controlPoint, @NonNull UUID toControl) {
|
||||
this.controlPoint = controlPoint;
|
||||
this.toControl = toControl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ControlPoint getControlPoint() {
|
||||
return controlPoint;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UUID getElementToControl() {
|
||||
return toControl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
rendererContext.canvasMatrix.mapPoints(centreOnScreen, Bounds.CENTRE);
|
||||
rendererContext.canvasMatrix.copyTo(matrix);
|
||||
size = rendererContext.context.getResources().getDimensionPixelSize(R.dimen.crop_area_renderer_edge_size);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
float[] hitPointOnScreen = new float[2];
|
||||
matrix.mapPoints(hitPointOnScreen, new float[]{ x, y });
|
||||
|
||||
float dx = centreOnScreen[0] - hitPointOnScreen[0];
|
||||
float dy = centreOnScreen[1] - hitPointOnScreen[1];
|
||||
|
||||
return dx * dx + dy * dy < size * size;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static final Creator<CropThumbRenderer> CREATOR = new Creator<CropThumbRenderer>() {
|
||||
@Override
|
||||
public CropThumbRenderer createFromParcel(Parcel in) {
|
||||
return new CropThumbRenderer(ControlPoint.values()[in.readInt()], ParcelUtils.readUUID(in));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CropThumbRenderer[] newArray(int size) {
|
||||
return new CropThumbRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(controlPoint.ordinal());
|
||||
ParcelUtils.writeUUID(dest, toControl);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
/**
|
||||
* Flags for an {@link EditorElement}.
|
||||
* <p>
|
||||
* Values you set are not persisted unless you call {@link #persist()}.
|
||||
* <p>
|
||||
* This allows temporary state for editing and an easy way to revert to the persisted state via {@link #reset()}.
|
||||
*/
|
||||
public final class EditorFlags {
|
||||
|
||||
private static final int ASPECT_LOCK = 1;
|
||||
private static final int ROTATE_LOCK = 2;
|
||||
private static final int SELECTABLE = 4;
|
||||
private static final int VISIBLE = 8;
|
||||
private static final int CHILDREN_VISIBLE = 16;
|
||||
private static final int EDITABLE = 32;
|
||||
|
||||
private int flags;
|
||||
private int markedFlags;
|
||||
private int persistedFlags;
|
||||
|
||||
EditorFlags() {
|
||||
this(ASPECT_LOCK | SELECTABLE | VISIBLE | CHILDREN_VISIBLE | EDITABLE);
|
||||
}
|
||||
|
||||
EditorFlags(int flags) {
|
||||
this.flags = flags;
|
||||
this.persistedFlags = flags;
|
||||
}
|
||||
|
||||
public EditorFlags setRotateLocked(boolean rotateLocked) {
|
||||
setFlag(ROTATE_LOCK, rotateLocked);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isRotateLocked() {
|
||||
return isFlagSet(ROTATE_LOCK);
|
||||
}
|
||||
|
||||
public EditorFlags setAspectLocked(boolean aspectLocked) {
|
||||
setFlag(ASPECT_LOCK, aspectLocked);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isAspectLocked() {
|
||||
return isFlagSet(ASPECT_LOCK);
|
||||
}
|
||||
|
||||
public EditorFlags setSelectable(boolean selectable) {
|
||||
setFlag(SELECTABLE, selectable);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isSelectable() {
|
||||
return isFlagSet(SELECTABLE);
|
||||
}
|
||||
|
||||
public EditorFlags setEditable(boolean canEdit) {
|
||||
setFlag(EDITABLE, canEdit);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isEditable() {
|
||||
return isFlagSet(EDITABLE);
|
||||
}
|
||||
|
||||
public EditorFlags setVisible(boolean visible) {
|
||||
setFlag(VISIBLE, visible);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isVisible() {
|
||||
return isFlagSet(VISIBLE);
|
||||
}
|
||||
|
||||
public EditorFlags setChildrenVisible(boolean childrenVisible) {
|
||||
setFlag(CHILDREN_VISIBLE, childrenVisible);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isChildrenVisible() {
|
||||
return isFlagSet(CHILDREN_VISIBLE);
|
||||
}
|
||||
|
||||
private void setFlag(int flag, boolean set) {
|
||||
if (set) {
|
||||
this.flags |= flag;
|
||||
} else {
|
||||
this.flags &= ~flag;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isFlagSet(int flag) {
|
||||
return (flags & flag) != 0;
|
||||
}
|
||||
|
||||
int asInt() {
|
||||
return persistedFlags;
|
||||
}
|
||||
|
||||
int getCurrentState() {
|
||||
return flags;
|
||||
}
|
||||
|
||||
public void persist() {
|
||||
persistedFlags = flags;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
restoreState(persistedFlags);
|
||||
}
|
||||
|
||||
void restoreState(int flags) {
|
||||
this.flags = flags;
|
||||
}
|
||||
|
||||
void mark() {
|
||||
markedFlags = flags;
|
||||
}
|
||||
|
||||
void restore() {
|
||||
flags = markedFlags;
|
||||
}
|
||||
|
||||
public void set(@NonNull EditorFlags from) {
|
||||
this.persistedFlags = from.persistedFlags;
|
||||
this.flags = from.flags;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Stack;
|
||||
|
||||
/**
|
||||
* Contains a stack of elements for undo and redo stacks.
|
||||
* <p>
|
||||
* Elements are mutable, so this stack serializes the element and keeps a stack of serialized data.
|
||||
* <p>
|
||||
* The stack has a {@link #limit} and if it exceeds that limit during a push the second to earliest item
|
||||
* is removed so that it can always go back to the first state. Effectively collapsing the history for
|
||||
* the start of the stack.
|
||||
*/
|
||||
final class ElementStack implements Parcelable {
|
||||
|
||||
private final int limit;
|
||||
private final Stack<byte[]> stack = new Stack<>();
|
||||
|
||||
ElementStack(int limit) {
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
private ElementStack(@NonNull Parcel in) {
|
||||
this(in.readInt());
|
||||
final int count = in.readInt();
|
||||
for (int i = 0; i < count; i++) {
|
||||
stack.add(i, in.createByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes an element to the stack iff the element's serialized value is different to any found at
|
||||
* the top of the stack.
|
||||
* <p>
|
||||
* Removes the second to earliest item if it is overflowing.
|
||||
*
|
||||
* @param element new editor element state.
|
||||
* @return true iff the pushed item was different to the top item.
|
||||
*/
|
||||
boolean tryPush(@NonNull EditorElement element) {
|
||||
byte[] bytes = getBytes(element);
|
||||
boolean push = stack.isEmpty() || !Arrays.equals(bytes, stack.peek());
|
||||
|
||||
if (push) {
|
||||
stack.push(bytes);
|
||||
if (stack.size() > limit) {
|
||||
stack.remove(1);
|
||||
}
|
||||
}
|
||||
return push;
|
||||
}
|
||||
|
||||
static byte[] getBytes(@NonNull Parcelable parcelable) {
|
||||
Parcel parcel = Parcel.obtain();
|
||||
byte[] bytes;
|
||||
try {
|
||||
parcel.writeParcelable(parcelable, 0);
|
||||
bytes = parcel.marshall();
|
||||
} finally {
|
||||
parcel.recycle();
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pops the first different state from the supplied element.
|
||||
*/
|
||||
@Nullable EditorElement pop(@NonNull EditorElement element) {
|
||||
if (stack.empty()) return null;
|
||||
|
||||
byte[] elementBytes = getBytes(element);
|
||||
byte[] stackData = null;
|
||||
|
||||
while (!stack.empty() && stackData == null) {
|
||||
byte[] topData = stack.pop();
|
||||
|
||||
if (!Arrays.equals(topData, elementBytes)) {
|
||||
stackData = topData;
|
||||
}
|
||||
}
|
||||
|
||||
if (stackData == null) return null;
|
||||
|
||||
Parcel parcel = Parcel.obtain();
|
||||
try {
|
||||
parcel.unmarshall(stackData, 0, stackData.length);
|
||||
parcel.setDataPosition(0);
|
||||
return parcel.readParcelable(EditorElement.class.getClassLoader());
|
||||
} finally {
|
||||
parcel.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
stack.clear();
|
||||
}
|
||||
|
||||
public static final Creator<ElementStack> CREATOR = new Creator<ElementStack>() {
|
||||
@Override
|
||||
public ElementStack createFromParcel(Parcel in) {
|
||||
return new ElementStack(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ElementStack[] newArray(int size) {
|
||||
return new ElementStack[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(limit);
|
||||
final int count = stack.size();
|
||||
dest.writeInt(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
dest.writeByteArray(stack.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
boolean stackContainsStateDifferentFrom(@NonNull EditorElement element) {
|
||||
if (stack.isEmpty()) return false;
|
||||
|
||||
byte[] currentStateBytes = getBytes(element);
|
||||
|
||||
for (byte[] item : stack) {
|
||||
if (!Arrays.equals(item, currentStateBytes)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
final class InBoundsMemory {
|
||||
|
||||
private final Matrix lastGoodUserCrop = new Matrix();
|
||||
private final Matrix lastGoodMainImage = new Matrix();
|
||||
|
||||
void push(@Nullable EditorElement mainImage, @NonNull EditorElement userCrop) {
|
||||
if (mainImage == null) {
|
||||
lastGoodMainImage.reset();
|
||||
} else {
|
||||
lastGoodMainImage.set(mainImage.getLocalMatrix());
|
||||
lastGoodMainImage.preConcat(mainImage.getEditorMatrix());
|
||||
}
|
||||
|
||||
lastGoodUserCrop.set(userCrop.getLocalMatrix());
|
||||
lastGoodUserCrop.preConcat(userCrop.getEditorMatrix());
|
||||
}
|
||||
|
||||
void restore(@Nullable EditorElement mainImage, @NonNull EditorElement cropEditorElement, @Nullable Runnable invalidate) {
|
||||
if (mainImage != null) {
|
||||
mainImage.animateLocalTo(lastGoodMainImage, invalidate);
|
||||
}
|
||||
cropEditorElement.animateLocalTo(lastGoodUserCrop, invalidate);
|
||||
}
|
||||
|
||||
Matrix getLastKnownGoodMainImageMatrix() {
|
||||
return new Matrix(lastGoodMainImage);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public final class ParcelUtils {
|
||||
|
||||
private ParcelUtils() {
|
||||
}
|
||||
|
||||
public static void writeMatrix(@NonNull Parcel dest, @NonNull Matrix matrix) {
|
||||
float[] values = new float[9];
|
||||
matrix.getValues(values);
|
||||
dest.writeFloatArray(values);
|
||||
}
|
||||
|
||||
public static void readMatrix(@NonNull Matrix matrix, @NonNull Parcel in) {
|
||||
float[] values = new float[9];
|
||||
in.readFloatArray(values);
|
||||
matrix.setValues(values);
|
||||
}
|
||||
|
||||
public static @NonNull Matrix readMatrix(@NonNull Parcel in) {
|
||||
Matrix matrix = new Matrix();
|
||||
readMatrix(matrix, in);
|
||||
return matrix;
|
||||
}
|
||||
|
||||
public static void writeRect(@NonNull Parcel dest, @NonNull RectF rect) {
|
||||
dest.writeFloat(rect.left);
|
||||
dest.writeFloat(rect.top);
|
||||
dest.writeFloat(rect.right);
|
||||
dest.writeFloat(rect.bottom);
|
||||
}
|
||||
|
||||
public static @NonNull RectF readRectF(@NonNull Parcel in) {
|
||||
float left = in.readFloat();
|
||||
float top = in.readFloat();
|
||||
float right = in.readFloat();
|
||||
float bottom = in.readFloat();
|
||||
return new RectF(left, top, right, bottom);
|
||||
}
|
||||
|
||||
static UUID readUUID(@NonNull Parcel in) {
|
||||
return new UUID(in.readLong(), in.readLong());
|
||||
}
|
||||
|
||||
static void writeUUID(@NonNull Parcel dest, @NonNull UUID uuid) {
|
||||
dest.writeLong(uuid.getMostSignificantBits());
|
||||
dest.writeLong(uuid.getLeastSignificantBits());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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();
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package org.signal.imageeditor.core.model;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
final class UndoRedoStacks implements Parcelable {
|
||||
|
||||
private final ElementStack undoStack;
|
||||
private final ElementStack redoStack;
|
||||
|
||||
@NonNull
|
||||
private byte[] unchangedState;
|
||||
|
||||
UndoRedoStacks(int limit) {
|
||||
this(new ElementStack(limit), new ElementStack(limit), null);
|
||||
}
|
||||
|
||||
private UndoRedoStacks(ElementStack undoStack, ElementStack redoStack, @Nullable byte[] unchangedState) {
|
||||
this.undoStack = undoStack;
|
||||
this.redoStack = redoStack;
|
||||
this.unchangedState = unchangedState != null ? unchangedState : new byte[0];
|
||||
}
|
||||
|
||||
public static final Creator<UndoRedoStacks> CREATOR = new Creator<UndoRedoStacks>() {
|
||||
@Override
|
||||
public UndoRedoStacks createFromParcel(Parcel in) {
|
||||
return new UndoRedoStacks(
|
||||
in.readParcelable(ElementStack.class.getClassLoader()),
|
||||
in.readParcelable(ElementStack.class.getClassLoader()),
|
||||
in.createByteArray()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UndoRedoStacks[] newArray(int size) {
|
||||
return new UndoRedoStacks[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeParcelable(undoStack, flags);
|
||||
dest.writeParcelable(redoStack, flags);
|
||||
dest.writeByteArray(unchangedState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
ElementStack getUndoStack() {
|
||||
return undoStack;
|
||||
}
|
||||
|
||||
ElementStack getRedoStack() {
|
||||
return redoStack;
|
||||
}
|
||||
|
||||
void pushState(@NonNull EditorElement element) {
|
||||
if (undoStack.tryPush(element)) {
|
||||
redoStack.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void clear(@NonNull EditorElement element) {
|
||||
undoStack.clear();
|
||||
redoStack.clear();
|
||||
unchangedState = ElementStack.getBytes(element);
|
||||
}
|
||||
|
||||
boolean isChanged(@NonNull EditorElement element) {
|
||||
return !Arrays.equals(ElementStack.getBytes(element), unchangedState);
|
||||
}
|
||||
|
||||
/**
|
||||
* As long as there is something different in the stack somewhere, then we can undo.
|
||||
*/
|
||||
boolean canUndo(@NonNull EditorElement currentState) {
|
||||
return undoStack.stackContainsStateDifferentFrom(currentState);
|
||||
}
|
||||
|
||||
/**
|
||||
* As long as there is something different in the stack somewhere, then we can redo.
|
||||
*/
|
||||
boolean canRedo(@NonNull EditorElement currentState) {
|
||||
return redoStack.stackContainsStateDifferentFrom(currentState);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Given points for a line to go though, automatically finds control points.
|
||||
* <p>
|
||||
* Based on http://www.particleincell.com/2012/bezier-splines/
|
||||
* <p>
|
||||
* Can then draw that line to a {@link Canvas} given a {@link Paint}.
|
||||
* <p>
|
||||
* Allocation efficient so that adding new points does not result in lots of array allocations.
|
||||
*/
|
||||
final class AutomaticControlPointBezierLine implements Parcelable {
|
||||
|
||||
private static final int INITIAL_CAPACITY = 256;
|
||||
|
||||
private float[] x;
|
||||
private float[] y;
|
||||
|
||||
// control points
|
||||
private float[] p1x;
|
||||
private float[] p1y;
|
||||
private float[] p2x;
|
||||
private float[] p2y;
|
||||
|
||||
private int count;
|
||||
|
||||
private final Path path = new Path();
|
||||
|
||||
private AutomaticControlPointBezierLine(@Nullable float[] x, @Nullable float[] y, int count) {
|
||||
this.count = count;
|
||||
this.x = x != null ? x : new float[INITIAL_CAPACITY];
|
||||
this.y = y != null ? y : new float[INITIAL_CAPACITY];
|
||||
allocControlPointsAndWorkingMemory(this.x.length);
|
||||
recalculateControlPoints();
|
||||
}
|
||||
|
||||
AutomaticControlPointBezierLine() {
|
||||
this(null, null, 0);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
count = 0;
|
||||
path.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new point to the end of the line but ignores points that are too close to the last.
|
||||
*
|
||||
* @param x new x point
|
||||
* @param y new y point
|
||||
* @param thickness the maximum distance to allow, line thickness is recommended.
|
||||
*/
|
||||
void addPointFiltered(float x, float y, float thickness) {
|
||||
if (count > 0) {
|
||||
float dx = this.x[count - 1] - x;
|
||||
float dy = this.y[count - 1] - y;
|
||||
if (dx * dx + dy * dy < thickness * thickness) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
addPoint(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new point to the end of the line.
|
||||
*
|
||||
* @param x new x point
|
||||
* @param y new y point
|
||||
*/
|
||||
void addPoint(float x, float y) {
|
||||
if (this.x == null || count == this.x.length) {
|
||||
resize(this.x != null ? this.x.length << 1 : INITIAL_CAPACITY);
|
||||
}
|
||||
|
||||
this.x[count] = x;
|
||||
this.y[count] = y;
|
||||
count++;
|
||||
|
||||
recalculateControlPoints();
|
||||
}
|
||||
|
||||
private void resize(int newCapacity) {
|
||||
x = Arrays.copyOf(x, newCapacity);
|
||||
y = Arrays.copyOf(y, newCapacity);
|
||||
allocControlPointsAndWorkingMemory(newCapacity - 1);
|
||||
}
|
||||
|
||||
private void allocControlPointsAndWorkingMemory(int max) {
|
||||
p1x = new float[max];
|
||||
p1y = new float[max];
|
||||
p2x = new float[max];
|
||||
p2y = new float[max];
|
||||
|
||||
a = new float[max];
|
||||
b = new float[max];
|
||||
c = new float[max];
|
||||
r = new float[max];
|
||||
}
|
||||
|
||||
private void recalculateControlPoints() {
|
||||
path.reset();
|
||||
|
||||
if (count > 2) {
|
||||
computeControlPoints(x, p1x, p2x, count);
|
||||
computeControlPoints(y, p1y, p2y, count);
|
||||
}
|
||||
|
||||
path.moveTo(x[0], y[0]);
|
||||
switch (count) {
|
||||
case 1:
|
||||
path.lineTo(x[0], y[0]);
|
||||
break;
|
||||
case 2:
|
||||
path.lineTo(x[1], y[1]);
|
||||
break;
|
||||
default:
|
||||
for (int i = 1; i < count - 1; i++) {
|
||||
path.cubicTo(p1x[i], p1y[i], p2x[i], p2y[i], x[i + 1], y[i + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the line.
|
||||
*
|
||||
* @param canvas The canvas to draw on.
|
||||
* @param paint The paint to use.
|
||||
*/
|
||||
void draw(@NonNull Canvas canvas, @NonNull Paint paint) {
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
// rhs vector for computeControlPoints method
|
||||
private float[] a;
|
||||
private float[] b;
|
||||
private float[] c;
|
||||
private float[] r;
|
||||
|
||||
/**
|
||||
* Based on http://www.particleincell.com/2012/bezier-splines/
|
||||
*
|
||||
* @param k knots x or y, must be at least 2 entries
|
||||
* @param p1 corresponding first control point x or y
|
||||
* @param p2 corresponding second control point x or y
|
||||
* @param count number of k to process
|
||||
*/
|
||||
private void computeControlPoints(float[] k, float[] p1, float[] p2, int count) {
|
||||
final int n = count - 1;
|
||||
|
||||
// left most segment
|
||||
a[0] = 0;
|
||||
b[0] = 2;
|
||||
c[0] = 1;
|
||||
r[0] = k[0] + 2 * k[1];
|
||||
|
||||
// internal segments
|
||||
for (int i = 1; i < n - 1; i++) {
|
||||
a[i] = 1;
|
||||
b[i] = 4;
|
||||
c[i] = 1;
|
||||
r[i] = 4 * k[i] + 2 * k[i + 1];
|
||||
}
|
||||
|
||||
// right segment
|
||||
a[n - 1] = 2;
|
||||
b[n - 1] = 7;
|
||||
c[n - 1] = 0;
|
||||
r[n - 1] = 8 * k[n - 1] + k[n];
|
||||
|
||||
// solves Ax=b with the Thomas algorithm
|
||||
for (int i = 1; i < n; i++) {
|
||||
float m = a[i] / b[i - 1];
|
||||
b[i] = b[i] - m * c[i - 1];
|
||||
r[i] = r[i] - m * r[i - 1];
|
||||
}
|
||||
|
||||
p1[n - 1] = r[n - 1] / b[n - 1];
|
||||
for (int i = n - 2; i >= 0; --i) {
|
||||
p1[i] = (r[i] - c[i] * p1[i + 1]) / b[i];
|
||||
}
|
||||
|
||||
// we have p1, now compute p2
|
||||
for (int i = 0; i < n - 1; i++) {
|
||||
p2[i] = 2 * k[i + 1] - p1[i + 1];
|
||||
}
|
||||
|
||||
p2[n - 1] = 0.5f * (k[n] + p1[n - 1]);
|
||||
}
|
||||
|
||||
public static final Creator<AutomaticControlPointBezierLine> CREATOR = new Creator<AutomaticControlPointBezierLine>() {
|
||||
@Override
|
||||
public AutomaticControlPointBezierLine createFromParcel(Parcel in) {
|
||||
float[] x = in.createFloatArray();
|
||||
float[] y = in.createFloatArray();
|
||||
return new AutomaticControlPointBezierLine(x, y, x != null ? x.length : 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AutomaticControlPointBezierLine[] newArray(int size) {
|
||||
return new AutomaticControlPointBezierLine[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeFloatArray(Arrays.copyOfRange(x, 0, count));
|
||||
dest.writeFloatArray(Arrays.copyOfRange(y, 0, count));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PointF;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.imageeditor.core.ColorableRenderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
/**
|
||||
* Renders a {@link AutomaticControlPointBezierLine} with {@link #thickness}, {@link #color} and {@link #cap} end type.
|
||||
*/
|
||||
public final class BezierDrawingRenderer extends InvalidateableRenderer implements ColorableRenderer {
|
||||
|
||||
private final Paint paint;
|
||||
private final AutomaticControlPointBezierLine bezierLine;
|
||||
private final Paint.Cap cap;
|
||||
|
||||
@Nullable
|
||||
private final RectF clipRect;
|
||||
|
||||
private int color;
|
||||
private float thickness;
|
||||
|
||||
private BezierDrawingRenderer(int color, float thickness, @NonNull Paint.Cap cap, @Nullable AutomaticControlPointBezierLine bezierLine, @Nullable RectF clipRect) {
|
||||
this.paint = new Paint();
|
||||
this.color = color;
|
||||
this.thickness = thickness;
|
||||
this.cap = cap;
|
||||
this.clipRect = clipRect;
|
||||
this.bezierLine = bezierLine != null ? bezierLine : new AutomaticControlPointBezierLine();
|
||||
|
||||
updatePaint();
|
||||
}
|
||||
|
||||
public BezierDrawingRenderer(int color, float thickness, @NonNull Paint.Cap cap, @Nullable RectF clipRect) {
|
||||
this(color, thickness, cap,null, clipRect != null ? new RectF(clipRect) : null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setColor(int color) {
|
||||
if (this.color != color) {
|
||||
this.color = color;
|
||||
updatePaint();
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public void setThickness(float thickness) {
|
||||
if (this.thickness != thickness) {
|
||||
this.thickness = thickness;
|
||||
updatePaint();
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updatePaint() {
|
||||
paint.setColor(color);
|
||||
paint.setStrokeWidth(thickness);
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
paint.setAntiAlias(true);
|
||||
paint.setStrokeCap(cap);
|
||||
}
|
||||
|
||||
public void setFirstPoint(PointF point) {
|
||||
bezierLine.reset();
|
||||
bezierLine.addPoint(point.x, point.y);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void addNewPoint(PointF point) {
|
||||
if (cap != Paint.Cap.ROUND) {
|
||||
bezierLine.addPointFiltered(point.x, point.y, thickness * 0.5f);
|
||||
} else {
|
||||
bezierLine.addPoint(point.x, point.y);
|
||||
}
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
super.render(rendererContext);
|
||||
Canvas canvas = rendererContext.canvas;
|
||||
canvas.save();
|
||||
if (clipRect != null) {
|
||||
canvas.clipRect(clipRect);
|
||||
}
|
||||
|
||||
int alpha = paint.getAlpha();
|
||||
paint.setAlpha(rendererContext.getAlpha(alpha));
|
||||
|
||||
paint.setXfermode(rendererContext.getMaskPaint() != null ? rendererContext.getMaskPaint().getXfermode() : null);
|
||||
|
||||
bezierLine.draw(canvas, paint);
|
||||
|
||||
paint.setAlpha(alpha);
|
||||
rendererContext.canvas.restore();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static final Creator<BezierDrawingRenderer> CREATOR = new Creator<BezierDrawingRenderer>() {
|
||||
@Override
|
||||
public BezierDrawingRenderer createFromParcel(Parcel in) {
|
||||
int color = in.readInt();
|
||||
float thickness = in.readFloat();
|
||||
Paint.Cap cap = Paint.Cap.values()[in.readInt()];
|
||||
AutomaticControlPointBezierLine bezierLine = in.readParcelable(AutomaticControlPointBezierLine.class.getClassLoader());
|
||||
RectF clipRect = in.readParcelable(RectF.class.getClassLoader());
|
||||
|
||||
return new BezierDrawingRenderer(color, thickness, cap, bezierLine, clipRect);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BezierDrawingRenderer[] newArray(int size) {
|
||||
return new BezierDrawingRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(color);
|
||||
dest.writeFloat(thickness);
|
||||
dest.writeInt(cap.ordinal());
|
||||
dest.writeParcelable(bezierLine, flags);
|
||||
dest.writeParcelable(clipRect, flags);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import android.content.res.Resources;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.Bounds;
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
/**
|
||||
* A rectangle that will be rendered on the blur mask layer. Intended for blurring faces.
|
||||
*/
|
||||
public final class FaceBlurRenderer implements Renderer {
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
rendererContext.canvas.drawRect(Bounds.FULL_BOUNDS, rendererContext.getMaskPaint());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
return Bounds.FULL_BOUNDS.contains(x, y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
}
|
||||
|
||||
public static final Creator<FaceBlurRenderer> CREATOR = new Creator<FaceBlurRenderer>() {
|
||||
@Override
|
||||
public FaceBlurRenderer createFromParcel(Parcel in) {
|
||||
return new FaceBlurRenderer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public FaceBlurRenderer[] newArray(int size) {
|
||||
return new FaceBlurRenderer[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.imageeditor.core.Bounds;
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
/**
|
||||
* Renders the {@link color} outside of the {@link Bounds}.
|
||||
* <p>
|
||||
* Hit tests outside of the bounds.
|
||||
*/
|
||||
public final class FillRenderer implements Renderer {
|
||||
|
||||
private final int color;
|
||||
|
||||
private final RectF dst = new RectF();
|
||||
private final Path path = new Path();
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
rendererContext.canvas.save();
|
||||
|
||||
rendererContext.mapRect(dst, Bounds.FULL_BOUNDS);
|
||||
rendererContext.canvasMatrix.setToIdentity();
|
||||
|
||||
path.reset();
|
||||
path.addRoundRect(dst, DimensionUnit.DP.toPixels(18), DimensionUnit.DP.toPixels(18), Path.Direction.CW);
|
||||
|
||||
rendererContext.canvas.clipPath(path);
|
||||
rendererContext.canvas.drawColor(color);
|
||||
rendererContext.canvas.restore();
|
||||
}
|
||||
|
||||
public FillRenderer(@ColorInt int color) {
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
private FillRenderer(Parcel in) {
|
||||
this(in.readInt());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
return !Bounds.contains(x, y);
|
||||
}
|
||||
|
||||
public static final Creator<FillRenderer> CREATOR = new Creator<FillRenderer>() {
|
||||
@Override
|
||||
public FillRenderer createFromParcel(Parcel in) {
|
||||
return new FillRenderer(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FillRenderer[] newArray(int size) {
|
||||
return new FillRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(color);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
/**
|
||||
* Maintains a weak reference to the an invalidate callback allowing future invalidation without memory leak risk.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.imageeditor.core.Bounds;
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
/**
|
||||
* Renders the {@link color} outside of the {@link Bounds}.
|
||||
* <p>
|
||||
* Hit tests outside of the bounds.
|
||||
*/
|
||||
public final class InverseFillRenderer implements Renderer {
|
||||
|
||||
private final int color;
|
||||
|
||||
private final RectF dst = new RectF();
|
||||
private final Path path = new Path();
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
rendererContext.canvas.save();
|
||||
|
||||
rendererContext.mapRect(dst, Bounds.FULL_BOUNDS);
|
||||
rendererContext.canvasMatrix.setToIdentity();
|
||||
|
||||
path.reset();
|
||||
path.addRoundRect(dst, DimensionUnit.DP.toPixels(18), DimensionUnit.DP.toPixels(18), Path.Direction.CW);
|
||||
|
||||
rendererContext.canvas.clipPath(path);
|
||||
rendererContext.canvas.drawColor(color);
|
||||
rendererContext.canvas.restore();
|
||||
}
|
||||
|
||||
public InverseFillRenderer(@ColorInt int color) {
|
||||
this.color = color;
|
||||
path.toggleInverseFillType();
|
||||
}
|
||||
|
||||
private InverseFillRenderer(Parcel in) {
|
||||
this(in.readInt());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
return !Bounds.contains(x, y);
|
||||
}
|
||||
|
||||
public static final Creator<InverseFillRenderer> CREATOR = new Creator<InverseFillRenderer>() {
|
||||
@Override
|
||||
public InverseFillRenderer createFromParcel(Parcel in) {
|
||||
return new InverseFillRenderer(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InverseFillRenderer[] newArray(int size) {
|
||||
return new InverseFillRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(color);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package org.signal.imageeditor.core.renderers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.ColorRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.signal.imageeditor.R;
|
||||
import org.signal.imageeditor.core.Bounds;
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
/**
|
||||
* Renders an oval inside of the {@link Bounds}.
|
||||
* <p>
|
||||
* Hit tests outside of the bounds.
|
||||
*/
|
||||
public final class OvalGuideRenderer implements Renderer {
|
||||
|
||||
private final @ColorRes int ovalGuideColor;
|
||||
|
||||
private final Paint paint;
|
||||
|
||||
private final RectF dst = new RectF();
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
rendererContext.save();
|
||||
|
||||
Canvas canvas = rendererContext.canvas;
|
||||
Context context = rendererContext.context;
|
||||
int stroke = context.getResources().getDimensionPixelSize(R.dimen.oval_guide_stroke_width);
|
||||
float halfStroke = stroke / 2f;
|
||||
|
||||
this.paint.setStrokeWidth(stroke);
|
||||
paint.setColor(ContextCompat.getColor(context, ovalGuideColor));
|
||||
|
||||
rendererContext.mapRect(dst, Bounds.FULL_BOUNDS);
|
||||
dst.set(dst.left + halfStroke, dst.top + halfStroke, dst.right - halfStroke, dst.bottom - halfStroke);
|
||||
|
||||
rendererContext.canvasMatrix.setToIdentity();
|
||||
canvas.drawOval(dst, paint);
|
||||
|
||||
rendererContext.restore();
|
||||
}
|
||||
|
||||
public OvalGuideRenderer(@ColorRes int color) {
|
||||
this.ovalGuideColor = color;
|
||||
|
||||
this.paint = new Paint();
|
||||
this.paint.setStyle(Paint.Style.STROKE);
|
||||
this.paint.setAntiAlias(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
return !Bounds.contains(x, y);
|
||||
}
|
||||
|
||||
public static final Creator<OvalGuideRenderer> CREATOR = new Creator<OvalGuideRenderer>() {
|
||||
@Override
|
||||
public @NonNull OvalGuideRenderer createFromParcel(@NonNull Parcel in) {
|
||||
return new OvalGuideRenderer(in.readInt());
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull OvalGuideRenderer[] newArray(int size) {
|
||||
return new OvalGuideRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||
dest.writeInt(ovalGuideColor);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
image-editor/lib/src/main/res/values/crop_area_renderer.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<dimen name="crop_area_renderer_edge_size">32dp</dimen>
|
||||
<dimen name="crop_area_renderer_edge_thickness">2dp</dimen>
|
||||
<dimen name="oval_guide_stroke_width">1dp</dimen>
|
||||
|
||||
<color name="crop_area_renderer_edge_color">#ffffffff</color>
|
||||
<color name="crop_area_renderer_outer_color">#7f000000</color>
|
||||
<color name="crop_circle_guide_color">#66FFFFFF</color>
|
||||
|
||||
</resources>
|
||||
6
image-editor/lib/src/main/res/values/styles.xml
Normal 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>
|
||||