diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/EditorController.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/EditorController.kt index 49104988c6..20f236b9db 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/EditorController.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/EditorController.kt @@ -9,11 +9,15 @@ import android.graphics.Paint import android.net.Uri import androidx.compose.runtime.Stable import androidx.compose.runtime.annotation.RememberInComposition +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap +import org.signal.imageeditor.core.SelectableRenderer +import org.signal.imageeditor.core.model.EditorElement import org.signal.imageeditor.core.model.EditorModel +import org.signal.imageeditor.core.renderers.MultiLineTextRenderer @Stable sealed interface EditorController { @@ -30,8 +34,7 @@ sealed interface EditorController { @Stable class Image @RememberInComposition constructor(val editorModel: EditorModel) : EditorController { - override val isUserInEdit: Boolean - get() = mode != Mode.NONE + override val isUserInEdit: Boolean by derivedStateOf { mode != Mode.NONE } val imageEditorState = ImageEditorState(editorModel).also { it.onGestureCompleted = { drawSessionDirty = true } @@ -49,17 +52,38 @@ sealed interface EditorController { private var initialDialImageDegrees: Float = 0f private var minDialScaleDown: Float = 1f private var drawSessionSnapshot: ByteArray? = null - private var drawSessionDirty: Boolean = false + private var drawSessionDirty: Boolean by mutableStateOf(false) + + var textEditingElement: EditorElement? by mutableStateOf(null) + private set + + var selectedElement: EditorElement? by mutableStateOf(null) + private set + + val textColorBarState = HSVColorBarState() var showDiscardDialog: Boolean by mutableStateOf(false) private set - val hasUnsavedChanges: Boolean - get() = when { + private val isInDrawSession: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT || mode == Mode.BLUR } + + val hasUnsavedChanges: Boolean by derivedStateOf { + when { mode == Mode.CROP -> imageEditorState.undoAvailable + mode == Mode.TEXT -> (textEditingElement?.renderer as? MultiLineTextRenderer)?.text?.isNotEmpty() == true isInDrawSession -> drawSessionDirty else -> false } + } + + val shouldDisplayColorBar: Boolean by derivedStateOf { + textEditingElement != null || mode == Mode.MOVE_TEXT + } + + val isUserDrawing: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT } + val isUserBlurring: Boolean by derivedStateOf { mode == Mode.BLUR } + val isUserEnteringText: Boolean by derivedStateOf { mode == Mode.TEXT } + val isUserInsertingSticker: Boolean by derivedStateOf { mode == Mode.INSERT_STICKER } fun requestCancelEdit() { if (hasUnsavedChanges) { @@ -78,18 +102,6 @@ sealed interface EditorController { cancelEdit() } - val isUserDrawing: Boolean - get() = mode == Mode.DRAW || mode == Mode.HIGHLIGHT - - val isUserBlurring: Boolean - get() = mode == Mode.BLUR - - val isUserEnteringText: Boolean - get() = mode == Mode.TEXT - - val isUserInsertingSticker: Boolean - get() = mode == Mode.INSERT_STICKER - fun beginDrawEdit() { enterDrawMode() } @@ -100,27 +112,37 @@ sealed interface EditorController { fun cancelEdit() { when { + mode == Mode.TEXT -> { + finishTextEditing() + } mode == Mode.CROP -> { editorModel.clearUndoStack() editorModel.doneCrop() + exitEditMode() } isInDrawSession -> { drawSessionSnapshot?.let { editorModel.restoreFromSnapshot(it) } + exitEditMode() } + else -> exitEditMode() } - exitEditMode() } fun commitEdit() { - if (mode == Mode.CROP) { - editorModel.doneCrop() + when (mode) { + Mode.TEXT -> finishTextEditing() + Mode.CROP -> { + editorModel.doneCrop() + exitEditMode() + } + else -> exitEditMode() } - exitEditMode() } private fun exitEditMode() { drawSessionSnapshot = null drawSessionDirty = false + selectedElement = null mode = Mode.NONE imageEditorState.isDrawing = false imageEditorState.isBlur = false @@ -159,9 +181,6 @@ sealed interface EditorController { 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 @@ -175,7 +194,87 @@ sealed interface EditorController { } fun enterTextMode() { + snapshotIfNewDrawSession() + val renderer = MultiLineTextRenderer("", textColorBarState.color, MultiLineTextRenderer.Mode.REGULAR) + val element = EditorElement(renderer, EditorModel.Z_TEXT) + editorModel.addElementCentered(element, 1f) + beginTextEditing(element) + } + + private fun beginTextEditing(element: EditorElement) { mode = Mode.TEXT + textEditingElement = element + imageEditorState.textEditingElement = element + editorModel.addFade() + editorModel.setSelectionVisible(false) + (element.renderer as? MultiLineTextRenderer)?.setFocused(true) + } + + fun finishTextEditing() { + 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() + } + + fun onTextChanged(text: String) { + val element = textEditingElement ?: return + val renderer = element.renderer as? MultiLineTextRenderer ?: return + renderer.setText(text) + imageEditorState.invalidate() + } + + fun onTextSelectionChanged(selStart: Int, selEnd: Int) { + val element = textEditingElement ?: return + val renderer = element.renderer as? MultiLineTextRenderer ?: return + renderer.setSelection(selStart, selEnd) + editorModel.zoomToTextElement(element, renderer) + imageEditorState.invalidate() + } + + fun setTextColor(color: Int) { + val element = textEditingElement ?: selectedElement + val renderer = element?.renderer as? MultiLineTextRenderer ?: return + renderer.color = color + imageEditorState.invalidate() + } + + fun onEntityTapped(element: EditorElement?) { + if (element != null && element.renderer is SelectableRenderer) { + (element.renderer as SelectableRenderer).onSelected(true) + editorModel.setSelected(element) + selectedElement = element + mode = when (element.renderer) { + is MultiLineTextRenderer -> Mode.MOVE_TEXT + else -> Mode.MOVE_STICKER + } + } else { + clearSelection() + } + } + + private fun clearSelection() { + if (selectedElement != null) { + (selectedElement?.renderer as? SelectableRenderer)?.onSelected(false) + editorModel.setSelected(null) + selectedElement = null + mode = Mode.NONE + imageEditorState.invalidate() + } } fun enterStickerMode() { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/HSVColorBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/HSVColorBar.kt new file mode 100644 index 0000000000..f249508c2b --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/HSVColorBar.kt @@ -0,0 +1,263 @@ +/* + * 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.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import androidx.core.graphics.ColorUtils +import org.signal.core.ui.WindowBreakpoint +import org.signal.core.ui.compose.PhonePortraitDayPreview +import org.signal.core.ui.compose.PhonePortraitNightPreview +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.core.ui.rememberWindowBreakpoint + +@Composable +fun HSVColorBar( + state: HSVColorBarState, + onColorChanged: (Int) -> Unit, + modifier: Modifier = Modifier +) { + val orientation = if (rememberWindowBreakpoint() == WindowBreakpoint.SMALL) { + ColorBarOrientation.HORIZONTAL + } else { + ColorBarOrientation.VERTICAL + } + val colors = remember { HSVColors.composeColors } + val thumbColor = SignalTheme.colors.colorSurface5 + + Canvas( + modifier = modifier.then(orientation.barModifier) + .pointerInput(Unit) { + detectTapGestures { offset -> + state.fraction = orientation.fractionAt( + offset = offset, + width = size.width.toFloat(), + height = size.height.toFloat() + ) + onColorChanged(state.color) + } + } + .pointerInput(Unit) { + detectDragGestures { change, _ -> + change.consume() + state.fraction = orientation.fractionAt( + offset = change.position, + width = size.width.toFloat(), + height = size.height.toFloat() + ) + onColorChanged(state.color) + } + } + ) { + drawTrack(orientation = orientation, colors = colors) + val thumbCenter = orientation.thumbCenter(fraction = state.fraction, size = size) + drawThumb( + center = thumbCenter, + thumbColor = thumbColor, + color = state.color + ) + } +} + +private enum class ColorBarOrientation(val barModifier: Modifier) { + HORIZONTAL( + Modifier + .widthIn(max = MAX_BAR_LENGTH_DP.dp) + .fillMaxWidth() + .height(THUMB_DIAMETER_DP.dp) + ), + VERTICAL( + Modifier + .heightIn(max = MAX_BAR_LENGTH_DP.dp) + .fillMaxHeight() + .width(THUMB_DIAMETER_DP.dp) + ); + + fun fractionAt(offset: Offset, width: Float, height: Float): Float { + val distance = when (this) { + HORIZONTAL -> offset.x + VERTICAL -> offset.y + } + + val maxDistance = when (this) { + HORIZONTAL -> width + VERTICAL -> height + } + + return (distance / maxDistance).coerceIn(0f, 1f) + } + + fun thumbCenter(fraction: Float, size: Size): Offset { + return when (this) { + HORIZONTAL -> Offset(fraction * size.width, size.height / 2f) + VERTICAL -> Offset(size.width / 2f, fraction * size.height) + } + } +} + +private fun DrawScope.drawTrack( + orientation: ColorBarOrientation, + colors: List +) { + val thickness = TRACK_THICKNESS_DP.dp.toPx() + val cornerRadius = CornerRadius(thickness / 2f) + + when (orientation) { + ColorBarOrientation.HORIZONTAL -> { + val trackY = (size.height - thickness) / 2f + drawRoundRect( + brush = Brush.horizontalGradient(colors), + topLeft = Offset(0f, trackY), + size = Size(size.width, thickness), + cornerRadius = cornerRadius + ) + } + + ColorBarOrientation.VERTICAL -> { + val trackX = (size.width - thickness) / 2f + drawRoundRect( + brush = Brush.verticalGradient(colors), + topLeft = Offset(trackX, 0f), + size = Size(thickness, size.height), + cornerRadius = cornerRadius + ) + } + } +} + +private fun DrawScope.drawThumb( + center: Offset, + thumbColor: Color, + color: Int +) { + val outerRadius = THUMB_DIAMETER_DP.dp.toPx() / 2f + val borderWidth = THUMB_BORDER_DP.dp.toPx() + val innerRadius = outerRadius - borderWidth + + drawCircle( + color = thumbColor, + radius = outerRadius, + center = center + ) + drawCircle( + color = Color(color), + radius = innerRadius, + center = center + ) +} + +@Stable +class HSVColorBarState { + var fraction: Float by mutableFloatStateOf(DEFAULT_FRACTION) + + val color: Int + get() = HSVColors.colorAt(fraction) +} + +@Composable +fun rememberHSVColorBarState(): HSVColorBarState { + return remember { HSVColorBarState() } +} + +private object HSVColors { + private const val MAX_HUE = 360 + private const val BLACK_DIVISIONS = 175 + private const val COLOR_DIVISIONS = 1023 + private const val WHITE_DIVISIONS = 125 + private const val STANDARD_LIGHTNESS = 0.4f + + val intColors: IntArray = buildColors() + val composeColors: List = intColors.map { Color(it) } + + fun colorAt(fraction: Float): Int { + val index = (fraction * (intColors.size - 1)).toInt().coerceIn(0, intColors.size - 1) + return intColors[index] + } + + private fun buildColors(): IntArray { + val result = IntArray(BLACK_DIVISIONS + COLOR_DIVISIONS + WHITE_DIVISIONS + 3) + var i = 0 + + for (v in 0..BLACK_DIVISIONS) { + result[i++] = ColorUtils.HSLToColor( + floatArrayOf(MAX_HUE.toFloat(), 1f, v.toFloat() / BLACK_DIVISIONS * STANDARD_LIGHTNESS) + ) + } + + for (hue in 0..COLOR_DIVISIONS) { + val h = hue.toFloat() * MAX_HUE / COLOR_DIVISIONS + result[i++] = ColorUtils.HSLToColor( + floatArrayOf(h, 1f, calculateLightness(h, STANDARD_LIGHTNESS)) + ) + } + + for (v in 0..WHITE_DIVISIONS) { + val endLightness = calculateLightness(MAX_HUE.toFloat(), STANDARD_LIGHTNESS) + val lightness = endLightness + (1f - endLightness) * v.toFloat() / WHITE_DIVISIONS + result[i++] = ColorUtils.HSLToColor( + floatArrayOf(MAX_HUE.toFloat(), 1f, lightness) + ) + } + + return result + } + + private fun calculateLightness(hue: Float, valueFor60To80: Float): Float { + return when { + hue < 60f -> interpolate(0f, 0.45f, 60f, valueFor60To80, hue) + hue < 180f -> valueFor60To80 + hue < 240f -> interpolate(180f, valueFor60To80, 240f, 0.5f, hue) + hue < 300f -> interpolate(240f, 0.5f, 300f, 0.4f, hue) + hue < 360f -> interpolate(300f, 0.4f, 360f, 0.45f, hue) + else -> 0.45f + } + } + + private fun interpolate(x1: Float, y1: Float, x2: Float, y2: Float, x: Float): Float { + return ((y1 * (x2 - x)) + (y2 * (x - x1))) / (x2 - x1) + } +} + +private const val DEFAULT_FRACTION = 0.14f +private const val MAX_BAR_LENGTH_DP = 320 +private const val TRACK_THICKNESS_DP = 20 +private const val THUMB_DIAMETER_DP = 24 +private const val THUMB_BORDER_DP = 6 + +@PhonePortraitDayPreview +@PhonePortraitNightPreview +@Composable +private fun HSVColorBarPreview() { + Previews.Preview { + HSVColorBar( + state = rememberHSVColorBarState(), + onColorChanged = {} + ) + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditor.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditor.kt index 588c9f6158..bafd3404bf 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditor.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditor.kt @@ -9,60 +9,139 @@ import android.graphics.PointF import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester 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 androidx.compose.ui.platform.LocalSoftwareKeyboardController +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 @Composable fun ImageEditor( - state: ImageEditorState, + controller: EditorController.Image, modifier: Modifier = Modifier ) { val context = LocalContext.current + val state = controller.imageEditorState DisposableEffect(state) { state.attach() onDispose { state.detach() } } - Canvas( - modifier = modifier - .clipToBounds() - .onSizeChanged { state.updateViewMatrix(it.width.toFloat(), it.height.toFloat()) } - .imageEditorPointerInput(state) - ) { - state.revision + Box(modifier = modifier) { + Canvas( + modifier = Modifier + .matchParentSize() + .clipToBounds() + .onSizeChanged { state.updateViewMatrix(it.width.toFloat(), it.height.toFloat()) } + .imageEditorPointerInput(state, controller) + ) { + 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() + val nativeCanvas = drawContext.canvas.nativeCanvas + val rendererContext = state.getOrCreateRendererContext(context, nativeCanvas) + rendererContext.save() + try { + rendererContext.canvasMatrix.initial(state.viewMatrix) + state.editorModel.draw(rendererContext, state.textEditingElement) + } finally { + rendererContext.restore() + } + } + + if (controller.textEditingElement != null) { + HiddenTextInput(controller = controller) } } } -private fun Modifier.imageEditorPointerInput(state: ImageEditorState): Modifier { - return this.pointerInput(state) { +@Composable +private fun HiddenTextInput(controller: EditorController.Image) { + var text by remember { mutableStateOf(TextFieldValue("")) } + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + BasicTextField( + value = text, + onValueChange = { newValue -> + text = newValue + controller.onTextChanged(newValue.text) + controller.onTextSelectionChanged(newValue.selection.start, newValue.selection.end) + }, + modifier = Modifier + .size(1.dp) + .alpha(0f) + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.None) + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + DisposableEffect(Unit) { + onDispose { keyboardController?.hide() } + } +} + +private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller: EditorController.Image): Modifier { + return this.pointerInput(controller, controller.textEditingElement) { val touchHandler = ImageEditorTouchHandler() awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = true) - down.consume() + + if (state.textEditingElement != null) { + // During text editing, a tap on the canvas finishes editing + down.consume() + while (true) { + val event = awaitPointerEvent() + val anyPressed = event.changes.any { it.pressed } + event.changes.forEach { it.consume() } + if (!anyPressed) { + controller.finishTextEditing() + break + } + } + return@awaitEachGesture + } + touchHandler.setDrawing(state.isDrawing, state.isBlur) touchHandler.setDrawingBrush(state.drawColor, state.drawThickness, state.drawCap) - touchHandler.onDown(state.editorModel, state.viewMatrix, down.position.toPointF()) + val hitElement = touchHandler.onDown(state.editorModel, state.viewMatrix, down.position.toPointF()) + + if (!state.isDrawing && !state.isBlur) { + controller.onEntityTapped(hitElement) + } + + // In NONE mode with nothing hit, let the pager handle the gesture + if (controller.mode == EditorController.Image.Mode.NONE && !touchHandler.hasActiveSession()) { + return@awaitEachGesture + } + + down.consume() var previousPointerCount = 1 diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorState.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorState.kt index 97340cbc4e..828146304b 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorState.kt @@ -20,6 +20,7 @@ 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.EditorElement import org.signal.imageeditor.core.model.EditorModel /** @@ -43,6 +44,7 @@ class ImageEditorState( var redoAvailable: Boolean by mutableStateOf(false) private set + var textEditingElement: EditorElement? = null var isDrawing: Boolean = false var isBlur: Boolean = false var drawColor: Int = 0xff000000.toInt() diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorToolbar.kt index 1d01f8097c..2b5177dc5b 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorToolbar.kt @@ -43,11 +43,18 @@ fun ImageEditorToolbar( imageEditorController: EditorController.Image, modifier: Modifier = Modifier ) { - when (imageEditorController.mode) { - EditorController.Image.Mode.NONE -> { + when { + imageEditorController.shouldDisplayColorBar -> { + HSVColorBar( + state = imageEditorController.textColorBarState, + onColorChanged = imageEditorController::setTextColor, + modifier = modifier + ) + } + imageEditorController.mode == EditorController.Image.Mode.NONE -> { ImageEditorNoneStateToolbar(imageEditorController, modifier) } - EditorController.Image.Mode.CROP -> { + imageEditorController.mode == EditorController.Image.Mode.CROP -> { ImageEditorCropAndResizeToolbar(imageEditorController, modifier) } else -> { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt index 3e7e9b9ae7..31681bd1ef 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn @@ -79,7 +80,7 @@ fun MediaEditScreen( when (val editorState = state.editorStateMap[uri]) { is EditorState.Image -> { ImageEditor( - state = controllers.getOrCreateImageController(uri, editorState.model).imageEditorState, + controller = controllers.getOrCreateImageController(uri, editorState.model), modifier = Modifier.fillMaxSize() ) } @@ -98,12 +99,16 @@ fun MediaEditScreen( } } + val isTextEditing = currentController is EditorController.Image && currentController.textEditingElement != null + Column( verticalArrangement = spacedBy(20.dp), horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.align(Alignment.BottomCenter) + modifier = Modifier + .align(Alignment.BottomCenter) + .then(if (isTextEditing) Modifier.imePadding() else Modifier) ) { - if (state.selectedMedia.isNotEmpty()) { + if (state.selectedMedia.isNotEmpty() && currentController?.isUserInEdit != true) { ThumbnailRow( selectedMedia = state.selectedMedia, pagerState = pagerState, @@ -133,21 +138,26 @@ fun MediaEditScreen( is EditorController.VideoTrim, null -> Unit } - AddAMessageRow( - message = state.message, - callback = callback, - onNextClick = { backStack.goToSend() }, - modifier = Modifier - .widthIn(max = 624.dp) - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp) - ) + if (currentController?.isUserInEdit != true) { + AddAMessageRow( + message = state.message, + callback = callback, + onNextClick = { backStack.goToSend() }, + modifier = Modifier + .widthIn(max = 624.dp) + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + ) + } } if (!isSmallWindowBreakpoint && currentController is EditorController.Image) { ImageEditorToolbar( imageEditorController = currentController, - modifier = Modifier.align(Alignment.CenterEnd).padding(end = 24.dp) + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 24.dp) + .then(if (isTextEditing) Modifier.imePadding() else Modifier) ) } } diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml index d6b00d08c9..fb06cc84c7 100644 --- a/feature/media-send/src/main/res/values/strings.xml +++ b/feature/media-send/src/main/res/values/strings.xml @@ -1,6 +1,9 @@ + Discard changes? + You\'ll lose any changes you\'ve made to this photo. + Discard diff --git a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.java b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.java deleted file mode 100644 index 721c042588..0000000000 --- a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.java +++ /dev/null @@ -1,196 +0,0 @@ -/* - * 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}. - *

- * 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. - *

- * 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); - } -} diff --git a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.kt b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.kt new file mode 100644 index 0000000000..11fa3a7c09 --- /dev/null +++ b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.kt @@ -0,0 +1,195 @@ +/* + * 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 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 [EditorModel]. + * + * Encapsulates the [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. + * + * Usage: call the on* methods in order as pointer events arrive. The handler manages + * edit session state internally. + */ +class ImageEditorTouchHandler { + + private var drawing: Boolean = false + private var blur: Boolean = false + private var drawColor: Int = 0xff000000.toInt() + private var drawThickness: Float = 0.02f + private var drawCap: Paint.Cap = Paint.Cap.ROUND + + private var editSession: EditSession? = null + private var moreThanOnePointerUsedInSession: Boolean = false + + /** Configures whether the next gesture should create a drawing session if no element is hit. */ + fun setDrawing(drawing: Boolean, blur: Boolean) { + this.drawing = drawing + this.blur = blur + } + + /** Sets the brush parameters used when creating new drawing sessions. */ + fun setDrawingBrush(color: Int, thickness: Float, cap: Paint.Cap) { + drawColor = color + drawThickness = thickness + drawCap = cap + } + + /** Begins a new gesture. Creates either a move/resize, thumb drag, or drawing session. */ + fun onDown(model: EditorModel, viewMatrix: Matrix, point: PointF): EditorElement? { + val inverse = Matrix() + val selected = model.findElementAtPoint(point, viewMatrix, inverse) + + moreThanOnePointerUsedInSession = false + model.pushUndoPoint() + editSession = startEdit(model, viewMatrix, inverse, point, selected) + + return editSession?.selected + } + + /** Feeds pointer positions to the active session. Call for every move event. */ + fun onMove(model: EditorModel, pointers: Array) { + val currentEditSession = editSession ?: return + + val pointerCount = minOf(2, pointers.size) + for (p in 0 until pointerCount) { + currentEditSession.movePoint(p, pointers[p]) + } + model.moving(currentEditSession.selected) + } + + /** Transitions a single-finger session to a two-finger session (e.g. pinch-to-zoom). */ + fun onSecondPointerDown(model: EditorModel, viewMatrix: Matrix, newPointerPoint: PointF, pointerIndex: Int) { + val currentEditSession = editSession ?: return + + moreThanOnePointerUsedInSession = true + currentEditSession.commit() + model.pushUndoPoint() + + val newInverse = model.findElementInverseMatrix(currentEditSession.selected, viewMatrix) + editSession = if (newInverse != null) { + currentEditSession.newPoint(newInverse, newPointerPoint, pointerIndex) + } else { + null + } + + if (editSession == null) { + model.dragDropRelease() + } + } + + /** Transitions a two-finger session back to single-finger when one pointer lifts. */ + fun onSecondPointerUp(model: EditorModel, viewMatrix: Matrix, releasedIndex: Int) { + val currentEditSession = editSession ?: return + + currentEditSession.commit() + model.pushUndoPoint() + model.dragDropRelease() + + val newInverse = model.findElementInverseMatrix(currentEditSession.selected, viewMatrix) + editSession = if (newInverse != null) { + currentEditSession.removePoint(newInverse, releasedIndex) + } else { + null + } + } + + /** Ends the current gesture: commits the session and calls [EditorModel.postEdit]. */ + fun onUp(model: EditorModel) { + editSession?.let { + it.commit() + model.dragDropRelease() + editSession = null + } + model.postEdit(moreThanOnePointerUsedInSession) + } + + fun cancel() { + editSession = null + } + + fun hasActiveSession(): Boolean { + return editSession != null + } + + fun getSelected(): EditorElement? { + return editSession?.selected + } + + private fun startEdit( + model: EditorModel, + viewMatrix: Matrix, + inverse: Matrix, + point: PointF, + selected: EditorElement? + ): EditSession? { + val session = startMoveAndResizeSession(model, viewMatrix, inverse, point, selected) + if (session == null && drawing) { + return startDrawingSession(model, viewMatrix, point) + } + return session + } + + private fun startDrawingSession(model: EditorModel, viewMatrix: Matrix, point: PointF): EditSession { + val renderer = BezierDrawingRenderer( + drawColor, + drawThickness * Bounds.FULL_BOUNDS.width(), + drawCap, + model.findCropRelativeToRoot() + ) + val element = EditorElement(renderer, if (blur) EditorModel.Z_MASK else EditorModel.Z_DRAWING) + + model.addElementCentered(element, 1f) + + val elementInverseMatrix = model.findElementInverseMatrix(element, viewMatrix) + + return DrawingSession.start(element, renderer, elementInverseMatrix, point) + } + + private companion object { + fun startMoveAndResizeSession( + model: EditorModel, + viewMatrix: Matrix, + inverse: Matrix, + point: PointF, + selected: EditorElement? + ): EditSession? { + if (selected == null) return null + + if (selected.renderer is ThumbRenderer) { + val thumb = selected.renderer as ThumbRenderer + + val thumbControlledElement = model.findById(thumb.elementToControl) ?: return null + val thumbsParent = model.root.findParent(selected) ?: return null + val thumbContainerRelativeMatrix = model.findRelativeMatrix(thumbsParent, thumbControlledElement) ?: return null + + val elementInverseMatrix = model.findElementInverseMatrix(thumbControlledElement, viewMatrix) + return if (elementInverseMatrix != null) { + ThumbDragEditSession.startDrag( + thumbControlledElement, + elementInverseMatrix, + thumbContainerRelativeMatrix, + thumb.controlPoint, + point + ) + } else { + null + } + } + + return ElementDragEditSession.startDrag(selected, inverse, point) + } + } +}