mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-28 07:51:42 +01:00
Add new ImageEditor compose component and wire in crop and drawing tools.
This commit is contained in:
committed by
Cody Henthorne
parent
629b96dd20
commit
c2d927029a
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.annotation.RememberInComposition
|
||||
@@ -19,7 +20,7 @@ sealed interface EditorController {
|
||||
|
||||
@Stable
|
||||
class Container @RememberInComposition constructor() {
|
||||
val controllers = SnapshotStateMap<Uri, EditorController>()
|
||||
private val controllers = SnapshotStateMap<Uri, EditorController>()
|
||||
|
||||
fun getOrCreateImageController(uri: Uri, editorModel: EditorModel): Image {
|
||||
return controllers.getOrPut(uri) { Image(editorModel) } as Image
|
||||
@@ -32,11 +33,51 @@ sealed interface EditorController {
|
||||
override val isUserInEdit: Boolean
|
||||
get() = mode != Mode.NONE
|
||||
|
||||
val imageEditorState = ImageEditorState(editorModel).also {
|
||||
it.onGestureCompleted = { drawSessionDirty = true }
|
||||
}
|
||||
|
||||
var mode: Mode by mutableStateOf(Mode.NONE)
|
||||
|
||||
var isCropLocked: Boolean by mutableStateOf(editorModel.isCropAspectLocked)
|
||||
var isCropAspectRatioLocked: Boolean by mutableStateOf(editorModel.isCropAspectLocked)
|
||||
private set
|
||||
|
||||
val dialRotation: Float
|
||||
get() = editorModel.mainImage?.let { Math.toDegrees(it.localRotationAngle.toDouble()).toFloat() } ?: 0f
|
||||
|
||||
private var initialDialScale: Float = editorModel.mainImage?.localScaleX ?: 1f
|
||||
private var initialDialImageDegrees: Float = 0f
|
||||
private var minDialScaleDown: Float = 1f
|
||||
private var drawSessionSnapshot: ByteArray? = null
|
||||
private var drawSessionDirty: Boolean = false
|
||||
|
||||
var showDiscardDialog: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val hasUnsavedChanges: Boolean
|
||||
get() = when {
|
||||
mode == Mode.CROP -> imageEditorState.undoAvailable
|
||||
isInDrawSession -> drawSessionDirty
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun requestCancelEdit() {
|
||||
if (hasUnsavedChanges) {
|
||||
showDiscardDialog = true
|
||||
} else {
|
||||
cancelEdit()
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissDiscardDialog() {
|
||||
showDiscardDialog = false
|
||||
}
|
||||
|
||||
fun confirmDiscardEdit() {
|
||||
showDiscardDialog = false
|
||||
cancelEdit()
|
||||
}
|
||||
|
||||
val isUserDrawing: Boolean
|
||||
get() = mode == Mode.DRAW || mode == Mode.HIGHLIGHT
|
||||
|
||||
@@ -58,26 +99,78 @@ sealed interface EditorController {
|
||||
}
|
||||
|
||||
fun cancelEdit() {
|
||||
mode = Mode.NONE
|
||||
when {
|
||||
mode == Mode.CROP -> {
|
||||
editorModel.clearUndoStack()
|
||||
editorModel.doneCrop()
|
||||
}
|
||||
isInDrawSession -> {
|
||||
drawSessionSnapshot?.let { editorModel.restoreFromSnapshot(it) }
|
||||
}
|
||||
}
|
||||
exitEditMode()
|
||||
}
|
||||
|
||||
fun commitEdit() {
|
||||
if (mode == Mode.CROP) {
|
||||
editorModel.doneCrop()
|
||||
}
|
||||
exitEditMode()
|
||||
}
|
||||
|
||||
private fun exitEditMode() {
|
||||
drawSessionSnapshot = null
|
||||
drawSessionDirty = false
|
||||
mode = Mode.NONE
|
||||
imageEditorState.isDrawing = false
|
||||
imageEditorState.isBlur = false
|
||||
}
|
||||
|
||||
fun enterDrawMode() {
|
||||
snapshotIfNewDrawSession()
|
||||
mode = Mode.DRAW
|
||||
syncDrawingState()
|
||||
}
|
||||
|
||||
fun enterHighlightMode() {
|
||||
snapshotIfNewDrawSession()
|
||||
mode = Mode.HIGHLIGHT
|
||||
syncDrawingState()
|
||||
}
|
||||
|
||||
fun enterBlurMode() {
|
||||
snapshotIfNewDrawSession()
|
||||
mode = Mode.BLUR
|
||||
syncDrawingState()
|
||||
}
|
||||
|
||||
private fun snapshotIfNewDrawSession() {
|
||||
if (!isInDrawSession) {
|
||||
drawSessionSnapshot = editorModel.createSnapshot()
|
||||
drawSessionDirty = false
|
||||
}
|
||||
}
|
||||
|
||||
fun setDrawColor(color: Int) {
|
||||
imageEditorState.drawColor = color
|
||||
}
|
||||
|
||||
fun setDrawThickness(thickness: Float) {
|
||||
imageEditorState.drawThickness = thickness
|
||||
}
|
||||
|
||||
private val isInDrawSession: Boolean
|
||||
get() = mode == Mode.DRAW || mode == Mode.HIGHLIGHT || mode == Mode.BLUR
|
||||
|
||||
private fun syncDrawingState() {
|
||||
imageEditorState.isDrawing = true
|
||||
imageEditorState.isBlur = mode == Mode.BLUR
|
||||
imageEditorState.drawCap = if (mode == Mode.HIGHLIGHT) Paint.Cap.SQUARE else Paint.Cap.ROUND
|
||||
}
|
||||
|
||||
fun enterCropMode() {
|
||||
editorModel.startCrop()
|
||||
initialDialScale = editorModel.mainImage?.localScaleX ?: 1f
|
||||
mode = Mode.CROP
|
||||
}
|
||||
|
||||
@@ -91,12 +184,12 @@ sealed interface EditorController {
|
||||
|
||||
fun lockCrop() {
|
||||
editorModel.setCropAspectLock(true)
|
||||
isCropLocked = true
|
||||
isCropAspectRatioLocked = true
|
||||
}
|
||||
|
||||
fun unlockCrop() {
|
||||
editorModel.setCropAspectLock(false)
|
||||
isCropLocked = false
|
||||
isCropAspectRatioLocked = false
|
||||
}
|
||||
|
||||
fun flip() {
|
||||
@@ -107,6 +200,26 @@ sealed interface EditorController {
|
||||
editorModel.rotate90anticlockwise()
|
||||
}
|
||||
|
||||
fun onDialGestureStart() {
|
||||
val mainImage = editorModel.mainImage ?: return
|
||||
initialDialScale = mainImage.localScaleX
|
||||
minDialScaleDown = 1f
|
||||
editorModel.pushUndoPoint()
|
||||
editorModel.updateUndoRedoAvailabilityState()
|
||||
initialDialImageDegrees = Math.toDegrees(mainImage.localRotationAngle.toDouble()).toFloat()
|
||||
}
|
||||
|
||||
fun onDialRotationChanged(degrees: Float) {
|
||||
editorModel.setMainImageEditorMatrixRotation(degrees - initialDialImageDegrees, minDialScaleDown)
|
||||
}
|
||||
|
||||
fun onDialGestureEnd() {
|
||||
val mainImage = editorModel.mainImage ?: return
|
||||
mainImage.commitEditorMatrix()
|
||||
editorModel.postEdit(true)
|
||||
initialDialScale = mainImage.localScaleX
|
||||
}
|
||||
|
||||
fun toggleImageQuality() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@@ -5,39 +5,98 @@
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
|
||||
import android.graphics.PointF
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.signal.imageeditor.core.ImageEditorView
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.input.pointer.changedToDown
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import org.signal.imageeditor.core.ImageEditorTouchHandler
|
||||
|
||||
@Composable
|
||||
fun ImageEditor(
|
||||
controller: EditorController.Image,
|
||||
state: ImageEditorState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context -> ImageEditorView(context) },
|
||||
update = { view ->
|
||||
view.model = controller.editorModel
|
||||
view.mode = mapMode(controller.mode)
|
||||
},
|
||||
onReset = { },
|
||||
modifier = modifier.clipToBounds()
|
||||
)
|
||||
}
|
||||
val context = LocalContext.current
|
||||
|
||||
private fun mapMode(mode: EditorController.Image.Mode): ImageEditorView.Mode {
|
||||
return when (mode) {
|
||||
EditorController.Image.Mode.NONE -> ImageEditorView.Mode.MoveAndResize
|
||||
EditorController.Image.Mode.CROP -> ImageEditorView.Mode.MoveAndResize
|
||||
EditorController.Image.Mode.TEXT -> ImageEditorView.Mode.MoveAndResize
|
||||
EditorController.Image.Mode.DRAW -> ImageEditorView.Mode.Draw
|
||||
EditorController.Image.Mode.HIGHLIGHT -> ImageEditorView.Mode.Draw
|
||||
EditorController.Image.Mode.BLUR -> ImageEditorView.Mode.Blur
|
||||
EditorController.Image.Mode.MOVE_STICKER -> ImageEditorView.Mode.MoveAndResize
|
||||
EditorController.Image.Mode.MOVE_TEXT -> ImageEditorView.Mode.MoveAndResize
|
||||
EditorController.Image.Mode.DELETE -> ImageEditorView.Mode.MoveAndResize
|
||||
EditorController.Image.Mode.INSERT_STICKER -> ImageEditorView.Mode.MoveAndResize
|
||||
DisposableEffect(state) {
|
||||
state.attach()
|
||||
onDispose { state.detach() }
|
||||
}
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.onSizeChanged { state.updateViewMatrix(it.width.toFloat(), it.height.toFloat()) }
|
||||
.imageEditorPointerInput(state)
|
||||
) {
|
||||
state.revision
|
||||
|
||||
val nativeCanvas = drawContext.canvas.nativeCanvas
|
||||
val rendererContext = state.getOrCreateRendererContext(context, nativeCanvas)
|
||||
rendererContext.save()
|
||||
try {
|
||||
rendererContext.canvasMatrix.initial(state.viewMatrix)
|
||||
state.editorModel.draw(rendererContext, null)
|
||||
} finally {
|
||||
rendererContext.restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.imageEditorPointerInput(state: ImageEditorState): Modifier {
|
||||
return this.pointerInput(state) {
|
||||
val touchHandler = ImageEditorTouchHandler()
|
||||
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = true)
|
||||
down.consume()
|
||||
touchHandler.setDrawing(state.isDrawing, state.isBlur)
|
||||
touchHandler.setDrawingBrush(state.drawColor, state.drawThickness, state.drawCap)
|
||||
touchHandler.onDown(state.editorModel, state.viewMatrix, down.position.toPointF())
|
||||
|
||||
var previousPointerCount = 1
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val currentPressed = event.changes.filter { it.pressed }
|
||||
val currentCount = currentPressed.size
|
||||
|
||||
if (currentCount == 0) {
|
||||
event.changes.forEach { it.consume() }
|
||||
touchHandler.onUp(state.editorModel)
|
||||
state.onGestureCompleted?.invoke()
|
||||
break
|
||||
}
|
||||
|
||||
if (currentCount == 2 && previousPointerCount < 2) {
|
||||
val newPointer = event.changes.firstOrNull { it.changedToDown() } ?: currentPressed.last()
|
||||
val pointerIndex = event.changes.indexOf(newPointer).coerceIn(0, 1)
|
||||
touchHandler.onSecondPointerDown(state.editorModel, state.viewMatrix, newPointer.position.toPointF(), pointerIndex)
|
||||
} else if (currentCount == 1 && previousPointerCount == 2) {
|
||||
val released = event.changes.firstOrNull { !it.pressed && it.previousPressed }
|
||||
val releasedIndex = if (released != null) event.changes.indexOf(released).coerceIn(0, 1) else 0
|
||||
touchHandler.onSecondPointerUp(state.editorModel, state.viewMatrix, releasedIndex)
|
||||
} else if (touchHandler.hasActiveSession()) {
|
||||
val pointers = currentPressed.take(2).map { it.position.toPointF() }.toTypedArray()
|
||||
touchHandler.onMove(state.editorModel, pointers)
|
||||
state.invalidate()
|
||||
}
|
||||
|
||||
event.changes.forEach { it.consume() }
|
||||
previousPointerCount = currentCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Offset.toPointF(): PointF = PointF(x, y)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
|
||||
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 androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import org.signal.imageeditor.core.Bounds
|
||||
import org.signal.imageeditor.core.Renderer
|
||||
import org.signal.imageeditor.core.RendererContext
|
||||
import org.signal.imageeditor.core.model.EditorModel
|
||||
|
||||
/**
|
||||
* Compose-observable wrapper around [EditorModel].
|
||||
*
|
||||
* Hooks into the model's invalidation callback so that a revision counter (read during the
|
||||
* Canvas draw phase) triggers redraws whenever the model changes. This gives us unidirectional
|
||||
* flow: mutations go into the model, the composable only reads and renders.
|
||||
*/
|
||||
@Stable
|
||||
class ImageEditorState(
|
||||
val editorModel: EditorModel
|
||||
) {
|
||||
|
||||
var revision: Long by mutableLongStateOf(0L)
|
||||
private set
|
||||
|
||||
var undoAvailable: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var redoAvailable: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var isDrawing: Boolean = false
|
||||
var isBlur: Boolean = false
|
||||
var drawColor: Int = 0xff000000.toInt()
|
||||
var drawThickness: Float = 0.02f
|
||||
var drawCap: Paint.Cap = Paint.Cap.ROUND
|
||||
var onGestureCompleted: (() -> Unit)? = null
|
||||
|
||||
val viewMatrix: Matrix = Matrix()
|
||||
val visibleViewPort: RectF = RectF(Bounds.LEFT, Bounds.TOP, Bounds.RIGHT, Bounds.BOTTOM)
|
||||
|
||||
private val viewPort: RectF = RectF(Bounds.LEFT, Bounds.TOP, Bounds.RIGHT, Bounds.BOTTOM)
|
||||
private val screen: RectF = RectF()
|
||||
|
||||
private var rendererContext: RendererContext? = null
|
||||
|
||||
private val rendererReady = RendererContext.Ready { renderer: Renderer, cropMatrix: Matrix?, size: Point? ->
|
||||
editorModel.onReady(renderer, cropMatrix, size)
|
||||
revision++
|
||||
}
|
||||
|
||||
private val rendererInvalidate = RendererContext.Invalidate { _: Renderer ->
|
||||
revision++
|
||||
}
|
||||
|
||||
private val defaultTypefaceProvider = RendererContext.TypefaceProvider { _: Context, _: Renderer, _: RendererContext.Invalidate ->
|
||||
Typeface.DEFAULT
|
||||
}
|
||||
|
||||
/** Manually triggers a Canvas redraw. Call after touch moves that modify the model directly. */
|
||||
fun invalidate() {
|
||||
revision++
|
||||
}
|
||||
|
||||
/** Hooks into the [EditorModel]'s invalidation and undo/redo callbacks. Call in [DisposableEffect]. */
|
||||
fun attach() {
|
||||
editorModel.setInvalidate { revision++ }
|
||||
editorModel.setUndoRedoStackListener { undo, redo ->
|
||||
undoAvailable = undo
|
||||
redoAvailable = redo
|
||||
}
|
||||
}
|
||||
|
||||
/** Unhooks from the [EditorModel]. Call in [DisposableEffect]'s onDispose. */
|
||||
fun detach() {
|
||||
editorModel.setInvalidate(null)
|
||||
editorModel.setUndoRedoStackListener(null)
|
||||
}
|
||||
|
||||
/** Recomputes the view matrix to map the editor's coordinate space to the given pixel dimensions. */
|
||||
fun updateViewMatrix(width: Float, height: Float) {
|
||||
screen.set(0f, 0f, width, height)
|
||||
viewMatrix.setRectToRect(viewPort, screen, Matrix.ScaleToFit.FILL)
|
||||
|
||||
val values = FloatArray(9)
|
||||
viewMatrix.getValues(values)
|
||||
val scale = values[0] / values[4]
|
||||
|
||||
val tempViewPort = RectF(Bounds.LEFT, Bounds.TOP, Bounds.RIGHT, Bounds.BOTTOM)
|
||||
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)
|
||||
editorModel.setVisibleViewPort(visibleViewPort)
|
||||
revision++
|
||||
}
|
||||
|
||||
/** Returns a cached [RendererContext], recreating it only when the canvas instance changes. */
|
||||
fun getOrCreateRendererContext(context: Context, canvas: Canvas): RendererContext {
|
||||
val current = rendererContext
|
||||
if (current != null && current.canvas === canvas) return current
|
||||
return RendererContext(context, canvas, rendererReady, rendererInvalidate, defaultTypefaceProvider).also {
|
||||
rendererContext = it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,7 @@ private fun ImageEditorCropAndResizeToolbar(
|
||||
val cropUnlockImageVector = SignalIcons.CropUnlock.imageVector
|
||||
|
||||
IconCrossfadeToggleButton(
|
||||
target = if (imageEditorController.isCropLocked) CropLock.LOCKED else CropLock.UNLOCKED,
|
||||
target = if (imageEditorController.isCropAspectRatioLocked) CropLock.LOCKED else CropLock.UNLOCKED,
|
||||
setTarget = { target ->
|
||||
when (target) {
|
||||
CropLock.LOCKED -> imageEditorController.lockCrop()
|
||||
@@ -208,9 +208,16 @@ private fun CommitButton(imageEditorController: EditorController.Image) {
|
||||
|
||||
@Composable
|
||||
private fun DiscardButton(imageEditorController: EditorController.Image) {
|
||||
if (imageEditorController.showDiscardDialog) {
|
||||
MediaSendDialogs.DiscardEditsConfirmationDialog(
|
||||
onDiscard = imageEditorController::confirmDiscardEdit,
|
||||
onDismiss = imageEditorController::dismissDiscardDialog
|
||||
)
|
||||
}
|
||||
|
||||
ImageEditorButton(
|
||||
imageVector = SignalIcons.X.imageVector,
|
||||
onClick = imageEditorController::cancelEdit,
|
||||
onClick = imageEditorController::requestCancelEdit,
|
||||
colors = IconButtons.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
|
||||
@@ -79,7 +79,7 @@ fun MediaEditScreen(
|
||||
when (val editorState = state.editorStateMap[uri]) {
|
||||
is EditorState.Image -> {
|
||||
ImageEditor(
|
||||
controller = controllers.getOrCreateImageController(uri, editorState.model),
|
||||
state = controllers.getOrCreateImageController(uri, editorState.model).imageEditorState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
@@ -118,6 +118,14 @@ fun MediaEditScreen(
|
||||
|
||||
when (currentController) {
|
||||
is EditorController.Image -> {
|
||||
if (currentController.mode == EditorController.Image.Mode.CROP) {
|
||||
RotationDial(
|
||||
imageEditorController = currentController,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 380.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
if (isSmallWindowBreakpoint) {
|
||||
ImageEditorToolbar(imageEditorController = currentController)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.mediasend.R
|
||||
|
||||
object MediaSendDialogs {
|
||||
|
||||
@Composable
|
||||
fun DiscardEditsConfirmationDialog(
|
||||
onDiscard: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.MediaSendDialogs__discard_changes),
|
||||
body = stringResource(R.string.MediaSendDialogs__youll_lose_any_changes),
|
||||
confirm = stringResource(R.string.MediaSendDialogs__discard),
|
||||
onConfirm = onDiscard,
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun DiscardEditsConfirmationDialogPreview() {
|
||||
Previews.Preview {
|
||||
MediaSendDialogs.DiscardEditsConfirmationDialog(
|
||||
onDiscard = {},
|
||||
onDismiss = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.drag
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.imageeditor.core.model.EditorModel
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val MAX_DEGREES: Float = 44.99999f
|
||||
private const val MIN_DEGREES: Float = -44.99999f
|
||||
|
||||
private val SPACE_BETWEEN_INDICATORS = 12.dp
|
||||
private val INDICATOR_WIDTH = 1.dp
|
||||
private val MINOR_INDICATOR_HEIGHT = 12.dp
|
||||
private val MAJOR_INDICATOR_HEIGHT = 24.dp
|
||||
|
||||
@Composable
|
||||
fun RotationDial(
|
||||
imageEditorController: EditorController.Image,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val spaceBetweenIndicatorsPx = with(LocalDensity.current) { SPACE_BETWEEN_INDICATORS.toPx() }
|
||||
|
||||
var degrees by remember { mutableFloatStateOf(imageEditorController.dialRotation) }
|
||||
var isInGesture by remember { mutableStateOf(false) }
|
||||
|
||||
val snapDegrees = calculateSnapDegrees(degrees, isInGesture)
|
||||
val dialDegrees = getDialDegrees(snapDegrees)
|
||||
val displayDegree = dialDegrees.roundToInt()
|
||||
|
||||
val modFiveColor = MaterialTheme.colorScheme.onSurface
|
||||
val minorColor = MaterialTheme.colorScheme.outline
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
down.consume()
|
||||
|
||||
isInGesture = true
|
||||
degrees = imageEditorController.dialRotation
|
||||
imageEditorController.onDialGestureStart()
|
||||
|
||||
val dragged = drag(down.id) { change ->
|
||||
val dragAmount = change.positionChange().x
|
||||
change.consume()
|
||||
|
||||
val degreeIncrement = -dragAmount / spaceBetweenIndicatorsPx
|
||||
val prevDialDegrees = getDialDegrees(degrees)
|
||||
val newDialDegrees = getDialDegrees(degrees + degreeIncrement)
|
||||
|
||||
val offEndOfMax = prevDialDegrees >= MAX_DEGREES / 2f && newDialDegrees <= MIN_DEGREES / 2f
|
||||
val offEndOfMin = newDialDegrees >= MAX_DEGREES / 2f && prevDialDegrees <= MIN_DEGREES / 2f
|
||||
|
||||
if (prevDialDegrees.roundToInt() != newDialDegrees.roundToInt()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
}
|
||||
|
||||
val newDegrees = when {
|
||||
offEndOfMax -> degrees + (MAX_DEGREES - prevDialDegrees)
|
||||
offEndOfMin -> degrees - (MAX_DEGREES - abs(prevDialDegrees))
|
||||
else -> degrees + degreeIncrement
|
||||
}
|
||||
|
||||
degrees = newDegrees
|
||||
val newSnapDegrees = calculateSnapDegrees(newDegrees, true)
|
||||
imageEditorController.onDialRotationChanged(newSnapDegrees)
|
||||
}
|
||||
|
||||
isInGesture = false
|
||||
val finalSnapDegrees = calculateSnapDegrees(degrees, false)
|
||||
imageEditorController.onDialRotationChanged(finalSnapDegrees)
|
||||
imageEditorController.onDialGestureEnd()
|
||||
}
|
||||
}
|
||||
) {
|
||||
// Tick marks layer (full width, centered on pill center)
|
||||
Canvas(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
|
||||
val canvasWidth = size.width
|
||||
val canvasHeight = size.height
|
||||
val centerY = canvasHeight / 2f
|
||||
val indicatorWidthPx = INDICATOR_WIDTH.toPx()
|
||||
val minorHeightPx = MINOR_INDICATOR_HEIGHT.toPx()
|
||||
val majorHeightPx = MAJOR_INDICATOR_HEIGHT.toPx()
|
||||
val spacingPx = SPACE_BETWEEN_INDICATORS.toPx()
|
||||
|
||||
val approximateCenterDegree = dialDegrees.roundToInt()
|
||||
val fractionalOffset = dialDegrees - approximateCenterDegree
|
||||
val dialOffset = spacingPx * fractionalOffset
|
||||
|
||||
val centerX = canvasWidth / 2f
|
||||
val startX = centerX - dialOffset
|
||||
|
||||
fun heightForDegree(degree: Int): Float = if (degree == 0) majorHeightPx else minorHeightPx
|
||||
|
||||
// Draw rightward from center
|
||||
var currentDegree = approximateCenterDegree
|
||||
var x = startX
|
||||
while (x < canvasWidth && currentDegree <= ceil(MAX_DEGREES).toInt()) {
|
||||
val h = heightForDegree(currentDegree)
|
||||
val color = if (currentDegree % 5 == 0) modFiveColor else minorColor
|
||||
drawRect(
|
||||
color = color,
|
||||
topLeft = Offset(x - indicatorWidthPx / 2f, centerY - h / 2f),
|
||||
size = Size(indicatorWidthPx, h)
|
||||
)
|
||||
x += spacingPx
|
||||
currentDegree += 1
|
||||
}
|
||||
|
||||
// Draw leftward from center
|
||||
currentDegree = approximateCenterDegree - 1
|
||||
x = startX - spacingPx
|
||||
while (x >= 0 && currentDegree >= floor(MIN_DEGREES).toInt()) {
|
||||
val h = heightForDegree(currentDegree)
|
||||
val color = if (currentDegree % 5 == 0) modFiveColor else minorColor
|
||||
drawRect(
|
||||
color = color,
|
||||
topLeft = Offset(x - indicatorWidthPx / 2f, centerY - h / 2f),
|
||||
size = Size(indicatorWidthPx, h)
|
||||
)
|
||||
x -= spacingPx
|
||||
currentDegree -= 1
|
||||
}
|
||||
|
||||
// Draw the center-most indicator on top
|
||||
val centerHeight = heightForDegree(approximateCenterDegree)
|
||||
drawRect(
|
||||
color = modFiveColor,
|
||||
topLeft = Offset(centerX - indicatorWidthPx / 2f, centerY - centerHeight / 2f),
|
||||
size = Size(indicatorWidthPx, centerHeight)
|
||||
)
|
||||
}
|
||||
|
||||
// Degree text overlay (on top of ticks, with background to cover them)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxHeight()
|
||||
) {
|
||||
Text(
|
||||
text = "$displayDegree",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(start = 16.dp, end = 24.dp)
|
||||
)
|
||||
|
||||
Dividers.Vertical(
|
||||
thickness = Dp.Hairline,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.fillMaxHeight().padding(vertical = 22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDialDegrees(degrees: Float): Float {
|
||||
val alpha = degrees % 360f
|
||||
|
||||
if (alpha % 90 == 0f) {
|
||||
return 0f
|
||||
}
|
||||
|
||||
val beta = floor(alpha / 90f)
|
||||
val offset = alpha - beta * 90f
|
||||
|
||||
return if (offset > 45f) {
|
||||
offset - 90f
|
||||
} else {
|
||||
offset
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateSnapDegrees(degrees: Float, isInGesture: Boolean): Float {
|
||||
return if (isInGesture) {
|
||||
val dialDegrees = getDialDegrees(degrees)
|
||||
if (dialDegrees.roundToInt() == 0) {
|
||||
degrees - dialDegrees
|
||||
} else {
|
||||
degrees
|
||||
}
|
||||
} else {
|
||||
degrees
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RotationDialPreview() {
|
||||
Previews.Preview {
|
||||
RotationDial(
|
||||
imageEditorController = remember {
|
||||
EditorController.Image(EditorModel.create(0))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="MediaSendDialogs__discard_changes">Discard changes?</string>
|
||||
<string name="MediaSendDialogs__youll_lose_any_changes">You\'ll lose any changes you\'ve made to this photo.</string>
|
||||
<string name="MediaSendDialogs__discard">Discard</string>
|
||||
</resources>
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PointF;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Public facade for touch handling on an {@link EditorModel}.
|
||||
* <p>
|
||||
* Encapsulates the {@link EditSession} creation and lifecycle so that callers outside
|
||||
* this package (e.g. a Compose component) can drive the editor without accessing
|
||||
* package-private classes directly.
|
||||
* <p>
|
||||
* Usage: call the on* methods in order as pointer events arrive. The handler manages
|
||||
* edit session state internally.
|
||||
*/
|
||||
public final class ImageEditorTouchHandler {
|
||||
|
||||
private boolean drawing;
|
||||
private boolean blur;
|
||||
private int drawColor = 0xff000000;
|
||||
private float drawThickness = 0.02f;
|
||||
@NonNull
|
||||
private Paint.Cap drawCap = Paint.Cap.ROUND;
|
||||
|
||||
@Nullable private EditSession editSession;
|
||||
private boolean moreThanOnePointerUsedInSession;
|
||||
|
||||
/** Configures whether the next gesture should create a drawing session if no element is hit. */
|
||||
public void setDrawing(boolean drawing, boolean blur) {
|
||||
this.drawing = drawing;
|
||||
this.blur = blur;
|
||||
}
|
||||
|
||||
/** Sets the brush parameters used when creating new drawing sessions. */
|
||||
public void setDrawingBrush(int color, float thickness, @NonNull Paint.Cap cap) {
|
||||
this.drawColor = color;
|
||||
this.drawThickness = thickness;
|
||||
this.drawCap = cap;
|
||||
}
|
||||
|
||||
/** Begins a new gesture. Creates either a move/resize, thumb drag, or drawing session. */
|
||||
@Nullable
|
||||
public EditorElement onDown(@NonNull EditorModel model, @NonNull Matrix viewMatrix, @NonNull PointF point) {
|
||||
Matrix inverse = new Matrix();
|
||||
EditorElement selected = model.findElementAtPoint(point, viewMatrix, inverse);
|
||||
|
||||
moreThanOnePointerUsedInSession = false;
|
||||
model.pushUndoPoint();
|
||||
editSession = startEdit(model, viewMatrix, inverse, point, selected);
|
||||
|
||||
return editSession != null ? editSession.getSelected() : null;
|
||||
}
|
||||
|
||||
/** Feeds pointer positions to the active session. Call for every move event. */
|
||||
public void onMove(@NonNull EditorModel model, @NonNull PointF[] pointers) {
|
||||
if (editSession == null) return;
|
||||
|
||||
int pointerCount = Math.min(2, pointers.length);
|
||||
for (int p = 0; p < pointerCount; p++) {
|
||||
editSession.movePoint(p, pointers[p]);
|
||||
}
|
||||
model.moving(editSession.getSelected());
|
||||
}
|
||||
|
||||
/** Transitions a single-finger session to a two-finger session (e.g. pinch-to-zoom). */
|
||||
public void onSecondPointerDown(@NonNull EditorModel model, @NonNull Matrix viewMatrix, @NonNull PointF newPointerPoint, int pointerIndex) {
|
||||
if (editSession == null) return;
|
||||
|
||||
moreThanOnePointerUsedInSession = true;
|
||||
editSession.commit();
|
||||
model.pushUndoPoint();
|
||||
|
||||
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
|
||||
if (newInverse != null) {
|
||||
editSession = editSession.newPoint(newInverse, newPointerPoint, pointerIndex);
|
||||
} else {
|
||||
editSession = null;
|
||||
}
|
||||
|
||||
if (editSession == null) {
|
||||
model.dragDropRelease();
|
||||
}
|
||||
}
|
||||
|
||||
/** Transitions a two-finger session back to single-finger when one pointer lifts. */
|
||||
public void onSecondPointerUp(@NonNull EditorModel model, @NonNull Matrix viewMatrix, int releasedIndex) {
|
||||
if (editSession == null) return;
|
||||
|
||||
editSession.commit();
|
||||
model.pushUndoPoint();
|
||||
model.dragDropRelease();
|
||||
|
||||
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
|
||||
if (newInverse != null) {
|
||||
editSession = editSession.removePoint(newInverse, releasedIndex);
|
||||
} else {
|
||||
editSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends the current gesture: commits the session and calls {@link EditorModel#postEdit}. */
|
||||
public void onUp(@NonNull EditorModel model) {
|
||||
if (editSession != null) {
|
||||
editSession.commit();
|
||||
model.dragDropRelease();
|
||||
editSession = null;
|
||||
}
|
||||
model.postEdit(moreThanOnePointerUsedInSession);
|
||||
}
|
||||
|
||||
public void cancel() {
|
||||
editSession = null;
|
||||
}
|
||||
|
||||
public boolean hasActiveSession() {
|
||||
return editSession != null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public EditorElement getSelected() {
|
||||
return editSession != null ? editSession.getSelected() : null;
|
||||
}
|
||||
|
||||
private @Nullable EditSession startEdit(
|
||||
@NonNull EditorModel model,
|
||||
@NonNull Matrix viewMatrix,
|
||||
@NonNull Matrix inverse,
|
||||
@NonNull PointF point,
|
||||
@Nullable EditorElement selected
|
||||
) {
|
||||
EditSession session = startMoveAndResizeSession(model, viewMatrix, inverse, point, selected);
|
||||
if (session == null && drawing) {
|
||||
return startDrawingSession(model, viewMatrix, point);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private @Nullable EditSession startDrawingSession(@NonNull EditorModel model, @NonNull Matrix viewMatrix, @NonNull PointF point) {
|
||||
BezierDrawingRenderer renderer = new BezierDrawingRenderer(drawColor, drawThickness * Bounds.FULL_BOUNDS.width(), drawCap, model.findCropRelativeToRoot());
|
||||
EditorElement element = new EditorElement(renderer, 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 static @Nullable EditSession startMoveAndResizeSession(
|
||||
@NonNull EditorModel model,
|
||||
@NonNull Matrix viewMatrix,
|
||||
@NonNull Matrix inverse,
|
||||
@NonNull PointF point,
|
||||
@Nullable EditorElement selected
|
||||
) {
|
||||
if (selected == null) return null;
|
||||
|
||||
if (selected.getRenderer() instanceof ThumbRenderer) {
|
||||
ThumbRenderer thumb = (ThumbRenderer) selected.getRenderer();
|
||||
|
||||
EditorElement thumbControlledElement = model.findById(thumb.getElementToControl());
|
||||
if (thumbControlledElement == null) return null;
|
||||
|
||||
EditorElement thumbsParent = model.getRoot().findParent(selected);
|
||||
if (thumbsParent == null) return null;
|
||||
|
||||
Matrix thumbContainerRelativeMatrix = model.findRelativeMatrix(thumbsParent, thumbControlledElement);
|
||||
if (thumbContainerRelativeMatrix == null) return null;
|
||||
|
||||
selected = thumbControlledElement;
|
||||
|
||||
Matrix 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);
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||
public void setMainImageEditorMatrixRotation(float angle, float minScaleDown) {
|
||||
setEditorMatrixToRotationMatrixAboutParentsOrigin(editorElementHierarchy.getMainImage(), angle);
|
||||
scaleMainImageEditorMatrixToFitInsideCropBounds(minScaleDown, 2f);
|
||||
invalidate.run();
|
||||
}
|
||||
|
||||
private void scaleMainImageEditorMatrixToFitInsideCropBounds(float minScaleDown, float maxScaleUp) {
|
||||
@@ -276,6 +277,32 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||
return editorElementHierarchy.getRoot().findElement(element, viewMatrix, outInverseModelMatrix) == element;
|
||||
}
|
||||
|
||||
/** Serializes the current element tree for later restoration via {@link #restoreFromSnapshot}. */
|
||||
public byte[] createSnapshot() {
|
||||
return ElementStack.getBytes(editorElementHierarchy.getRoot());
|
||||
}
|
||||
|
||||
/** Restores the element tree from a snapshot created by {@link #createSnapshot}. */
|
||||
public void restoreFromSnapshot(@NonNull byte[] snapshot) {
|
||||
final EditorElement oldRootElement = editorElementHierarchy.getRoot();
|
||||
|
||||
Parcel parcel = Parcel.obtain();
|
||||
try {
|
||||
parcel.unmarshall(snapshot, 0, snapshot.length);
|
||||
parcel.setDataPosition(0);
|
||||
EditorElement newRoot = parcel.readParcelable(EditorElement.class.getClassLoader());
|
||||
if (newRoot != null) {
|
||||
setEditorElementHierarchy(EditorElementHierarchy.create(newRoot));
|
||||
restoreStateWithAnimations(oldRootElement, editorElementHierarchy.getRoot(), invalidate, false);
|
||||
invalidate.run();
|
||||
editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate);
|
||||
inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement());
|
||||
}
|
||||
} finally {
|
||||
parcel.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
public void pushUndoPoint() {
|
||||
boolean cropping = isCropping();
|
||||
if (cropping && !currentCropIsAcceptable()) {
|
||||
@@ -457,6 +484,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||
}
|
||||
|
||||
updateUndoRedoAvailableState(getActiveUndoRedoStacks(cropping));
|
||||
invalidate.run();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user