342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
import React, { type RefObject } from 'react'
|
|
import {
|
|
Plus, X, RefreshCw, ChevronLeft, ChevronRight, Layers, GitMerge,
|
|
FolderPlus, Folder, Trash2, FolderOpen,
|
|
} from 'lucide-react'
|
|
import type { Asset } from '../../types/project-model'
|
|
import { COLOR_LABELS } from './video-editor-utils'
|
|
import { equalAssetBins, selectAssetBins, selectAssets, selectRegenerationState } from './editor-selectors'
|
|
import { useEditorActions, useEditorStore } from './editor-store'
|
|
|
|
export interface AssetContextMenuProps {
|
|
asset: Asset
|
|
targetIds: string[]
|
|
assetContextMenu: { assetId: string; x: number; y: number }
|
|
assetContextMenuRef: RefObject<HTMLDivElement>
|
|
addClipToTimeline: (asset: Asset, trackIndex?: number, startTime?: number) => void
|
|
handleRegenerate: (assetId: string) => void
|
|
handleCancelRegeneration: () => void
|
|
canCancelInFlight: boolean
|
|
setTakesViewAssetId: (assetId: string | null) => void
|
|
setSelectedAssetIds: React.Dispatch<React.SetStateAction<Set<string>>>
|
|
setAssetContextMenu: React.Dispatch<React.SetStateAction<{ assetId: string; x: number; y: number } | null>>
|
|
createAssetFromTake: (asset: Asset, take: NonNullable<Asset['takes']>[number]) => Asset
|
|
openCreateBinEditor: (assetIds: string[]) => void
|
|
}
|
|
|
|
export function AssetContextMenu({
|
|
asset,
|
|
targetIds,
|
|
assetContextMenu,
|
|
assetContextMenuRef,
|
|
addClipToTimeline,
|
|
handleRegenerate,
|
|
handleCancelRegeneration,
|
|
canCancelInFlight,
|
|
setTakesViewAssetId,
|
|
setSelectedAssetIds,
|
|
setAssetContextMenu,
|
|
createAssetFromTake,
|
|
openCreateBinEditor,
|
|
}: AssetContextMenuProps) {
|
|
const actions = useEditorActions()
|
|
const assets = useEditorStore(selectAssets)
|
|
const bins = useEditorStore(selectAssetBins, equalAssetBins)
|
|
const regenerationState = useEditorStore(selectRegenerationState)
|
|
const isRegenerating = regenerationState.regeneratingAssetId !== null || regenerationState.regeneratingClipId !== null
|
|
const regeneratingAssetId = regenerationState.regeneratingAssetId
|
|
const isMulti = targetIds.length > 1
|
|
|
|
const closeMenu = () => setAssetContextMenu(null)
|
|
const clearSelection = () => setSelectedAssetIds(new Set())
|
|
|
|
const setActiveTake = (assetId: string, takeIndex: number) => {
|
|
actions.setAssetActiveTake(assetId, takeIndex)
|
|
}
|
|
|
|
const setColor = (colorLabel?: string) => {
|
|
for (const id of targetIds) {
|
|
actions.setAssetColorLabel(id, colorLabel)
|
|
}
|
|
closeMenu()
|
|
}
|
|
|
|
const moveToBin = (binId?: string) => {
|
|
actions.assignAssetsToBin(targetIds, binId)
|
|
clearSelection()
|
|
closeMenu()
|
|
}
|
|
|
|
const deleteTargetAssets = () => {
|
|
actions.deleteAssets(targetIds)
|
|
clearSelection()
|
|
closeMenu()
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={assetContextMenuRef}
|
|
className="fixed bg-zinc-800 border border-zinc-700 rounded-xl shadow-2xl py-1.5 z-[60] min-w-[180px] text-xs"
|
|
style={{ left: assetContextMenu.x, top: assetContextMenu.y }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{isMulti && (
|
|
<div className="px-3 py-1 text-[10px] text-blue-400 font-medium">
|
|
{targetIds.length} assets selected
|
|
</div>
|
|
)}
|
|
|
|
{!isMulti && (
|
|
<button
|
|
onClick={() => {
|
|
addClipToTimeline(asset, 0)
|
|
closeMenu()
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
|
|
>
|
|
<Plus className="h-3.5 w-3.5 text-zinc-500" />
|
|
<span>Add to Timeline</span>
|
|
</button>
|
|
)}
|
|
|
|
{!isMulti && asset.path && (
|
|
<button
|
|
onClick={() => {
|
|
window.electronAPI?.showItemInFolder({ filePath: asset.path! })
|
|
closeMenu()
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
|
|
>
|
|
<FolderOpen className="h-3.5 w-3.5 text-zinc-500" />
|
|
<span>Download file</span>
|
|
</button>
|
|
)}
|
|
|
|
{!isMulti && asset.generationParams && (
|
|
<>
|
|
{isRegenerating && regeneratingAssetId === asset.id && canCancelInFlight ? (
|
|
<button
|
|
onClick={() => {
|
|
handleCancelRegeneration()
|
|
closeMenu()
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-zinc-700 flex items-center gap-3"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
<span>Cancel Regeneration</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
handleRegenerate(asset.id)
|
|
closeMenu()
|
|
}}
|
|
disabled={isRegenerating}
|
|
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3 disabled:opacity-50"
|
|
>
|
|
<RefreshCw className="h-3.5 w-3.5 text-zinc-500" />
|
|
<span>Regenerate</span>
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{!isMulti && asset.takes && asset.takes.length > 1 && (
|
|
<>
|
|
<div className="px-3 py-1.5 flex items-center gap-2">
|
|
<span className="text-[10px] text-zinc-500">Take:</span>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
const idx = Math.max(0, (asset.activeTakeIndex ?? 0) - 1)
|
|
setActiveTake(asset.id, idx)
|
|
}}
|
|
disabled={(asset.activeTakeIndex ?? 0) === 0}
|
|
className="p-0.5 rounded hover:bg-zinc-600 text-zinc-400 hover:text-white disabled:text-zinc-600 disabled:hover:bg-transparent"
|
|
>
|
|
<ChevronLeft className="h-3 w-3" />
|
|
</button>
|
|
<span className="text-[10px] text-zinc-300 min-w-[28px] text-center">
|
|
{(asset.activeTakeIndex ?? 0) + 1}/{asset.takes.length}
|
|
</span>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
const idx = Math.min(asset.takes!.length - 1, (asset.activeTakeIndex ?? 0) + 1)
|
|
setActiveTake(asset.id, idx)
|
|
}}
|
|
disabled={(asset.activeTakeIndex ?? 0) >= asset.takes.length - 1}
|
|
className="p-0.5 rounded hover:bg-zinc-600 text-zinc-400 hover:text-white disabled:text-zinc-600 disabled:hover:bg-transparent"
|
|
>
|
|
<ChevronRight className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setTakesViewAssetId(asset.id)
|
|
clearSelection()
|
|
closeMenu()
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
|
|
>
|
|
<Layers className="h-3.5 w-3.5 text-zinc-500" />
|
|
<span>View All Takes</span>
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
if (!asset.takes) return
|
|
const splitAssets = asset.takes.slice(1).map(take => createAssetFromTake(asset, take))
|
|
const firstTake = asset.takes[0]
|
|
if (splitAssets.length > 0) actions.addAssetsToEditor(splitAssets)
|
|
actions.updateAsset(asset.id, {
|
|
takes: [firstTake],
|
|
activeTakeIndex: 0,
|
|
path: firstTake.path,
|
|
bigThumbnailPath: firstTake.bigThumbnailPath,
|
|
smallThumbnailPath: firstTake.smallThumbnailPath,
|
|
width: firstTake.width,
|
|
height: firstTake.height,
|
|
})
|
|
closeMenu()
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
|
|
>
|
|
<GitMerge className="h-3.5 w-3.5 text-zinc-500 rotate-180" />
|
|
<span>Ungroup Takes</span>
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
const activeIdx = asset.activeTakeIndex ?? 0
|
|
if (confirm(`Delete take ${activeIdx + 1}?`)) {
|
|
actions.deleteAssetTake(asset.id, activeIdx)
|
|
}
|
|
closeMenu()
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-red-900/30 flex items-center gap-3"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
<span>Delete Active Take</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
<div className="h-px bg-zinc-700 my-1" />
|
|
|
|
<div className="px-3 py-1 text-[10px] text-zinc-500 font-semibold uppercase tracking-wider">Label</div>
|
|
<div className="px-3 py-1.5 flex items-center gap-1 flex-wrap">
|
|
<button
|
|
onClick={() => setColor(undefined)}
|
|
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center transition-all ${
|
|
!asset.colorLabel ? 'border-white scale-110' : 'border-zinc-600 hover:border-zinc-400'
|
|
}`}
|
|
title="No label"
|
|
>
|
|
<X className="h-2 w-2 text-zinc-400" />
|
|
</button>
|
|
{COLOR_LABELS.map(cl => (
|
|
<button
|
|
key={cl.id}
|
|
onClick={() => setColor(cl.id)}
|
|
className={`w-4 h-4 rounded-full transition-all ${
|
|
asset.colorLabel === cl.id ? 'ring-2 ring-white ring-offset-1 ring-offset-zinc-800 scale-110' : 'hover:scale-125'
|
|
}`}
|
|
style={{ backgroundColor: cl.color }}
|
|
title={cl.label}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<div className="h-px bg-zinc-700 my-1" />
|
|
|
|
<div className="px-3 py-1 text-[10px] text-zinc-500 font-semibold uppercase tracking-wider">Move to Bin</div>
|
|
|
|
<button
|
|
onClick={() => moveToBin(undefined)}
|
|
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
|
|
>
|
|
<X className="h-3.5 w-3.5 text-zinc-500" />
|
|
<span>Remove from Bin</span>
|
|
</button>
|
|
|
|
{bins.map(bin => (
|
|
<button
|
|
key={bin.id}
|
|
onClick={() => moveToBin(bin.id)}
|
|
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
|
|
>
|
|
<Folder className="h-3.5 w-3.5 text-zinc-500" />
|
|
<span>{bin.name}</span>
|
|
</button>
|
|
))}
|
|
|
|
<button
|
|
onClick={() => {
|
|
openCreateBinEditor(targetIds)
|
|
closeMenu()
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
|
|
>
|
|
<FolderPlus className="h-3.5 w-3.5 text-zinc-500" />
|
|
<span>New Bin...</span>
|
|
</button>
|
|
|
|
{isMulti && (
|
|
<>
|
|
<div className="h-px bg-zinc-700 my-1" />
|
|
<button
|
|
onClick={() => {
|
|
const selectedAssets = assets.filter(a => targetIds.includes(a.id))
|
|
if (selectedAssets.length < 2) return
|
|
const primary = selectedAssets[0]
|
|
const newTakes = selectedAssets.map(a => ({
|
|
path: a.path,
|
|
bigThumbnailPath: a.bigThumbnailPath,
|
|
smallThumbnailPath: a.smallThumbnailPath,
|
|
width: a.width,
|
|
height: a.height,
|
|
createdAt: a.createdAt,
|
|
}))
|
|
actions.updateAsset(primary.id, {
|
|
takes: newTakes,
|
|
activeTakeIndex: 0,
|
|
path: newTakes[0].path,
|
|
bigThumbnailPath: newTakes[0].bigThumbnailPath,
|
|
smallThumbnailPath: newTakes[0].smallThumbnailPath,
|
|
width: newTakes[0].width,
|
|
height: newTakes[0].height,
|
|
})
|
|
actions.deleteAssets(selectedAssets.slice(1).map(a => a.id))
|
|
clearSelection()
|
|
closeMenu()
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-blue-300 hover:bg-zinc-700 flex items-center gap-3"
|
|
>
|
|
<GitMerge className="h-3.5 w-3.5" />
|
|
<span>Group as Takes</span>
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
clearSelection()
|
|
closeMenu()
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
|
|
>
|
|
<X className="h-3.5 w-3.5 text-zinc-500" />
|
|
<span>Clear Selection</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
<div className="h-px bg-zinc-700 my-1" />
|
|
|
|
<button
|
|
onClick={deleteTargetAssets}
|
|
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-zinc-700 flex items-center gap-3"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
<span>{isMulti ? `Delete ${targetIds.length} Assets` : 'Delete Asset'}</span>
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|