Skip to content

Commit

Permalink
Merge pull request #1204 from hillerstorm/port_cata_drag_drop
Browse files Browse the repository at this point in the history
[UI] Port APL drag and drop improvements from cata repo
  • Loading branch information
hillerstorm authored Jan 4, 2025
2 parents c03724a + 508f96e commit f07dd38
Show file tree
Hide file tree
Showing 6 changed files with 250 additions and 62 deletions.
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 //...",
Expand Down
2 changes: 1 addition & 1 deletion ui/core/components/individual_sim_ui/apl_values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
277 changes: 218 additions & 59 deletions ui/core/components/list_picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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');
Expand All @@ -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');
},
Expand All @@ -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();
Expand All @@ -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);

Expand All @@ -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 {
Expand Down
Loading

0 comments on commit f07dd38

Please sign in to comment.