implement double click handler on default controls

This commit is contained in:
jeffvli
2025-11-13 20:54:18 -08:00
parent c5e11cca58
commit a75f64d204
6 changed files with 291 additions and 61 deletions
@@ -176,6 +176,7 @@ export const ItemCardControls = ({
icon="ellipsisHorizontal" icon="ellipsisHorizontal"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault();
controls?.onMore?.({ controls?.onMore?.({
event: e, event: e,
internalState, internalState,
@@ -183,6 +184,10 @@ export const ItemCardControls = ({
itemType, itemType,
}); });
}} }}
onDoubleClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
/> />
)} )}
{controls?.onExpand && ( {controls?.onExpand && (
@@ -260,14 +265,25 @@ interface SecondaryButtonProps {
onClick?: (e: MouseEvent<HTMLButtonElement>) => void; onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
} }
const SecondaryButton = ({ className, icon, onClick }: SecondaryButtonProps) => { const SecondaryButton = ({
className,
icon,
onClick,
onDoubleClick,
}: SecondaryButtonProps & { onDoubleClick?: (e: MouseEvent<HTMLButtonElement>) => void }) => {
return ( return (
<button <button
className={clsx(styles.secondaryButton, className)} className={clsx(styles.secondaryButton, className)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault();
onClick?.(e); onClick?.(e);
}} }}
onDoubleClick={(e) => {
e.stopPropagation();
e.preventDefault();
onDoubleClick?.(e);
}}
> >
<Icon icon={icon} size="lg" /> <Icon icon={icon} size="lg" />
</button> </button>
+124 -36
View File
@@ -15,6 +15,7 @@ import { Image } from '/@/shared/components/image/image';
import { Separator } from '/@/shared/components/separator/separator'; import { Separator } from '/@/shared/components/separator/separator';
import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { useDoubleClick } from '/@/shared/hooks/use-double-click';
import { import {
Album, Album,
AlbumArtist, AlbumArtist,
@@ -138,22 +139,50 @@ const CompactItemCard = ({
} }
}; };
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleClick = useDoubleClick({
onSingleClick: (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) {
return;
}
// Don't trigger selection if clicking on interactive elements
const target = e.target as HTMLElement;
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) {
return;
}
controls.onClick?.({
event: e,
internalState,
item: data as any,
itemType,
});
},
onDoubleClick: (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) {
return;
}
controls.onDoubleClick?.({
event: e,
internalState,
item: data as any,
itemType,
});
},
});
const handleContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) { if (!data || !controls || !internalState) {
return; return;
} }
// Don't trigger selection if clicking on interactive elements e.preventDefault();
const target = e.target as HTMLElement; controls.onMore?.({
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) {
return;
}
controls.onClick?.({
event: e, event: e,
internalState, internalState,
item: data as any, item: data as any,
@@ -170,6 +199,7 @@ const CompactItemCard = ({
<div <div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })} className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
@@ -242,22 +272,50 @@ const DefaultItemCard = ({
} }
}; };
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleClick = useDoubleClick({
onSingleClick: (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) {
return;
}
// Don't trigger selection if clicking on interactive elements
const target = e.target as HTMLElement;
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) {
return;
}
controls.onClick?.({
event: e,
internalState,
item: data as any,
itemType,
});
},
onDoubleClick: (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) {
return;
}
controls.onDoubleClick?.({
event: e,
internalState,
item: data as any,
itemType,
});
},
});
const handleContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) { if (!data || !controls || !internalState) {
return; return;
} }
// Don't trigger selection if clicking on interactive elements e.preventDefault();
const target = e.target as HTMLElement; controls.onMore?.({
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) {
return;
}
controls.onClick?.({
event: e, event: e,
internalState, internalState,
item: data as any, item: data as any,
@@ -274,6 +332,7 @@ const DefaultItemCard = ({
<div <div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })} className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
@@ -390,22 +449,50 @@ const PosterItemCard = ({
} }
}; };
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleClick = useDoubleClick({
onSingleClick: (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) {
return;
}
// Don't trigger selection if clicking on interactive elements
const target = e.target as HTMLElement;
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) {
return;
}
controls.onClick?.({
event: e,
internalState,
item: data as any,
itemType,
});
},
onDoubleClick: (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) {
return;
}
controls.onDoubleClick?.({
event: e,
internalState,
item: data as any,
itemType,
});
},
});
const handleContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) { if (!data || !controls || !internalState) {
return; return;
} }
// Don't trigger selection if clicking on interactive elements e.preventDefault();
const target = e.target as HTMLElement; controls.onMore?.({
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) {
return;
}
controls.onClick?.({
event: e, event: e,
internalState, internalState,
item: data as any, item: data as any,
@@ -424,6 +511,7 @@ const PosterItemCard = ({
<div <div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })} className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
@@ -2,11 +2,30 @@ import {
ItemTableListInnerColumn, ItemTableListInnerColumn,
TableColumnContainer, TableColumnContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemListItem } from '/@/renderer/components/item-list/types';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
export const ActionsColumn = (props: ItemTableListInnerColumn) => { export const ActionsColumn = (props: ItemTableListInnerColumn) => {
const row: any = (props.data as (any | undefined)[])[props.rowIndex]; const row: any = (props.data as (any | undefined)[])[props.rowIndex];
const handleActionClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
if (row !== undefined) {
props.controls.onMore?.({
event,
internalState: props.internalState,
item: row as ItemListItem,
itemType: props.itemType,
});
}
};
const handleActionDoubleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
};
if (row !== undefined) { if (row !== undefined) {
return ( return (
<TableColumnContainer {...props}> <TableColumnContainer {...props}>
@@ -17,6 +36,8 @@ export const ActionsColumn = (props: ItemTableListInnerColumn) => {
color: 'muted', color: 'muted',
size: 'md', size: 'md',
}} }}
onDoubleClick={handleActionDoubleClick}
onClick={handleActionClick}
size="xs" size="xs"
variant="subtle" variant="subtle"
/> />
@@ -22,6 +22,8 @@ export const FavoriteColumn = (props: ItemTableListInnerColumn) => {
size: 'md', size: 'md',
}} }}
onClick={(event) => { onClick={(event) => {
event.stopPropagation();
event.preventDefault();
props.controls.onFavorite?.({ props.controls.onFavorite?.({
event, event,
favorite: !row, favorite: !row,
@@ -30,6 +32,10 @@ export const FavoriteColumn = (props: ItemTableListInnerColumn) => {
itemType: props.itemType, itemType: props.itemType,
}); });
}} }}
onDoubleClick={(event) => {
event.stopPropagation();
event.preventDefault();
}}
size="xs" size="xs"
variant="subtle" variant="subtle"
/> />
@@ -36,6 +36,7 @@ import { Flex } from '/@/shared/components/flex/flex';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { useDoubleClick } from '/@/shared/hooks/use-double-click';
import { LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types'; import { LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop'; import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';
import { TableColumn } from '/@/shared/types/types'; import { TableColumn } from '/@/shared/types/types';
@@ -426,19 +427,43 @@ export const TableColumnTextContainer = (
} }
}, [isDataRow, props.rowIndex, props.isDraggedOver, props.tableId]); }, [isDataRow, props.rowIndex, props.isDraggedOver, props.tableId]);
const handleCellClick = (event: React.MouseEvent<HTMLDivElement>) => { const handleClick = useDoubleClick({
// Don't trigger row selection if clicking on interactive elements onDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement; if (isDataRow && item) {
const isInteractiveElement = target.closest( props.controls.onDoubleClick?.({
'button, a, input, select, textarea, [role="button"]', event,
); internalState: props.internalState,
item: item as ItemListItem,
itemType: props.itemType,
});
}
},
onSingleClick: (event: React.MouseEvent<HTMLDivElement>) => {
// Don't trigger row selection if clicking on interactive elements
const target = event.target as HTMLElement;
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) { if (isInteractiveElement) {
return; return;
} }
if (isDataRow && item && props.enableSelection) { if (isDataRow && item && props.enableSelection) {
props.controls.onClick?.({ props.controls.onClick?.({
event,
internalState: props.internalState,
item: item as ItemListItem,
itemType: props.itemType,
});
}
},
});
const handleContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
if (isDataRow && item) {
event.preventDefault();
props.controls.onMore?.({
event, event,
internalState: props.internalState, internalState: props.internalState,
item: item as ItemListItem, item: item as ItemListItem,
@@ -478,7 +503,8 @@ export const TableColumnTextContainer = (
[styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn, [styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn,
})} })}
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
onClick={handleCellClick} onClick={handleClick}
onContextMenu={handleContextMenu}
ref={mergedRef} ref={mergedRef}
style={props.style} style={props.style}
> >
@@ -604,19 +630,43 @@ export const TableColumnContainer = (
} }
}, [isDataRow, props.rowIndex, props.isDraggedOver, props.tableId]); }, [isDataRow, props.rowIndex, props.isDraggedOver, props.tableId]);
const handleCellClick = (event: React.MouseEvent<HTMLDivElement>) => { const handleClick = useDoubleClick({
// Don't trigger row selection if clicking on interactive elements onDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement; if (isDataRow && item) {
const isInteractiveElement = target.closest( props.controls.onDoubleClick?.({
'button, a, input, select, textarea, [role="button"]', event,
); internalState: props.internalState,
item: item as ItemListItem,
itemType: props.itemType,
});
}
},
onSingleClick: (event: React.MouseEvent<HTMLDivElement>) => {
// Don't trigger row selection if clicking on interactive elements
const target = event.target as HTMLElement;
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) { if (isInteractiveElement) {
return; return;
} }
if (isDataRow && item && props.enableSelection) { if (isDataRow && item && props.enableSelection) {
props.controls.onClick?.({ props.controls.onClick?.({
event,
internalState: props.internalState,
item: item as ItemListItem,
itemType: props.itemType,
});
}
},
});
const handleContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
if (isDataRow && item) {
event.preventDefault();
props.controls.onMore?.({
event, event,
internalState: props.internalState, internalState: props.internalState,
item: item as ItemListItem, item: item as ItemListItem,
@@ -656,7 +706,8 @@ export const TableColumnContainer = (
[styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn, [styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn,
})} })}
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
onClick={handleCellClick} onClick={handleClick}
onContextMenu={handleContextMenu}
ref={mergedRef} ref={mergedRef}
style={{ ...props.containerStyle, ...props.style }} style={{ ...props.containerStyle, ...props.style }}
> >
+48
View File
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useRef } from 'react';
export const useDoubleClick = ({
latency = 160,
onDoubleClick = () => null,
onSingleClick = () => null,
}: {
latency?: number;
onDoubleClick?: (e: any) => void;
onSingleClick?: (e: any) => void;
}) => {
const clickCountRef = useRef(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleClick = useCallback(
(e: any) => {
clickCountRef.current += 1;
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set a new timeout to determine if it's a single or double click
timeoutRef.current = setTimeout(() => {
if (clickCountRef.current === 1) {
onSingleClick(e);
} else if (clickCountRef.current === 2) {
onDoubleClick(e);
}
clickCountRef.current = 0;
}, latency);
},
[latency, onDoubleClick, onSingleClick],
);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return handleClick;
};