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, 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 extends Input { + 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 extends Input extends Input = { 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 extends Input { + 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 extends Input 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 extends Input { - if (!curDragData || curDragData.listPicker != this) { + if (invalidDropTarget()) { return; } event.preventDefault(); @@ -269,30 +398,80 @@ export class ListPicker extends Input { - 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 extends Input { - 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 ( - - - - ) as HTMLAnchorElement; + + ) as HTMLButtonElement; } static getItemHeaderElem(itemPicker: Input): 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 {