From a75f64d2044702bd34f633958a050ada7e736a43 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 13 Nov 2025 20:54:18 -0800 Subject: [PATCH] implement double click handler on default controls --- .../item-card/item-card-controls.tsx | 18 +- .../components/item-card/item-card.tsx | 160 ++++++++++++++---- .../columns/actions-column.tsx | 21 +++ .../columns/favorite-column.tsx | 6 + .../item-table-list-column.tsx | 99 ++++++++--- src/shared/hooks/use-double-click.ts | 48 ++++++ 6 files changed, 291 insertions(+), 61 deletions(-) create mode 100644 src/shared/hooks/use-double-click.ts diff --git a/src/renderer/components/item-card/item-card-controls.tsx b/src/renderer/components/item-card/item-card-controls.tsx index 23a153f8b..fdcce6094 100644 --- a/src/renderer/components/item-card/item-card-controls.tsx +++ b/src/renderer/components/item-card/item-card-controls.tsx @@ -176,6 +176,7 @@ export const ItemCardControls = ({ icon="ellipsisHorizontal" onClick={(e) => { e.stopPropagation(); + e.preventDefault(); controls?.onMore?.({ event: e, internalState, @@ -183,6 +184,10 @@ export const ItemCardControls = ({ itemType, }); }} + onDoubleClick={(e) => { + e.stopPropagation(); + e.preventDefault(); + }} /> )} {controls?.onExpand && ( @@ -260,14 +265,25 @@ interface SecondaryButtonProps { onClick?: (e: MouseEvent) => void; } -const SecondaryButton = ({ className, icon, onClick }: SecondaryButtonProps) => { +const SecondaryButton = ({ + className, + icon, + onClick, + onDoubleClick, +}: SecondaryButtonProps & { onDoubleClick?: (e: MouseEvent) => void }) => { return ( diff --git a/src/renderer/components/item-card/item-card.tsx b/src/renderer/components/item-card/item-card.tsx index 3d115c4c1..9b837b557 100644 --- a/src/renderer/components/item-card/item-card.tsx +++ b/src/renderer/components/item-card/item-card.tsx @@ -15,6 +15,7 @@ import { Image } from '/@/shared/components/image/image'; import { Separator } from '/@/shared/components/separator/separator'; import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Text } from '/@/shared/components/text/text'; +import { useDoubleClick } from '/@/shared/hooks/use-double-click'; import { Album, AlbumArtist, @@ -138,22 +139,50 @@ const CompactItemCard = ({ } }; - const handleClick = (e: React.MouseEvent) => { + const handleClick = useDoubleClick({ + onSingleClick: (e: React.MouseEvent) => { + 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) => { + if (!data || !controls || !internalState) { + return; + } + + controls.onDoubleClick?.({ + event: e, + internalState, + item: data as any, + itemType, + }); + }, + }); + + const handleContextMenu = (e: React.MouseEvent) => { 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?.({ + e.preventDefault(); + controls.onMore?.({ event: e, internalState, item: data as any, @@ -170,6 +199,7 @@ const CompactItemCard = ({
@@ -242,22 +272,50 @@ const DefaultItemCard = ({ } }; - const handleClick = (e: React.MouseEvent) => { + const handleClick = useDoubleClick({ + onSingleClick: (e: React.MouseEvent) => { + 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) => { + if (!data || !controls || !internalState) { + return; + } + + controls.onDoubleClick?.({ + event: e, + internalState, + item: data as any, + itemType, + }); + }, + }); + + const handleContextMenu = (e: React.MouseEvent) => { 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?.({ + e.preventDefault(); + controls.onMore?.({ event: e, internalState, item: data as any, @@ -274,6 +332,7 @@ const DefaultItemCard = ({
@@ -390,22 +449,50 @@ const PosterItemCard = ({ } }; - const handleClick = (e: React.MouseEvent) => { + const handleClick = useDoubleClick({ + onSingleClick: (e: React.MouseEvent) => { + 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) => { + if (!data || !controls || !internalState) { + return; + } + + controls.onDoubleClick?.({ + event: e, + internalState, + item: data as any, + itemType, + }); + }, + }); + + const handleContextMenu = (e: React.MouseEvent) => { 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?.({ + e.preventDefault(); + controls.onMore?.({ event: e, internalState, item: data as any, @@ -424,6 +511,7 @@ const PosterItemCard = ({
diff --git a/src/renderer/components/item-list/item-table-list/columns/actions-column.tsx b/src/renderer/components/item-list/item-table-list/columns/actions-column.tsx index f593b3395..0eb64c1e9 100644 --- a/src/renderer/components/item-list/item-table-list/columns/actions-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/actions-column.tsx @@ -2,11 +2,30 @@ import { ItemTableListInnerColumn, TableColumnContainer, } 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'; export const ActionsColumn = (props: ItemTableListInnerColumn) => { const row: any = (props.data as (any | undefined)[])[props.rowIndex]; + const handleActionClick = (event: React.MouseEvent) => { + 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) => { + event.stopPropagation(); + event.preventDefault(); + }; + if (row !== undefined) { return ( @@ -17,6 +36,8 @@ export const ActionsColumn = (props: ItemTableListInnerColumn) => { color: 'muted', size: 'md', }} + onDoubleClick={handleActionDoubleClick} + onClick={handleActionClick} size="xs" variant="subtle" /> diff --git a/src/renderer/components/item-list/item-table-list/columns/favorite-column.tsx b/src/renderer/components/item-list/item-table-list/columns/favorite-column.tsx index f883b33bb..34e0cabb6 100644 --- a/src/renderer/components/item-list/item-table-list/columns/favorite-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/favorite-column.tsx @@ -22,6 +22,8 @@ export const FavoriteColumn = (props: ItemTableListInnerColumn) => { size: 'md', }} onClick={(event) => { + event.stopPropagation(); + event.preventDefault(); props.controls.onFavorite?.({ event, favorite: !row, @@ -30,6 +32,10 @@ export const FavoriteColumn = (props: ItemTableListInnerColumn) => { itemType: props.itemType, }); }} + onDoubleClick={(event) => { + event.stopPropagation(); + event.preventDefault(); + }} size="xs" variant="subtle" /> diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index 81fd31c6b..5682944d8 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -36,6 +36,7 @@ import { Flex } from '/@/shared/components/flex/flex'; import { Icon } from '/@/shared/components/icon/icon'; import { Skeleton } from '/@/shared/components/skeleton/skeleton'; 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 { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop'; import { TableColumn } from '/@/shared/types/types'; @@ -426,19 +427,43 @@ export const TableColumnTextContainer = ( } }, [isDataRow, props.rowIndex, props.isDraggedOver, props.tableId]); - const handleCellClick = (event: React.MouseEvent) => { - // 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"]', - ); + const handleClick = useDoubleClick({ + onDoubleClick: (event: React.MouseEvent) => { + if (isDataRow && item) { + props.controls.onDoubleClick?.({ + event, + internalState: props.internalState, + item: item as ItemListItem, + itemType: props.itemType, + }); + } + }, + onSingleClick: (event: React.MouseEvent) => { + // 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) { - return; - } + if (isInteractiveElement) { + return; + } - if (isDataRow && item && props.enableSelection) { - props.controls.onClick?.({ + if (isDataRow && item && props.enableSelection) { + props.controls.onClick?.({ + event, + internalState: props.internalState, + item: item as ItemListItem, + itemType: props.itemType, + }); + } + }, + }); + + const handleContextMenu = (event: React.MouseEvent) => { + if (isDataRow && item) { + event.preventDefault(); + props.controls.onMore?.({ event, internalState: props.internalState, item: item as ItemListItem, @@ -478,7 +503,8 @@ export const TableColumnTextContainer = ( [styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn, })} data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} - onClick={handleCellClick} + onClick={handleClick} + onContextMenu={handleContextMenu} ref={mergedRef} style={props.style} > @@ -604,19 +630,43 @@ export const TableColumnContainer = ( } }, [isDataRow, props.rowIndex, props.isDraggedOver, props.tableId]); - const handleCellClick = (event: React.MouseEvent) => { - // 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"]', - ); + const handleClick = useDoubleClick({ + onDoubleClick: (event: React.MouseEvent) => { + if (isDataRow && item) { + props.controls.onDoubleClick?.({ + event, + internalState: props.internalState, + item: item as ItemListItem, + itemType: props.itemType, + }); + } + }, + onSingleClick: (event: React.MouseEvent) => { + // 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) { - return; - } + if (isInteractiveElement) { + return; + } - if (isDataRow && item && props.enableSelection) { - props.controls.onClick?.({ + if (isDataRow && item && props.enableSelection) { + props.controls.onClick?.({ + event, + internalState: props.internalState, + item: item as ItemListItem, + itemType: props.itemType, + }); + } + }, + }); + + const handleContextMenu = (event: React.MouseEvent) => { + if (isDataRow && item) { + event.preventDefault(); + props.controls.onMore?.({ event, internalState: props.internalState, item: item as ItemListItem, @@ -656,7 +706,8 @@ export const TableColumnContainer = ( [styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn, })} data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} - onClick={handleCellClick} + onClick={handleClick} + onContextMenu={handleContextMenu} ref={mergedRef} style={{ ...props.containerStyle, ...props.style }} > diff --git a/src/shared/hooks/use-double-click.ts b/src/shared/hooks/use-double-click.ts new file mode 100644 index 000000000..f690c1fb5 --- /dev/null +++ b/src/shared/hooks/use-double-click.ts @@ -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(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; +};