diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java index 5bece0f868..b93f9a44f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java @@ -30,7 +30,6 @@ import org.signal.imageeditor.core.RendererContext; import org.signal.imageeditor.core.SelectableRenderer; import org.signal.imageeditor.core.model.EditorElement; import org.signal.imageeditor.core.model.EditorModel; -import org.signal.imageeditor.core.renderers.SelectedElementGuideRenderer; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.GlideRequest; @@ -66,8 +65,6 @@ public final class UriGlideRenderer implements SelectableRenderer { private boolean selected; - private final SelectedElementGuideRenderer selectedElementGuideRenderer = new SelectedElementGuideRenderer(); - @Nullable private Bitmap bitmap; @Nullable private Bitmap blurredBitmap; @Nullable private Paint blurPaint; @@ -141,10 +138,6 @@ public final class UriGlideRenderer implements SelectableRenderer { // If failed to load, we draw a black out, in case image was sticker positioned to cover private info. rendererContext.canvas.drawRect(Bounds.FULL_BOUNDS, paint); } - - if (selected && rendererContext.isEditing()) { - selectedElementGuideRenderer.render(rendererContext); - } } private void renderBlurOverlay(RendererContext rendererContext) { @@ -207,7 +200,7 @@ public final class UriGlideRenderer implements SelectableRenderer { @Override public boolean hitTest(float x, float y) { - return pixelAlphaNotZero(x, y); + return selected ? Bounds.contains(x, y) : pixelAlphaNotZero(x, y); } private boolean pixelAlphaNotZero(float x, float y) { @@ -339,4 +332,9 @@ public final class UriGlideRenderer implements SelectableRenderer { this.selected = selected; } } + + @Override + public void getSelectionBounds(@NonNull RectF bounds) { + bounds.set(Bounds.FULL_BOUNDS); + } } diff --git a/image-editor/lib/src/main/java/org/signal/imageeditor/core/ImageEditorView.java b/image-editor/lib/src/main/java/org/signal/imageeditor/core/ImageEditorView.java index f03fd9bb6f..11f1235ee8 100644 --- a/image-editor/lib/src/main/java/org/signal/imageeditor/core/ImageEditorView.java +++ b/image-editor/lib/src/main/java/org/signal/imageeditor/core/ImageEditorView.java @@ -121,6 +121,7 @@ public final class ImageEditorView extends FrameLayout { public void startTextEditing(@NonNull EditorElement editorElement) { getModel().addFade(); if (editorElement.getRenderer() instanceof MultiLineTextRenderer) { + getModel().setSelectionVisible(false); editText.setCurrentTextEditorElement(editorElement); } } @@ -136,7 +137,9 @@ public final class ImageEditorView extends FrameLayout { public void doneTextEditing() { getModel().zoomOut(); getModel().removeFade(); + getModel().setSelectionVisible(true); if (editText.getCurrentTextEntity() != null) { + getModel().setSelected(null); editText.setCurrentTextEditorElement(null); editText.hideKeyboard(); } @@ -391,13 +394,22 @@ public final class ImageEditorView extends FrameLayout { if (selected.getRenderer() instanceof ThumbRenderer) { ThumbRenderer thumb = (ThumbRenderer) selected.getRenderer(); - selected = getModel().findById(thumb.getElementToControl()); + EditorElement thumbControlledElement = getModel().findById(thumb.getElementToControl()); + if (thumbControlledElement == null) return null; - if (selected == 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, thumb.getControlPoint(), point); + return ThumbDragEditSession.startDrag(selected, elementInverseMatrix, thumbContainerRelativeMatrix, thumb.getControlPoint(), point); } else { return null; } @@ -501,9 +513,11 @@ public final class ImageEditorView extends FrameLayout { 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; } diff --git a/image-editor/lib/src/main/java/org/signal/imageeditor/core/SelectableRenderer.kt b/image-editor/lib/src/main/java/org/signal/imageeditor/core/SelectableRenderer.kt index 3d16cb2153..30748c6cce 100644 --- a/image-editor/lib/src/main/java/org/signal/imageeditor/core/SelectableRenderer.kt +++ b/image-editor/lib/src/main/java/org/signal/imageeditor/core/SelectableRenderer.kt @@ -1,8 +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) } diff --git a/image-editor/lib/src/main/java/org/signal/imageeditor/core/ThumbDragEditSession.java b/image-editor/lib/src/main/java/org/signal/imageeditor/core/ThumbDragEditSession.java index 1a2f76a8e2..07a2c447ef 100644 --- a/image-editor/lib/src/main/java/org/signal/imageeditor/core/ThumbDragEditSession.java +++ b/image-editor/lib/src/main/java/org/signal/imageeditor/core/ThumbDragEditSession.java @@ -10,18 +10,33 @@ import org.signal.imageeditor.core.model.ThumbRenderer; class ThumbDragEditSession extends ElementEditSession { - @NonNull - private final ThumbRenderer.ControlPoint controlPoint; + private final PointF oppositeControlPoint = new PointF(); + private final float[] oppositeControlPointOnControlParent = new float[2]; + private final float[] oppositeControlPointOnElement = new float[2]; - private ThumbDragEditSession(@NonNull EditorElement selected, @NonNull ThumbRenderer.ControlPoint controlPoint, @NonNull Matrix inverseMatrix) { + @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.controlPoint = controlPoint; + this.thumbContainerRelativeMatrix = thumbContainerRelativeMatrix; } - static EditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull ThumbRenderer.ControlPoint controlPoint, @NonNull PointF point) { + 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); + ElementEditSession elementDragEditSession = new ThumbDragEditSession(selected, controlPoint, inverseViewModelMatrix, thumbContainerRelativeMatrix); elementDragEditSession.setScreenStartPoint(0, point); elementDragEditSession.setScreenEndPoint(0, point); return elementDragEditSession; @@ -35,8 +50,16 @@ class ThumbDragEditSession extends ElementEditSession { editorMatrix.reset(); - float x = controlPoint.opposite().getX(); - float y = controlPoint.opposite().getY(); + // 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; @@ -44,17 +67,25 @@ class ThumbDragEditSession extends ElementEditSession { float xEnd = controlPoint.getX() + dx; float yEnd = controlPoint.getY() + dy; - boolean aspectLocked = selected.getFlags().isAspectLocked() && !controlPoint.isCenter(); + 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); - 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()); + scale(editorMatrix, aspectLocked, scaleX, scaleY, controlPoint.opposite()); + } } - private void scale(Matrix editorMatrix, boolean aspectLocked, float scaleX, float scaleY, ThumbRenderer.ControlPoint around) { + 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); @@ -67,6 +98,14 @@ class ThumbDragEditSession extends ElementEditSession { 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; @@ -76,4 +115,31 @@ class ThumbDragEditSession extends ElementEditSession { public EditSession removePoint(@NonNull Matrix newInverse, int p) { return null; } + + /** + * Find relative distance between an old and new Point relative to an anchor. + *
+ *
+ * |to - anchor| / |from - anchor| + *+ * + * @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; + } } \ No newline at end of file diff --git a/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorElement.java b/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorElement.java index 978ff371f9..5d1f1356b7 100644 --- a/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorElement.java +++ b/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorElement.java @@ -212,6 +212,34 @@ public final class EditorElement implements Parcelable { } } + 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
+ *
* 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 @@ -44,6 +47,9 @@ import org.signal.imageeditor.core.renderers.TrashRenderer; * | | | | | |- Top right thumb * | | | | | |- Bottom left thumb * | | | | | |- Bottom right thumb + * | | |- selection - matches the aspect and overall matrix of the selected item's selectedBounds + * | | | |- Selection thumbs + **/ final class EditorElementHierarchy { @@ -74,6 +80,9 @@ final class EditorElementHierarchy { 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; @@ -82,6 +91,7 @@ final class EditorElementHierarchy { 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); @@ -124,6 +134,9 @@ final class EditorElementHierarchy { 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(R.color.crop_area_renderer_outer_color, renderCenterThumbs)); @@ -203,6 +216,60 @@ final class EditorElementHierarchy { return thumbs; } + void removeAllSelectionArtifacts() { + selection.deleteAllChildren(); + selectedElement = null; + } + + 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())); @@ -223,6 +290,14 @@ final class EditorElementHierarchy { return imageRoot; } + EditorElement getSelection() { + return selection; + } + + public @Nullable EditorElement getSelectedElement() { + return selectedElement; + } + EditorElement getTrash() { return trash; } diff --git a/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorModel.java b/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorModel.java index cf7862c452..310881ab90 100644 --- a/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorModel.java +++ b/image-editor/lib/src/main/java/org/signal/imageeditor/core/model/EditorModel.java @@ -67,6 +67,23 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { private final EditingPurpose editingPurpose; private float fixedRatio; + public void setSelected(@Nullable EditorElement editorElement) { + if (editorElement == null) { + editorElementHierarchy.removeAllSelectionArtifacts(); + } else { + Matrix overlayMappingMatrix = findRelativeMatrix(editorElement, editorElementHierarchy.getOverlay()); + editorElementHierarchy.setOrUpdateSelectionThumbsForElement(editorElement, overlayMappingMatrix); + } + } + + public void setSelectionVisible(boolean visible) { + editorElementHierarchy.getSelection() + .getFlags() + .setVisible(visible) + .setChildrenVisible(visible) + .persist(); + } + private enum EditingPurpose { IMAGE, AVATAR_CAPTURE, @@ -271,7 +288,8 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { final EditorElement popped = fromStack.pop(oldRootElement); if (popped != null) { - editorElementHierarchy = EditorElementHierarchy.create(popped); + setEditorElementHierarchy(EditorElementHierarchy.create(popped)); + toStack.tryPush(oldRootElement); restoreStateWithAnimations(oldRootElement, editorElementHierarchy.getRoot(), invalidate, keepEditorState); @@ -284,6 +302,13 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { } } + /** Replaces the hierarchy, maintaining any selection if possible */ + private void setEditorElementHierarchy(@NonNull EditorElementHierarchy hierarchy) { + EditorElement selectedElement = editorElementHierarchy.getSelectedElement(); + editorElementHierarchy = hierarchy; + setSelected(selectedElement != null ? findById(selectedElement.getId()) : null); + } + private static void restoreStateWithAnimations(@NonNull EditorElement fromRootElement, @NonNull EditorElement toRootElement, @NonNull Runnable onInvalidate, boolean keepEditorState) { Map