From d28b1d6874b740ad3a0cfb874f234a162fc144cb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Johan=20Hillerstr=C3=B6m?= <progr@mmer.nu>
Date: Tue, 31 Dec 2024 20:31:48 +0100
Subject: [PATCH] [UI] Port APL drag and drop improvements from cata repo

---
 package.json                                  |   6 +
 .../individual_sim_ui/apl_values.ts           |   2 +-
 ui/core/components/list_picker.tsx            | 277 ++++++++++++++----
 ui/scss/core/components/_list_picker.scss     |  24 +-
 .../_apl_rotation_picker.scss                 |   1 +
 ui/scss/shared/_global.scss                   |   2 +-
 6 files changed, 250 insertions(+), 62 deletions(-)

diff --git a/package.json b/package.json
index 1e224d0e04..c3c58c14a1 100644
--- a/package.json
+++ b/package.json
@@ -2,6 +2,12 @@
 	"name": "sod",
 	"version": "0.1.0",
 	"private": true,
+	"engines": {
+		"node": ">=20"
+	},
+	"volta": {
+		"node": "20.11.1"
+	},
 	"scripts": {
 		"build": "bazel build //...",
 		"test": "bazel test //...",
diff --git a/ui/core/components/individual_sim_ui/apl_values.ts b/ui/core/components/individual_sim_ui/apl_values.ts
index e6cdc94462..5e8c058e77 100644
--- a/ui/core/components/individual_sim_ui/apl_values.ts
+++ b/ui/core/components/individual_sim_ui/apl_values.ts
@@ -447,7 +447,7 @@ export function valueListFieldConfig(field: string): AplHelpers.APLPickerBuilder
 					index: number,
 					config: ListItemPickerConfig<Player<any>, APLValue | undefined>,
 				) => new APLValuePicker(parent, player, config),
-				allowedActions: ['create', 'delete'],
+				allowedActions: ['copy', 'create', 'delete', 'move'],
 				actions: {
 					create: {
 						useIcon: true,
diff --git a/ui/core/components/list_picker.tsx b/ui/core/components/list_picker.tsx
index dcf223d831..7a8eeaf857 100644
--- a/ui/core/components/list_picker.tsx
+++ b/ui/core/components/list_picker.tsx
@@ -159,6 +159,24 @@ export class ListPicker<ModObject, ItemType> extends Input<ModObject, Array<Item
 		return !this.config.allowedActions || this.config.allowedActions.includes(action);
 	}
 
+	private addHoverListeners(button: HTMLButtonElement) {
+		button.addEventListener(
+			'mouseenter',
+			() => {
+				button.classList.add('hover');
+			},
+			{ signal: this.signal },
+		);
+
+		button.addEventListener(
+			'mouseleave',
+			() => {
+				button.classList.remove('hover');
+			},
+			{ signal: this.signal },
+		);
+	}
+
 	private addNewPicker() {
 		const index = this.itemPickerPairs.length;
 		const itemContainer = document.createElement('div');
@@ -174,6 +192,12 @@ export class ListPicker<ModObject, ItemType> extends Input<ModObject, Array<Item
 		const itemHeader = document.createElement('div');
 		itemHeader.classList.add('list-picker-item-header');
 
+		const popover = document.createElement('div');
+		popover.classList.add('list-picker-item-popover');
+		popover.setAttribute('popover', 'auto');
+		itemHeader.appendChild(popover);
+		let hasActions = false;
+
 		if (this.config.inlineMenuBar) {
 			itemContainer.appendChild(itemElem);
 			itemContainer.appendChild(itemHeader);
@@ -200,9 +224,65 @@ export class ListPicker<ModObject, ItemType> extends Input<ModObject, Array<Item
 
 		const item: ItemPickerPair<ItemType> = { elem: itemContainer, picker: itemPicker, idx: index };
 
+		if (this.actionEnabled('delete')) {
+			if (!this.config.minimumItems || index + 1 > this.config.minimumItems) {
+				hasActions = true;
+				const deleteButton = ListPicker.makeActionElem('list-picker-item-delete', 'fa-times');
+				deleteButton.classList.add('link-danger');
+				popover.appendChild(deleteButton);
+
+				const deleteButtonTooltip = tippy(deleteButton, {
+					allowHTML: false,
+					content: `Delete ${this.config.itemLabel}`,
+				});
+
+				deleteButton.addEventListener(
+					'click',
+					() => {
+						const newList = this.config.getValue(this.modObject);
+						newList.splice(index, 1);
+						this.config.setValue(TypedEvent.nextEventID(), this.modObject, newList);
+						deleteButtonTooltip.hide();
+					},
+					{ signal: this.signal },
+				);
+				this.addOnDisposeCallback(() => deleteButtonTooltip?.destroy());
+				this.addHoverListeners(deleteButton);
+			}
+		}
+
+		if (this.actionEnabled('copy')) {
+			hasActions = true;
+			const copyButton = ListPicker.makeActionElem('list-picker-item-copy', 'fa-copy');
+			popover.appendChild(copyButton);
+			const copyButtonTooltip = tippy(copyButton, {
+				allowHTML: false,
+				content: `Copy to New ${this.config.itemLabel}`,
+			});
+
+			copyButton.addEventListener(
+				'click',
+				() => {
+					const newList = this.config.getValue(this.modObject).slice();
+					newList.splice(index, 0, this.config.copyItem(newList[index]));
+					this.config.setValue(TypedEvent.nextEventID(), this.modObject, newList);
+					copyButtonTooltip.hide();
+				},
+				{ signal: this.signal },
+			);
+			this.addOnDisposeCallback(() => copyButtonTooltip?.destroy());
+			this.addHoverListeners(copyButton);
+		}
+
 		if (this.actionEnabled('move')) {
+			hasActions = true;
+			itemContainer.classList.add('draggable');
+			if (this.config.itemLabel) {
+				itemContainer.classList.add(this.config.itemLabel.toLowerCase().replace(' ', '-'));
+			}
+
 			const moveButton = ListPicker.makeActionElem('list-picker-item-move', 'fa-arrows-up-down');
-			itemHeader.appendChild(moveButton);
+			popover.appendChild(moveButton);
 
 			const moveButtonTooltip = tippy(moveButton, {
 				allowHTML: false,
@@ -220,11 +300,32 @@ export class ListPicker<ModObject, ItemType> extends Input<ModObject, Array<Item
 				moveButtonTooltip?.destroy();
 			});
 
-			moveButton.draggable = true;
+			this.addHoverListeners(moveButton);
+
+			moveButton.addEventListener(
+				'mousedown',
+				() => {
+					moveButton.setAttribute('draggable', 'true');
+					itemContainer.setAttribute('draggable', 'true');
+				},
+				{ signal: this.signal },
+			);
+
+			moveButton.addEventListener(
+				'mouseup',
+				() => {
+					moveButton.removeAttribute('draggable');
+					itemContainer.removeAttribute('draggable');
+				},
+				{ signal: this.signal },
+			);
+
 			moveButton.addEventListener(
 				'dragstart',
 				event => {
 					if (event.target == moveButton) {
+						const popoverRect = popover.getBoundingClientRect();
+						event.dataTransfer!.setDragImage(itemContainer, 0, popoverRect.height / 2);
 						event.dataTransfer!.dropEffect = 'move';
 						event.dataTransfer!.effectAllowed = 'move';
 						itemContainer.classList.add('dragfrom');
@@ -237,14 +338,42 @@ export class ListPicker<ModObject, ItemType> extends Input<ModObject, Array<Item
 				{ signal: this.signal },
 			);
 
+			const droppingActionOnOtherList = () => curDragData && this.config.itemLabel === 'Action' && curDragData.listPicker !== this;
+			const targetIsSelf = () => curDragData && curDragData.listPicker === this && curDragData.item.idx === index;
+			const targetIsChild = () => curDragData && curDragData.item.elem.contains(itemContainer);
+
+			const invalidDropTarget = (checkSelf = true) => {
+				// Only allow dropping on the same type of list, Value -> Value, Action -> Action
+				if (!curDragData || curDragData.listPicker.config.itemLabel !== this.config.itemLabel) {
+					return true;
+				}
+
+				// Only allow dropping Actions within the same list
+				if (droppingActionOnOtherList()) {
+					return true;
+				}
+
+				// Just skip trying to drop on itself?
+				if (checkSelf && targetIsSelf()) {
+					return true;
+				}
+
+				// Can't drop within itself
+				if (checkSelf && targetIsChild()) {
+					return true;
+				}
+
+				return false;
+			};
+
 			let dragEnterCounter = 0;
 			itemContainer.addEventListener(
 				'dragenter',
 				event => {
-					if (!curDragData || curDragData.listPicker != this) {
+					if (invalidDropTarget()) {
 						return;
 					}
-					event.preventDefault();
+					event.stopPropagation();
 					dragEnterCounter++;
 					itemContainer.classList.add('dragto');
 				},
@@ -254,7 +383,7 @@ export class ListPicker<ModObject, ItemType> extends Input<ModObject, Array<Item
 			itemContainer.addEventListener(
 				'dragleave',
 				event => {
-					if (!curDragData || curDragData.listPicker != this) {
+					if (invalidDropTarget()) {
 						return;
 					}
 					event.preventDefault();
@@ -269,30 +398,80 @@ export class ListPicker<ModObject, ItemType> extends Input<ModObject, Array<Item
 			itemContainer.addEventListener(
 				'dragover',
 				event => {
-					if (!curDragData || curDragData.listPicker != this) {
+					if (invalidDropTarget()) {
+						if (droppingActionOnOtherList() || targetIsSelf()) {
+							event.dataTransfer!.dropEffect = 'none';
+						}
+
 						return;
 					}
+					event.dataTransfer!.dropEffect = 'move';
+					event.stopPropagation();
 					event.preventDefault();
 				},
 				{ signal: this.signal },
 			);
 
+			const cleanupAfterDrag = () => {
+				if (!curDragData) {
+					return;
+				}
+				moveButton.removeAttribute('draggable');
+				itemContainer.removeAttribute('draggable');
+				curDragData.item.elem.removeAttribute('draggable');
+				[...document.querySelectorAll('.dragfrom,.dragto')].forEach(elem => {
+					elem.classList.remove('dragfrom');
+					elem.classList.remove('dragto');
+				});
+			};
+
+			itemContainer.addEventListener(
+				'dragend',
+				event => {
+					if (invalidDropTarget(false)) {
+						return;
+					}
+					event.stopPropagation();
+					cleanupAfterDrag();
+					curDragData = null;
+				},
+				{ signal: this.signal },
+			);
+
 			itemContainer.addEventListener(
 				'drop',
 				event => {
-					if (!curDragData || curDragData.listPicker != this) {
+					if (!curDragData || invalidDropTarget()) {
+						if (targetIsSelf()) {
+							event.stopPropagation();
+							cleanupAfterDrag();
+						}
 						return;
 					}
-					event.preventDefault();
-					dragEnterCounter = 0;
-					itemContainer.classList.remove('dragto');
-					curDragData.item.elem.classList.remove('dragfrom');
+					event.stopPropagation();
+					cleanupAfterDrag();
 
 					const srcIdx = curDragData.item.idx;
-					const dstIdx = index;
+					let dstIdx = index;
+
+					const targetRect = itemContainer.getBoundingClientRect();
+					if (event.clientY > targetRect.top + targetRect.height / 2) {
+						dstIdx++;
+					}
+
 					const newList = this.config.getValue(this.modObject);
-					const arrElem = newList[srcIdx];
-					newList.splice(srcIdx, 1);
+					let arrElem;
+
+					if (curDragData.listPicker !== this) {
+						const oldList = curDragData.listPicker.config.getValue(curDragData.listPicker.modObject);
+						arrElem = oldList[srcIdx];
+						oldList.splice(srcIdx, 1);
+						curDragData.listPicker.config.setValue(TypedEvent.nextEventID(), curDragData.listPicker.modObject, oldList);
+					} else {
+						arrElem = newList[srcIdx];
+						newList.splice(srcIdx, 1);
+					}
+
 					newList.splice(dstIdx, 0, arrElem);
 					this.config.setValue(TypedEvent.nextEventID(), this.modObject, newList);
 
@@ -302,61 +481,41 @@ export class ListPicker<ModObject, ItemType> extends Input<ModObject, Array<Item
 			);
 		}
 
-		if (this.actionEnabled('copy')) {
-			const copyButton = ListPicker.makeActionElem('list-picker-item-copy', 'fa-copy');
-			itemHeader.appendChild(copyButton);
-			const copyButtonTooltip = tippy(copyButton, {
-				allowHTML: false,
-				content: `Copy to New ${this.config.itemLabel}`,
-			});
-
-			copyButton.addEventListener(
-				'click',
+		if (hasActions) {
+			const actionsButton = ListPicker.makeActionElem('list-picker-item-actions', 'fa-ellipsis');
+			itemHeader.appendChild(actionsButton);
+			actionsButton.addEventListener(
+				'mouseover',
 				() => {
-					const newList = this.config.getValue(this.modObject).slice();
-					newList.splice(index, 0, this.config.copyItem(newList[index]));
-					this.config.setValue(TypedEvent.nextEventID(), this.modObject, newList);
-					copyButtonTooltip.hide();
+					popover.showPopover();
+					const actionsButtonRect = actionsButton.getBoundingClientRect();
+					const popoverRect = popover.getBoundingClientRect();
+					const diff = (popoverRect.height - actionsButtonRect.height) / 2;
+					popover.style.top = actionsButtonRect.top - diff + 'px';
+					popover.style.left = actionsButtonRect.right - popoverRect.width + 10 + 'px';
+					popover.classList.add('hover');
+				},
+				{ signal: this.signal },
+			);
+			popover.addEventListener(
+				'mouseleave',
+				() => {
+					popover.classList.remove('hover');
+					popover.hidePopover();
 				},
 				{ signal: this.signal },
 			);
-			this.addOnDisposeCallback(() => copyButtonTooltip?.destroy());
-		}
-
-		if (this.actionEnabled('delete')) {
-			if (!this.config.minimumItems || index + 1 > this.config.minimumItems) {
-				const deleteButton = ListPicker.makeActionElem('list-picker-item-delete', 'fa-times');
-				deleteButton.classList.add('link-danger');
-				itemHeader.appendChild(deleteButton);
-
-				const deleteButtonTooltip = tippy(deleteButton, {
-					allowHTML: false,
-					content: `Delete ${this.config.itemLabel}`,
-				});
-
-				deleteButton.addEventListener(
-					'click',
-					() => {
-						const newList = this.config.getValue(this.modObject);
-						newList.splice(index, 1);
-						this.config.setValue(TypedEvent.nextEventID(), this.modObject, newList);
-						deleteButtonTooltip.hide();
-					},
-					{ signal: this.signal },
-				);
-				this.addOnDisposeCallback(() => deleteButtonTooltip?.destroy());
-			}
 		}
 
 		this.itemPickerPairs.push(item);
 	}
 
-	static makeActionElem(cssClass: string, iconCssClass: string): HTMLAnchorElement {
+	static makeActionElem(cssClass: string, iconCssClass: string): HTMLButtonElement {
 		return (
-			<a href="javascript:void(0)" className={clsx('list-picker-item-action', cssClass)} attributes={{ role: 'button' }}>
-				<i className={clsx('fa', 'fa-xl', iconCssClass)}></i>
-			</a>
-		) as HTMLAnchorElement;
+			<button type="button" className={clsx('list-picker-item-action', cssClass)}>
+				<i className={clsx('fa', 'fa-xl', iconCssClass)} />
+			</button>
+		) as HTMLButtonElement;
 	}
 
 	static getItemHeaderElem(itemPicker: Input<any, any>): HTMLElement {
diff --git a/ui/scss/core/components/_list_picker.scss b/ui/scss/core/components/_list_picker.scss
index 496ccdb028..c24c71abff 100644
--- a/ui/scss/core/components/_list_picker.scss
+++ b/ui/scss/core/components/_list_picker.scss
@@ -4,6 +4,16 @@
 	flex-direction: column;
 	align-items: center;
 
+	&.dragfrom {
+		background-color: color-mix(in srgb, var(--bs-body-bg) 80%, transparent);
+		filter: opacity(0.5);
+		cursor: move;
+	}
+
+	&.draggable:has(> .list-picker-item-header .list-picker-item-popover.hover) {
+		background-color: color-mix(in srgb, var(--bs-primary) 5%, transparent);
+	}
+	
 	&:not(:last-child) {
 		margin-bottom: calc(2 * var(--spacer-3));
 	}
@@ -32,7 +42,6 @@
 					padding: 0;
 					border: 0;
 					margin: 0;
-					flex: 0;
 				}
 			}
 
@@ -61,7 +70,20 @@
 
 				.list-picker-item-action {
 					margin-left: var(--spacer-2);
+
+					&.list-picker-item-move {
+						cursor: move;
+					}
 				}
+				
+				.list-picker-item-popover:popover-open {
+					inset: unset;
+					position: relative;
+					background-color: color-mix(in srgb, var(--bs-body-bg) 80%, transparent);
+					border: 1px solid black;
+					border-radius: 5px;
+					padding: 5px 10px;
+ 				}
 			}
 
 			.target-input-picker-root {
diff --git a/ui/scss/core/components/individual_sim_ui/_apl_rotation_picker.scss b/ui/scss/core/components/individual_sim_ui/_apl_rotation_picker.scss
index e2b56ce450..a9c9914d9d 100644
--- a/ui/scss/core/components/individual_sim_ui/_apl_rotation_picker.scss
+++ b/ui/scss/core/components/individual_sim_ui/_apl_rotation_picker.scss
@@ -26,6 +26,7 @@
 
 			.list-picker-item-header {
 				align-items: flex-start;
+				justify-content: flex-end;
 			}
 
 			& > .list-picker-item {
diff --git a/ui/scss/shared/_global.scss b/ui/scss/shared/_global.scss
index 9f51283c2e..09f7832afd 100644
--- a/ui/scss/shared/_global.scss
+++ b/ui/scss/shared/_global.scss
@@ -123,7 +123,7 @@ kbd {
 }
 
 .dragto:not(.dragfrom) {
-	filter: brightness(0.75);
+	filter: brightness(0.5);
 }
 
 .interactive {