mirror of https://github.com/jetkvm/kvm.git
create sortable list component
This commit is contained in:
parent
0206d798a3
commit
48d8523122
|
@ -0,0 +1,311 @@
|
||||||
|
import { useState, useCallback, ReactNode } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { LuGripVertical } from 'react-icons/lu';
|
||||||
|
import { cva } from "@/cva.config";
|
||||||
|
|
||||||
|
interface SortableListProps<T> {
|
||||||
|
items: T[];
|
||||||
|
keyFn: (item: T) => string;
|
||||||
|
onSort: (newItems: T[]) => Promise<void>;
|
||||||
|
children: (item: T, index: number) => ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
itemClassName?: string;
|
||||||
|
variant?: 'list' | 'grid';
|
||||||
|
size?: keyof typeof sizes;
|
||||||
|
renderHandle?: (isDragging: boolean) => ReactNode;
|
||||||
|
hideHandle?: boolean;
|
||||||
|
handlePosition?: 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
XS: "min-h-[24.5px] py-1 px-3 text-xs",
|
||||||
|
SM: "min-h-[32px] py-1.5 px-3 text-[13px]",
|
||||||
|
MD: "min-h-[40px] py-2 px-4 text-sm",
|
||||||
|
LG: "min-h-[48px] py-2.5 px-4 text-base",
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
list: {
|
||||||
|
XS: 'space-y-1',
|
||||||
|
SM: 'space-y-2',
|
||||||
|
MD: 'space-y-3',
|
||||||
|
LG: 'space-y-4'
|
||||||
|
},
|
||||||
|
grid: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortableItemVariants = cva({
|
||||||
|
base: 'transition-all duration-300 ease-out rounded',
|
||||||
|
variants: {
|
||||||
|
size: sizes,
|
||||||
|
isDragging: {
|
||||||
|
true: 'shadow-lg bg-blue-100/80 dark:bg-blue-900/40 border-blue-200 dark:border-blue-800 z-50',
|
||||||
|
false: ''
|
||||||
|
},
|
||||||
|
isDropTarget: {
|
||||||
|
true: 'border border-dashed border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/10',
|
||||||
|
false: ''
|
||||||
|
},
|
||||||
|
handlePosition: {
|
||||||
|
left: 'flex-row',
|
||||||
|
right: 'flex-row-reverse'
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
true: 'pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80',
|
||||||
|
false: 'hover:bg-blue-50/80 active:bg-blue-100/60 dark:hover:bg-slate-700 dark:active:bg-slate-800/60'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'MD',
|
||||||
|
isDragging: false,
|
||||||
|
isDropTarget: false,
|
||||||
|
handlePosition: 'left',
|
||||||
|
disabled: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const DefaultHandle = ({ isDragging, disabled }: { isDragging: boolean; disabled?: boolean }) => (
|
||||||
|
<div className={clsx(
|
||||||
|
disabled ? 'cursor-not-allowed' : 'cursor-grab active:cursor-grabbing touch-none',
|
||||||
|
'p-1',
|
||||||
|
'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300',
|
||||||
|
isDragging && 'text-slate-700 dark:text-slate-300',
|
||||||
|
disabled && 'opacity-50'
|
||||||
|
)}>
|
||||||
|
<LuGripVertical className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function SortableList<T>({
|
||||||
|
items,
|
||||||
|
keyFn,
|
||||||
|
onSort,
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
className = '',
|
||||||
|
itemClassName = '',
|
||||||
|
variant = 'list',
|
||||||
|
size = 'MD',
|
||||||
|
renderHandle,
|
||||||
|
hideHandle = false,
|
||||||
|
handlePosition = 'left',
|
||||||
|
}: SortableListProps<T>) {
|
||||||
|
const [dragItem, setDragItem] = useState<number | null>(null);
|
||||||
|
const [dragOverItem, setDragOverItem] = useState<number | null>(null);
|
||||||
|
const [touchStartY, setTouchStartY] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const containerClasses = clsx(
|
||||||
|
'sortable-list',
|
||||||
|
variant === 'grid' ? containerVariants.grid : containerVariants.list[size],
|
||||||
|
className
|
||||||
|
);
|
||||||
|
|
||||||
|
const getItemClasses = (index: number) => clsx(
|
||||||
|
'relative flex items-center',
|
||||||
|
sortableItemVariants({
|
||||||
|
size,
|
||||||
|
isDragging: dragItem === index,
|
||||||
|
isDropTarget: dragOverItem === index && dragItem !== index,
|
||||||
|
handlePosition,
|
||||||
|
disabled
|
||||||
|
}),
|
||||||
|
itemClassName
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((index: number) => {
|
||||||
|
if (disabled) return;
|
||||||
|
setDragItem(index);
|
||||||
|
|
||||||
|
const allItems = document.querySelectorAll('[data-sortable-item]');
|
||||||
|
const draggedElement = allItems[index];
|
||||||
|
if (draggedElement) {
|
||||||
|
draggedElement.classList.add('dragging');
|
||||||
|
}
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
||||||
|
if (disabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOverItem(index);
|
||||||
|
|
||||||
|
const allItems = document.querySelectorAll('[data-sortable-item]');
|
||||||
|
allItems.forEach(el => el.classList.remove('drop-target'));
|
||||||
|
|
||||||
|
const targetElement = allItems[index];
|
||||||
|
if (targetElement) {
|
||||||
|
targetElement.classList.add('drop-target');
|
||||||
|
}
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
|
if (disabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragItem === null || dragOverItem === null) return;
|
||||||
|
|
||||||
|
const itemsCopy = [...items];
|
||||||
|
const draggedItem = itemsCopy.splice(dragItem, 1)[0];
|
||||||
|
itemsCopy.splice(dragOverItem, 0, draggedItem);
|
||||||
|
|
||||||
|
await onSort(itemsCopy);
|
||||||
|
|
||||||
|
const allItems = document.querySelectorAll('[data-sortable-item]');
|
||||||
|
allItems.forEach(el => {
|
||||||
|
el.classList.remove('drop-target');
|
||||||
|
el.classList.remove('dragging');
|
||||||
|
});
|
||||||
|
|
||||||
|
setDragItem(null);
|
||||||
|
setDragOverItem(null);
|
||||||
|
}, [disabled, dragItem, dragOverItem, items, onSort]);
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => {
|
||||||
|
if (disabled) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
setTouchStartY(touch.clientY);
|
||||||
|
setDragItem(index);
|
||||||
|
|
||||||
|
const element = e.currentTarget as HTMLElement;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Create ghost element
|
||||||
|
const ghost = element.cloneNode(true) as HTMLElement;
|
||||||
|
ghost.id = 'ghost-item';
|
||||||
|
ghost.className = 'sortable-ghost';
|
||||||
|
ghost.style.height = `${rect.height}px`;
|
||||||
|
element.parentNode?.insertBefore(ghost, element);
|
||||||
|
|
||||||
|
// Set up dragged element
|
||||||
|
element.style.position = 'fixed';
|
||||||
|
element.style.left = `${rect.left}px`;
|
||||||
|
element.style.top = `${rect.top}px`;
|
||||||
|
element.style.width = `${rect.width}px`;
|
||||||
|
element.style.zIndex = '50';
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||||
|
if (disabled || touchStartY === null || dragItem === null) return;
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const deltaY = touch.clientY - touchStartY;
|
||||||
|
const element = e.currentTarget as HTMLElement;
|
||||||
|
|
||||||
|
element.style.transform = `translateY(${deltaY}px)`;
|
||||||
|
|
||||||
|
const sortableElements = document.querySelectorAll('[data-sortable-item]');
|
||||||
|
const draggedRect = element.getBoundingClientRect();
|
||||||
|
const draggedMiddle = draggedRect.top + draggedRect.height / 2;
|
||||||
|
|
||||||
|
sortableElements.forEach((el, i) => {
|
||||||
|
if (i === dragItem) return;
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const elementMiddle = rect.top + rect.height / 2;
|
||||||
|
const distance = Math.abs(draggedMiddle - elementMiddle);
|
||||||
|
|
||||||
|
if (distance < rect.height) {
|
||||||
|
const direction = draggedMiddle > elementMiddle ? -1 : 1;
|
||||||
|
(el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`;
|
||||||
|
(el as HTMLElement).style.transition = 'transform 0.15s ease-out';
|
||||||
|
} else {
|
||||||
|
(el as HTMLElement).style.transform = '';
|
||||||
|
(el as HTMLElement).style.transition = 'transform 0.15s ease-out';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [disabled, touchStartY, dragItem]);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(async (e: React.TouchEvent) => {
|
||||||
|
if (disabled || dragItem === null) return;
|
||||||
|
|
||||||
|
const element = e.currentTarget as HTMLElement;
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
|
||||||
|
// Remove ghost element
|
||||||
|
const ghost = document.getElementById('ghost-item');
|
||||||
|
ghost?.parentNode?.removeChild(ghost);
|
||||||
|
|
||||||
|
// Reset dragged element styles
|
||||||
|
element.style.position = '';
|
||||||
|
element.style.left = '';
|
||||||
|
element.style.top = '';
|
||||||
|
element.style.width = '';
|
||||||
|
element.style.zIndex = '';
|
||||||
|
element.style.transform = '';
|
||||||
|
element.style.transition = '';
|
||||||
|
|
||||||
|
const sortableElements = document.querySelectorAll('[data-sortable-item]');
|
||||||
|
let targetIndex = dragItem;
|
||||||
|
|
||||||
|
// Find the closest element to the final touch position
|
||||||
|
const finalY = touch.clientY;
|
||||||
|
let closestDistance = Infinity;
|
||||||
|
|
||||||
|
sortableElements.forEach((el, i) => {
|
||||||
|
if (i === dragItem) return;
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const distance = Math.abs(finalY - (rect.top + rect.height / 2));
|
||||||
|
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closestDistance = distance;
|
||||||
|
targetIndex = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset other elements
|
||||||
|
(el as HTMLElement).style.transform = '';
|
||||||
|
(el as HTMLElement).style.transition = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (targetIndex !== dragItem && closestDistance < 50) {
|
||||||
|
const itemsCopy = [...items];
|
||||||
|
const [draggedItem] = itemsCopy.splice(dragItem, 1);
|
||||||
|
itemsCopy.splice(targetIndex, 0, draggedItem);
|
||||||
|
await onSort(itemsCopy);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTouchStartY(null);
|
||||||
|
setDragItem(null);
|
||||||
|
}, [disabled, dragItem, items, onSort]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClasses}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={keyFn(item)}
|
||||||
|
data-sortable-item={index}
|
||||||
|
draggable={!disabled}
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragOver={e => handleDragOver(e, index)}
|
||||||
|
onDragEnd={() => {
|
||||||
|
const allItems = document.querySelectorAll('[data-sortable-item]');
|
||||||
|
allItems.forEach(el => {
|
||||||
|
el.classList.remove('drop-target');
|
||||||
|
el.classList.remove('dragging');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onTouchStart={e => handleTouchStart(e, index)}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
className={getItemClasses(index)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
|
{handlePosition === 'left' && !hideHandle && (
|
||||||
|
renderHandle ?
|
||||||
|
renderHandle(dragItem === index) :
|
||||||
|
<DefaultHandle isDragging={dragItem === index} disabled={disabled} />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{children(item, index)}
|
||||||
|
</div>
|
||||||
|
{handlePosition === 'right' && !hideHandle && (
|
||||||
|
renderHandle ?
|
||||||
|
renderHandle(dragItem === index) :
|
||||||
|
<DefaultHandle isDragging={dragItem === index} disabled={disabled} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -201,46 +201,3 @@ video::-webkit-media-controls {
|
||||||
.hide-scrollbar::-webkit-scrollbar {
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Macro list sortable */
|
|
||||||
.macro-sortable, [data-macro-item] {
|
|
||||||
transition: box-shadow 0.15s ease-out, background-color 0.2s ease-out, transform 0.1s, border-color 0.2s;
|
|
||||||
position: relative;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.macro-sortable.dragging, [data-macro-item].dragging {
|
|
||||||
z-index: 10;
|
|
||||||
opacity: 0.8;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
@apply bg-blue-500/10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.macro-sortable.drop-target, [data-macro-item].drop-target {
|
|
||||||
@apply border-2 border-dashed border-blue-500 bg-blue-500/5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.macro-sortable.ghost {
|
|
||||||
position: static;
|
|
||||||
opacity: 0.3;
|
|
||||||
pointer-events: none;
|
|
||||||
background-color: transparent;
|
|
||||||
border: 2px dashed rgb(148 163 184);
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-handle, .macro-sortable-handle {
|
|
||||||
cursor: grab;
|
|
||||||
touch-action: none;
|
|
||||||
@apply flex items-center p-1 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-handle:active, .macro-sortable-handle:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: none) {
|
|
||||||
.macro-sortable, [data-macro-item] {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useRef, useCallback, Fragment } from "react";
|
import { useState, useEffect, useCallback, Fragment } from "react";
|
||||||
import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo, LuCopy, LuArrowUp, LuArrowDown, LuMoveRight, LuCornerDownRight } from "react-icons/lu";
|
import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuInfo, LuCopy, LuArrowUp, LuArrowDown, LuMoveRight, LuCornerDownRight } from "react-icons/lu";
|
||||||
|
|
||||||
import { KeySequence, useMacrosStore } from "@/hooks/stores";
|
import { KeySequence, useMacrosStore } from "@/hooks/stores";
|
||||||
import { SettingsPageHeader } from "@/components/SettingsPageheader";
|
import { SettingsPageHeader } from "@/components/SettingsPageheader";
|
||||||
|
@ -16,6 +16,7 @@ import EmptyCard from "@/components/EmptyCard";
|
||||||
import { Combobox } from "@/components/Combobox";
|
import { Combobox } from "@/components/Combobox";
|
||||||
import { CardHeader } from "@/components/CardHeader";
|
import { CardHeader } from "@/components/CardHeader";
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
|
import { SortableList } from "@/components/SortableList";
|
||||||
|
|
||||||
const DEFAULT_DELAY = 50;
|
const DEFAULT_DELAY = 50;
|
||||||
|
|
||||||
|
@ -302,122 +303,6 @@ const updateStepKeys = (
|
||||||
return newSteps;
|
return newSteps;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useTouchSort = (items: KeySequence[], onSort: (newItems: KeySequence[]) => void) => {
|
|
||||||
const [touchStartY, setTouchStartY] = useState<number | null>(null);
|
|
||||||
const [touchedIndex, setTouchedIndex] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => {
|
|
||||||
const touch = e.touches[0];
|
|
||||||
setTouchStartY(touch.clientY);
|
|
||||||
setTouchedIndex(index);
|
|
||||||
|
|
||||||
const element = e.currentTarget as HTMLElement;
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
|
|
||||||
// Create ghost element
|
|
||||||
const ghost = element.cloneNode(true) as HTMLElement;
|
|
||||||
ghost.id = 'ghost-macro';
|
|
||||||
ghost.className = 'macro-sortable ghost';
|
|
||||||
ghost.style.height = `${rect.height}px`;
|
|
||||||
element.parentNode?.insertBefore(ghost, element);
|
|
||||||
|
|
||||||
// Set up dragged element
|
|
||||||
element.style.position = 'fixed';
|
|
||||||
element.style.left = `${rect.left}px`;
|
|
||||||
element.style.top = `${rect.top}px`;
|
|
||||||
element.style.width = `${rect.width}px`;
|
|
||||||
element.style.zIndex = '50';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
|
||||||
if (touchStartY === null || touchedIndex === null) return;
|
|
||||||
|
|
||||||
const touch = e.touches[0];
|
|
||||||
const deltaY = touch.clientY - touchStartY;
|
|
||||||
const element = e.currentTarget as HTMLElement;
|
|
||||||
|
|
||||||
// Smooth movement of dragged element
|
|
||||||
element.style.transform = `translateY(${deltaY}px)`;
|
|
||||||
|
|
||||||
const macroElements = document.querySelectorAll('[data-macro-item]');
|
|
||||||
const draggedRect = element.getBoundingClientRect();
|
|
||||||
const draggedMiddle = draggedRect.top + draggedRect.height / 2;
|
|
||||||
|
|
||||||
macroElements.forEach((el, i) => {
|
|
||||||
if (i === touchedIndex) return;
|
|
||||||
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const elementMiddle = rect.top + rect.height / 2;
|
|
||||||
const distance = Math.abs(draggedMiddle - elementMiddle);
|
|
||||||
|
|
||||||
if (distance < rect.height) {
|
|
||||||
const direction = draggedMiddle > elementMiddle ? -1 : 1;
|
|
||||||
(el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`;
|
|
||||||
(el as HTMLElement).style.transition = 'transform 0.15s ease-out';
|
|
||||||
} else {
|
|
||||||
(el as HTMLElement).style.transform = '';
|
|
||||||
(el as HTMLElement).style.transition = 'transform 0.15s ease-out';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [touchStartY, touchedIndex]);
|
|
||||||
|
|
||||||
const handleTouchEnd = useCallback(async (e: React.TouchEvent) => {
|
|
||||||
if (touchedIndex === null) return;
|
|
||||||
|
|
||||||
const element = e.currentTarget as HTMLElement;
|
|
||||||
const touch = e.changedTouches[0];
|
|
||||||
|
|
||||||
// Remove ghost element
|
|
||||||
const ghost = document.getElementById('ghost-macro');
|
|
||||||
ghost?.parentNode?.removeChild(ghost);
|
|
||||||
|
|
||||||
// Reset dragged element styles
|
|
||||||
element.style.position = '';
|
|
||||||
element.style.left = '';
|
|
||||||
element.style.top = '';
|
|
||||||
element.style.width = '';
|
|
||||||
element.style.zIndex = '';
|
|
||||||
element.style.transform = '';
|
|
||||||
element.style.boxShadow = '';
|
|
||||||
element.style.transition = '';
|
|
||||||
|
|
||||||
const macroElements = document.querySelectorAll('[data-macro-item]');
|
|
||||||
let targetIndex = touchedIndex;
|
|
||||||
|
|
||||||
// Find the closest element to the final touch position
|
|
||||||
const finalY = touch.clientY;
|
|
||||||
let closestDistance = Infinity;
|
|
||||||
|
|
||||||
macroElements.forEach((el, i) => {
|
|
||||||
if (i === touchedIndex) return;
|
|
||||||
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const distance = Math.abs(finalY - (rect.top + rect.height / 2));
|
|
||||||
|
|
||||||
if (distance < closestDistance) {
|
|
||||||
closestDistance = distance;
|
|
||||||
targetIndex = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset other elements
|
|
||||||
(el as HTMLElement).style.transform = '';
|
|
||||||
(el as HTMLElement).style.transition = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
if (targetIndex !== touchedIndex && closestDistance < 50) {
|
|
||||||
const itemsCopy = [...items];
|
|
||||||
const [draggedItem] = itemsCopy.splice(touchedIndex, 1);
|
|
||||||
itemsCopy.splice(targetIndex, 0, draggedItem);
|
|
||||||
onSort(itemsCopy);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTouchStartY(null);
|
|
||||||
setTouchedIndex(null);
|
|
||||||
}, [touchedIndex, items, onSort]);
|
|
||||||
|
|
||||||
return { handleTouchStart, handleTouchMove, handleTouchEnd };
|
|
||||||
};
|
|
||||||
|
|
||||||
interface StepError {
|
interface StepError {
|
||||||
keys?: string;
|
keys?: string;
|
||||||
modifiers?: string;
|
modifiers?: string;
|
||||||
|
@ -438,10 +323,6 @@ export default function SettingsMacrosRoute() {
|
||||||
description: "",
|
description: "",
|
||||||
steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
|
steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
|
||||||
});
|
});
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const dragItem = useRef<number | null>(null);
|
|
||||||
const dragOverItem = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const [macroToDelete, setMacroToDelete] = useState<string | null>(null);
|
const [macroToDelete, setMacroToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
|
const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
|
||||||
|
@ -631,99 +512,6 @@ export default function SettingsMacrosRoute() {
|
||||||
}
|
}
|
||||||
}, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]);
|
}, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]);
|
||||||
|
|
||||||
const handleDragStart = (index: number) => {
|
|
||||||
dragItem.current = index;
|
|
||||||
setIsDragging(true);
|
|
||||||
|
|
||||||
const allItems = document.querySelectorAll('[data-macro-item]');
|
|
||||||
const draggedElement = allItems[index];
|
|
||||||
if (draggedElement) {
|
|
||||||
draggedElement.classList.add('dragging');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
|
||||||
e.preventDefault();
|
|
||||||
dragOverItem.current = index;
|
|
||||||
|
|
||||||
const allItems = document.querySelectorAll('[data-macro-item]');
|
|
||||||
allItems.forEach(el => el.classList.remove('drop-target'));
|
|
||||||
|
|
||||||
const targetElement = allItems[index];
|
|
||||||
if (targetElement) {
|
|
||||||
targetElement.classList.add('drop-target');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = async (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (dragItem.current === null || dragOverItem.current === null) return;
|
|
||||||
|
|
||||||
const macroCopy = [...macros];
|
|
||||||
const draggedItem = macroCopy.splice(dragItem.current, 1)[0];
|
|
||||||
macroCopy.splice(dragOverItem.current, 0, draggedItem);
|
|
||||||
const updatedMacros = normalizeSortOrders(macroCopy);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await saveMacros(updatedMacros);
|
|
||||||
notifications.success("Macro order updated successfully");
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
notifications.error(`Failed to reorder macros: ${error.message}`);
|
|
||||||
showTemporaryError(error.message);
|
|
||||||
} else {
|
|
||||||
notifications.error("Failed to reorder macros");
|
|
||||||
showTemporaryError("Failed to save reordered macros");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allItems = document.querySelectorAll('[data-macro-item]');
|
|
||||||
allItems.forEach(el => {
|
|
||||||
el.classList.remove('drop-target');
|
|
||||||
el.classList.remove('dragging');
|
|
||||||
});
|
|
||||||
|
|
||||||
dragItem.current = null;
|
|
||||||
dragOverItem.current = null;
|
|
||||||
setIsDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateMacro = useCallback(async () => {
|
|
||||||
if (!editingMacro) return;
|
|
||||||
|
|
||||||
const validationErrors = validateMacro(editingMacro);
|
|
||||||
if (Object.keys(validationErrors).length > 0) {
|
|
||||||
setErrors(validationErrors);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsUpdating(true);
|
|
||||||
try {
|
|
||||||
const newMacros = macros.map(m =>
|
|
||||||
m.id === editingMacro.id ? {
|
|
||||||
...editingMacro,
|
|
||||||
name: editingMacro.name.trim(),
|
|
||||||
description: editingMacro.description?.trim() || "",
|
|
||||||
} : m
|
|
||||||
);
|
|
||||||
|
|
||||||
await saveMacros(normalizeSortOrders(newMacros));
|
|
||||||
setEditingMacro(null);
|
|
||||||
clearErrors();
|
|
||||||
notifications.success(`Macro "${editingMacro.name}" updated successfully`);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
notifications.error(`Failed to update macro: ${error.message}`);
|
|
||||||
showTemporaryError(error.message);
|
|
||||||
} else {
|
|
||||||
notifications.error("Failed to update macro");
|
|
||||||
showTemporaryError("Failed to update macro");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsUpdating(false);
|
|
||||||
}
|
|
||||||
}, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]);
|
|
||||||
|
|
||||||
const handleEditMacro = (macro: KeySequence) => {
|
const handleEditMacro = (macro: KeySequence) => {
|
||||||
setEditingMacro({
|
setEditingMacro({
|
||||||
...macro,
|
...macro,
|
||||||
|
@ -815,28 +603,45 @@ export default function SettingsMacrosRoute() {
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchSort(
|
|
||||||
macros,
|
|
||||||
async (newMacros) => {
|
|
||||||
const updatedMacros = normalizeSortOrders(newMacros);
|
|
||||||
try {
|
|
||||||
await saveMacros(updatedMacros);
|
|
||||||
notifications.success("Macro order updated successfully");
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
notifications.error(`Failed to reorder macros: ${error.message}`);
|
|
||||||
showTemporaryError(error.message);
|
|
||||||
} else {
|
|
||||||
notifications.error("Failed to reorder macros");
|
|
||||||
showTemporaryError("Failed to save reordered macros");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||||
const [showAddMacro, setShowAddMacro] = useState(false);
|
const [showAddMacro, setShowAddMacro] = useState(false);
|
||||||
|
|
||||||
|
const handleUpdateMacro = useCallback(async () => {
|
||||||
|
if (!editingMacro) return;
|
||||||
|
|
||||||
|
const validationErrors = validateMacro(editingMacro);
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setErrors(validationErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
const newMacros = macros.map(m =>
|
||||||
|
m.id === editingMacro.id ? {
|
||||||
|
...editingMacro,
|
||||||
|
name: editingMacro.name.trim(),
|
||||||
|
description: editingMacro.description?.trim() || "",
|
||||||
|
} : m
|
||||||
|
);
|
||||||
|
|
||||||
|
await saveMacros(normalizeSortOrders(newMacros));
|
||||||
|
setEditingMacro(null);
|
||||||
|
clearErrors();
|
||||||
|
notifications.success(`Macro "${editingMacro.name}" updated successfully`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
notifications.error(`Failed to update macro: ${error.message}`);
|
||||||
|
showTemporaryError(error.message);
|
||||||
|
} else {
|
||||||
|
notifications.error("Failed to update macro");
|
||||||
|
showTemporaryError("Failed to update macro");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
}, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape' && editingMacro) {
|
if (e.key === 'Escape' && editingMacro) {
|
||||||
|
@ -886,7 +691,6 @@ export default function SettingsMacrosRoute() {
|
||||||
<FieldError error={error} />
|
<FieldError error={error} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsPageHeader
|
<SettingsPageHeader
|
||||||
|
@ -1087,10 +891,33 @@ export default function SettingsMacrosRoute() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{macros.length > 0 && (
|
{macros.length > 0 && (
|
||||||
<div className="space-y-1">
|
<SortableList<KeySequence>
|
||||||
{macros.map((macro, index) =>
|
keyFn={(macro) => macro.id}
|
||||||
|
items={macros}
|
||||||
|
itemClassName="rounded-md border border-slate-200 p-2 dark:border-slate-700 bg-white dark:bg-slate-800"
|
||||||
|
onSort={async (newMacros) => {
|
||||||
|
const updatedMacros = normalizeSortOrders(newMacros);
|
||||||
|
try {
|
||||||
|
await saveMacros(updatedMacros);
|
||||||
|
notifications.success("Macro order updated successfully");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
notifications.error(`Failed to reorder macros: ${error.message}`);
|
||||||
|
showTemporaryError(error.message);
|
||||||
|
} else {
|
||||||
|
notifications.error("Failed to reorder macros");
|
||||||
|
showTemporaryError("Failed to save reordered macros");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!!editingMacro}
|
||||||
|
variant="list"
|
||||||
|
size="XS"
|
||||||
|
handlePosition="left"
|
||||||
|
>
|
||||||
|
{(macro) => (
|
||||||
editingMacro && editingMacro.id === macro.id ? (
|
editingMacro && editingMacro.id === macro.id ? (
|
||||||
<Card key={macro.id} className="border-blue-300 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/20">
|
<Card className="border-blue-300 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/20">
|
||||||
<CardHeader
|
<CardHeader
|
||||||
headline="Edit Macro"
|
headline="Edit Macro"
|
||||||
/>
|
/>
|
||||||
|
@ -1211,35 +1038,8 @@ export default function SettingsMacrosRoute() {
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="flex items-center justify-between">
|
||||||
key={macro.id}
|
<div className="flex-1 min-w-0 flex flex-col justify-center">
|
||||||
data-macro-item={index}
|
|
||||||
draggable={!editingMacro}
|
|
||||||
onDragStart={() => handleDragStart(index)}
|
|
||||||
onDragOver={e => handleDragOver(e, index)}
|
|
||||||
onDragEnd={() => {
|
|
||||||
const allItems = document.querySelectorAll('[data-macro-item]');
|
|
||||||
allItems.forEach(el => {
|
|
||||||
el.classList.remove('drop-target');
|
|
||||||
el.classList.remove('dragging');
|
|
||||||
});
|
|
||||||
setIsDragging(false);
|
|
||||||
}}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onTouchStart={(e) => handleTouchStart(e, index)}
|
|
||||||
onTouchMove={handleTouchMove}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
className={`macro-sortable flex items-center justify-between rounded-md border border-slate-200 p-2 dark:border-slate-700 ${
|
|
||||||
isDragging && dragItem.current === index
|
|
||||||
? "bg-blue-50 dark:bg-blue-900/20"
|
|
||||||
: "bg-white dark:bg-slate-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="macro-sortable-handle">
|
|
||||||
<LuGripVertical className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pl-4 flex-1 overflow-hidden">
|
|
||||||
<h3 className="truncate text-sm font-semibold text-black dark:text-white">
|
<h3 className="truncate text-sm font-semibold text-black dark:text-white">
|
||||||
{macro.name}
|
{macro.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
@ -1292,7 +1092,7 @@ export default function SettingsMacrosRoute() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1 ml-2 flex-shrink-0">
|
<div className="flex items-center gap-1 ml-4">
|
||||||
{macroToDelete === macro.id ? (
|
{macroToDelete === macro.id ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
@ -1343,7 +1143,7 @@ export default function SettingsMacrosRoute() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</SortableList>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Reference in New Issue