Add sticker support to V3 media send. Relocate editors into view model and add trash.

This commit is contained in:
Alex Hart
2026-07-31 10:36:26 -03:00
parent 4a2f9f6bdd
commit 4218189b05
21 changed files with 356 additions and 33 deletions
@@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.rememberRecipientField
import org.thoughtcrime.securesms.registration.olddevice.QuickTransferOldDeviceActivity
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.scribbles.StickerSelectActivityContract
import org.thoughtcrime.securesms.util.CommunicationActions
/**
@@ -53,6 +54,10 @@ class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomShee
private val viewModel: MediaSendViewModel by viewModels { MediaSendViewModel.Factory(args = contractArgs) }
private val stickerLauncher = registerForActivityResult(StickerSelectActivityContract()) { result ->
viewModel.onStickerSelected(result?.toRenderer())
}
override val textStoryDestinations: Set<ContactSearchKey.RecipientSearchKey>
get() = destinations().toSet()
@@ -130,6 +135,8 @@ class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomShee
)
}
is HudCommand.SelectSticker -> stickerLauncher.launch(Unit)
is HudCommand.GoToConversation -> {
lifecycleScope.launch(Dispatchers.Default) {
val recipient = Recipient.resolved(RecipientId.from(it.recipientId.id))
@@ -5,7 +5,15 @@
package org.thoughtcrime.securesms.mediasend.v3
import org.signal.imageeditor.core.Renderer
import org.signal.imageeditor.core.renderers.UriGlideRenderer
import org.signal.mediasend.SentMediaQuality
import org.signal.mediasend.StorySendRequirements
import org.thoughtcrime.securesms.mms.PushMediaConstraints
import org.thoughtcrime.securesms.scribbles.StickerSelectResult
import org.thoughtcrime.securesms.scribbles.stickers.AnalogClockStickerRenderer
import org.thoughtcrime.securesms.scribbles.stickers.DigitalClockStickerRenderer
import org.thoughtcrime.securesms.scribbles.stickers.FeatureSticker
import org.thoughtcrime.securesms.stories.Stories
/**
@@ -16,3 +24,18 @@ fun StorySendRequirements.toAppSendRequirements(): Stories.MediaTransform.SendRe
StorySendRequirements.CAN_NOT_SEND -> Stories.MediaTransform.SendRequirements.CAN_NOT_SEND
StorySendRequirements.REQUIRES_CROP -> Stories.MediaTransform.SendRequirements.REQUIRES_CLIP
}
/**
* Turns a sticker pick into a [Renderer] the image editor can place.
*/
internal fun StickerSelectResult.toRenderer(): Renderer = when (this) {
is StickerSelectResult.Sticker -> {
val constraints = PushMediaConstraints(SentMediaQuality.HIGH)
UriGlideRenderer(uri, true, constraints.imageMaxWidth, constraints.imageMaxHeight)
}
is StickerSelectResult.Feature -> when (featureSticker) {
FeatureSticker.DIGITAL_CLOCK -> DigitalClockStickerRenderer(System.currentTimeMillis())
FeatureSticker.ANALOG_CLOCK -> AnalogClockStickerRenderer(System.currentTimeMillis())
}
}
@@ -44,6 +44,7 @@ import org.signal.imageeditor.core.ColorableRenderer;
import org.signal.imageeditor.core.ImageEditorView;
import org.signal.imageeditor.core.Renderer;
import org.signal.imageeditor.core.SelectableRenderer;
import org.signal.imageeditor.core.TappableRenderer;
import org.signal.imageeditor.core.model.EditorElement;
import org.signal.imageeditor.core.model.EditorModel;
import org.signal.imageeditor.core.renderers.BezierDrawingRenderer;
@@ -64,7 +65,6 @@ import org.thoughtcrime.securesms.mms.PushMediaConstraints;
import org.thoughtcrime.securesms.scribbles.stickers.AnalogClockStickerRenderer;
import org.thoughtcrime.securesms.scribbles.stickers.DigitalClockStickerRenderer;
import org.thoughtcrime.securesms.scribbles.stickers.FeatureSticker;
import org.thoughtcrime.securesms.scribbles.stickers.TappableRenderer;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.SaveAttachmentUtil;
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter;
@@ -0,0 +1,52 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.scribbles
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.result.contract.ActivityResultContract
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.scribbles.stickers.FeatureSticker
/**
* Launches [ImageEditorStickerSelectActivity] and describes what was picked.
*/
internal class StickerSelectActivityContract : ActivityResultContract<Unit, StickerSelectResult?>() {
companion object {
private val TAG = Log.tag(StickerSelectActivityContract::class)
}
override fun createIntent(context: Context, input: Unit): Intent {
return Intent(context, ImageEditorStickerSelectActivity::class.java)
}
override fun parseResult(resultCode: Int, intent: Intent?): StickerSelectResult? {
if (resultCode != Activity.RESULT_OK || intent == null) {
return null
}
val featureStickerType: String? = intent.getStringExtra(ImageEditorStickerSelectActivity.EXTRA_FEATURE_STICKER)
if (featureStickerType != null) {
val featureSticker = FeatureSticker.entries.firstOrNull { it.type == featureStickerType }
if (featureSticker == null) {
Log.w(TAG, "Unrecognized feature sticker type.")
return null
}
return StickerSelectResult.Feature(featureSticker)
}
return intent.data?.let { StickerSelectResult.Sticker(it) }
}
}
internal sealed interface StickerSelectResult {
data class Sticker(val uri: Uri) : StickerSelectResult
data class Feature(val featureSticker: FeatureSticker) : StickerSelectResult
}
@@ -7,6 +7,7 @@ import android.os.Parcelable
import org.signal.imageeditor.core.Bounds
import org.signal.imageeditor.core.RendererContext
import org.signal.imageeditor.core.SelectableRenderer
import org.signal.imageeditor.core.TappableRenderer
import org.signal.imageeditor.core.renderers.InvalidateableRenderer
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -7,6 +7,7 @@ import android.os.Parcelable
import org.signal.imageeditor.core.Bounds
import org.signal.imageeditor.core.RendererContext
import org.signal.imageeditor.core.SelectableRenderer
import org.signal.imageeditor.core.TappableRenderer
import org.signal.imageeditor.core.renderers.InvalidateableRenderer
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -36,6 +36,9 @@ sealed interface HudCommand {
val isViewOnceAvailable: Boolean
) : HudCommand
/** Show the app's sticker picker. The pick is handed back via [MediaSendViewModel.onStickerSelected]. */
data object SelectSticker : HudCommand
data class GoToConversation(val recipientId: MediaRecipientId) : HudCommand
data object GoToLinkedDevices : HudCommand
data class GoToQuickTransfer(val qrData: String) : HudCommand
@@ -31,6 +31,7 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.showSnackbar
import org.signal.mediasend.capture.MediaCaptureScreen
import org.signal.mediasend.edit.ImageController
import org.signal.mediasend.edit.MediaEditScreen
import org.signal.mediasend.select.MediaSelectScreen
import org.signal.mediasend.select.MediaSelectScreenState
@@ -45,6 +46,7 @@ import org.signal.mediasend.select.MediaSelectScreenState
internal fun MediaSendNavDisplay(
stateFlow: StateFlow<MediaSendState>,
snackbarEvents: Flow<SnackbarEvent>,
imageControllers: ImageController.Container,
backStack: NavBackStack<NavKey>,
eventHandler: MediaSendEventHandler,
modifier: Modifier = Modifier,
@@ -103,7 +105,8 @@ internal fun MediaSendNavDisplay(
val state by stateFlow.collectAsStateWithLifecycle()
MediaEditScreen(
state = state,
onEvent = eventHandler::onMediaEditScreenEvent
onEvent = eventHandler::onMediaEditScreenEvent,
imageControllers = imageControllers
)
}
@@ -151,6 +154,7 @@ private fun MediaSendNavDisplayPreview() {
MediaSendNavDisplay(
stateFlow = MutableStateFlow(MediaSendState(isCameraFirst = true)),
snackbarEvents = emptyFlow(),
imageControllers = remember { ImageController.Container() },
backStack = rememberNavBackStack(MediaSendNavKey.Edit),
eventHandler = MediaSendEventHandler.Empty,
textStoryEditorSlot = { BoxWithText("Text Story Editor Slot") },
@@ -76,6 +76,7 @@ fun MediaSendScreen(
MediaSendNavDisplay(
stateFlow = viewModel.state,
snackbarEvents = viewModel.snackbarEvents,
imageControllers = viewModel.imageControllers,
backStack = viewModel.backStack,
eventHandler = viewModel,
modifier = modifier,
@@ -50,13 +50,16 @@ import org.signal.core.util.ContentTypeUtil
import org.signal.core.util.StringUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.next
import org.signal.imageeditor.core.Renderer
import org.signal.imageeditor.core.model.EditorElement
import org.signal.imageeditor.core.model.EditorModel
import org.signal.imageeditor.core.renderers.UriGlideRenderer
import org.signal.mediasend.capture.CameraXScreenEvent
import org.signal.mediasend.capture.MediaCaptureScreenEvent
import org.signal.mediasend.edit.ImageController
import org.signal.mediasend.edit.MediaEditScreenEvent
import org.signal.mediasend.edit.image.BrushTool
import org.signal.mediasend.edit.image.BrushWidthsState
import org.signal.mediasend.edit.video.VideoTrimData
import org.signal.mediasend.preupload.PreUploadController
import org.signal.mediasend.preupload.PreUploadResult
@@ -146,6 +149,9 @@ class MediaSendViewModel(
private val hudCommandChannel = Channel<HudCommand>(Channel.BUFFERED)
val hudCommands: Flow<HudCommand> = hudCommandChannel.receiveAsFlow()
/** Per-image editor controllers, held here so results arriving from outside the flow can be applied immediately. */
internal val imageControllers = ImageController.Container(BrushWidthsState(internalState.value.brushWidths))
/** Media filter errors. */
private val _mediaErrors = MutableSharedFlow<MediaFilterError>(replay = 1)
val mediaErrors: SharedFlow<MediaFilterError> = _mediaErrors.asSharedFlow()
@@ -302,6 +308,10 @@ class MediaSendViewModel(
)
}
MediaEditScreenEvent.StickerClick -> {
sendHudCommand(HudCommand.SelectSticker)
}
MediaEditScreenEvent.NavigateToGallery -> {
backStack.goToFolders()
}
@@ -324,6 +334,28 @@ class MediaSendViewModel(
}
}
/**
* Result of the picker opened for [HudCommand.SelectSticker], applied to the focused image. A null [renderer] means
* the picker was dismissed.
*/
fun onStickerSelected(renderer: Renderer?) {
val controller = focusedImageController() ?: return
if (renderer != null) {
controller.insertSticker(renderer)
} else {
controller.cancelStickerInsertion()
}
}
private fun focusedImageController(): ImageController? {
val snapshot = state.value
val uri = snapshot.focusedMedia?.uri ?: return null
val editorState = snapshot.editorStateMap[uri] as? EditorState.Image ?: return null
return imageControllers.getOrCreate(uri, editorState.model)
}
private fun setBrushWidth(tool: BrushTool, fraction: Float) {
val brushWidths = state.value.brushWidths.with(tool, fraction)
@@ -633,6 +665,8 @@ class MediaSendViewModel(
)
}
media.forEach { imageControllers.remove(it.uri) }
if (newSelection.isEmpty() && !snapshot.suppressEmptyError) {
viewModelScope.launch {
_mediaErrors.emit(MediaFilterError.NoItems)
@@ -8,13 +8,14 @@ package org.signal.mediasend.edit
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.Renderer
import org.signal.imageeditor.core.SelectableRenderer
import org.signal.imageeditor.core.TappableRenderer
import org.signal.imageeditor.core.model.EditorElement
import org.signal.imageeditor.core.model.EditorModel
import org.signal.imageeditor.core.renderers.MultiLineTextRenderer
@@ -31,11 +32,16 @@ import org.signal.mediasend.edit.image.ImageEditorState
* trim/seek interaction flows through the screen's event channel.
*/
@Stable
class ImageController @RememberInComposition constructor(
internal class ImageController(
private val editorModel: EditorModel,
private val brushWidths: BrushWidthsState = BrushWidthsState()
) {
companion object {
/** Size of a new sticker, relative to the visible area of the image. */
private const val STICKER_SCALE = 0.4f
}
val isUserInEdit: Boolean by derivedStateOf { mode != Mode.NONE }
val imageEditorState = ImageEditorState(editorModel).also {
@@ -55,6 +61,8 @@ class ImageController @RememberInComposition constructor(
private var minDialScaleDown: Float = 1f
private var drawSessionSnapshot: ByteArray? = null
private var drawSessionDirty: Boolean by mutableStateOf(false)
private var modeBeforeStickerInsertion: Mode = Mode.NONE
private var modeBeforeDrag: Mode = Mode.NONE
var textEditingElement: EditorElement? by mutableStateOf(null)
private set
@@ -102,6 +110,11 @@ class ImageController @RememberInComposition constructor(
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
fun requestCancelEdit() {
if (hasUnsavedChanges) {
showDiscardDialog = true
@@ -288,20 +301,32 @@ class ImageController @RememberInComposition constructor(
imageEditorState.invalidate()
}
fun onEntityTapped(element: EditorElement?) {
fun onEntityDown(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
}
selectElement(element)
} else {
clearSelection()
}
}
fun onEntitySingleTap(element: EditorElement?) {
val tappable = element?.renderer as? TappableRenderer ?: return
tappable.onTapped()
imageEditorState.invalidate()
}
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
}
}
private fun clearSelection() {
if (selectedElement != null) {
(selectedElement?.renderer as? SelectableRenderer)?.onSelected(false)
@@ -313,9 +338,93 @@ class ImageController @RememberInComposition constructor(
}
fun enterStickerMode() {
modeBeforeStickerInsertion = mode
mode = Mode.INSERT_STICKER
}
fun insertSticker(renderer: Renderer) {
val element = EditorElement(renderer, EditorModel.Z_STICKERS)
editorModel.addElementCentered(element, STICKER_SCALE)
// The picker can be opened mid-draw-session, where touches would otherwise keep painting.
imageEditorState.isDrawing = false
imageEditorState.isBlur = false
selectElement(element)
imageEditorState.invalidate()
}
fun cancelStickerInsertion() {
if (mode == Mode.INSERT_STICKER) {
mode = modeBeforeStickerInsertion
}
}
/** Reveals the trash so the drag can end in a delete. */
fun onDragStarted(element: EditorElement?) {
if (mode == Mode.CROP || element == null || element.renderer !is SelectableRenderer) {
return
}
modeBeforeDrag = mode
selectElement(element)
isDraggedElementOverTrash = false
mode = Mode.DELETE
setTrashVisible(true)
}
fun onDragMoved(element: EditorElement?, isOverTrash: Boolean) {
if (mode != Mode.DELETE || element == null || isOverTrash == isDraggedElementOverTrash) {
return
}
isDraggedElementOverTrash = isOverTrash
if (isOverTrash) {
element.animatePartialFadeOut(imageEditorState::invalidate)
} else {
element.animatePartialFadeIn(imageEditorState::invalidate)
}
}
fun onDragEnded(element: EditorElement?, isOverTrash: Boolean) {
if (mode != Mode.DELETE) {
return
}
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()
}
} else {
element.animatePartialFadeIn(imageEditorState::invalidate)
selectElement(element)
}
}
private fun setTrashVisible(visible: Boolean) {
editorModel.trash.flags
.setVisible(visible)
.persist()
imageEditorState.invalidate()
}
fun lockCrop() {
editorModel.setCropAspectLock(true)
isCropAspectRatioLocked = true
@@ -369,14 +478,28 @@ class ImageController @RememberInComposition constructor(
INSERT_STICKER
}
/**
* The controller for each image in the selection. Owned by the view-model rather than the composition so edits from
* outside the Edit screen apply right away.
*/
@Stable
class Container @RememberInComposition constructor(
class Container(
private val brushWidths: BrushWidthsState = BrushWidthsState()
) {
private val controllers = SnapshotStateMap<Uri, ImageController>()
fun getOrCreate(uri: Uri, editorModel: EditorModel): ImageController {
return controllers.getOrPut(uri) { ImageController(editorModel, brushWidths) }
val existing = controllers[uri]
if (existing != null && existing.editorModel === editorModel) {
return existing
}
// Re-adding a removed item builds it a fresh model, leaving any cached controller editing one nobody renders.
return ImageController(editorModel, brushWidths).also { controllers[uri] = it }
}
fun remove(uri: Uri) {
controllers.remove(uri)
}
}
}
@@ -61,7 +61,6 @@ import org.signal.mediasend.MediaSendState
import org.signal.mediasend.edit.document.DocumentPage
import org.signal.mediasend.edit.image.BrushWidthBar
import org.signal.mediasend.edit.image.BrushWidthPreview
import org.signal.mediasend.edit.image.BrushWidthsState
import org.signal.mediasend.edit.image.DrawModeColorBar
import org.signal.mediasend.edit.image.ImageEditor
import org.signal.mediasend.edit.image.ImageEditorClearAllButton
@@ -74,9 +73,10 @@ import org.signal.mediasend.edit.video.VideoTrimBar
import org.signal.mediasend.rememberPreviewState
@Composable
fun MediaEditScreen(
internal fun MediaEditScreen(
state: MediaSendState,
onEvent: (MediaEditScreenEvent) -> Unit
onEvent: (MediaEditScreenEvent) -> Unit,
imageControllers: ImageController.Container
) {
val scope = rememberCoroutineScope()
@@ -105,8 +105,6 @@ fun MediaEditScreen(
Box(modifier = Modifier.fillMaxSize()) {
val isSmallWindowBreakpoint = rememberWindowBreakpoint() is WindowBreakpoint.Small
val imageControllers = remember { ImageController.Container(BrushWidthsState(state.brushWidths)) }
val videoEditorViewModel = rememberVideoEditorViewModel()
val focusedUri = state.focusedMedia?.uri
@@ -508,7 +506,8 @@ private fun MediaEditScreenPreview() {
selectedMedia.first().uri to EditorState.Image(EditorModel.create(0))
)
),
onEvent = {}
onEvent = {},
imageControllers = remember { ImageController.Container() }
)
}
}
@@ -14,6 +14,7 @@ sealed interface MediaEditScreenEvent {
data class FocusedMediaChanged(val media: Media) : MediaEditScreenEvent
data class ReorderSelectedMedia(val fromIndex: Int, val toIndex: Int) : MediaEditScreenEvent
data class AddMessageClick(val startWithEmojiKeyboard: Boolean = false) : MediaEditScreenEvent
data object StickerClick : MediaEditScreenEvent
data object NextClick : MediaEditScreenEvent
data object NavigateBack : MediaEditScreenEvent
data object NavigateToGallery : MediaEditScreenEvent
@@ -7,7 +7,6 @@ package org.signal.mediasend.edit.image
import android.os.Parcelable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.annotation.RememberInComposition
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -66,7 +65,7 @@ data class BrushWidths(
* selection draws with the same brush.
*/
@Stable
class BrushWidthsState @RememberInComposition constructor(initialWidths: BrushWidths = BrushWidths()) {
internal class BrushWidthsState(initialWidths: BrushWidths = BrushWidths()) {
var widths: BrushWidths by mutableStateOf(initialWidths)
private set
@@ -5,6 +5,7 @@
package org.signal.mediasend.edit.image
import android.graphics.Matrix
import android.graphics.PointF
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
@@ -27,30 +28,40 @@ 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.hapticfeedback.HapticFeedbackType
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.LocalHapticFeedback
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
import org.signal.imageeditor.core.model.EditorElement
import org.signal.mediasend.edit.ImageController
@Composable
fun ImageEditor(
internal fun ImageEditor(
controller: ImageController,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val state = controller.imageEditorState
val hapticFeedback = LocalHapticFeedback.current
DisposableEffect(state) {
state.attach()
onDispose { state.detach() }
}
LaunchedEffect(controller.isDraggedElementOverTrash) {
if (controller.isDraggedElementOverTrash) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
Box(modifier = modifier) {
Canvas(
modifier = Modifier
@@ -107,6 +118,9 @@ private fun HiddenTextInput(controller: ImageController) {
}
}
/** How far a finger travels before a touch on an element counts as a drag. */
private const val MAX_MOVE_SQUARED_BEFORE_DRAG = 10f
private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller: ImageController): Modifier {
return this.pointerInput(controller, controller.textEditingElement) {
val touchHandler = ImageEditorTouchHandler()
@@ -134,7 +148,7 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller
val hitElement = touchHandler.onDown(state.editorModel, state.viewMatrix, down.position.toPointF())
if (!state.isDrawing && !state.isBlur) {
controller.onEntityTapped(hitElement)
controller.onEntityDown(hitElement)
}
// In NONE mode with nothing hit, let the pager handle the gesture
@@ -146,6 +160,9 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller
state.isGestureActive = true
var previousPointerCount = 1
var inDrag = false
var draggedElement: EditorElement? = null
var droppedOnTrash = false
try {
while (true) {
@@ -155,8 +172,23 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller
if (currentCount == 0) {
event.changes.forEach { it.consume() }
if (inDrag) {
val upPoint = (event.changes.firstOrNull()?.position ?: down.position).toPointF()
droppedOnTrash = previousPointerCount == 1 &&
touchHandler.checkTrashIntersect(state.editorModel, upPoint) &&
state.editorModel.findElementAtPoint(upPoint, state.viewMatrix, Matrix()) === draggedElement
}
val wasSingleTap = !inDrag && !touchHandler.isDrawingSession()
touchHandler.onUp(state.editorModel)
state.onGestureCompleted?.invoke()
if (wasSingleTap) {
controller.onEntitySingleTap(hitElement)
}
break
}
@@ -174,11 +206,24 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller
state.invalidate()
}
val position = currentPressed.first().position
if (inDrag) {
controller.onDragMoved(draggedElement, touchHandler.checkTrashIntersect(state.editorModel, position.toPointF()))
} else if (currentCount == 1 && !touchHandler.isDrawingSession() && (position - down.position).getDistanceSquared() > MAX_MOVE_SQUARED_BEFORE_DRAG) {
inDrag = true
draggedElement = touchHandler.getSelected()
controller.onDragStarted(draggedElement)
}
event.changes.forEach { it.consume() }
previousPointerCount = currentCount
}
} finally {
state.isGestureActive = false
if (inDrag) {
controller.onDragEnded(draggedElement, droppedOnTrash)
}
}
}
}
@@ -15,7 +15,7 @@ import org.signal.mediasend.edit.ImageController
* Hides or shows content based on whether the user is performing a gesture.
*/
@Composable
fun ImageEditorGestureAwareControl(
internal fun ImageEditorGestureAwareControl(
imageEditorController: ImageController?,
modifier: Modifier = Modifier,
extraCheck: () -> Boolean = { true },
@@ -32,7 +32,7 @@ import org.signal.imageeditor.core.model.EditorModel
* flow: mutations go into the model, the composable only reads and renders.
*/
@Stable
class ImageEditorState(
internal class ImageEditorState(
val editorModel: EditorModel
) {
@@ -36,13 +36,15 @@ import org.signal.mediasend.rememberPreviewState
import java.util.EnumMap
@Composable
fun ImageEditorToolbar(
internal fun ImageEditorToolbar(
imageEditorController: ImageController,
state: MediaSendState,
onEvent: (MediaEditScreenEvent) -> Unit,
modifier: Modifier = Modifier
) {
when {
// The trash is the only thing on screen during a drag.
imageEditorController.isUserDraggingElement -> Unit
imageEditorController.shouldDisplayTextColorBar -> {
HSVColorBar(
state = imageEditorController.textColorBarState,
@@ -57,7 +59,7 @@ fun ImageEditorToolbar(
ImageEditorCropAndResizeToolbar(imageEditorController, modifier)
}
else -> {
ImageEditorDrawStateToolbar(imageEditorController, modifier)
ImageEditorDrawStateToolbar(imageEditorController, onEvent, modifier)
}
}
}
@@ -94,6 +96,7 @@ private fun ImageEditorNoneStateToolbar(
@Composable
private fun ImageEditorDrawStateToolbar(
imageEditorController: ImageController,
onEvent: (MediaEditScreenEvent) -> Unit,
modifier: Modifier = Modifier
) {
MediaEditorToolbar(
@@ -131,6 +134,7 @@ private fun ImageEditorDrawStateToolbar(
onCheckChanged = {
if (!imageEditorController.isUserInsertingSticker) {
imageEditorController.enterStickerMode()
onEvent(MediaEditScreenEvent.StickerClick)
}
}
)
@@ -290,7 +294,8 @@ private fun ImageEditorDrawStateToolbarPreview() {
ImageController(EditorModel.create(0)).apply {
enterDrawMode()
}
}
},
onEvent = {}
)
}
}
@@ -58,7 +58,7 @@ private val MINOR_INDICATOR_HEIGHT = 12.dp
private val MAJOR_INDICATOR_HEIGHT = 24.dp
@Composable
fun RotationDial(
internal fun RotationDial(
imageEditorController: ImageController,
modifier: Modifier = Modifier
) {
@@ -12,6 +12,7 @@ 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
import org.signal.imageeditor.core.renderers.TrashRenderer
/**
* Public facade for touch handling on an [EditorModel].
@@ -33,6 +34,7 @@ class ImageEditorTouchHandler {
private var editSession: EditSession? = null
private var moreThanOnePointerUsedInSession: Boolean = false
private var drawingSession: Boolean = false
/** Configures whether the next gesture should create a drawing session if no element is hit. */
fun setDrawing(drawing: Boolean, blur: Boolean) {
@@ -124,6 +126,28 @@ class ImageEditorTouchHandler {
return editSession != null
}
/** True when the gesture is laying down a stroke rather than moving an element. */
fun isDrawingSession(): Boolean {
return drawingSession
}
/** Whether [point] is over the trash, growing or shrinking it to match. False while the trash is hidden. */
fun checkTrashIntersect(model: EditorModel, point: PointF): Boolean {
if (drawingSession) {
return false
}
val trashRenderer = model.trash.renderer as? TrashRenderer
return if (model.checkTrashIntersectsPoint(point)) {
trashRenderer?.expand()
true
} else {
trashRenderer?.shrink()
false
}
}
fun getSelected(): EditorElement? {
return editSession?.selected
}
@@ -137,8 +161,11 @@ class ImageEditorTouchHandler {
): EditSession? {
val session = startMoveAndResizeSession(model, viewMatrix, inverse, point, selected)
if (session == null && drawing) {
drawingSession = true
return startDrawingSession(model, viewMatrix, point)
}
drawingSession = false
return session
}
@@ -1,6 +1,4 @@
package org.thoughtcrime.securesms.scribbles.stickers
import org.signal.imageeditor.core.Renderer
package org.signal.imageeditor.core
/**
* A renderer that can handle a tap event