Signal-Android/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java

784 lines
26 KiB
Java

package org.thoughtcrime.securesms.imageeditor.model;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.RectF;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.thoughtcrime.securesms.imageeditor.Bounds;
import org.thoughtcrime.securesms.imageeditor.ColorableRenderer;
import org.thoughtcrime.securesms.imageeditor.Renderer;
import org.thoughtcrime.securesms.imageeditor.RendererContext;
import org.thoughtcrime.securesms.imageeditor.UndoRedoStackListener;
import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* Contains a reference to the root {@link EditorElement}, maintains undo and redo stacks and has a
* reference to the {@link EditorElementHierarchy}.
* <p>
* As such it is the entry point for all operations that change the image.
*/
public final class EditorModel implements Parcelable, RendererContext.Ready {
public static final int Z_DRAWING = 0;
public static final int Z_STICKERS = 0;
public static final int Z_TEXT = 1;
private static final Runnable NULL_RUNNABLE = () -> {
};
private static final int MINIMUM_OUTPUT_WIDTH = 1024;
private static final int MINIMUM_CROP_PIXEL_COUNT = 100;
private static final Point MINIMUM_RATIO = new Point(15, 1);
@NonNull
private Runnable invalidate = NULL_RUNNABLE;
private UndoRedoStackListener undoRedoStackListener;
private final UndoRedoStacks undoRedoStacks;
private final UndoRedoStacks cropUndoRedoStacks;
private final InBoundsMemory inBoundsMemory = new InBoundsMemory();
private EditorElementHierarchy editorElementHierarchy;
private final RectF visibleViewPort = new RectF();
private final Point size;
private final boolean circleEditing;
public EditorModel() {
this(false, EditorElementHierarchy.create());
}
private EditorModel(@NonNull Parcel in) {
ClassLoader classLoader = getClass().getClassLoader();
this.circleEditing = in.readByte() == 1;
this.size = new Point(in.readInt(), in.readInt());
//noinspection ConstantConditions
this.editorElementHierarchy = EditorElementHierarchy.create(in.readParcelable(classLoader));
this.undoRedoStacks = in.readParcelable(classLoader);
this.cropUndoRedoStacks = in.readParcelable(classLoader);
}
public EditorModel(boolean circleEditing, @NonNull EditorElementHierarchy editorElementHierarchy) {
this.circleEditing = circleEditing;
this.size = new Point(1024, 1024);
this.editorElementHierarchy = editorElementHierarchy;
this.undoRedoStacks = new UndoRedoStacks(50);
this.cropUndoRedoStacks = new UndoRedoStacks(50);
}
public static EditorModel create() {
return new EditorModel(false, EditorElementHierarchy.create());
}
public static EditorModel createForCircleEditing() {
EditorModel editorModel = new EditorModel(true, EditorElementHierarchy.createForCircleEditing());
editorModel.setCropAspectLock(true);
return editorModel;
}
public void setInvalidate(@Nullable Runnable invalidate) {
this.invalidate = invalidate != null ? invalidate : NULL_RUNNABLE;
}
public void setUndoRedoStackListener(UndoRedoStackListener undoRedoStackListener) {
this.undoRedoStackListener = undoRedoStackListener;
updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping()));
}
/**
* Renders tree with the following matrix:
* <p>
* viewModelMatrix * matrix * editorMatrix
* <p>
* Child nodes are supplied with a viewModelMatrix' = viewModelMatrix * matrix * editorMatrix
*
* @param rendererContext Canvas to draw on to.
* @param renderOnTop This element will appear on top of the overlay.
*/
public void draw(@NonNull RendererContext rendererContext, @Nullable EditorElement renderOnTop) {
EditorElement root = editorElementHierarchy.getRoot();
if (renderOnTop != null) {
root.forAllInTree(element -> element.getFlags().mark());
renderOnTop.getFlags().setVisible(false);
}
// pass 1
root.draw(rendererContext);
if (renderOnTop != null) {
// hide all
try {
root.forAllInTree(element -> element.getFlags().setVisible(renderOnTop == element));
// pass 2
root.draw(rendererContext);
} finally {
root.forAllInTree(element -> element.getFlags().restore());
}
}
}
public @Nullable Matrix findElementInverseMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) {
Matrix inverse = new Matrix();
if (findElement(element, viewMatrix, inverse)) {
return inverse;
}
return null;
}
private @Nullable Matrix findElementMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) {
Matrix inverse = findElementInverseMatrix(element, viewMatrix);
if (inverse != null) {
Matrix regular = new Matrix();
inverse.invert(regular);
return regular;
}
return null;
}
public EditorElement findElementAtPoint(@NonNull PointF point, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) {
return editorElementHierarchy.getRoot().findElementAt(point.x, point.y, viewMatrix, outInverseModelMatrix);
}
private boolean findElement(@NonNull EditorElement element, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) {
return editorElementHierarchy.getRoot().findElement(element, viewMatrix, outInverseModelMatrix) == element;
}
public void pushUndoPoint() {
boolean cropping = isCropping();
if (cropping && !currentCropIsAcceptable()) {
return;
}
getActiveUndoRedoStacks(cropping).pushState(editorElementHierarchy.getRoot());
}
public void undo() {
boolean cropping = isCropping();
UndoRedoStacks stacks = getActiveUndoRedoStacks(cropping);
undoRedo(stacks.getUndoStack(), stacks.getRedoStack(), cropping);
updateUndoRedoAvailableState(stacks);
}
public void redo() {
boolean cropping = isCropping();
UndoRedoStacks stacks = getActiveUndoRedoStacks(cropping);
undoRedo(stacks.getRedoStack(), stacks.getUndoStack(), cropping);
updateUndoRedoAvailableState(stacks);
}
private void undoRedo(@NonNull ElementStack fromStack, @NonNull ElementStack toStack, boolean keepEditorState) {
final EditorElement oldRootElement = editorElementHierarchy.getRoot();
final EditorElement popped = fromStack.pop(oldRootElement);
if (popped != null) {
editorElementHierarchy = EditorElementHierarchy.create(popped);
toStack.tryPush(oldRootElement);
restoreStateWithAnimations(oldRootElement, editorElementHierarchy.getRoot(), invalidate, keepEditorState);
invalidate.run();
// re-zoom image root as the view port might be different now
editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate);
inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement());
}
}
private static void restoreStateWithAnimations(@NonNull EditorElement fromRootElement, @NonNull EditorElement toRootElement, @NonNull Runnable onInvalidate, boolean keepEditorState) {
Map<UUID, EditorElement> fromMap = getElementMap(fromRootElement);
Map<UUID, EditorElement> toMap = getElementMap(toRootElement);
for (EditorElement fromElement : fromMap.values()) {
fromElement.stopAnimation();
EditorElement toElement = toMap.get(fromElement.getId());
if (toElement != null) {
toElement.animateFrom(fromElement.getLocalMatrixAnimating(), onInvalidate);
if (keepEditorState) {
toElement.getEditorMatrix().set(fromElement.getEditorMatrix());
toElement.getFlags().set(fromElement.getFlags());
}
} else {
// element is removed
EditorElement parentFrom = fromRootElement.parentOf(fromElement);
if (parentFrom != null) {
EditorElement toParent = toMap.get(parentFrom.getId());
if (toParent != null) {
toParent.addDeletedChildFadingOut(fromElement, onInvalidate);
}
}
}
}
for (EditorElement toElement : toMap.values()) {
if (!fromMap.containsKey(toElement.getId())) {
// new item
toElement.animateFadeIn(onInvalidate);
}
}
}
private void updateUndoRedoAvailableState(UndoRedoStacks currentStack) {
if (undoRedoStackListener == null) return;
EditorElement root = editorElementHierarchy.getRoot();
undoRedoStackListener.onAvailabilityChanged(currentStack.canUndo(root), currentStack.canRedo(root));
}
private static Map<UUID, EditorElement> getElementMap(@NonNull EditorElement element) {
final Map<UUID, EditorElement> result = new HashMap<>();
element.buildMap(result);
return result;
}
public void startCrop() {
pushUndoPoint();
cropUndoRedoStacks.clear(editorElementHierarchy.getRoot());
editorElementHierarchy.startCrop(invalidate);
inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement());
updateUndoRedoAvailableState(cropUndoRedoStacks);
}
public void doneCrop() {
editorElementHierarchy.doneCrop(visibleViewPort, invalidate);
updateUndoRedoAvailableState(undoRedoStacks);
}
public void setCropAspectLock(boolean locked) {
EditorFlags flags = editorElementHierarchy.getCropEditorElement().getFlags();
int currentState = flags.setAspectLocked(locked).getCurrentState();
flags.reset();
flags.setAspectLocked(locked)
.persist();
flags.restoreState(currentState);
}
public boolean isCropAspectLocked() {
return editorElementHierarchy.getCropEditorElement().getFlags().isAspectLocked();
}
public void postEdit(boolean allowScaleToRepairCrop) {
boolean cropping = isCropping();
if (cropping) {
ensureFitsBounds(allowScaleToRepairCrop);
}
updateUndoRedoAvailableState(getActiveUndoRedoStacks(cropping));
}
/**
* @param cropping Set to true if cropping is underway.
* @return The correct stack for the mode of operation.
*/
private UndoRedoStacks getActiveUndoRedoStacks(boolean cropping) {
return cropping ? cropUndoRedoStacks : undoRedoStacks;
}
private void ensureFitsBounds(boolean allowScaleToRepairCrop) {
EditorElement mainImage = editorElementHierarchy.getMainImage();
if (mainImage == null) return;
EditorElement cropEditorElement = editorElementHierarchy.getCropEditorElement();
if (!currentCropIsAcceptable()) {
if (allowScaleToRepairCrop) {
if (!tryToScaleToFit(cropEditorElement, 0.9f)) {
tryToScaleToFit(mainImage, 2f);
}
} else {
tryToFixTranslationOutOfBounds(mainImage, inBoundsMemory.getLastKnownGoodMainImageMatrix());
}
if (!currentCropIsAcceptable()) {
inBoundsMemory.restore(mainImage, cropEditorElement, invalidate);
} else {
inBoundsMemory.push(mainImage, cropEditorElement);
}
}
editorElementHierarchy.dragDropRelease(visibleViewPort, invalidate);
}
/**
* Attempts to scale the supplied element such that {@link #cropIsWithinMainImageBounds} is true.
* <p>
* Does not respect minimum scale, so does need a further check to {@link #currentCropIsAcceptable} afterwards.
*
* @param element The element to be scaled. If successful, it will be animated to the correct position.
* @param scaleAtMost The amount of scale to apply at most. Use < 1 for the crop, and > 1 for the image.
* @return true if successfully scaled the element. false if the element was left unchanged.
*/
private boolean tryToScaleToFit(@NonNull EditorElement element, float scaleAtMost) {
return Bisect.bisectToTest(element,
1,
scaleAtMost,
this::cropIsWithinMainImageBounds,
(matrix, scale) -> matrix.preScale(scale, scale),
invalidate);
}
/**
* Attempts to translate the supplied element such that {@link #cropIsWithinMainImageBounds} is true.
* If you supply both x and y, it will attempt to find a fit on the diagonal with vector x, y.
*
* @param element The element to be translated. If successful, it will be animated to the correct position.
* @param translateXAtMost The maximum translation to apply in the x axis.
* @param translateYAtMost The maximum translation to apply in the y axis.
* @return a matrix if successfully translated the element. null if the element unable to be translated to fit.
*/
private Matrix tryToTranslateToFit(@NonNull EditorElement element, float translateXAtMost, float translateYAtMost) {
return Bisect.bisectToTest(element,
0,
1,
this::cropIsWithinMainImageBounds,
(matrix, factor) -> matrix.postTranslate(factor * translateXAtMost, factor * translateYAtMost));
}
/**
* Tries to fix an element that is out of bounds by adjusting it's translation.
*
* @param element Element to move.
* @param lastKnownGoodPosition Last known good position of element.
* @return true iff fixed the element.
*/
private boolean tryToFixTranslationOutOfBounds(@NonNull EditorElement element, @NonNull Matrix lastKnownGoodPosition) {
final Matrix elementMatrix = element.getLocalMatrix();
final Matrix original = new Matrix(elementMatrix);
final float[] current = new float[9];
final float[] lastGood = new float[9];
Matrix matrix;
elementMatrix.getValues(current);
lastKnownGoodPosition.getValues(lastGood);
final float xTranslate = current[2] - lastGood[2];
final float yTranslate = current[5] - lastGood[5];
if (Math.abs(xTranslate) < Bisect.ACCURACY && Math.abs(yTranslate) < Bisect.ACCURACY) {
return false;
}
float pass1X;
float pass1Y;
float pass2X;
float pass2Y;
// try the fix by the smallest user translation first
if (Math.abs(xTranslate) < Math.abs(yTranslate)) {
// try to bisect along x
pass1X = -xTranslate;
pass1Y = 0;
// then y
pass2X = 0;
pass2Y = -yTranslate;
} else {
// try to bisect along y
pass1X = 0;
pass1Y = -yTranslate;
// then x
pass2X = -xTranslate;
pass2Y = 0;
}
matrix = tryToTranslateToFit(element, pass1X, pass1Y);
if (matrix != null) {
element.animateLocalTo(matrix, invalidate);
return true;
}
matrix = tryToTranslateToFit(element, pass2X, pass2Y);
if (matrix != null) {
element.animateLocalTo(matrix, invalidate);
return true;
}
// apply pass 1 fully
elementMatrix.postTranslate(pass1X, pass1Y);
matrix = tryToTranslateToFit(element, pass2X, pass2Y);
elementMatrix.set(original);
if (matrix != null) {
element.animateLocalTo(matrix, invalidate);
return true;
}
return false;
}
public void dragDropRelease() {
editorElementHierarchy.dragDropRelease(visibleViewPort, invalidate);
}
/**
* Pixel count must be no smaller than {@link #MINIMUM_CROP_PIXEL_COUNT} (unless its original size was less than that)
* and all points must be within the bounds.
*/
private boolean currentCropIsAcceptable() {
Point outputSize = getOutputSize();
int outputPixelCount = outputSize.x * outputSize.y;
int minimumPixelCount = Math.min(size.x * size.y, MINIMUM_CROP_PIXEL_COUNT);
Point thinnestRatio = MINIMUM_RATIO;
if (compareRatios(size, thinnestRatio) < 0) {
// original is narrower than the thinnestRatio
thinnestRatio = size;
}
return compareRatios(outputSize, thinnestRatio) >= 0 &&
outputPixelCount >= minimumPixelCount &&
cropIsWithinMainImageBounds();
}
/**
* -1 iff a is a narrower ratio than b.
* +1 iff a is a squarer ratio than b.
* 0 if the ratios are the same.
*/
private static int compareRatios(@NonNull Point a, @NonNull Point b) {
int smallA = Math.min(a.x, a.y);
int largeA = Math.max(a.x, a.y);
int smallB = Math.min(b.x, b.y);
int largeB = Math.max(b.x, b.y);
return Integer.compare(smallA * largeB, smallB * largeA);
}
/**
* @return true if and only if the current crop rect is fully in the bounds.
*/
private boolean cropIsWithinMainImageBounds() {
return Bounds.boundsRemainInBounds(editorElementHierarchy.imageMatrixRelativeToCrop());
}
/**
* Called as edits are underway.
*/
public void moving(@NonNull EditorElement editorElement) {
if (!isCropping()) return;
EditorElement mainImage = editorElementHierarchy.getMainImage();
EditorElement cropEditorElement = editorElementHierarchy.getCropEditorElement();
if (editorElement == mainImage || editorElement == cropEditorElement) {
if (currentCropIsAcceptable()) {
inBoundsMemory.push(mainImage, cropEditorElement);
}
}
}
public void setVisibleViewPort(@NonNull RectF visibleViewPort) {
this.visibleViewPort.set(visibleViewPort);
this.editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate);
}
public Set<Integer> getUniqueColorsIgnoringAlpha() {
final Set<Integer> colors = new LinkedHashSet<>();
editorElementHierarchy.getRoot().forAllInTree(element -> {
Renderer renderer = element.getRenderer();
if (renderer instanceof ColorableRenderer) {
colors.add(((ColorableRenderer) renderer).getColor() | 0xff000000);
}
});
return colors;
}
public static final Creator<EditorModel> CREATOR = new Creator<EditorModel>() {
@Override
public EditorModel createFromParcel(Parcel in) {
return new EditorModel(in);
}
@Override
public EditorModel[] newArray(int size) {
return new EditorModel[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeByte((byte) (circleEditing ? 1 : 0));
dest.writeInt(size.x);
dest.writeInt(size.y);
dest.writeParcelable(editorElementHierarchy.getRoot(), flags);
dest.writeParcelable(undoRedoStacks, flags);
dest.writeParcelable(cropUndoRedoStacks, flags);
}
/**
* Blocking render of the model.
*/
@WorkerThread
public @NonNull Bitmap render(@NonNull Context context) {
return render(context, null);
}
/**
* Blocking render of the model.
*/
@WorkerThread
public @NonNull Bitmap render(@NonNull Context context, @Nullable Point size) {
EditorElement image = editorElementHierarchy.getFlipRotate();
RectF cropRect = editorElementHierarchy.getCropRect();
Point outputSize = size != null ? size : getOutputSize();
Bitmap bitmap = Bitmap.createBitmap(outputSize.x, outputSize.y, Bitmap.Config.ARGB_8888);
try {
Canvas canvas = new Canvas(bitmap);
RendererContext rendererContext = new RendererContext(context, canvas, RendererContext.Ready.NULL, RendererContext.Invalidate.NULL);
RectF bitmapArea = new RectF();
bitmapArea.right = bitmap.getWidth();
bitmapArea.bottom = bitmap.getHeight();
Matrix viewMatrix = new Matrix();
viewMatrix.setRectToRect(cropRect, bitmapArea, Matrix.ScaleToFit.FILL);
rendererContext.setIsEditing(false);
rendererContext.setBlockingLoad(true);
EditorElement overlay = editorElementHierarchy.getOverlay();
overlay.getFlags().setVisible(false).setChildrenVisible(false);
try {
rendererContext.canvasMatrix.initial(viewMatrix);
image.draw(rendererContext);
} finally {
overlay.getFlags().reset();
}
} catch (Exception e) {
bitmap.recycle();
throw e;
}
return bitmap;
}
@NonNull
private Point getOutputSize() {
PointF outputSize = editorElementHierarchy.getOutputSize(size);
int width = (int) Math.max(MINIMUM_OUTPUT_WIDTH, outputSize.x);
int height = (int) (width * outputSize.y / outputSize.x);
return new Point(width, height);
}
@Override
public void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size) {
if (cropMatrix != null && size != null && isRendererOfMainImage(renderer)) {
boolean changedBefore = isChanged();
Matrix imageCropMatrix = editorElementHierarchy.getImageCrop().getLocalMatrix();
this.size.set(size.x, size.y);
if (imageCropMatrix.isIdentity()) {
imageCropMatrix.set(cropMatrix);
if (circleEditing) {
Matrix userCropMatrix = editorElementHierarchy.getCropEditorElement().getLocalMatrix();
if (size.x > size.y) {
userCropMatrix.setScale(size.y / (float) size.x, 1f);
} else {
userCropMatrix.setScale(1f, size.x / (float) size.y);
}
}
editorElementHierarchy.doneCrop(visibleViewPort, null);
if (!changedBefore) {
undoRedoStacks.clear(editorElementHierarchy.getRoot());
}
if (circleEditing) {
startCrop();
}
}
}
}
private boolean isRendererOfMainImage(@NonNull Renderer renderer) {
EditorElement mainImage = editorElementHierarchy.getMainImage();
Renderer mainImageRenderer = mainImage != null ? mainImage.getRenderer() : null;
return mainImageRenderer == renderer;
}
/**
* Add a new {@link EditorElement} centered in the current visible crop area.
*
* @param element New element to add.
* @param scale Initial scale for new element.
*/
public void addElementCentered(@NonNull EditorElement element, float scale) {
Matrix localMatrix = element.getLocalMatrix();
editorElementHierarchy.getMainImageFullMatrix().invert(localMatrix);
localMatrix.preScale(scale, scale);
addElement(element);
}
/**
* Add an element to the main image, or if there is no main image, make the new element the main image.
*
* @param element New element to add.
*/
public void addElement(@NonNull EditorElement element) {
pushUndoPoint();
EditorElement mainImage = editorElementHierarchy.getMainImage();
EditorElement parent = mainImage != null ? mainImage : editorElementHierarchy.getImageRoot();
parent.addElement(element);
if (parent != mainImage) {
undoRedoStacks.clear(editorElementHierarchy.getRoot());
}
updateUndoRedoAvailableState(undoRedoStacks);
}
public boolean isChanged() {
return undoRedoStacks.isChanged(editorElementHierarchy.getRoot());
}
public RectF findCropRelativeToRoot() {
return findCropRelativeTo(editorElementHierarchy.getRoot());
}
RectF findCropRelativeTo(EditorElement element) {
return findRelativeBounds(editorElementHierarchy.getCropEditorElement(), element);
}
RectF findRelativeBounds(EditorElement from, EditorElement to) {
Matrix relative = findRelativeMatrix(from, to);
RectF dst = new RectF(Bounds.FULL_BOUNDS);
if (relative != null) {
relative.mapRect(dst, Bounds.FULL_BOUNDS);
}
return dst;
}
/**
* Returns a matrix that maps points in the {@param from} element in to points in the {@param to} element.
*
* @param from
* @param to
* @return
*/
@Nullable Matrix findRelativeMatrix(@NonNull EditorElement from, @NonNull EditorElement to) {
Matrix matrix = findElementInverseMatrix(to, new Matrix());
Matrix outOf = findElementMatrix(from, new Matrix());
if (outOf != null && matrix != null) {
matrix.preConcat(outOf);
return matrix;
}
return null;
}
public void rotate90clockwise() {
flipRotate(90, 1, 1);
}
public void rotate90anticlockwise() {
flipRotate(-90, 1, 1);
}
public void flipHorizontal() {
flipRotate(0, -1, 1);
}
public void flipVertical() {
flipRotate(0, 1, -1);
}
private void flipRotate(int degrees, int scaleX, int scaleY) {
pushUndoPoint();
editorElementHierarchy.flipRotate(degrees, scaleX, scaleY, visibleViewPort, invalidate);
updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping()));
}
public EditorElement getRoot() {
return editorElementHierarchy.getRoot();
}
public void delete(@NonNull EditorElement editorElement) {
editorElementHierarchy.getImageRoot().forAllInTree(element -> element.deleteChild(editorElement, invalidate));
}
public @Nullable EditorElement findById(@NonNull UUID uuid) {
return getElementMap(getRoot()).get(uuid);
}
/**
* Changes the temporary view so that the text element is centered in it.
*
* @param entity Entity to center on.
* @param textRenderer The text renderer, which can make additional adjustments to the zoom matrix
* to leave space for the keyboard for example.
*/
public void zoomToTextElement(@NonNull EditorElement entity, @NonNull MultiLineTextRenderer textRenderer) {
Matrix elementInverseMatrix = findElementInverseMatrix(entity, new Matrix());
if (elementInverseMatrix != null) {
EditorElement root = editorElementHierarchy.getRoot();
elementInverseMatrix.preConcat(root.getEditorMatrix());
textRenderer.applyRecommendedEditorMatrix(elementInverseMatrix);
root.animateEditorTo(elementInverseMatrix, invalidate);
}
}
public void zoomOut() {
editorElementHierarchy.getRoot().rollbackEditorMatrix(invalidate);
}
public void indicateSelected(@NonNull EditorElement selected) {
selected.singleScalePulse(invalidate);
}
public boolean isCropping() {
return editorElementHierarchy.getCropEditorElement().getFlags().isVisible();
}
}