diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/components/DragAndDropExample.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/components/DragAndDropExample.web.kt index 931cf4f360bcf..85b48899405a4 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/components/DragAndDropExample.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/components/DragAndDropExample.web.kt @@ -16,10 +16,199 @@ package androidx.compose.mpp.demo.components +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.draganddrop.dragAndDropSource +import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.draganddrop.DragAndDropTransferData +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp @Composable actual fun DragAndDropExample() { - Text("No drag and drop in web target yet") + val exportedText = "Hello, DnD!" + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize() + ) { + val textMeasurer = rememberTextMeasurer() + + Column(modifier = Modifier.height(300.dp), verticalArrangement = Arrangement.SpaceEvenly) { + Box( + Modifier + .width(100.dp) + .height(100.dp) + .background(Color.LightGray) + .dragAndDropSource( + drawDragDecoration = { + drawRect( + color = Color.LightGray, + topLeft = Offset(x = 0f, y = 0f), + size = Size(size.width, size.height) + ) + drawRect( + color = Color(255f, 0f, 0f, 0.5f), + topLeft = Offset(x = 50f, y = 50f), + size = Size(size.width / 2, size.height / 2) + ) + drawRect( + color = Color(0f, 255f, 0f, 0.5f), + topLeft = Offset(x = 70f, y = 70f), + size = Size(size.width / 2, size.height / 2) + ) + drawRect( + color = Color(0f, 0f, 255f, 0.5f), + topLeft = Offset(x = 90f, y = 90f), + size = Size(size.width / 2, size.height / 2) + ) + + + val textLayoutResult = textMeasurer.measure( + text = AnnotatedString(exportedText), + layoutDirection = layoutDirection, + density = this + ) + drawText( + textLayoutResult = textLayoutResult, + topLeft = Offset( + x = (size.width - textLayoutResult.size.width) / 2, + y = (size.height - textLayoutResult.size.height) / 2, + ) + ) + } + ) { offset -> + DragAndDropTransferData() + } + ) { + Text("Drag Me", Modifier.align(Alignment.Center)) + } + + Box( + Modifier + .width(100.dp) + .height(100.dp) + .background(Color.LightGray) + .dragAndDropSource( + drawDragDecoration = { + drawRect( + color = Color.Magenta, + topLeft = Offset(x = 0f, y = 0f), + size = Size(size.width, size.height) + ) + drawRect( + color = Color(0f, 0f, 255f, 0.5f), + topLeft = Offset(x = 50f, y = 50f), + size = Size(size.width / 2, size.height / 2) + ) + drawRect( + color = Color(0f, 255f, 0f, 0.5f), + topLeft = Offset(x = 70f, y = 70f), + size = Size(size.width / 2, size.height / 2) + ) + drawRect( + color = Color(255f, 0f, 0f, 0.5f), + topLeft = Offset(x = 90f, y = 90f), + size = Size(size.width / 2, size.height / 2) + ) + + + val textLayoutResult = textMeasurer.measure( + text = AnnotatedString(exportedText), + layoutDirection = layoutDirection, + density = this + ) + drawText( + textLayoutResult = textLayoutResult, + topLeft = Offset( + x = (size.width - textLayoutResult.size.width) / 2, + y = (size.height - textLayoutResult.size.height) / 2, + ) + ) + } + ) { offset -> + DragAndDropTransferData() + } + ) { + Text("Nope, Drag Me!!!", Modifier.align(Alignment.Center)) + } + + } + + var showTargetBorder by remember { mutableStateOf(false) } + var showHovered by remember { mutableStateOf(false) } + var dragCounter by remember { mutableStateOf(0) } + var targetText by remember { mutableStateOf("Drop Here") } + + val dragAndDropTarget = remember { + object: DragAndDropTarget { + override fun onStarted(event: DragAndDropEvent) { + showTargetBorder = true + } + + override fun onEnded(event: DragAndDropEvent) { + showTargetBorder = false + } + + override fun onMoved(event: DragAndDropEvent) { + } + + override fun onEntered(event: DragAndDropEvent) { + showHovered = true + } + + override fun onExited(event: DragAndDropEvent) { + showHovered = false + } + + override fun onDrop(event: DragAndDropEvent): Boolean { + showHovered = false + dragCounter++ + return true + } + } + } + + Box( + Modifier + .size(200.dp) + .background(if (showHovered) Color.Magenta else Color.LightGray, shape = CircleShape) + .border(border = BorderStroke(3.dp, if (showTargetBorder) Color.Black else Color.Transparent), shape = CircleShape) + .clip(CircleShape) + .dragAndDropTarget( + shouldStartDragAndDrop = { true }, + target = dragAndDropTarget + ), + contentAlignment = Alignment.Center + ) { + Text(targetText + " [" + dragCounter + "]", Modifier.align(Alignment.Center)) + } + } } \ No newline at end of file diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.jsWasm.kt b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.jsWasm.kt index 507b000555cb1..96f01fb6fbd24 100644 --- a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.jsWasm.kt +++ b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.jsWasm.kt @@ -21,14 +21,14 @@ import androidx.compose.ui.geometry.Offset /** * A representation of an event sent by the platform during a drag and drop operation. */ -actual class DragAndDropEvent +actual class DragAndDropEvent internal constructor(internal val offset: Offset) /** * Returns the position of this [DragAndDropEvent] relative to the root Compose View in the * layout hierarchy. */ internal actual val DragAndDropEvent.positionInRoot: Offset - get() = TODO("Not yet implemented") + get() = offset /** * Definition for a type representing transferable data. It could be a remote URI, diff --git a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/draganddrop/WebDragAndDropManager.kt b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/draganddrop/WebDragAndDropManager.kt new file mode 100644 index 0000000000000..12dd6179110ca --- /dev/null +++ b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/draganddrop/WebDragAndDropManager.kt @@ -0,0 +1,197 @@ +package androidx.compose.ui.draganddrop + +import androidx.compose.ui.events.EventTargetListener +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.platform.PlatformDragAndDropManager +import androidx.compose.ui.platform.PlatformDragAndDropSource +import androidx.compose.ui.scene.ComposeSceneDragAndDropNode +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import kotlin.math.roundToInt +import kotlinx.browser.document +import kotlinx.browser.window +import org.khronos.webgl.Uint8ClampedArray +import org.w3c.dom.CanvasRenderingContext2D +import org.w3c.dom.DragEvent +import org.w3c.dom.HTMLCanvasElement +import org.w3c.dom.ImageData +import org.w3c.dom.HTMLElement + +internal abstract class WebDragAndDropManager(eventListener: EventTargetListener, globalEventsListener: EventTargetListener, private val density: Density) : + PlatformDragAndDropManager { + override val isRequestDragAndDropTransferRequired: Boolean + get() = false + + abstract val rootDragAndDropNode: ComposeSceneDragAndDropNode + + private val startTransferScope = object : PlatformDragAndDropSource.StartTransferScope { + /** + * Context for an ongoing drag session initiated from Compose. + */ + var dragSessionContext: DragSessionContext? = null + + + override fun startDragAndDropTransfer( + transferData: DragAndDropTransferData, + decorationSize: Size, + drawDragDecoration: DrawScope.() -> Unit + ): Boolean { + dragSessionContext = DragSessionContext() + + val imageBitmap = ImageBitmap( + width = decorationSize.width.roundToInt(), + height = decorationSize.height.roundToInt() + ) + + val canvas = Canvas(imageBitmap) + val canvasScope = CanvasDrawScope() + + canvasScope.draw(density, LayoutDirection.Ltr, canvas, decorationSize, drawDragDecoration) + + val intArray = IntArray(imageBitmap.width * imageBitmap.height) + imageBitmap.readPixels(intArray) + + val uint8ClampedArray = intArray.toUint8ClampedArray() + + val imageData = ImageData(uint8ClampedArray, imageBitmap.width, imageBitmap.height) + + val canvasConverter = document.createElement("canvas") as HTMLCanvasElement + + val scale = density.density + + canvasConverter.width = imageBitmap.width + canvasConverter.height = imageBitmap.height + + require(scale > 0f) + + val width = (decorationSize.width / scale).toInt() + val height = (decorationSize.height / scale).toInt() + + canvasConverter.style.width = "${width}px" + canvasConverter.style.height = "${height}px" + + val canvasConverterContext = canvasConverter.getContext("2d") as CanvasRenderingContext2D + canvasConverterContext.putImageData(imageData, 0.0, 0.0) + + dragSessionContext?.ghostImage = canvasConverter + + return true + } + } + + + init { + initEvents(eventListener, globalEventsListener) + } + + private fun DragEvent.setAsDragImage(ghostImage: HTMLElement) { + with (ghostImage.style) { + position = "absolute" + + top = "0" + left = "0" + + setProperty("pointer-events", "none") + } + + // non-image elements passed to setDragImage should be present on document + // the only browser the only browser not burdened with this limitation is Firefox + document.body?.appendChild(ghostImage) + + dataTransfer?.setDragImage(ghostImage, 0, 0) + + // After browser made a snapshot we can safely remove ghostImage from document + // But it should be done in different frame + window.requestAnimationFrame { + ghostImage.remove() + } + } + + private fun initEvents(eventListener: EventTargetListener, globalEventsListener: EventTargetListener) { + eventListener.addDisposableEvent("dragstart") { event -> + event as DragEvent + + with (rootDragAndDropNode) { + startTransferScope.startDragAndDropTransfer(event.offset) { + startTransferScope.dragSessionContext != null + } + + if (startTransferScope.dragSessionContext != null) { + val dragEvent = DragAndDropEvent(event.offset) + val acceptedTransfer = acceptDragAndDropTransfer(dragEvent) + + if (acceptedTransfer) { + onStarted(dragEvent) + onEntered(dragEvent) + + startTransferScope.dragSessionContext?.ghostImage?.let { ghostImage -> + event.setAsDragImage(ghostImage) + } + } + } else { + event.preventDefault() + } + } + } + + eventListener.addDisposableEvent("drag") { event -> + event as DragEvent + rootDragAndDropNode.onMoved(DragAndDropEvent(event.offset)) + } + + eventListener.addDisposableEvent("dragend") { event -> + event as DragEvent + val dragAndDropEvent = DragAndDropEvent(event.offset) + rootDragAndDropNode.onDrop(dragAndDropEvent) + rootDragAndDropNode.onEnded(dragAndDropEvent) + + startTransferScope.dragSessionContext = null + } + + globalEventsListener.addDisposableEvent("dragover") { event -> + event as DragEvent + event.preventDefault() + event.dataTransfer?.dropEffect = "move" + } + } + + private val DragEvent.offset get() = Offset( + x = offsetX.toFloat(), + y = offsetY.toFloat() + ) * density.density +} + +private class DragSessionContext( + var ghostImage: HTMLCanvasElement? = null +) + +@Suppress("UNUSED_PARAMETER") +private fun setMethodImplForUint8ClampedArray(obj: Uint8ClampedArray, index: Int, value: Int) { js("obj[index] = value;") } +private operator fun Uint8ClampedArray.set(index: Int, value: Int) = setMethodImplForUint8ClampedArray(this, index, value) + +private fun IntArray.toUint8ClampedArray(): Uint8ClampedArray { + val uint8ClampedArray = Uint8ClampedArray(size * 4) + + forEachIndexed { index, intValue -> + val offset = index * 4 + + // red + uint8ClampedArray[offset] = (intValue shr 16) and 0xFF + + // green + uint8ClampedArray[offset + 1] = (intValue shr 8) and 0xFF + + // blue + uint8ClampedArray[offset + 2] = intValue and 0xFF + + // alpha + uint8ClampedArray[offset + 3] = (intValue shr 24) and 0xFF + } + + return uint8ClampedArray +} \ No newline at end of file diff --git a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt index 9b4f5bdee2eb9..e44634033975b 100644 --- a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt +++ b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.InternalComposeApi import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.LocalSystemTheme +import androidx.compose.ui.draganddrop.WebDragAndDropManager import androidx.compose.ui.events.EventTargetListener import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -41,10 +42,12 @@ import androidx.compose.ui.input.pointer.composeButtons import androidx.compose.ui.platform.DefaultInputModeManager import androidx.compose.ui.platform.LocalInternalViewModelStoreOwner import androidx.compose.ui.platform.PlatformContext +import androidx.compose.ui.platform.PlatformDragAndDropManager import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.WebTextInputService import androidx.compose.ui.platform.WindowInfoImpl import androidx.compose.ui.scene.CanvasLayersComposeScene +import androidx.compose.ui.scene.ComposeSceneDragAndDropNode import androidx.compose.ui.scene.ComposeScenePointer import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize @@ -70,10 +73,10 @@ import org.jetbrains.skia.Canvas import org.jetbrains.skiko.SkiaLayer import org.jetbrains.skiko.SkikoRenderDelegate import org.w3c.dom.AddEventListenerOptions +import org.w3c.dom.DragEvent import org.w3c.dom.Element import org.w3c.dom.HTMLCanvasElement import org.w3c.dom.HTMLStyleElement -import org.w3c.dom.HTMLTextAreaElement import org.w3c.dom.HTMLTitleElement import org.w3c.dom.MediaQueryListEvent import org.w3c.dom.TouchEvent @@ -182,6 +185,11 @@ internal class ComposeWindow( override val inputModeManager: InputModeManager = DefaultInputModeManager() + override val dragAndDropManager: PlatformDragAndDropManager = object : WebDragAndDropManager(canvasEvents, state.globalEvents, density) { + override val rootDragAndDropNode: ComposeSceneDragAndDropNode + get() = scene.rootDragAndDropNode + } + override val textInputService = object : WebTextInputService() { override fun getOffset(rect: Rect): Offset { @@ -318,6 +326,7 @@ internal class ComposeWindow( state.init() canvas.setAttribute("tabindex", "0") + canvas.setAttribute("draggable", "true") scene.density = density @@ -567,4 +576,4 @@ fun ComposeViewport( content = content, state = DefaultWindowState(viewportContainer) ) -} +} \ No newline at end of file