Răsfoiți Sursa

added better selection, multiple wallpapers opened locally now open in multiple tabs

UwU 1 lună în urmă
părinte
comite
8842abd67b

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "we-manager",
   "name": "we-manager",
-  "version": "0.5.0",
+  "version": "0.5.2",
   "type": "module",
   "type": "module",
   "description": "Wallpaper Engine workshop manager",
   "description": "Wallpaper Engine workshop manager",
   "author": "UwU <260361707+WallpaperEngineLover@users.noreply.github.com>",
   "author": "UwU <260361707+WallpaperEngineLover@users.noreply.github.com>",

+ 25 - 0
src/main/ipc/shell.handlers.ts

@@ -2,6 +2,8 @@ import { ipcMain, shell } from 'electron'
 import { IpcChannels } from '@shared/ipc-channels'
 import { IpcChannels } from '@shared/ipc-channels'
 import * as path from 'path'
 import * as path from 'path'
 import * as fs from 'fs'
 import * as fs from 'fs'
+import { spawn } from 'child_process'
+import { isCommandAvailable } from '../utils/platform'
 
 
 export function registerShellHandlers(): void {
 export function registerShellHandlers(): void {
   // Open a file with the OS default application (e.g. video in media player)
   // Open a file with the OS default application (e.g. video in media player)
@@ -27,6 +29,29 @@ export function registerShellHandlers(): void {
     return { ok: !result, error: result || undefined }
     return { ok: !result, error: result || undefined }
   })
   })
 
 
+  // Open several folders in one file manager window (tabs) instead of one window per path
+  ipcMain.handle(IpcChannels.SHELL_OPEN_PATHS, async (_e, targetPaths: string[]) => {
+    const resolved = [...new Set(targetPaths.map((p) => path.resolve(p)))].filter((p) =>
+      fs.existsSync(p)
+    )
+    if (resolved.length === 0) return { ok: false, error: 'No valid paths' }
+    if (resolved.length === 1) {
+      const result = await shell.openPath(resolved[0])
+      return { ok: !result, error: result || undefined }
+    }
+
+    const tabbedFileManager = ['dolphin', 'nautilus'].find(isCommandAvailable)
+    if (tabbedFileManager) {
+      spawn(tabbedFileManager, resolved, { detached: true, stdio: 'ignore' }).unref()
+      return { ok: true }
+    }
+
+    for (const p of resolved) {
+      await shell.openPath(p)
+    }
+    return { ok: true }
+  })
+
   // Open a URL or protocol link (e.g. steam://, https://) with the OS handler
   // Open a URL or protocol link (e.g. steam://, https://) with the OS handler
   ipcMain.handle(IpcChannels.SHELL_OPEN_EXTERNAL, async (_e, url: string) => {
   ipcMain.handle(IpcChannels.SHELL_OPEN_EXTERNAL, async (_e, url: string) => {
     try {
     try {

+ 2 - 0
src/preload/index.ts

@@ -301,6 +301,8 @@ const api = {
       ipcRenderer.invoke(IpcChannels.SHELL_OPEN_WITH_DEFAULT, filePath),
       ipcRenderer.invoke(IpcChannels.SHELL_OPEN_WITH_DEFAULT, filePath),
     openPath: (targetPath: string): Promise<{ ok: boolean; error?: string }> =>
     openPath: (targetPath: string): Promise<{ ok: boolean; error?: string }> =>
       ipcRenderer.invoke(IpcChannels.SHELL_OPEN_PATH, targetPath),
       ipcRenderer.invoke(IpcChannels.SHELL_OPEN_PATH, targetPath),
+    openPaths: (targetPaths: string[]): Promise<{ ok: boolean; error?: string }> =>
+      ipcRenderer.invoke(IpcChannels.SHELL_OPEN_PATHS, targetPaths),
     openExternal: (url: string): Promise<{ ok: boolean; error?: string }> =>
     openExternal: (url: string): Promise<{ ok: boolean; error?: string }> =>
       ipcRenderer.invoke(IpcChannels.SHELL_OPEN_EXTERNAL, url)
       ipcRenderer.invoke(IpcChannels.SHELL_OPEN_EXTERNAL, url)
   },
   },

+ 24 - 157
src/renderer/src/components/library/LibraryView.tsx

@@ -35,6 +35,7 @@ import type { LibraryFilters, WallpaperFolder, LweStatus } from '@shared/types'
 import clsx from 'clsx'
 import clsx from 'clsx'
 import { WE_TYPES, WE_LIBRARY_AGE_RATINGS } from '../../constants/weFilters'
 import { WE_TYPES, WE_LIBRARY_AGE_RATINGS } from '../../constants/weFilters'
 import { usePreviewSize, previewGridStyle } from '../../hooks/usePreviewSize'
 import { usePreviewSize, previewGridStyle } from '../../hooks/usePreviewSize'
+import { useSelectableGrid } from '../../hooks/useSelectableGrid'
 import { useClickOutside } from '../../hooks/useClickOutside'
 import { useClickOutside } from '../../hooks/useClickOutside'
 import { useClampedPosition, useFlipSide } from '../../hooks/useContextMenuPosition'
 import { useClampedPosition, useFlipSide } from '../../hooks/useContextMenuPosition'
 import { toggle } from '../../utils/array'
 import { toggle } from '../../utils/array'
@@ -250,13 +251,6 @@ function FolderMenu({
   )
   )
 }
 }
 
 
-interface Marquee {
-  startX: number
-  startY: number
-  endX: number
-  endY: number
-}
-
 interface LibraryViewProps {
 interface LibraryViewProps {
   onBrowseCreator?: (creatorSteamId: string) => void
   onBrowseCreator?: (creatorSteamId: string) => void
 }
 }
@@ -289,12 +283,6 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
   const [renamingId, setRenamingId] = useState<string | null>(null)
   const [renamingId, setRenamingId] = useState<string | null>(null)
   const [renameValue, setRenameValue] = useState('')
   const [renameValue, setRenameValue] = useState('')
 
 
-  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
-  const [lastClickedId, setLastClickedId] = useState<string | null>(null)
-  const [marquee, setMarquee] = useState<Marquee | null>(null)
-  const marqueeActive = useRef(false)
-  const gridRef = useRef<HTMLDivElement>(null)
-
   const [ctxMenu, setCtxMenu] = useState<{
   const [ctxMenu, setCtxMenu] = useState<{
     x: number
     x: number
     y: number
     y: number
@@ -482,6 +470,23 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
 
 
   useEffect(() => { setPage(1) }, [filterState, activeFolder, search, sortBy, sortDir, pageSize])
   useEffect(() => { setPage(1) }, [filterState, activeFolder, search, sortBy, sortDir, pageSize])
 
 
+  const wallpaperIds = useMemo(() => wallpapers.map((w) => w.id), [wallpapers])
+  const {
+    selectedIds,
+    setSelectedIds,
+    setLastClickedId,
+    gridRef,
+    handleCardSelect,
+    handleMarqueeStart,
+    handleMarqueeMove,
+    handleMarqueeEnd,
+    marqueeRect
+  } = useSelectableGrid({
+    ids: wallpaperIds,
+    dataAttr: 'data-wallpaper-id',
+    onOpenDetail: setDetailId
+  })
+
   // Count for "All" sidebar button: unsorted items only
   // Count for "All" sidebar button: unsorted items only
   const unsortedCount = useMemo(
   const unsortedCount = useMemo(
     () => filtered.filter((w) => !allFolderItemIds.has(w.id)).length,
     () => filtered.filter((w) => !allFolderItemIds.has(w.id)).length,
@@ -667,124 +672,6 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
     [refetchFolders, selectedIds]
     [refetchFolders, selectedIds]
   )
   )
 
 
-  const handleCardSelect = useCallback(
-    (wallpaperId: string, e: React.MouseEvent) => {
-      if (e.ctrlKey || e.metaKey) {
-        // Ctrl+click: toggle individual
-        setSelectedIds((prev) => {
-          const next = new Set(prev)
-          if (next.has(wallpaperId)) next.delete(wallpaperId)
-          else next.add(wallpaperId)
-          return next
-        })
-        setLastClickedId(wallpaperId)
-      } else if (e.shiftKey && lastClickedId) {
-        // Shift+click: range select
-        const ids = wallpapers.map((w) => w.id)
-        const from = ids.indexOf(lastClickedId)
-        const to = ids.indexOf(wallpaperId)
-        if (from !== -1 && to !== -1) {
-          const start = Math.min(from, to)
-          const end = Math.max(from, to)
-          const rangeIds = ids.slice(start, end + 1)
-          setSelectedIds((prev) => {
-            const next = new Set(prev)
-            for (const id of rangeIds) next.add(id)
-            return next
-          })
-        }
-      } else {
-        // Normal click: select single
-        setSelectedIds(new Set([wallpaperId]))
-        setLastClickedId(wallpaperId)
-      }
-    },
-    [lastClickedId, wallpapers]
-  )
-
-  const handleMarqueeStart = useCallback(
-    (e: React.MouseEvent) => {
-      // Only start marquee when clicking on the grid background, not on a card
-      if ((e.target as HTMLElement).closest('[data-wallpaper-id]')) return
-      if (e.button !== 0) return
-
-      const container = gridRef.current
-      if (!container) return
-
-      const rect = container.getBoundingClientRect()
-      const x = e.clientX - rect.left + container.scrollLeft
-      const y = e.clientY - rect.top + container.scrollTop
-
-      marqueeActive.current = true
-      setMarquee({ startX: x, startY: y, endX: x, endY: y })
-
-      if (!e.ctrlKey && !e.metaKey) {
-        setSelectedIds(new Set())
-      }
-    },
-    []
-  )
-
-  const handleMarqueeMove = useCallback(
-    (e: React.MouseEvent) => {
-      if (!marqueeActive.current || !marquee) return
-
-      const container = gridRef.current
-      if (!container) return
-
-      const rect = container.getBoundingClientRect()
-      const x = e.clientX - rect.left + container.scrollLeft
-      const y = e.clientY - rect.top + container.scrollTop
-
-      setMarquee((prev) => prev ? { ...prev, endX: x, endY: y } : null)
-    },
-    [marquee]
-  )
-
-  const handleMarqueeEnd = useCallback(() => {
-    if (!marqueeActive.current || !marquee) return
-    marqueeActive.current = false
-
-    const container = gridRef.current
-    if (!container) return
-
-    const mx1 = Math.min(marquee.startX, marquee.endX)
-    const my1 = Math.min(marquee.startY, marquee.endY)
-    const mx2 = Math.max(marquee.startX, marquee.endX)
-    const my2 = Math.max(marquee.startY, marquee.endY)
-
-    // Skip if marquee is too small (just a click)
-    if (Math.abs(mx2 - mx1) < 5 && Math.abs(my2 - my1) < 5) {
-      setMarquee(null)
-      return
-    }
-
-    const containerRect = container.getBoundingClientRect()
-    const cards = container.querySelectorAll('[data-wallpaper-id]')
-    const hits = new Set<string>()
-
-    cards.forEach((card) => {
-      const cardRect = card.getBoundingClientRect()
-      const cx1 = cardRect.left - containerRect.left + container.scrollLeft
-      const cy1 = cardRect.top - containerRect.top + container.scrollTop
-      const cx2 = cx1 + cardRect.width
-      const cy2 = cy1 + cardRect.height
-
-      if (cx1 < mx2 && cx2 > mx1 && cy1 < my2 && cy2 > my1) {
-        const id = card.getAttribute('data-wallpaper-id')
-        if (id) hits.add(id)
-      }
-    })
-
-    setSelectedIds((prev) => {
-      const next = new Set(prev)
-      hits.forEach((id) => next.add(id))
-      return next
-    })
-
-    setMarquee(null)
-  }, [marquee])
-
   useEffect(() => {
   useEffect(() => {
     setSelectedIds(new Set())
     setSelectedIds(new Set())
     setLastClickedId(null)
     setLastClickedId(null)
@@ -792,16 +679,6 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
 
 
   useEffect(() => {
   useEffect(() => {
     function onKeyDown(e: KeyboardEvent) {
     function onKeyDown(e: KeyboardEvent) {
-      if (e.key === 'Escape') {
-        setSelectedIds(new Set())
-      }
-      if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
-        // Only if library grid is focused (not a text input)
-        const active = document.activeElement
-        if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
-        e.preventDefault()
-        setSelectedIds(new Set(wallpapers.map((w) => w.id)))
-      }
       if (e.key === '+' && selectedIds.size > 0) {
       if (e.key === '+' && selectedIds.size > 0) {
         const active = document.activeElement
         const active = document.activeElement
         if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
         if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
@@ -810,16 +687,7 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
     }
     }
     window.addEventListener('keydown', onKeyDown)
     window.addEventListener('keydown', onKeyDown)
     return () => window.removeEventListener('keydown', onKeyDown)
     return () => window.removeEventListener('keydown', onKeyDown)
-  }, [wallpapers, selectedIds, currentPlaylistId])
-
-  const marqueeRect = marquee
-    ? {
-        left: Math.min(marquee.startX, marquee.endX),
-        top: Math.min(marquee.startY, marquee.endY),
-        width: Math.abs(marquee.endX - marquee.startX),
-        height: Math.abs(marquee.endY - marquee.startY)
-      }
-    : null
+  }, [selectedIds, currentPlaylistId])
 
 
   const openWallpaperCtxMenu = useCallback(
   const openWallpaperCtxMenu = useCallback(
     (e: React.MouseEvent, wallpaperId: string) => {
     (e: React.MouseEvent, wallpaperId: string) => {
@@ -912,12 +780,11 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
 
 
   async function ctxOpenLocally() {
   async function ctxOpenLocally() {
     if (!ctxMenu) return
     if (!ctxMenu) return
-    for (const w of ctxWallpapers) {
-      if (w.localPath) {
-        await window.electronAPI.shell.openPath(w.localPath)
-      }
-    }
+    const paths = ctxWallpapers.filter((w) => w.localPath).map((w) => w.localPath!)
     closeCtxMenu()
     closeCtxMenu()
+    if (paths.length > 0) {
+      await window.electronAPI.shell.openPaths(paths)
+    }
   }
   }
 
 
   async function ctxPreviewVideo() {
   async function ctxPreviewVideo() {
@@ -1431,6 +1298,7 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
                 key={wallpaper.id}
                 key={wallpaper.id}
                 wallpaper={wallpaper}
                 wallpaper={wallpaper}
                 selected={selectedIds.has(wallpaper.id)}
                 selected={selectedIds.has(wallpaper.id)}
+                isDetailOpen={detailId === wallpaper.id}
                 lweInstalled={lweStatus?.installed ?? false}
                 lweInstalled={lweStatus?.installed ?? false}
                 isLiked={votedSet.has(wallpaper.id)}
                 isLiked={votedSet.has(wallpaper.id)}
                 currentPlaylistId={currentPlaylistId}
                 currentPlaylistId={currentPlaylistId}
@@ -1455,7 +1323,6 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
                 }
                 }
                 onSelect={(e) => handleCardSelect(wallpaper.id, e)}
                 onSelect={(e) => handleCardSelect(wallpaper.id, e)}
                 onContextMenu={(e) => openWallpaperCtxMenu(e, wallpaper.id)}
                 onContextMenu={(e) => openWallpaperCtxMenu(e, wallpaper.id)}
-                onOpenDetail={() => setDetailId(wallpaper.id)}
               />
               />
             ))}
             ))}
           </div>
           </div>

+ 9 - 13
src/renderer/src/components/library/WallpaperCard.tsx

@@ -10,6 +10,7 @@ import type { PreviewSize } from '../../hooks/usePreviewSize'
 interface WallpaperCardProps {
 interface WallpaperCardProps {
   wallpaper: WallpaperMeta
   wallpaper: WallpaperMeta
   selected?: boolean
   selected?: boolean
+  isDetailOpen?: boolean
   lweInstalled?: boolean
   lweInstalled?: boolean
   isLiked?: boolean
   isLiked?: boolean
   currentPlaylistId?: string | null
   currentPlaylistId?: string | null
@@ -21,12 +22,12 @@ interface WallpaperCardProps {
   onRedownloaded?: () => void
   onRedownloaded?: () => void
   onSelect?: (e: React.MouseEvent) => void
   onSelect?: (e: React.MouseEvent) => void
   onContextMenu?: (e: React.MouseEvent) => void
   onContextMenu?: (e: React.MouseEvent) => void
-  onOpenDetail?: () => void
 }
 }
 
 
 export default function WallpaperCard({
 export default function WallpaperCard({
   wallpaper,
   wallpaper,
   selected,
   selected,
+  isDetailOpen,
   lweInstalled,
   lweInstalled,
   isLiked = false,
   isLiked = false,
   currentPlaylistId = null,
   currentPlaylistId = null,
@@ -37,8 +38,7 @@ export default function WallpaperCard({
   onAddedToPlaylist,
   onAddedToPlaylist,
   onRedownloaded,
   onRedownloaded,
   onSelect,
   onSelect,
-  onContextMenu,
-  onOpenDetail
+  onContextMenu
 }: WallpaperCardProps) {
 }: WallpaperCardProps) {
   const { showToast } = useToast()
   const { showToast } = useToast()
   const [isApplying, setIsApplying] = useState(false)
   const [isApplying, setIsApplying] = useState(false)
@@ -170,9 +170,11 @@ export default function WallpaperCard({
       onContextMenu={onContextMenu}
       onContextMenu={onContextMenu}
       className={clsx(
       className={clsx(
         'group relative overflow-hidden rounded-lg bg-[#1a1a1a] transition-all cursor-pointer select-none',
         'group relative overflow-hidden rounded-lg bg-[#1a1a1a] transition-all cursor-pointer select-none',
-        selected
-          ? 'ring-2 ring-indigo-500'
-          : 'hover:ring-1 hover:ring-indigo-500/50'
+        isDetailOpen
+          ? 'ring-2 ring-sky-400'
+          : selected
+            ? 'ring-2 ring-indigo-500'
+            : 'hover:ring-1 hover:ring-indigo-500/50'
       )}
       )}
     >
     >
       <div
       <div
@@ -186,13 +188,7 @@ export default function WallpaperCard({
         <Check size={12} />
         <Check size={12} />
       </div>
       </div>
 
 
-      <div
-        className="aspect-video overflow-hidden bg-[#111] relative cursor-pointer"
-        onClick={(e) => {
-          e.stopPropagation()
-          onOpenDetail?.()
-        }}
-      >
+      <div className="aspect-video overflow-hidden bg-[#111] relative">
         {previewSrc ? (
         {previewSrc ? (
           <img
           <img
             src={previewSrc}
             src={previewSrc}

+ 30 - 161
src/renderer/src/components/workshop/WorkshopBrowser.tsx

@@ -32,6 +32,7 @@ import {
   WE_GENRES
   WE_GENRES
 } from '../../constants/weFilters'
 } from '../../constants/weFilters'
 import { usePreviewSize, previewGridStyle } from '../../hooks/usePreviewSize'
 import { usePreviewSize, previewGridStyle } from '../../hooks/usePreviewSize'
+import { useSelectableGrid } from '../../hooks/useSelectableGrid'
 import { useClickOutside } from '../../hooks/useClickOutside'
 import { useClickOutside } from '../../hooks/useClickOutside'
 import { useClampedPosition, useFlipSide } from '../../hooks/useContextMenuPosition'
 import { useClampedPosition, useFlipSide } from '../../hooks/useContextMenuPosition'
 import { useSubscriptionQueue } from '../../hooks/useSubscriptionQueue'
 import { useSubscriptionQueue } from '../../hooks/useSubscriptionQueue'
@@ -155,13 +156,6 @@ interface CtxMenu {
   showFolderSub: boolean
   showFolderSub: boolean
 }
 }
 
 
-interface Marquee {
-  startX: number
-  startY: number
-  endX: number
-  endY: number
-}
-
 interface WorkshopBrowserProps {
 interface WorkshopBrowserProps {
   creatorFilter?: string | null
   creatorFilter?: string | null
   onClearCreatorFilter?: () => void
   onClearCreatorFilter?: () => void
@@ -181,11 +175,6 @@ export default function WorkshopBrowser({
   const [queryType, setQueryType] = useState<WorkshopQueryType>('RankedByPublicationDate')
   const [queryType, setQueryType] = useState<WorkshopQueryType>('RankedByPublicationDate')
   const [showFilters, setShowFilters] = useState(true)
   const [showFilters, setShowFilters] = useState(true)
   const [filters, setFilters] = useState<WorkshopFilterState>(loadFilters)
   const [filters, setFilters] = useState<WorkshopFilterState>(loadFilters)
-  const [selection, setSelection] = useState<Set<string>>(new Set())
-  const [lastClickedId, setLastClickedId] = useState<string | null>(null)
-  const [marquee, setMarquee] = useState<Marquee | null>(null)
-  const marqueeActive = useRef(false)
-  const gridRef = useRef<HTMLDivElement>(null)
   const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
   const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
   const ctxRef = useRef<HTMLDivElement>(null)
   const ctxRef = useRef<HTMLDivElement>(null)
   const folderSubRef = useRef<HTMLDivElement>(null)
   const folderSubRef = useRef<HTMLDivElement>(null)
@@ -254,18 +243,6 @@ export default function WorkshopBrowser({
     setFilters((prev) => ({ ...DEFAULT_STATE, filterMode: prev.filterMode }))
     setFilters((prev) => ({ ...DEFAULT_STATE, filterMode: prev.filterMode }))
   }
   }
 
 
-  const openCtxMenu = useCallback(
-    (e: React.MouseEvent, itemId: string) => {
-      e.preventDefault()
-      const ids = selection.has(itemId) ? [...selection] : [itemId]
-      if (!selection.has(itemId)) {
-        setSelection(new Set(ids))
-      }
-      setCtxMenu({ x: e.clientX, y: e.clientY, ids, showFolderSub: false })
-    },
-    [selection]
-  )
-
   function closeCtxMenu() {
   function closeCtxMenu() {
     setCtxMenu(null)
     setCtxMenu(null)
   }
   }
@@ -481,6 +458,34 @@ export default function WorkshopBrowser({
     [showIgnored, pageShownItems, pageIgnoredItems]
     [showIgnored, pageShownItems, pageIgnoredItems]
   )
   )
 
 
+  const paginatedIds = useMemo(() => paginatedItems.map((i) => i.publishedFileId), [paginatedItems])
+  const {
+    selectedIds: selection,
+    setSelectedIds: setSelection,
+    gridRef,
+    handleCardSelect,
+    handleMarqueeStart,
+    handleMarqueeMove,
+    handleMarqueeEnd,
+    marqueeRect
+  } = useSelectableGrid({
+    ids: paginatedIds,
+    dataAttr: 'data-workshop-id',
+    onOpenDetail: setDetailId
+  })
+
+  const openCtxMenu = useCallback(
+    (e: React.MouseEvent, itemId: string) => {
+      e.preventDefault()
+      const ids = selection.has(itemId) ? [...selection] : [itemId]
+      if (!selection.has(itemId)) {
+        setSelection(new Set(ids))
+      }
+      setCtxMenu({ x: e.clientX, y: e.clientY, ids, showFolderSub: false })
+    },
+    [selection, setSelection]
+  )
+
   // Auto-fetch more Steam API pages if needed to fill the current view
   // Auto-fetch more Steam API pages if needed to fill the current view
   const totalResults = data?.pages[0]?.totalResults ?? 0
   const totalResults = data?.pages[0]?.totalResults ?? 0
   const needMore = page * pageSize > allItems.length && allItems.length < totalResults
   const needMore = page * pageSize > allItems.length && allItems.length < totalResults
@@ -492,142 +497,6 @@ export default function WorkshopBrowser({
 
 
   const assetSelected = filters.types.includes('Asset') || effectiveTypes.length === 0
   const assetSelected = filters.types.includes('Asset') || effectiveTypes.length === 0
 
 
-  // Card click handler: normal = select single, ctrl = toggle, shift = range
-  const handleCardSelect = useCallback(
-    (itemId: string, e: React.MouseEvent) => {
-      if (e.ctrlKey || e.metaKey) {
-        setSelection((prev) => {
-          const next = new Set(prev)
-          if (next.has(itemId)) next.delete(itemId)
-          else next.add(itemId)
-          return next
-        })
-        setLastClickedId(itemId)
-      } else if (e.shiftKey && lastClickedId) {
-        const ids = paginatedItems.map((i) => i.publishedFileId)
-        const from = ids.indexOf(lastClickedId)
-        const to = ids.indexOf(itemId)
-        if (from !== -1 && to !== -1) {
-          const start = Math.min(from, to)
-          const end = Math.max(from, to)
-          const rangeIds = ids.slice(start, end + 1)
-          setSelection((prev) => {
-            const next = new Set(prev)
-            for (const id of rangeIds) next.add(id)
-            return next
-          })
-        }
-      } else {
-        setSelection(new Set([itemId]))
-        setLastClickedId(itemId)
-      }
-    },
-    [lastClickedId, paginatedItems]
-  )
-
-  const handleMarqueeStart = useCallback((e: React.MouseEvent) => {
-    if ((e.target as HTMLElement).closest('[data-workshop-id]')) return
-    if (e.button !== 0) return
-
-    const container = gridRef.current
-    if (!container) return
-
-    const rect = container.getBoundingClientRect()
-    const x = e.clientX - rect.left + container.scrollLeft
-    const y = e.clientY - rect.top + container.scrollTop
-
-    marqueeActive.current = true
-    setMarquee({ startX: x, startY: y, endX: x, endY: y })
-
-    if (!e.ctrlKey && !e.metaKey) {
-      setSelection(new Set())
-    }
-  }, [])
-
-  const handleMarqueeMove = useCallback(
-    (e: React.MouseEvent) => {
-      if (!marqueeActive.current || !marquee) return
-
-      const container = gridRef.current
-      if (!container) return
-
-      const rect = container.getBoundingClientRect()
-      const x = e.clientX - rect.left + container.scrollLeft
-      const y = e.clientY - rect.top + container.scrollTop
-
-      setMarquee((prev) => (prev ? { ...prev, endX: x, endY: y } : null))
-    },
-    [marquee]
-  )
-
-  const handleMarqueeEnd = useCallback(() => {
-    if (!marqueeActive.current || !marquee) return
-    marqueeActive.current = false
-
-    const container = gridRef.current
-    if (!container) return
-
-    const mx1 = Math.min(marquee.startX, marquee.endX)
-    const my1 = Math.min(marquee.startY, marquee.endY)
-    const mx2 = Math.max(marquee.startX, marquee.endX)
-    const my2 = Math.max(marquee.startY, marquee.endY)
-
-    if (Math.abs(mx2 - mx1) < 5 && Math.abs(my2 - my1) < 5) {
-      setMarquee(null)
-      return
-    }
-
-    const containerRect = container.getBoundingClientRect()
-    const cards = container.querySelectorAll('[data-workshop-id]')
-    const hits = new Set<string>()
-
-    cards.forEach((card) => {
-      const cardRect = card.getBoundingClientRect()
-      const cx1 = cardRect.left - containerRect.left + container.scrollLeft
-      const cy1 = cardRect.top - containerRect.top + container.scrollTop
-      const cx2 = cx1 + cardRect.width
-      const cy2 = cy1 + cardRect.height
-
-      if (cx1 < mx2 && cx2 > mx1 && cy1 < my2 && cy2 > my1) {
-        const id = card.getAttribute('data-workshop-id')
-        if (id) hits.add(id)
-      }
-    })
-
-    setSelection((prev) => {
-      const next = new Set(prev)
-      hits.forEach((id) => next.add(id))
-      return next
-    })
-
-    setMarquee(null)
-  }, [marquee])
-
-  useEffect(() => {
-    function onKeyDown(e: KeyboardEvent) {
-      if (e.key === 'Escape') {
-        setSelection(new Set())
-      }
-      if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
-        const active = document.activeElement
-        if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
-        e.preventDefault()
-        setSelection(new Set(paginatedItems.map((i) => i.publishedFileId)))
-      }
-    }
-    window.addEventListener('keydown', onKeyDown)
-    return () => window.removeEventListener('keydown', onKeyDown)
-  }, [paginatedItems])
-
-  const marqueeRect = marquee
-    ? {
-        left: Math.min(marquee.startX, marquee.endX),
-        top: Math.min(marquee.startY, marquee.endY),
-        width: Math.abs(marquee.endX - marquee.startX),
-        height: Math.abs(marquee.endY - marquee.startY)
-      }
-    : null
-
   const ctxLibraryIds = ctxMenu ? ctxMenu.ids.filter((id) => libraryIdSet.has(id)) : []
   const ctxLibraryIds = ctxMenu ? ctxMenu.ids.filter((id) => libraryIdSet.has(id)) : []
   const ctxCurrentFolderIds = ctxMenu
   const ctxCurrentFolderIds = ctxMenu
     ? new Set(ctxMenu.ids.map((id) => folderOfItem.get(id)).filter((id): id is string => !!id))
     ? new Set(ctxMenu.ids.map((id) => folderOfItem.get(id)).filter((id): id is string => !!id))
@@ -926,6 +795,7 @@ export default function WorkshopBrowser({
                 key={item.publishedFileId}
                 key={item.publishedFileId}
                 item={item}
                 item={item}
                 selected={selection.has(item.publishedFileId)}
                 selected={selection.has(item.publishedFileId)}
+                isDetailOpen={detailId === item.publishedFileId}
                 ignored={ignoredCreatorSet.has(item.creatorSteamId)}
                 ignored={ignoredCreatorSet.has(item.creatorSteamId)}
                 isLiked={votedSet.has(item.publishedFileId)}
                 isLiked={votedSet.has(item.publishedFileId)}
                 canPlay={playableSet.has(item.publishedFileId)}
                 canPlay={playableSet.has(item.publishedFileId)}
@@ -942,7 +812,6 @@ export default function WorkshopBrowser({
                 }}
                 }}
                 onPlay={() => handlePlay(item.publishedFileId)}
                 onPlay={() => handlePlay(item.publishedFileId)}
                 onSubscribe={() => enqueueSubscribe([item.publishedFileId])}
                 onSubscribe={() => enqueueSubscribe([item.publishedFileId])}
-                onOpenDetail={() => setDetailId(item.publishedFileId)}
               />
               />
             ))}
             ))}
           </div>
           </div>

+ 10 - 12
src/renderer/src/components/workshop/WorkshopCard.tsx

@@ -9,6 +9,7 @@ import { formatFileSize } from '../../utils/format'
 interface WorkshopCardProps {
 interface WorkshopCardProps {
   item: WorkshopItem
   item: WorkshopItem
   selected?: boolean
   selected?: boolean
+  isDetailOpen?: boolean
   ignored?: boolean
   ignored?: boolean
   isLiked?: boolean
   isLiked?: boolean
   canPlay?: boolean
   canPlay?: boolean
@@ -20,12 +21,12 @@ interface WorkshopCardProps {
   onLiked?: () => void
   onLiked?: () => void
   onPlay?: () => void | Promise<void>
   onPlay?: () => void | Promise<void>
   onSubscribe?: () => void
   onSubscribe?: () => void
-  onOpenDetail?: () => void
 }
 }
 
 
 export default function WorkshopCard({
 export default function WorkshopCard({
   item,
   item,
   selected = false,
   selected = false,
+  isDetailOpen = false,
   ignored = false,
   ignored = false,
   isLiked = false,
   isLiked = false,
   canPlay = false,
   canPlay = false,
@@ -36,8 +37,7 @@ export default function WorkshopCard({
   onContextMenu,
   onContextMenu,
   onLiked,
   onLiked,
   onPlay,
   onPlay,
-  onSubscribe,
-  onOpenDetail
+  onSubscribe
 }: WorkshopCardProps) {
 }: WorkshopCardProps) {
   const [isLiking, setIsLiking] = useState(false)
   const [isLiking, setIsLiking] = useState(false)
   const [isApplying, setIsApplying] = useState(false)
   const [isApplying, setIsApplying] = useState(false)
@@ -85,8 +85,12 @@ export default function WorkshopCard({
     <div
     <div
       data-workshop-id={item.publishedFileId}
       data-workshop-id={item.publishedFileId}
       className={clsx(
       className={clsx(
-        'group relative overflow-hidden rounded-lg bg-[#1a1a1a] transition-all hover:ring-1',
-        selected ? 'ring-2 ring-indigo-500' : 'hover:ring-indigo-500/50'
+        'group relative overflow-hidden rounded-lg bg-[#1a1a1a] transition-all cursor-pointer select-none',
+        isDetailOpen
+          ? 'ring-2 ring-sky-400'
+          : selected
+            ? 'ring-2 ring-indigo-500'
+            : 'hover:ring-1 hover:ring-indigo-500/50'
       )}
       )}
       onContextMenu={onContextMenu}
       onContextMenu={onContextMenu}
       onClick={onSelect}
       onClick={onSelect}
@@ -109,13 +113,7 @@ export default function WorkshopCard({
         </div>
         </div>
       </div>
       </div>
 
 
-      <div
-        className="aspect-video overflow-hidden bg-[#111] relative cursor-pointer"
-        onClick={(e) => {
-          e.stopPropagation()
-          onOpenDetail?.()
-        }}
-      >
+      <div className="aspect-video overflow-hidden bg-[#111] relative">
         {item.previewUrl ? (
         {item.previewUrl ? (
           <img
           <img
             src={item.previewUrl}
             src={item.previewUrl}

+ 183 - 0
src/renderer/src/hooks/useSelectableGrid.ts

@@ -0,0 +1,183 @@
+import { useState, useRef, useCallback, useEffect } from 'react'
+
+export interface MarqueeRect {
+  left: number
+  top: number
+  width: number
+  height: number
+}
+
+interface Marquee {
+  startX: number
+  startY: number
+  endX: number
+  endY: number
+}
+
+interface UseSelectableGridOptions {
+  ids: string[]
+  dataAttr: string
+  onOpenDetail?: (id: string) => void
+}
+
+export function useSelectableGrid({ ids, dataAttr, onOpenDetail }: UseSelectableGridOptions) {
+  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
+  const [lastClickedId, setLastClickedId] = useState<string | null>(null)
+  const [marquee, setMarquee] = useState<Marquee | null>(null)
+  const marqueeActive = useRef(false)
+  const gridRef = useRef<HTMLDivElement>(null)
+
+  const handleCardSelect = useCallback(
+    (id: string, e: React.MouseEvent) => {
+      if (e.ctrlKey || e.metaKey) {
+        setSelectedIds((prev) => {
+          const next = new Set(prev)
+          if (next.has(id)) next.delete(id)
+          else next.add(id)
+          return next
+        })
+        setLastClickedId(id)
+        return
+      }
+      if (e.shiftKey && lastClickedId) {
+        const from = ids.indexOf(lastClickedId)
+        const to = ids.indexOf(id)
+        if (from !== -1 && to !== -1) {
+          const start = Math.min(from, to)
+          const end = Math.max(from, to)
+          const rangeIds = ids.slice(start, end + 1)
+          setSelectedIds((prev) => {
+            const next = new Set(prev)
+            for (const rangeId of rangeIds) next.add(rangeId)
+            return next
+          })
+        }
+        return
+      }
+      setSelectedIds(new Set([id]))
+      setLastClickedId(id)
+      onOpenDetail?.(id)
+    },
+    [ids, lastClickedId, onOpenDetail]
+  )
+
+  const handleMarqueeStart = useCallback(
+    (e: React.MouseEvent) => {
+      if ((e.target as HTMLElement).closest(`[${dataAttr}]`)) return
+      if (e.button !== 0) return
+
+      const container = gridRef.current
+      if (!container) return
+
+      const rect = container.getBoundingClientRect()
+      const x = e.clientX - rect.left + container.scrollLeft
+      const y = e.clientY - rect.top + container.scrollTop
+
+      marqueeActive.current = true
+      setMarquee({ startX: x, startY: y, endX: x, endY: y })
+
+      if (!e.ctrlKey && !e.metaKey) {
+        setSelectedIds(new Set())
+      }
+    },
+    [dataAttr]
+  )
+
+  const handleMarqueeMove = useCallback(
+    (e: React.MouseEvent) => {
+      if (!marqueeActive.current || !marquee) return
+
+      const container = gridRef.current
+      if (!container) return
+
+      const rect = container.getBoundingClientRect()
+      const x = e.clientX - rect.left + container.scrollLeft
+      const y = e.clientY - rect.top + container.scrollTop
+
+      setMarquee((prev) => (prev ? { ...prev, endX: x, endY: y } : null))
+    },
+    [marquee]
+  )
+
+  const handleMarqueeEnd = useCallback(() => {
+    if (!marqueeActive.current || !marquee) return
+    marqueeActive.current = false
+
+    const container = gridRef.current
+    if (!container) return
+
+    const mx1 = Math.min(marquee.startX, marquee.endX)
+    const my1 = Math.min(marquee.startY, marquee.endY)
+    const mx2 = Math.max(marquee.startX, marquee.endX)
+    const my2 = Math.max(marquee.startY, marquee.endY)
+
+    if (Math.abs(mx2 - mx1) < 5 && Math.abs(my2 - my1) < 5) {
+      setMarquee(null)
+      return
+    }
+
+    const containerRect = container.getBoundingClientRect()
+    const cards = container.querySelectorAll(`[${dataAttr}]`)
+    const hits = new Set<string>()
+
+    cards.forEach((card) => {
+      const cardRect = card.getBoundingClientRect()
+      const cx1 = cardRect.left - containerRect.left + container.scrollLeft
+      const cy1 = cardRect.top - containerRect.top + container.scrollTop
+      const cx2 = cx1 + cardRect.width
+      const cy2 = cy1 + cardRect.height
+
+      if (cx1 < mx2 && cx2 > mx1 && cy1 < my2 && cy2 > my1) {
+        const id = card.getAttribute(dataAttr)
+        if (id) hits.add(id)
+      }
+    })
+
+    setSelectedIds((prev) => {
+      const next = new Set(prev)
+      hits.forEach((id) => next.add(id))
+      return next
+    })
+
+    setMarquee(null)
+  }, [marquee, dataAttr])
+
+  useEffect(() => {
+    function onKeyDown(e: KeyboardEvent) {
+      if (e.key === 'Escape') {
+        setSelectedIds(new Set())
+        return
+      }
+      if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
+        const active = document.activeElement
+        if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
+        e.preventDefault()
+        setSelectedIds(new Set(ids))
+      }
+    }
+    window.addEventListener('keydown', onKeyDown)
+    return () => window.removeEventListener('keydown', onKeyDown)
+  }, [ids])
+
+  const marqueeRect: MarqueeRect | null = marquee
+    ? {
+        left: Math.min(marquee.startX, marquee.endX),
+        top: Math.min(marquee.startY, marquee.endY),
+        width: Math.abs(marquee.endX - marquee.startX),
+        height: Math.abs(marquee.endY - marquee.startY)
+      }
+    : null
+
+  return {
+    selectedIds,
+    setSelectedIds,
+    lastClickedId,
+    setLastClickedId,
+    gridRef,
+    handleCardSelect,
+    handleMarqueeStart,
+    handleMarqueeMove,
+    handleMarqueeEnd,
+    marqueeRect
+  }
+}

+ 1 - 0
src/shared/ipc-channels.ts

@@ -102,6 +102,7 @@ export const IpcChannels = {
 
 
   // Shell operations
   // Shell operations
   SHELL_OPEN_PATH: 'shell:open-path',
   SHELL_OPEN_PATH: 'shell:open-path',
+  SHELL_OPEN_PATHS: 'shell:open-paths',
   SHELL_OPEN_IN_FILE_MANAGER: 'shell:open-in-file-manager',
   SHELL_OPEN_IN_FILE_MANAGER: 'shell:open-in-file-manager',
   SHELL_OPEN_WITH_DEFAULT: 'shell:open-with-default',
   SHELL_OPEN_WITH_DEFAULT: 'shell:open-with-default',
   SHELL_OPEN_EXTERNAL: 'shell:open-external',
   SHELL_OPEN_EXTERNAL: 'shell:open-external',