mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-05 21:07:49 +01:00
Add text editing to new media send flow.
This commit is contained in:
@@ -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)
|
||||
|
||||
+7
-4
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
-34
@@ -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()
|
||||
}
|
||||
}
|
||||
+4
-6
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+8
-3
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user