Merge pull request #117 from amir1376/update-drag-and-drop-logic

update drag and drop logic
This commit is contained in:
AmirHossein Abdolmotallebi 2024-10-17 17:39:42 +03:30 committed by GitHub
commit ac3e9841aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 45 additions and 572 deletions

View File

@ -65,7 +65,6 @@ dependencies {
implementation(project(":integration:server"))
implementation(project(":desktop:shared"))
implementation(project(":desktop:tray"))
implementation(project(":desktop:external-draggable"))
implementation(project(":desktop:custom-window-frame"))
implementation(project(":shared:app-utils"))
implementation(project(":shared:utils"))

View File

@ -674,16 +674,14 @@ class HomeComponent(
currentActiveDrops.update { parsedLinks }
}
fun onExternalFilesDraggedIn(getFilePaths: () -> List<String>) {
val filePaths = getFilePaths().map {
URI.create(it)
}
.mapNotNull {
runCatching { File(it.path) }.getOrNull()
}
fun onExternalFilesDraggedIn(getFilePaths: () -> List<File>) {
val filePaths = kotlin.runCatching { getFilePaths() }
.getOrNull()?.filter { it.length() <= 1024 * 1024 } ?: return
onExternalTextDraggedIn {
filePaths.first()
.readText()
filePaths
.firstOrNull()
?.readText()
.orEmpty()
}
}

View File

@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import com.abdownloadmanager.desktop.ui.widget.Text
import androidx.compose.runtime.*
import com.abdownloadmanager.desktop.utils.externaldraggable.onExternalDrag
import androidx.compose.ui.*
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
@ -32,8 +31,12 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import com.abdownloadmanager.desktop.ui.widget.ActionButton
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.draganddrop.awtTransferable
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalDensity
@ -41,10 +44,11 @@ import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.window.Dialog
import com.abdownloadmanager.desktop.ui.customwindow.*
import com.abdownloadmanager.desktop.ui.widget.menu.ShowOptionsInDropDown
import com.abdownloadmanager.desktop.utils.externaldraggable.DragData
import com.abdownloadmanager.utils.category.Category
import com.abdownloadmanager.utils.category.rememberIconPainter
import ir.amirab.util.compose.action.MenuItem
import java.awt.datatransfer.DataFlavor
import java.io.File
@Composable
@ -162,27 +166,40 @@ fun HomePage(component: HomeComponent) {
Box(
Modifier
.fillMaxSize()
.onExternalDrag(
onDragStart = {
isDragging = true
it.availableDragData.get<DragData.Text>()?.also {
component.onExternalTextDraggedIn { it.readText() }
return@onExternalDrag
}
it.availableDragData.get<DragData.FilesList>()?.also {
//Caution FileList::readFiles sometimes throws exception
component.onExternalFilesDraggedIn { it.readFiles() }
return@onExternalDrag
}
.dragAndDropTarget(
shouldStartDragAndDrop = {
it.awtTransferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor) ||
it.awtTransferable.isDataFlavorSupported(DataFlavor.stringFlavor)
},
onDragExit = {
isDragging = false
component.onDragExit()
target = remember {
object : DragAndDropTarget {
override fun onStarted(event: DragAndDropEvent) {
isDragging = true
if (event.awtTransferable.isDataFlavorSupported(DataFlavor.stringFlavor)) {
component.onExternalTextDraggedIn { (event.awtTransferable.getTransferData(DataFlavor.stringFlavor) as String) }
return
}
if (event.awtTransferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
component.onExternalFilesDraggedIn {
(event.awtTransferable.getTransferData(DataFlavor.javaFileListFlavor) as List<File>)
}
return
}
}
override fun onEnded(event: DragAndDropEvent) {
isDragging = false
component.onDragExit()
}
override fun onDrop(event: DragAndDropEvent): Boolean {
isDragging = false
component.onDropped()
return true
}
}
}
) {
isDragging = false
component.onDropped()
}
)
) {
Column(
Modifier.alpha(

View File

@ -1,7 +0,0 @@
plugins{
id(MyPlugins.kotlin)
id(MyPlugins.composeDesktop)
}
dependencies{
implementation(project(":desktop:shared"))
}

View File

@ -1,462 +0,0 @@
package com.abdownloadmanager.desktop.utils.externaldraggable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.Density
import ir.amirab.util.desktop.LocalWindow
import java.awt.Component
import java.awt.GraphicsConfiguration
import java.awt.Point
import java.awt.Window
import java.awt.dnd.DnDConstants
import java.awt.dnd.DropTarget
import java.awt.dnd.DropTargetDragEvent
import java.awt.dnd.DropTargetDropEvent
import java.awt.dnd.DropTargetEvent
import java.awt.dnd.DropTargetListener
/**
* Represent data that is being dragged (or dropped) to a component from outside an application.
*/
interface DragData {
/**
* Represents list of files drag and dropped to a component.
*/
interface FilesList : DragData {
/**
* Returns list of file paths drag and droppped to an application in a URI format.
*/
fun readFiles(): List<String>
}
/**
* Represents an image drag and dropped to a component.
*/
interface Image : DragData {
/**
* Returns an image drag and dropped to an application as a [Painter] type.
*/
fun readImage(): Painter
}
/**
* Represent text drag and dropped to a component.
*/
interface Text : DragData {
/**
* Provides the best MIME type that describes text returned in [readText]
*/
val bestMimeType: String
/**
* Returns a text dropped to an application.
*/
fun readText(): String
}
}
/**
* Represent the current state of drag and drop to a component from outside an application.
* This state is passed to external drag callbacks.
*
* @see onExternalDrag
*/
@Immutable
class ExternalDragValue(
/**
* Position of the pointer relative to the component
*/
val dragPosition: Offset,
/**
* Data that it being dragged (or dropped) in a component bounds
*/
val availableDragData: AvailableDragData
)
/**
* Adds detector of external drag and drop (e.g. files DnD from Finder to an application)
*
* @param onDragStart will be called when the pointer with external content entered the component.
* @param onDrag will be called for pointer movements inside the component.
* @param onDragExit is called if the pointer exited the component bounds.
* @param onDrop is called when the pointer is released.
*/
@Composable
fun Modifier.onExternalDrag(
enabled: Boolean = true,
onDragStart: (ExternalDragValue) -> Unit = {},
onDrag: (ExternalDragValue) -> Unit = {},
onDragExit: () -> Unit = {},
onDrop: (ExternalDragValue) -> Unit = {},
): Modifier = composed {
if (!enabled) {
return@composed Modifier
}
val window = LocalWindow.current ?: return@composed Modifier
val componentDragHandler = rememberUpdatedState(
AwtWindowDropTarget.ComponentDragHandler(onDragStart, onDrag, onDragExit, onDrop)
)
var componentDragHandleId by remember { mutableStateOf<Int?>(null) }
DisposableEffect(window) {
when (val currentDropTarget = window.dropTarget) {
is AwtWindowDropTarget -> {
// if our drop target is already assigned simply add new drag handler for the current component
componentDragHandleId =
currentDropTarget.installComponentDragHandler(componentDragHandler)
}
null -> {
// drop target is not installed for the window, so assign it and add new drag handler for the current component
val newDropTarget = AwtWindowDropTarget(window)
componentDragHandleId =
newDropTarget.installComponentDragHandler(componentDragHandler)
window.dropTarget = newDropTarget
}
else -> {
error("Window already has unknown external dnd handler, cannot attach onExternalDrag")
}
}
onDispose {
// stop drag events handling for this component when window is changed
// or the component leaves the composition
val dropTarget = window.dropTarget as? AwtWindowDropTarget ?: return@onDispose
val handleIdToRemove = componentDragHandleId ?: return@onDispose
dropTarget.stopDragHandling(handleIdToRemove)
}
}
Modifier
.onGloballyPositioned { position ->
// provide new component bounds to Swing to properly detect drag events
val dropTarget = window.dropTarget as? AwtWindowDropTarget
?: return@onGloballyPositioned
val handleIdToUpdate = componentDragHandleId ?: return@onGloballyPositioned
val componentBounds = position.boundsInWindow()
dropTarget.updateComponentBounds(handleIdToUpdate, componentBounds)
}
}
/**
* Provides a way to subscribe on external drag for given [window] using [installComponentDragHandler]
*
* [Window] allows having only one [DropTarget], so this is the main [DropTarget] that handles all the drag subscriptions
*/
internal class AwtWindowDropTarget(
private val window: Window
) : DropTarget(window, DnDConstants.ACTION_MOVE, null, true) {
private var idsCounter = 0
// all components that are subscribed to external drag and drop for the window
// handler's callbacks can be changed on recompositions, so State is kept here
private val handlers = mutableMapOf<Int, State<ComponentDragHandler>>()
// bounds of all components that are subscribed to external drag and drop for the window
private val componentBoundsHolder = mutableMapOf<Int, Rect>()
// state of ongoing external drag and drop in the [window], contains pointer coordinates and data that is dragged
private var currentDragValue: AwtWindowDragTargetListener.WindowDragValue? = null
val dragTargetListener = AwtWindowDragTargetListener(
window,
// notify components on window border that drag is started.
onDragEnterWindow = { newDragValue ->
currentDragValue = newDragValue
forEachPositionedComponent { handler, componentBounds ->
handleDragEvent(
handler,
oldComponentBounds = componentBounds, currentComponentBounds = componentBounds,
oldDragValue = null, currentDragValue = newDragValue,
)
}
},
// drag moved inside window, we should calculate whether drag entered/exited components or just moved inside them
onDragInsideWindow = { newDragValue ->
val oldDragValue = currentDragValue
currentDragValue = newDragValue
forEachPositionedComponent { handler, componentBounds ->
handleDragEvent(
handler,
oldComponentBounds = componentBounds, currentComponentBounds = componentBounds,
oldDragValue, newDragValue
)
}
},
// notify components on window border drag exited window
onDragExit = {
val oldDragValue = currentDragValue
currentDragValue = null
forEachPositionedComponent { handler, componentBounds ->
handleDragEvent(
handler,
oldComponentBounds = componentBounds, currentComponentBounds = componentBounds,
oldDragValue = oldDragValue, currentDragValue = null
)
}
},
// notify all components under the pointer that drop happened
onDrop = { newDragValue ->
var anyDrops = false
currentDragValue = null
forEachPositionedComponent { handler, componentBounds ->
val isInside = isExternalDragInsideComponent(
componentBounds,
newDragValue.dragPositionInWindow
)
if (isInside) {
val offset = calculateOffset(componentBounds, newDragValue.dragPositionInWindow)
handler.onDrop(ExternalDragValue(offset, newDragValue.dragData))
anyDrops = true
}
}
// tell swing whether some components accepted the drop
return@AwtWindowDragTargetListener anyDrops
}
)
init {
addDropTargetListener(dragTargetListener)
}
override fun setActive(isActive: Boolean) {
super.setActive(isActive)
if (!isActive) {
currentDragValue = null
}
}
/**
* Adds handler that will be notified on drag events for [window].
* If component bounds are provided using [updateComponentBounds],
* given lambdas will be called on drag events.
*
* [handlerState]'s callbacks can be changed on recompositions.
* New callbacks won't be called with old events, they will be called on new AWT events only.
*
* @return handler id that can be used later to remove subscription using [stopDragHandling]
* or to update component bounds using [updateComponentBounds]
*/
fun installComponentDragHandler(handlerState: State<ComponentDragHandler>): Int {
isActive = true
val handleId = idsCounter++
handlers[handleId] = handlerState
return handleId
}
/**
* Unsubscribes handler with [handleId].
* Calls [ComponentDragHandler.onDragCancel] if drag is going and handler's component is under pointer
*
* Disable drag handling for [window] if there are no more handlers.
*
* @param handleId id provided by [installComponentDragHandler] function
*/
fun stopDragHandling(handleId: Int) {
val handler = handlers.remove(handleId)
val componentBounds = componentBoundsHolder.remove(handleId)
if (handler != null && componentBounds != null &&
isExternalDragInsideComponent(componentBounds, currentDragValue?.dragPositionInWindow)
) {
handler.value.onDragCancel()
}
if (handlers.isEmpty()) {
isActive = false
}
}
/**
* Updates component bounds within the [window], so drag events will be properly handled.
* If drag is going and component is under the pointer, onDragStart and onDrag will be called.
* If drag is going and component moved/became smaller, so that pointer now is not the component, onDragCancel is called.
*
* All further drag events will use [newComponentBounds] to notify handler with [handleId].
*
* @param newComponentBounds new bounds of the component inside [window] used to properly detect when drag entered/exited component
*/
fun updateComponentBounds(handleId: Int, newComponentBounds: Rect) {
val handler = handlers[handleId] ?: return
val oldComponentBounds = componentBoundsHolder.put(handleId, newComponentBounds)
handleDragEvent(
handler.value, oldComponentBounds, newComponentBounds,
oldDragValue = currentDragValue,
currentDragValue = currentDragValue
)
}
private inline fun forEachPositionedComponent(action: (handler: ComponentDragHandler, bounds: Rect) -> Unit) {
for ((handleId, handler) in handlers) {
val bounds = componentBoundsHolder[handleId] ?: continue
action(handler.value, bounds)
}
}
data class ComponentDragHandler(
val onDragStart: (ExternalDragValue) -> Unit,
val onDrag: (ExternalDragValue) -> Unit,
val onDragCancel: () -> Unit,
val onDrop: (ExternalDragValue) -> Unit
)
companion object {
private fun isExternalDragInsideComponent(
componentBounds: Rect?,
windowDragCoordinates: Offset?
): Boolean {
if (componentBounds == null || windowDragCoordinates == null) {
return false
}
return componentBounds.contains(windowDragCoordinates)
}
private fun calculateOffset(
componentBounds: Rect,
windowDragCoordinates: Offset
): Offset {
return windowDragCoordinates - componentBounds.topLeft
}
/**
* Notifies [handler] about drag events.
*
* Note: this function is pure, so it doesn't update any states
*/
private fun handleDragEvent(
handler: ComponentDragHandler,
oldComponentBounds: Rect?,
currentComponentBounds: Rect?,
oldDragValue: AwtWindowDragTargetListener.WindowDragValue?,
currentDragValue: AwtWindowDragTargetListener.WindowDragValue?,
) {
val wasDragInside = isExternalDragInsideComponent(
oldComponentBounds,
oldDragValue?.dragPositionInWindow
)
val newIsDragInside = isExternalDragInsideComponent(
currentComponentBounds,
currentDragValue?.dragPositionInWindow
)
if (!wasDragInside && newIsDragInside) {
val dragOffset = calculateOffset(
currentComponentBounds!!,
currentDragValue!!.dragPositionInWindow
)
handler.onDragStart(ExternalDragValue(dragOffset, currentDragValue.dragData))
return
}
if (wasDragInside && !newIsDragInside) {
handler.onDragCancel()
return
}
if (newIsDragInside) {
val dragOffset = calculateOffset(
currentComponentBounds!!,
currentDragValue!!.dragPositionInWindow
)
handler.onDrag(ExternalDragValue(dragOffset, currentDragValue.dragData))
return
}
}
}
}
private val GraphicsConfiguration.density: Density
get() = Density(
defaultTransform.scaleX.toFloat(),
fontScale = 1f
)
private val Component.density: Density get() = graphicsConfiguration.density
internal class AwtWindowDragTargetListener(
private val window: Window,
val onDragEnterWindow: (WindowDragValue) -> Unit,
val onDragInsideWindow: (WindowDragValue) -> Unit,
val onDragExit: () -> Unit,
val onDrop: (WindowDragValue) -> Boolean,
) : DropTargetListener {
private val density = window.density.density
override fun dragEnter(dtde: DropTargetDragEvent) {
onDragEnterWindow(
WindowDragValue(
dtde.location.windowOffset(),
AvailableDragData(dtde.transferable.dragData())
)
)
}
override fun dragOver(dtde: DropTargetDragEvent) {
onDragInsideWindow(
WindowDragValue(
dtde.location.windowOffset(),
AvailableDragData(dtde.transferable.dragData())
)
)
}
// takes title bar and other insets into account
private fun Point.windowOffset(): Offset {
val offsetX = (x - window.insets.left) * density
val offsetY = (y - window.insets.top) * density
return Offset(offsetX, offsetY)
}
override fun dropActionChanged(dtde: DropTargetDragEvent) {
// Should we notify about it?
}
override fun dragExit(dte: DropTargetEvent) {
onDragExit()
}
override fun drop(dtde: DropTargetDropEvent) {
dtde.acceptDrop(dtde.dropAction)
val transferable = dtde.transferable
try {
onDrop(WindowDragValue(dtde.location.windowOffset(), transferable.dragData()))
dtde.dropComplete(true)
} catch (e: Exception) {
onDragExit()
dtde.dropComplete(false)
}
}
data class WindowDragValue(
val dragPositionInWindow: Offset,
val dragData: AvailableDragData
)
}
data class AvailableDragData(val list: List<DragData>) : List<DragData> by list {
inline fun <reified T : DragData> get(): T? {
for (i in this) {
if (i is T) {
return i
}
}
return null
}
}

View File

@ -1,71 +0,0 @@
package com.abdownloadmanager.desktop.utils.externaldraggable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toPainter
import java.awt.Image
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.DataFlavor.selectBestTextFlavor
import java.awt.datatransfer.Transferable
import java.awt.image.BufferedImage
import java.io.File
internal fun Transferable.dragData(): AvailableDragData {
val o = mutableListOf<DragData>()
if (isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
o.add(DragDataFilesListImpl(this))
}
if (isDataFlavorSupported(DataFlavor.imageFlavor)) {
o.add(DragDataImageImpl(this))
}
selectBestTextFlavor(transferDataFlavors)?.let {
o.add(DragDataTextImpl(it, this))
}
return AvailableDragData(o)
}
private class DragDataFilesListImpl(
private val transferable: Transferable
) : DragData.FilesList {
override fun readFiles(): List<String> {
val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
return files.filterIsInstance<File>().map { it.toURI().toString() }
}
}
private class DragDataImageImpl(
private val transferable: Transferable
) : DragData.Image {
override fun readImage(): Painter {
return (transferable.getTransferData(DataFlavor.imageFlavor) as Image).painter()
}
private fun Image.painter(): Painter {
if (this is BufferedImage) {
return this.toPainter()
}
val bufferedImage =
BufferedImage(getWidth(null), getHeight(null), BufferedImage.TYPE_INT_ARGB)
val g2 = bufferedImage.createGraphics()
try {
g2.drawImage(this, 0, 0, null)
} finally {
g2.dispose()
}
return bufferedImage.toPainter()
}
}
private class DragDataTextImpl(
private val bestTextFlavor: DataFlavor,
private val transferable: Transferable
) : DragData.Text {
override val bestMimeType: String = bestTextFlavor.mimeType
override fun readText(): String {
val reader = bestTextFlavor.getReaderForText(transferable)
return reader.readText()
}
}

View File

@ -19,7 +19,6 @@ include("desktop:app")
include("desktop:custom-window-frame")
include("desktop:shared")
include("desktop:tray")
include("desktop:external-draggable")
include("downloader:core")
include("downloader:monitor")
include("integration:server")