Implement proper text-entry component for large screen media send flow.

This commit is contained in:
Alex Hart
2026-04-15 12:51:15 -03:00
committed by jeffrey-signal
parent 2a8bd20bb0
commit b21a72153a
9 changed files with 718 additions and 256 deletions
@@ -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() {
@@ -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<Color>
) {
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<Color> = 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 = {}
)
}
}
@@ -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
@@ -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()
@@ -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 -> {
@@ -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)
)
}
}
@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Dialog title shown when the user tries to leave the image editor with unsaved edits. -->
<string name="MediaSendDialogs__discard_changes">Discard changes?</string>
<!-- Dialog body explaining that leaving the image editor will discard any edits made to the current photo. -->
<string name="MediaSendDialogs__youll_lose_any_changes">You\'ll lose any changes you\'ve made to this photo.</string>
<!-- Confirmation button that discards the user's image edits. -->
<string name="MediaSendDialogs__discard">Discard</string>
</resources>
@@ -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}.
* <p>
* Encapsulates the {@link EditSession} creation and lifecycle so that callers outside
* this package (e.g. a Compose component) can drive the editor without accessing
* package-private classes directly.
* <p>
* Usage: call the on* methods in order as pointer events arrive. The handler manages
* edit session state internally.
*/
public final class ImageEditorTouchHandler {
private boolean drawing;
private boolean blur;
private int drawColor = 0xff000000;
private float drawThickness = 0.02f;
@NonNull
private Paint.Cap drawCap = Paint.Cap.ROUND;
@Nullable private EditSession editSession;
private boolean moreThanOnePointerUsedInSession;
/** Configures whether the next gesture should create a drawing session if no element is hit. */
public void setDrawing(boolean drawing, boolean blur) {
this.drawing = drawing;
this.blur = blur;
}
/** Sets the brush parameters used when creating new drawing sessions. */
public void setDrawingBrush(int color, float thickness, @NonNull Paint.Cap cap) {
this.drawColor = color;
this.drawThickness = thickness;
this.drawCap = cap;
}
/** Begins a new gesture. Creates either a move/resize, thumb drag, or drawing session. */
@Nullable
public EditorElement onDown(@NonNull EditorModel model, @NonNull Matrix viewMatrix, @NonNull PointF point) {
Matrix inverse = new Matrix();
EditorElement selected = model.findElementAtPoint(point, viewMatrix, inverse);
moreThanOnePointerUsedInSession = false;
model.pushUndoPoint();
editSession = startEdit(model, viewMatrix, inverse, point, selected);
return editSession != null ? editSession.getSelected() : null;
}
/** Feeds pointer positions to the active session. Call for every move event. */
public void onMove(@NonNull EditorModel model, @NonNull PointF[] pointers) {
if (editSession == null) return;
int pointerCount = Math.min(2, pointers.length);
for (int p = 0; p < pointerCount; p++) {
editSession.movePoint(p, pointers[p]);
}
model.moving(editSession.getSelected());
}
/** Transitions a single-finger session to a two-finger session (e.g. pinch-to-zoom). */
public void onSecondPointerDown(@NonNull EditorModel model, @NonNull Matrix viewMatrix, @NonNull PointF newPointerPoint, int pointerIndex) {
if (editSession == null) return;
moreThanOnePointerUsedInSession = true;
editSession.commit();
model.pushUndoPoint();
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
if (newInverse != null) {
editSession = editSession.newPoint(newInverse, newPointerPoint, pointerIndex);
} else {
editSession = null;
}
if (editSession == null) {
model.dragDropRelease();
}
}
/** Transitions a two-finger session back to single-finger when one pointer lifts. */
public void onSecondPointerUp(@NonNull EditorModel model, @NonNull Matrix viewMatrix, int releasedIndex) {
if (editSession == null) return;
editSession.commit();
model.pushUndoPoint();
model.dragDropRelease();
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
if (newInverse != null) {
editSession = editSession.removePoint(newInverse, releasedIndex);
} else {
editSession = null;
}
}
/** Ends the current gesture: commits the session and calls {@link EditorModel#postEdit}. */
public void onUp(@NonNull EditorModel model) {
if (editSession != null) {
editSession.commit();
model.dragDropRelease();
editSession = null;
}
model.postEdit(moreThanOnePointerUsedInSession);
}
public void cancel() {
editSession = null;
}
public boolean hasActiveSession() {
return editSession != null;
}
@Nullable
public EditorElement getSelected() {
return editSession != null ? editSession.getSelected() : null;
}
private @Nullable EditSession startEdit(
@NonNull EditorModel model,
@NonNull Matrix viewMatrix,
@NonNull Matrix inverse,
@NonNull PointF point,
@Nullable EditorElement selected
) {
EditSession session = startMoveAndResizeSession(model, viewMatrix, inverse, point, selected);
if (session == null && drawing) {
return startDrawingSession(model, viewMatrix, point);
}
return session;
}
private @Nullable EditSession startDrawingSession(@NonNull EditorModel model, @NonNull Matrix viewMatrix, @NonNull PointF point) {
BezierDrawingRenderer renderer = new BezierDrawingRenderer(drawColor, drawThickness * Bounds.FULL_BOUNDS.width(), drawCap, model.findCropRelativeToRoot());
EditorElement element = new EditorElement(renderer, blur ? EditorModel.Z_MASK : EditorModel.Z_DRAWING);
model.addElementCentered(element, 1);
Matrix elementInverseMatrix = model.findElementInverseMatrix(element, viewMatrix);
return DrawingSession.start(element, renderer, elementInverseMatrix, point);
}
private static @Nullable EditSession startMoveAndResizeSession(
@NonNull EditorModel model,
@NonNull Matrix viewMatrix,
@NonNull Matrix inverse,
@NonNull PointF point,
@Nullable EditorElement selected
) {
if (selected == null) return null;
if (selected.getRenderer() instanceof ThumbRenderer) {
ThumbRenderer thumb = (ThumbRenderer) selected.getRenderer();
EditorElement thumbControlledElement = model.findById(thumb.getElementToControl());
if (thumbControlledElement == null) return null;
EditorElement thumbsParent = model.getRoot().findParent(selected);
if (thumbsParent == null) return null;
Matrix thumbContainerRelativeMatrix = model.findRelativeMatrix(thumbsParent, thumbControlledElement);
if (thumbContainerRelativeMatrix == null) return null;
selected = thumbControlledElement;
Matrix elementInverseMatrix = model.findElementInverseMatrix(selected, viewMatrix);
if (elementInverseMatrix != null) {
return ThumbDragEditSession.startDrag(selected, elementInverseMatrix, thumbContainerRelativeMatrix, thumb.getControlPoint(), point);
} else {
return null;
}
}
return ElementDragEditSession.startDrag(selected, inverse, point);
}
}
@@ -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<PointF>) {
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)
}
}
}