mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-23 08:40:14 +01:00
Implement proper text-entry component for large screen media send flow.
This commit is contained in:
committed by
jeffrey-signal
parent
2a8bd20bb0
commit
b21a72153a
@@ -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>
|
||||
|
||||
-196
@@ -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);
|
||||
}
|
||||
}
|
||||
+195
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user