Add text editing to new media send flow.

This commit is contained in:
Alex Hart
2026-07-31 12:48:59 -03:00
parent 967686657c
commit 8e484f8fb1
8 changed files with 320 additions and 181 deletions
@@ -49,6 +49,7 @@ internal class ImageController(
}
var mode: Mode by mutableStateOf(Mode.NONE)
private set
var isCropAspectRatioLocked: Boolean by mutableStateOf(editorModel.isCropAspectLocked)
private set
@@ -59,10 +60,15 @@ internal class ImageController(
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 drawSessionSnapshot: ByteArray? by mutableStateOf(null)
private var drawSessionDirty: Boolean by mutableStateOf(false)
private var modeBeforeStickerInsertion: Mode = Mode.NONE
private var modeBeforeDrag: Mode = Mode.NONE
/**
* The mode a transient one falls back to. Selecting, dragging, inserting a sticker and entering text are all things
* the user does *within* an editing session, so they must not be able to strand that session -- or start one.
*/
private var restingMode: Mode = Mode.NONE
var textEditingElement: EditorElement? by mutableStateOf(null)
private set
@@ -76,7 +82,14 @@ internal class ImageController(
var showDiscardDialog: Boolean by mutableStateOf(false)
private set
private val isInDrawSession: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT || mode == Mode.BLUR }
/**
* Whether there is an uncommitted session to commit or discard. Keyed off the snapshot rather than [mode], because
* the transient modes -- placing a sticker, entering text, dragging -- all sit on top of a live session.
*/
private val isInDrawSession: Boolean by derivedStateOf { drawSessionSnapshot != null }
/** Whether the brush itself is the active tool, which is a narrower question than [isInDrawSession]. */
private val isPaintMode: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT || mode == Mode.BLUR }
val hasUnsavedChanges: Boolean by derivedStateOf {
when {
@@ -88,7 +101,7 @@ internal class ImageController(
}
val shouldDisplayTextColorBar: Boolean by derivedStateOf {
textEditingElement != null || mode == Mode.MOVE_TEXT
textEditingElement != null
}
val isUserDrawing: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT }
@@ -110,11 +123,37 @@ internal class ImageController(
val isUserEnteringText: Boolean by derivedStateOf { mode == Mode.TEXT }
val isUserInsertingSticker: Boolean by derivedStateOf { mode == Mode.INSERT_STICKER }
val isUserDraggingElement: Boolean by derivedStateOf { mode == Mode.DELETE }
var isDraggedElementOverTrash: Boolean by mutableStateOf(false)
private set
/** Whether back should back out of an editor mode rather than leaving the screen. */
val canHandleBack: Boolean by derivedStateOf {
when (mode) {
// The sticker picker is its own window and owns back while it is up, and DELETE only exists mid-drag.
Mode.INSERT_STICKER, Mode.DELETE -> false
Mode.NONE -> selectedElement != null
else -> true
}
}
/**
* Backs out of the current mode into the one before it. Draw and crop sessions go through [requestCancelEdit] so that
* backing out of a dirty one asks first rather than silently throwing the work away.
*/
fun onBackPressed() {
// A selection is a level of its own: back gives that up before it gives up the mode.
if (selectedElement != null) {
clearSelection()
return
}
when (mode) {
Mode.TEXT -> finishTextEditing()
Mode.DRAW, Mode.HIGHLIGHT, Mode.BLUR, Mode.CROP -> requestCancelEdit()
Mode.NONE, Mode.INSERT_STICKER, Mode.DELETE -> Unit
}
}
fun requestCancelEdit() {
if (hasUnsavedChanges) {
showDiscardDialog = true
@@ -173,31 +212,59 @@ internal class ImageController(
drawSessionSnapshot = null
drawSessionDirty = false
selectedElement = null
mode = Mode.NONE
transitionTo(Mode.NONE)
imageEditorState.isDrawing = false
imageEditorState.isBlur = false
}
private fun transitionTo(newMode: Mode) {
if (!newMode.isTransient) {
restingMode = newMode
}
mode = newMode
}
/** Ends a transient mode by returning to the session the user was in before it. */
private fun returnToRestingMode() {
when (restingMode) {
Mode.DRAW, Mode.HIGHLIGHT, Mode.BLUR -> {
transitionTo(restingMode)
syncDrawingState()
}
// The crop is still open on the model, so there is nothing to restart -- only the mode to hand back.
Mode.CROP -> transitionTo(Mode.CROP)
else -> exitEditMode()
}
imageEditorState.invalidate()
}
fun enterDrawMode() {
clearSelection()
snapshotIfNewDrawSession()
mode = Mode.DRAW
transitionTo(Mode.DRAW)
syncDrawingState()
}
fun enterHighlightMode() {
clearSelection()
snapshotIfNewDrawSession()
mode = Mode.HIGHLIGHT
transitionTo(Mode.HIGHLIGHT)
syncDrawingState()
}
fun enterBlurMode() {
clearSelection()
snapshotIfNewDrawSession()
mode = Mode.BLUR
transitionTo(Mode.BLUR)
syncDrawingState()
}
private fun snapshotIfNewDrawSession() {
if (!isInDrawSession) {
if (drawSessionSnapshot == null) {
drawSessionSnapshot = editorModel.createSnapshot()
drawSessionDirty = false
}
@@ -228,8 +295,11 @@ internal class ImageController(
}
private fun syncDrawingState() {
imageEditorState.isDrawing = true
imageEditorState.isBlur = mode == Mode.BLUR
// A selected element takes the touch: with the brush live, a sticker the user just placed could not be moved.
val canPaint = selectedElement == null
imageEditorState.isDrawing = canPaint
imageEditorState.isBlur = canPaint && mode == Mode.BLUR
imageEditorState.drawCap = if (mode == Mode.HIGHLIGHT) Paint.Cap.SQUARE else Paint.Cap.ROUND
imageEditorState.drawThickness = brushThickness
setDrawColor(drawColorBarState.color)
@@ -238,7 +308,7 @@ internal class ImageController(
fun enterCropMode() {
editorModel.startCrop()
initialDialScale = editorModel.mainImage?.localScaleX ?: 1f
mode = Mode.CROP
transitionTo(Mode.CROP)
}
fun enterTextMode() {
@@ -250,7 +320,7 @@ internal class ImageController(
}
private fun beginTextEditing(element: EditorElement) {
mode = Mode.TEXT
transitionTo(Mode.TEXT)
textEditingElement = element
imageEditorState.textEditingElement = element
editorModel.addFade()
@@ -262,21 +332,31 @@ internal class ImageController(
val element = textEditingElement ?: return
val renderer = element.renderer as? MultiLineTextRenderer
val hasText = renderer?.text?.isNotEmpty() == true
val snapshot = drawSessionSnapshot
renderer?.setFocused(false)
editorModel.zoomOut()
editorModel.removeFade()
editorModel.setSelectionVisible(true)
if (!hasText && snapshot != null) {
editorModel.restoreFromSnapshot(snapshot)
}
editorModel.setSelected(null)
textEditingElement = null
imageEditorState.textEditingElement = null
exitEditMode()
if (hasText) {
drawSessionDirty = true
// Staying selected keeps the brush off it, so it can still be moved or double-tapped back open.
selectElement(element)
} else {
// Drop just the abandoned element. Restoring the draw session snapshot here would take every stroke made before
// text entry with it.
clearSelection()
editorModel.delete(element)
editorModel.updateUndoRedoAvailabilityState()
}
// Returning to the session that text entry was started from keeps its commit and discard available, rather than
// banking the work without the user ever confirming it.
returnToRestingMode()
}
fun onTextChanged(text: String) {
@@ -324,47 +404,64 @@ internal class ImageController(
imageEditorState.invalidate()
}
/** Re-opens an existing text element, which is how the user gets back to its color and style controls. */
fun onEntityDoubleTap(element: EditorElement?) {
if (mode == Mode.CROP || element == null || element.renderer !is MultiLineTextRenderer) {
return
}
beginTextEditing(element)
}
private fun selectElement(element: EditorElement) {
(selectedElement?.renderer as? SelectableRenderer)?.onSelected(false)
(element.renderer as? SelectableRenderer)?.onSelected(true)
editorModel.setSelected(element)
selectedElement = element
mode = when (element.renderer) {
is MultiLineTextRenderer -> Mode.MOVE_TEXT
else -> Mode.MOVE_STICKER
if (isPaintMode) {
syncDrawingState()
}
imageEditorState.invalidate()
}
private fun clearSelection() {
if (selectedElement != null) {
(selectedElement?.renderer as? SelectableRenderer)?.onSelected(false)
editorModel.setSelected(null)
selectedElement = null
mode = Mode.NONE
imageEditorState.invalidate()
val element = selectedElement ?: return
(element.renderer as? SelectableRenderer)?.onSelected(false)
editorModel.setSelected(null)
selectedElement = null
if (isPaintMode) {
syncDrawingState()
}
imageEditorState.invalidate()
}
fun enterStickerMode() {
modeBeforeStickerInsertion = mode
mode = Mode.INSERT_STICKER
// Re-opening the picker must not record INSERT_STICKER as the mode to come back to.
if (mode != Mode.INSERT_STICKER) {
modeBeforeStickerInsertion = mode
}
transitionTo(Mode.INSERT_STICKER)
}
fun insertSticker(renderer: Renderer) {
val element = EditorElement(renderer, EditorModel.Z_STICKERS)
editorModel.addElementCentered(element, STICKER_SCALE)
drawSessionDirty = true
// The picker can be opened mid-draw-session, where touches would otherwise keep painting.
imageEditorState.isDrawing = false
imageEditorState.isBlur = false
// Selecting first leaves the new sticker holding the touch, so a draw session resumed here does not paint over it.
selectElement(element)
imageEditorState.invalidate()
returnToRestingMode()
}
fun cancelStickerInsertion() {
if (mode == Mode.INSERT_STICKER) {
mode = modeBeforeStickerInsertion
transitionTo(modeBeforeStickerInsertion)
}
}
@@ -374,10 +471,9 @@ internal class ImageController(
return
}
modeBeforeDrag = mode
selectElement(element)
isDraggedElementOverTrash = false
mode = Mode.DELETE
transitionTo(Mode.DELETE)
setTrashVisible(true)
}
@@ -403,24 +499,21 @@ internal class ImageController(
isDraggedElementOverTrash = false
setTrashVisible(false)
if (element == null) {
exitEditMode()
return
}
if (isOverTrash) {
editorModel.delete(element)
editorModel.updateUndoRedoAvailabilityState()
when (modeBeforeDrag) {
Mode.DRAW, Mode.HIGHLIGHT, Mode.BLUR -> {
selectedElement = null
mode = modeBeforeDrag
}
else -> exitEditMode()
if (element == null || isOverTrash) {
element?.let {
editorModel.delete(it)
editorModel.updateUndoRedoAvailabilityState()
drawSessionDirty = true
}
clearSelection()
returnToRestingMode()
} else {
element.animatePartialFadeIn(imageEditorState::invalidate)
// Moving something is not an edit mode of its own, so the drag hands back whatever the user was in -- but the
// element they were just dragging stays selected.
returnToRestingMode()
selectElement(element)
}
}
@@ -480,10 +573,12 @@ internal class ImageController(
DRAW,
HIGHLIGHT,
BLUR,
MOVE_STICKER,
MOVE_TEXT,
DELETE,
INSERT_STICKER
INSERT_STICKER;
/** Whether this is something done within a session rather than a session in its own right. */
internal val isTransient: Boolean
get() = this == TEXT || this == DELETE || this == INSERT_STICKER
}
/**
@@ -0,0 +1,42 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import org.signal.mediasend.MediaSendMetrics
/**
* A control on the edit screen, and the two ways it gets out of the user's way. Going not-[visible] gives up its layout
* space, letting the rest of the stack settle into it, while [faded] holds onto the space -- releasing it mid-gesture
* would move whatever the user is dragging out from under their finger.
*/
@Composable
internal fun MediaEditControl(
faded: Boolean,
modifier: Modifier = Modifier,
visible: Boolean = true,
enter: EnterTransition = MediaSendMetrics.ControlEnterTransition,
exit: ExitTransition = MediaSendMetrics.ControlExitTransition,
content: @Composable () -> Unit
) {
val alpha by animateFloatAsState(targetValue = if (faded) 0f else 1f)
AnimatedVisibility(
visible = visible,
enter = enter,
exit = exit,
modifier = modifier.alpha(alpha)
) {
content()
}
}
@@ -8,10 +8,6 @@ package org.signal.mediasend.edit
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.snapping.SnapPosition
import androidx.compose.foundation.layout.Arrangement.spacedBy
@@ -36,7 +32,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
@@ -115,11 +110,21 @@ internal fun MediaEditScreen(
null
}
// Composed after the camera-first handler so it wins while an editor mode is open: back should step out of that mode
// rather than leave the screen.
BackHandler(enabled = imageController?.canHandleBack == true) {
imageController?.onBackPressed()
}
var isVideoInteracting by remember(focusedUri) { mutableStateOf(false) }
var isAdjustingBrushWidth by remember(focusedUri) { mutableStateOf(false) }
val isImageEditing = imageController?.isUserInEdit == true
val isInteracting = isImageEditing || isVideoInteracting
// Drags of the media itself, which every piece of chrome gets out of the way for. Sliders are excluded -- they are
// chrome themselves, and clearing the screen would hide what they adjust.
val isDragging = imageController?.imageEditorState?.isGestureActive == true || isVideoInteracting
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
@@ -205,19 +210,23 @@ internal fun MediaEditScreen(
modifier = Modifier.fillMaxSize()
)
BrushWidthBar(
fraction = imageController.brushWidthFraction,
onFractionChanged = { fraction, gestureComplete ->
isAdjustingBrushWidth = !gestureComplete
val tool = imageController.brushTool
imageController.setBrushWidthFraction(fraction)
if (gestureComplete && tool != null) {
onEvent(MediaEditScreenEvent.BrushWidthChanged(tool, fraction))
}
},
MediaEditControl(
faded = isDragging,
modifier = Modifier.align(Alignment.CenterStart)
)
) {
BrushWidthBar(
fraction = imageController.brushWidthFraction,
onFractionChanged = { fraction, gestureComplete ->
isAdjustingBrushWidth = !gestureComplete
val tool = imageController.brushTool
imageController.setBrushWidthFraction(fraction)
if (gestureComplete && tool != null) {
onEvent(MediaEditScreenEvent.BrushWidthChanged(tool, fraction))
}
}
)
}
}
val isTextEditing = imageController?.textEditingElement != null
@@ -245,7 +254,7 @@ internal fun MediaEditScreen(
}
if (state.selectedMedia.size > 1) {
MediaEditControl(visible = !isImageEditing, faded = isVideoInteracting) {
MediaEditControl(visible = !isImageEditing, faded = isDragging) {
ThumbnailRow(
selectedMedia = state.selectedMedia,
pagerState = pagerState,
@@ -280,7 +289,9 @@ internal fun MediaEditScreen(
}
if (controller.isUserDrawing) {
DrawModeColorBar(imageEditorController = controller)
MediaEditControl(faded = isDragging) {
DrawModeColorBar(imageEditorController = controller)
}
}
}
@@ -291,14 +302,15 @@ internal fun MediaEditScreen(
state = state,
onEvent = onEvent,
imageController = imageController,
isTextEditing = isTextEditing
isTextEditing = isTextEditing,
isDragging = isDragging
)
}
}
MediaEditControl(
visible = !isImageEditing,
faded = isVideoInteracting,
faded = isDragging,
enter = MediaSendMetrics.SlidingControlEnterTransition,
exit = MediaSendMetrics.SlidingControlExitTransition
) {
@@ -324,6 +336,7 @@ internal fun MediaEditScreen(
onEvent = onEvent,
imageController = imageController,
isTextEditing = isTextEditing,
isDragging = isDragging,
modifier = Modifier
.align(Alignment.CenterEnd)
)
@@ -332,18 +345,23 @@ internal fun MediaEditScreen(
val displayNameState = state.recipientId?.let { LocalDisplayNameProvider.current(it.id) } ?: remember { mutableStateOf(null) }
val displayName: String? by displayNameState
MediaEditSummaryPill(
displayName = displayName,
selectedMedia = state.selectedMedia,
selectedPage = pagerState.currentPage,
MediaEditControl(
faded = isDragging,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 10.dp)
.systemBarsPadding()
)
) {
MediaEditSummaryPill(
displayName = displayName,
selectedMedia = state.selectedMedia,
selectedPage = pagerState.currentPage
)
}
ImageEditorUndoRedoButtons(
imageEditorController = imageController,
isDragging = isDragging,
modifier = Modifier
.align(Alignment.TopStart)
.padding(top = 12.dp, start = 16.dp)
@@ -352,6 +370,7 @@ internal fun MediaEditScreen(
ImageEditorClearAllButton(
imageEditorController = imageController,
isDragging = isDragging,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 12.dp, end = 16.dp)
@@ -375,60 +394,37 @@ private fun MediaToolbar(
focusedEditorState: EditorState?,
imageController: ImageController?,
isTextEditing: Boolean,
isDragging: Boolean,
modifier: Modifier = Modifier
) {
if (focusedUri == null) {
if (focusedUri == null || focusedEditorState == null) {
return
}
when (focusedEditorState) {
null -> return
is EditorState.Image -> {
imageController?.let {
ImageEditorToolbar(
imageEditorController = it,
MediaEditControl(faded = isDragging, modifier = modifier) {
when (focusedEditorState) {
is EditorState.Image -> {
imageController?.let {
ImageEditorToolbar(
imageEditorController = it,
state = state,
onEvent = onEvent,
modifier = Modifier
.navigationBarsPadding()
.padding(end = 24.dp)
.then(if (isTextEditing) Modifier.imePadding() else Modifier)
)
}
}
else -> MediaEditorToolbar {
MediaEditorToolbarSharedButtons(
state = state,
onEvent = onEvent,
modifier = modifier
.navigationBarsPadding()
.padding(end = 24.dp)
.then(if (isTextEditing) Modifier.imePadding() else Modifier)
canSave = focusedEditorState is EditorState.VideoTrim
)
}
}
else -> MediaEditorToolbar(modifier = modifier) {
MediaEditorToolbarSharedButtons(
state = state,
onEvent = onEvent,
canSave = focusedEditorState is EditorState.VideoTrim
)
}
}
}
/**
* A control in the bottom stack, and the two ways it gets out of the user's way. Going not-[visible] gives up its layout
* space, letting the rest of the stack settle into it, while [faded] holds onto the space -- releasing it mid-gesture
* would move whatever the user is dragging out from under their finger.
*/
@Composable
private fun MediaEditControl(
visible: Boolean,
faded: Boolean,
enter: EnterTransition = MediaSendMetrics.ControlEnterTransition,
exit: ExitTransition = MediaSendMetrics.ControlExitTransition,
content: @Composable () -> Unit
) {
val alpha by animateFloatAsState(targetValue = if (faded) 0f else 1f)
AnimatedVisibility(
visible = visible,
enter = enter,
exit = exit,
modifier = Modifier.alpha(alpha)
) {
content()
}
}
@@ -35,11 +35,13 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import org.signal.imageeditor.core.ImageEditorTouchHandler
import org.signal.imageeditor.core.model.EditorElement
import org.signal.imageeditor.core.renderers.MultiLineTextRenderer
import org.signal.mediasend.edit.ImageController
@Composable
@@ -91,7 +93,14 @@ internal fun ImageEditor(
@Composable
private fun HiddenTextInput(controller: ImageController) {
var text by remember { mutableStateOf(TextFieldValue("")) }
val element = controller.textEditingElement
// Re-opening an existing element has to start from its current text, or the first keystroke would replace it.
var text by remember(element) {
val existing = (element?.renderer as? MultiLineTextRenderer)?.text ?: ""
mutableStateOf(TextFieldValue(existing, TextRange(existing.length)))
}
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
@@ -109,8 +118,11 @@ private fun HiddenTextInput(controller: ImageController) {
keyboardOptions = KeyboardOptions(imeAction = ImeAction.None)
)
LaunchedEffect(Unit) {
LaunchedEffect(element) {
focusRequester.requestFocus()
// Also zooms the element clear of the keyboard, which otherwise would not happen until the first keystroke.
controller.onTextSelectionChanged(text.selection.start, text.selection.end)
}
DisposableEffect(Unit) {
@@ -125,6 +137,10 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller
return this.pointerInput(controller, controller.textEditingElement) {
val touchHandler = ImageEditorTouchHandler()
// The tap that a second one has to land on, and beat the timeout from, to count as a double tap.
var lastTapElement: EditorElement? = null
var lastTapUptimeMillis = 0L
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = true)
@@ -163,6 +179,7 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller
var inDrag = false
var draggedElement: EditorElement? = null
var droppedOnTrash = false
var didPinch = false
try {
while (true) {
@@ -180,12 +197,28 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller
state.editorModel.findElementAtPoint(upPoint, state.viewMatrix, Matrix()) === draggedElement
}
val wasSingleTap = !inDrag && !touchHandler.isDrawingSession()
// A tap that neither painted, dragged nor pinched leaves the model exactly as it found it, so it must not
// count as an edit -- otherwise merely selecting something would arm the "Discard changes?" prompt.
val didModifyModel = inDrag || didPinch || touchHandler.isDrawingSession()
val wasSingleTap = !didModifyModel
touchHandler.onUp(state.editorModel)
state.onGestureCompleted?.invoke()
if (wasSingleTap) {
if (didModifyModel) {
state.onGestureCompleted?.invoke()
}
val isDoubleTap = wasSingleTap &&
hitElement != null &&
hitElement === lastTapElement &&
down.uptimeMillis - lastTapUptimeMillis <= viewConfiguration.doubleTapTimeoutMillis
lastTapElement = if (wasSingleTap && !isDoubleTap) hitElement else null
lastTapUptimeMillis = event.changes.firstOrNull()?.uptimeMillis ?: down.uptimeMillis
if (isDoubleTap) {
controller.onEntityDoubleTap(hitElement)
} else if (wasSingleTap) {
controller.onEntitySingleTap(hitElement)
}
@@ -193,6 +226,7 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller
}
if (currentCount == 2 && previousPointerCount < 2) {
didPinch = true
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)
@@ -19,17 +19,19 @@ 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
import org.signal.mediasend.edit.MediaEditControl
@Composable
internal fun ImageEditorClearAllButton(
imageEditorController: ImageController?,
isDragging: Boolean,
modifier: Modifier = Modifier,
canUndo: Boolean = imageEditorController?.imageEditorState?.undoAvailable ?: false
) {
ImageEditorGestureAwareControl(
imageEditorController = imageEditorController,
modifier = modifier,
extraCheck = { canUndo }
MediaEditControl(
visible = imageEditorController != null && imageEditorController.mode != ImageController.Mode.NONE && canUndo,
faded = isDragging,
modifier = modifier
) {
Buttons.MediumTonal(
colors = ButtonDefaults.buttonColors(
@@ -51,6 +53,7 @@ private fun ImageEditorClearAllButtonPreview() {
Previews.Preview {
ImageEditorClearAllButton(
imageEditorController = remember { ImageController(EditorModel.create(0x0)).apply { enterDrawMode() } },
isDragging = false,
canUndo = true
)
}
@@ -1,34 +0,0 @@
/*
* 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
internal 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()
}
}
@@ -43,8 +43,6 @@ internal fun ImageEditorToolbar(
modifier: Modifier = Modifier
) {
when {
// The trash is the only thing on screen during a drag.
imageEditorController.isUserDraggingElement -> Unit
imageEditorController.shouldDisplayTextColorBar -> {
TextModeColorBar(
imageEditorController = imageEditorController,
@@ -131,10 +129,10 @@ private fun ImageEditorDrawStateToolbar(
imageVector = SignalIcons.Sticker.imageVector,
checked = imageEditorController.isUserInsertingSticker,
onCheckChanged = {
if (!imageEditorController.isUserInsertingSticker) {
imageEditorController.enterStickerMode()
onEvent(MediaEditScreenEvent.StickerClick)
}
// Unconditional: if a previous pick never delivered a result the mode is still INSERT_STICKER, and gating on it
// would leave the button dead.
imageEditorController.enterStickerMode()
onEvent(MediaEditScreenEvent.StickerClick)
}
)
@@ -22,17 +22,21 @@ 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
import org.signal.mediasend.edit.MediaEditControl
@Composable
internal fun ImageEditorUndoRedoButtons(
imageEditorController: ImageController?,
isDragging: Boolean,
modifier: Modifier = Modifier,
canUndo: Boolean = imageEditorController?.imageEditorState?.undoAvailable ?: false,
canRedo: Boolean = imageEditorController?.imageEditorState?.redoAvailable ?: false
) {
ImageEditorGestureAwareControl(imageEditorController, modifier = modifier, extraCheck = {
canUndo || canRedo
}) {
MediaEditControl(
visible = imageEditorController != null && imageEditorController.mode != ImageController.Mode.NONE && (canUndo || canRedo),
faded = isDragging,
modifier = modifier
) {
Row(horizontalArrangement = spacedBy(8.dp)) {
IconButton(
enabled = canUndo,
@@ -73,6 +77,7 @@ private fun ImageEditorUndoRedoButtonsPreview() {
enterDrawMode()
}
},
isDragging = false,
canUndo = true,
canRedo = true
)