mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-07 05:48:09 +01:00
Add undo/redo and clearall.
This commit is contained in:
@@ -76,6 +76,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
|
||||
QualityHigh(icon(R.drawable.symbol_quality_high_24)),
|
||||
QualityHighSlash(icon(R.drawable.symbol_quality_high_slash_24)),
|
||||
Recent(icon(R.drawable.symbol_recent_24)),
|
||||
Redo(icon(R.drawable.symbol_redo_24)),
|
||||
Save(icon(R.drawable.symbol_save_android_24)),
|
||||
Search(icon(R.drawable.symbol_search_24)),
|
||||
Settings(icon(R.drawable.symbol_settings_android_24)),
|
||||
@@ -86,6 +87,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
|
||||
Transfer(icon(R.drawable.symbol_transfer_24)),
|
||||
TransferDisplay(icon(R.drawable.symbol_transfer_display_48)),
|
||||
Trash(icon(R.drawable.symbol_trash_24)),
|
||||
Undo(icon(R.drawable.symbol_undo_24)),
|
||||
X(icon(R.drawable.symbol_x_24)),
|
||||
XCircleFill(icon(R.drawable.symbol_x_circle_fill_24))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M21.88 9c0-0.23-0.1-0.45-0.26-0.62l-5.5-5.5c-0.34-0.34-0.9-0.34-1.24 0-0.34 0.34-0.34 0.9 0 1.24l4.13 4.13-1.76-0.13H8c-3.52 0-6.38 2.86-6.38 6.38S4.48 20.88 8 20.88h4c0.48 0 0.88-0.4 0.88-0.88s-0.4-0.88-0.88-0.88H8c-2.55 0-4.63-2.07-4.63-4.62 0-2.55 2.08-4.63 4.63-4.63h9.25l1.76-0.12-4.13 4.13c-0.34 0.34-0.34 0.9 0 1.24 0.34 0.34 0.9 0.34 1.24 0l5.5-5.5c0.16-0.17 0.25-0.39 0.25-0.62Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/signal_dark_colorOnSurface"
|
||||
android:pathData="M2.13 9c0-0.23 0.09-0.45 0.25-0.62l5.5-5.5c0.34-0.34 0.9-0.34 1.24 0 0.34 0.34 0.34 0.9 0 1.24L4.99 8.25l1.76-0.13H16c3.52 0 6.38 2.86 6.38 6.38s-2.86 6.38-6.38 6.38h-4c-0.48 0-0.88-0.4-0.88-0.88s0.4-0.88 0.88-0.88h4c2.55 0 4.63-2.07 4.63-4.62 0-2.55-2.08-4.63-4.63-4.63H6.75L4.99 9.76l4.13 4.13c0.34 0.34 0.34 0.9 0 1.24-0.34 0.34-0.9 0.34-1.24 0l-5.5-5.5C2.22 9.45 2.13 9.23 2.13 9Z"/>
|
||||
</vector>
|
||||
@@ -5,6 +5,10 @@
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -12,4 +16,7 @@ import androidx.compose.ui.unit.dp
|
||||
object MediaSendMetrics {
|
||||
val SelectedMediaPreviewSize = DpSize(44.dp, 44.dp)
|
||||
val SelectedMediaPreviewShape = RoundedCornerShape(8.dp)
|
||||
|
||||
val ControlEnterTransition: EnterTransition = fadeIn()
|
||||
val ControlExitTransition: ExitTransition = fadeOut()
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import org.signal.mediasend.edit.image.ImageEditorState
|
||||
*/
|
||||
@Stable
|
||||
class ImageController @RememberInComposition constructor(
|
||||
val editorModel: EditorModel,
|
||||
private val editorModel: EditorModel,
|
||||
private val brushWidths: BrushWidthsState = BrushWidthsState()
|
||||
) {
|
||||
|
||||
@@ -190,6 +190,20 @@ class ImageController @RememberInComposition constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun undo() {
|
||||
editorModel.undo()
|
||||
}
|
||||
|
||||
fun redo() {
|
||||
editorModel.redo()
|
||||
}
|
||||
|
||||
fun clearAllEdits() {
|
||||
while (imageEditorState.undoAvailable) {
|
||||
editorModel.undo()
|
||||
}
|
||||
}
|
||||
|
||||
fun setDrawColor(color: Int) {
|
||||
imageEditorState.drawColor = brushTool?.applyAlpha(color) ?: color
|
||||
}
|
||||
@@ -321,6 +335,7 @@ class ImageController @RememberInComposition constructor(
|
||||
}
|
||||
|
||||
fun onDialGestureStart() {
|
||||
imageEditorState.isGestureActive = true
|
||||
val mainImage = editorModel.mainImage ?: return
|
||||
initialDialScale = mainImage.localScaleX
|
||||
minDialScaleDown = 1f
|
||||
@@ -334,6 +349,7 @@ class ImageController @RememberInComposition constructor(
|
||||
}
|
||||
|
||||
fun onDialGestureEnd() {
|
||||
imageEditorState.isGestureActive = false
|
||||
val mainImage = editorModel.mainImage ?: return
|
||||
mainImage.commitEditorMatrix()
|
||||
editorModel.postEdit(true)
|
||||
|
||||
@@ -58,7 +58,9 @@ import org.signal.mediasend.edit.image.BrushWidthPreview
|
||||
import org.signal.mediasend.edit.image.BrushWidthsState
|
||||
import org.signal.mediasend.edit.image.DrawModeColorBar
|
||||
import org.signal.mediasend.edit.image.ImageEditor
|
||||
import org.signal.mediasend.edit.image.ImageEditorClearAllButton
|
||||
import org.signal.mediasend.edit.image.ImageEditorToolbar
|
||||
import org.signal.mediasend.edit.image.ImageEditorUndoRedoButtons
|
||||
import org.signal.mediasend.edit.image.RotationDial
|
||||
import org.signal.mediasend.edit.video.VideoEditorFragment
|
||||
import org.signal.mediasend.edit.video.VideoEditorToolbar
|
||||
@@ -353,6 +355,22 @@ fun MediaEditScreen(
|
||||
.systemBarsPadding()
|
||||
)
|
||||
|
||||
ImageEditorUndoRedoButtons(
|
||||
imageEditorController = imageController,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(top = 12.dp, start = 16.dp)
|
||||
.systemBarsPadding()
|
||||
)
|
||||
|
||||
ImageEditorClearAllButton(
|
||||
imageEditorController = imageController,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 12.dp, end = 16.dp)
|
||||
.systemBarsPadding()
|
||||
)
|
||||
|
||||
if (state.isSavingMedia) {
|
||||
MediaEditScreenDialogs.SavingToStorageProgressDialog()
|
||||
}
|
||||
|
||||
@@ -143,37 +143,42 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller
|
||||
}
|
||||
|
||||
down.consume()
|
||||
state.isGestureActive = true
|
||||
|
||||
var previousPointerCount = 1
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val currentPressed = event.changes.filter { it.pressed }
|
||||
val currentCount = currentPressed.size
|
||||
try {
|
||||
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()
|
||||
}
|
||||
|
||||
if (currentCount == 0) {
|
||||
event.changes.forEach { it.consume() }
|
||||
touchHandler.onUp(state.editorModel)
|
||||
state.onGestureCompleted?.invoke()
|
||||
break
|
||||
previousPointerCount = currentCount
|
||||
}
|
||||
|
||||
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
|
||||
} finally {
|
||||
state.isGestureActive = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit.image
|
||||
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.imageeditor.core.model.EditorModel
|
||||
import org.signal.mediasend.R
|
||||
import org.signal.mediasend.edit.ImageController
|
||||
|
||||
@Composable
|
||||
internal fun ImageEditorClearAllButton(
|
||||
imageEditorController: ImageController?,
|
||||
modifier: Modifier = Modifier,
|
||||
canUndo: Boolean = imageEditorController?.imageEditorState?.undoAvailable ?: false
|
||||
) {
|
||||
ImageEditorGestureAwareControl(
|
||||
imageEditorController = imageEditorController,
|
||||
modifier = modifier,
|
||||
extraCheck = { canUndo }
|
||||
) {
|
||||
Buttons.MediumTonal(
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = SignalTheme.colors.colorSurface5,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
onClick = {
|
||||
imageEditorController?.clearAllEdits()
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.ImageEditorClearAllButton__clear_all))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ImageEditorClearAllButtonPreview() {
|
||||
Previews.Preview {
|
||||
ImageEditorClearAllButton(
|
||||
imageEditorController = remember { ImageController(EditorModel.create(0x0)).apply { enterDrawMode() } },
|
||||
canUndo = true
|
||||
)
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit.image
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.signal.mediasend.MediaSendMetrics
|
||||
import org.signal.mediasend.edit.ImageController
|
||||
|
||||
/**
|
||||
* Hides or shows content based on whether the user is performing a gesture.
|
||||
*/
|
||||
@Composable
|
||||
fun ImageEditorGestureAwareControl(
|
||||
imageEditorController: ImageController?,
|
||||
modifier: Modifier = Modifier,
|
||||
extraCheck: () -> Boolean = { true },
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val isGestureActive = imageEditorController?.imageEditorState?.isGestureActive ?: false
|
||||
|
||||
AnimatedVisibility(
|
||||
modifier = modifier,
|
||||
enter = MediaSendMetrics.ControlEnterTransition,
|
||||
exit = MediaSendMetrics.ControlExitTransition,
|
||||
visible = imageEditorController != null && imageEditorController.mode != ImageController.Mode.NONE && !isGestureActive && extraCheck()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,10 @@ class ImageEditorState(
|
||||
var redoAvailable: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** True while the user is actively manipulating the image: drawing, moving, scaling, or rotating. */
|
||||
var isGestureActive: Boolean by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
var textEditingElement: EditorElement? = null
|
||||
var isDrawing: Boolean = false
|
||||
var isBlur: Boolean = false
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit.image
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.imageeditor.core.model.EditorModel
|
||||
import org.signal.mediasend.R
|
||||
import org.signal.mediasend.edit.ImageController
|
||||
|
||||
@Composable
|
||||
internal fun ImageEditorUndoRedoButtons(
|
||||
imageEditorController: ImageController?,
|
||||
modifier: Modifier = Modifier,
|
||||
canUndo: Boolean = imageEditorController?.imageEditorState?.undoAvailable ?: false,
|
||||
canRedo: Boolean = imageEditorController?.imageEditorState?.redoAvailable ?: false
|
||||
) {
|
||||
ImageEditorGestureAwareControl(imageEditorController, modifier = modifier, extraCheck = {
|
||||
canUndo || canRedo
|
||||
}) {
|
||||
Row(horizontalArrangement = spacedBy(8.dp)) {
|
||||
IconButton(
|
||||
enabled = canUndo,
|
||||
onClick = {
|
||||
imageEditorController?.undo()
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(containerColor = SignalTheme.colors.colorSurface5)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.Undo.imageVector,
|
||||
contentDescription = stringResource(R.string.ImageEditorUndoRedoButtons__undo_edit)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
enabled = canRedo,
|
||||
onClick = {
|
||||
imageEditorController?.redo()
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(containerColor = SignalTheme.colors.colorSurface5)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.Redo.imageVector,
|
||||
contentDescription = stringResource(R.string.ImageEditorUndoRedoButtons__redo_edit)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ImageEditorUndoRedoButtonsPreview() {
|
||||
Previews.Preview {
|
||||
ImageEditorUndoRedoButtons(
|
||||
imageEditorController = remember {
|
||||
ImageController(EditorModel.create(0x0)).apply {
|
||||
enterDrawMode()
|
||||
}
|
||||
},
|
||||
canUndo = true,
|
||||
canRedo = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,16 @@
|
||||
<!-- Content description for button to enable brush mode -->
|
||||
<string name="DrawModeColorBar__brush">Brush</string>
|
||||
|
||||
<!-- Label for the button that undoes every edit made to the image, returning it to its original state -->
|
||||
<string name="ImageEditorClearAllButton__clear_all">Clear all</string>
|
||||
|
||||
<!-- Content description for the button that undoes the last edit made to the image -->
|
||||
<string name="ImageEditorUndoRedoButtons__undo_edit">Undo edit</string>
|
||||
<!-- Content description for the button that redoes the last undone edit made to the image -->
|
||||
<string name="ImageEditorUndoRedoButtons__redo_edit">Redo edit</string>
|
||||
|
||||
<!-- Video editor play button content description -->
|
||||
|
||||
<string name="VideoEditorHud_play_video_description">Play video</string>
|
||||
|
||||
<!-- CameraFragment -->
|
||||
|
||||
Reference in New Issue
Block a user