mirror of
https://github.com/SteamDeckHomebrew/decky-frontend-lib.git
synced 2026-05-23 11:28:48 +02:00
301 lines
13 KiB
TypeScript
301 lines
13 KiB
TypeScript
import React, { Fragment, useEffect, useRef, useState } from "react";
|
|
|
|
import { FaEllipsisH, FaArrowsAltV } from "react-icons/fa";
|
|
import { ButtonItem, DialogButton, Field, Focusable } from "../deck-components";
|
|
import { GamepadButton, GamepadEvent } from "../deck-components/FooterLegend"
|
|
|
|
interface Positioned {
|
|
position: number
|
|
}
|
|
|
|
export type ReorderableEntry<T extends Positioned> = {
|
|
key: string,
|
|
label: string,
|
|
data: T
|
|
}
|
|
|
|
export type ReorderableEntryProps<T extends Positioned> = {
|
|
entry: ReorderableEntry<T>,
|
|
index: number,
|
|
action: (e:MouseEvent, entry:ReorderableEntry<T>) => void
|
|
}
|
|
|
|
export type ReorderableListData<T extends Positioned> = {
|
|
[key:string]: ReorderableEntry<T>
|
|
}
|
|
|
|
export type ReloadData = {
|
|
showReload: boolean,
|
|
reload: () => Promise<void>,
|
|
reloadLabel?: string
|
|
}
|
|
|
|
type ReorderableListProps<T extends Positioned> = {
|
|
data: ReorderableListData<T>,
|
|
action: (e:MouseEvent, entry:ReorderableEntry<T>) => void,
|
|
onUpdate: (data: {[key:string]:T}) => void,
|
|
reloadData: ReloadData
|
|
}
|
|
|
|
const ELEM_HEIGHT = 32; //height of each ReorderableEntry element
|
|
|
|
export function ReorderableList<T extends Positioned>(props: ReorderableListProps<T>) {
|
|
let reorderEnabled = useRef(false);
|
|
const touchOrigin = useRef({"x": -1, "y": -1});
|
|
const mouseOrigin = useRef({"x": -1, "y": -1});
|
|
let focusedSide = useRef(false); //false = left, true = right
|
|
let focusIdx = useRef(0);
|
|
|
|
let data = props.data;
|
|
let onUpdate = props.onUpdate;
|
|
let dataAsList:ReorderableEntry<T>[] = Object.values(props.data).sort((a, b) => a.data.position - b.data.position);;
|
|
|
|
const [update, setUpdate] = useState(0);
|
|
|
|
useEffect(() => {
|
|
dataAsList = [];
|
|
dataAsList = Object.values(props.data).sort((a, b) => a.data.position - b.data.position);
|
|
data = props.data;
|
|
});
|
|
|
|
function enableReorder() { reorderEnabled.current = true; }
|
|
function disabledReorder() { reorderEnabled.current = false; }
|
|
function forceUpdate() { setUpdate(update === 0 ? 1 : 0); }
|
|
|
|
function ReorderableEntry(props: ReorderableEntryProps<T>) {
|
|
const wrapperFocusable = useRef<HTMLDivElement>(null);
|
|
const reorderBtn = useRef<HTMLDivElement>(null);
|
|
const optionsBtn = useRef<HTMLDivElement>(null);
|
|
|
|
let lastEvent = false;
|
|
|
|
useEffect(() => {
|
|
if (focusIdx.current === props.index) {
|
|
if (!focusedSide.current) {
|
|
optionsBtn.current?.blur();
|
|
reorderBtn.current?.focus();
|
|
} else {
|
|
reorderBtn.current?.blur();
|
|
optionsBtn.current?.focus();
|
|
}
|
|
}
|
|
});
|
|
|
|
function reorder(down:boolean) {
|
|
if ((down && props.entry.data.position != dataAsList.length) || (!down && props.entry.data.position != 1)) {
|
|
const thisData = props.entry;
|
|
const previous = dataAsList[down ? props.index+1 : props.index-1];
|
|
const tmp = thisData.data.position;
|
|
thisData.data.position = previous.data.position;
|
|
previous.data.position = tmp;
|
|
|
|
const refs = data;
|
|
refs[thisData.key] = thisData;
|
|
refs[previous.key] = previous;
|
|
|
|
const toSave:{[key:string]:T} = {};
|
|
Object.values(refs).map((val:ReorderableEntry<T>) => {
|
|
toSave[val.key] = val.data;
|
|
})
|
|
onUpdate(toSave);
|
|
|
|
if (down) {
|
|
focusIdx.current++;
|
|
} else {
|
|
focusIdx.current--;
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Fragment>
|
|
<div className="custom-buttons">
|
|
<Field label={props.entry.label} onActivate={() => { focusIdx.current = props.index; }} ref={wrapperFocusable} style={{ width: "100%" }}>
|
|
<Focusable
|
|
style={{
|
|
display: "flex",
|
|
width: "100%"
|
|
}}
|
|
onGamepadDirection={(e:GamepadEvent) => {
|
|
switch(e.detail.button) {
|
|
case GamepadButton.DIR_DOWN: {
|
|
|
|
if (reorderEnabled.current && props.entry.data.position === dataAsList.length) {
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
}
|
|
|
|
if (reorderEnabled.current && props.entry.data.position != dataAsList.length) reorder(true);
|
|
|
|
if (props.entry.data.position != dataAsList.length) {
|
|
focusIdx.current++;
|
|
forceUpdate();
|
|
}
|
|
break;
|
|
}
|
|
case GamepadButton.DIR_UP: {
|
|
if (reorderEnabled.current && props.entry.data.position === 1) {
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
}
|
|
|
|
if (reorderEnabled.current && props.entry.data.position != 1) reorder(false);
|
|
|
|
if (props.entry.data.position != 1) {
|
|
focusIdx.current--;
|
|
forceUpdate();
|
|
}
|
|
break;
|
|
}
|
|
case GamepadButton.DIR_LEFT: {
|
|
lastEvent = true;
|
|
if (focusedSide.current) {
|
|
focusedSide.current = false;
|
|
}
|
|
reorderEnabled.current = false;
|
|
}
|
|
case GamepadButton.DIR_RIGHT: {
|
|
if (!lastEvent) {
|
|
if (!focusedSide.current) {
|
|
focusedSide.current = true;
|
|
}
|
|
reorderEnabled.current = false;
|
|
} else {
|
|
lastEvent = false;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}}
|
|
onMouseMove={(e:React.MouseEvent<HTMLDivElement>) => {
|
|
// once user has moved height of an entry, swap
|
|
if (reorderEnabled.current) {
|
|
const dy = e.clientY - mouseOrigin.current.y;
|
|
if (Math.abs(dy) >= ELEM_HEIGHT) {
|
|
reorder(dy > 0);
|
|
mouseOrigin.current = {
|
|
"x": e.clientX,
|
|
"y": e.clientY,
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
onTouchMove={(e:React.TouchEvent<HTMLDivElement>) => {
|
|
if (reorderEnabled.current) {
|
|
const dy = e.touches[0].clientY - touchOrigin.current.y;
|
|
if (Math.abs(dy) >= ELEM_HEIGHT) {
|
|
reorder(dy > 0);
|
|
touchOrigin.current = {
|
|
"x": e.touches[0].clientX,
|
|
"y": e.touches[0].clientY,
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
<DialogButton
|
|
style={{
|
|
marginRight: "14px",
|
|
minWidth: "30px",
|
|
maxWidth: "60px",
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
alignItems: "center"
|
|
}}
|
|
ref={reorderBtn}
|
|
// @ts-ignore
|
|
onOKActionDescription={"Hold to reorder items"}
|
|
onButtonDown={(e:GamepadEvent) => {
|
|
switch(e.detail.button) {
|
|
case GamepadButton.OK: {
|
|
enableReorder();
|
|
}
|
|
}
|
|
}}
|
|
onButtonUp={(e:GamepadEvent) => {
|
|
switch(e.detail.button) {
|
|
case GamepadButton.OK: {
|
|
disabledReorder();
|
|
}
|
|
}
|
|
}}
|
|
onMouseDown={(e:MouseEvent) => {
|
|
mouseOrigin.current = {
|
|
"x": e.clientX,
|
|
"y": e.clientY,
|
|
}
|
|
enableReorder();
|
|
}}
|
|
onTouchStart={(e:TouchEvent) => {
|
|
touchOrigin.current = {
|
|
"x": e.touches[0].clientX,
|
|
"y": e.touches[0].clientY,
|
|
}
|
|
enableReorder();
|
|
}}
|
|
>
|
|
<FaArrowsAltV />
|
|
</DialogButton>
|
|
<DialogButton
|
|
style={{
|
|
minWidth: "30px",
|
|
maxWidth: "60px",
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
alignItems: "center"
|
|
}}
|
|
onClick={(e:MouseEvent) => {props.action(e, props.entry);}}
|
|
ref={optionsBtn}
|
|
>
|
|
<FaEllipsisH />
|
|
</DialogButton>
|
|
</Focusable>
|
|
</Field>
|
|
</div>
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Fragment>
|
|
<style>{`
|
|
.scoper {
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
`}</style>
|
|
<div className="scoper"
|
|
onMouseUp={() => {
|
|
mouseOrigin.current = {
|
|
"x": -1,
|
|
"y": -1,
|
|
}
|
|
disabledReorder();
|
|
}}
|
|
onTouchEnd={() => {
|
|
touchOrigin.current = {
|
|
"x": -1,
|
|
"y": -1,
|
|
}
|
|
disabledReorder();
|
|
}}
|
|
>
|
|
{dataAsList.length > 0 ?
|
|
dataAsList.map((itm: ReorderableEntry<T>, i:number) => (
|
|
<ReorderableEntry entry={itm} index={i} action={props.action} />
|
|
)) : (
|
|
<div style={{width: "100%", display: "flex", justifyContent: "center", alignItems: "center", padding: "5px"}}>
|
|
No data to display right now.
|
|
</div>
|
|
)
|
|
}
|
|
{props.reloadData.showReload ? (
|
|
<ButtonItem layout="below" onClick={props.reloadData.reload} bottomSeparator='none'>
|
|
Reload {props.reloadData.reloadLabel}
|
|
</ButtonItem>
|
|
) : ""}
|
|
</div>
|
|
</Fragment>
|
|
);
|
|
} |