mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-05 21:07:49 +01:00
Add sticker support to V3 media send. Relocate editors into view model and add trash.
This commit is contained in:
@@ -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;
|
||||
|
||||
+52
@@ -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
|
||||
}
|
||||
+1
@@ -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
|
||||
|
||||
|
||||
+1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 },
|
||||
|
||||
+1
-1
@@ -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
|
||||
) {
|
||||
|
||||
|
||||
+8
-3
@@ -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
-3
@@ -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
|
||||
Reference in New Issue
Block a user