WorkspaceItem.svelte
1 <script lang="ts"> 2 import type { Workspace, ChildWorkspace, TabWorkspace } from '../../lib/types'; 3 import { isTabWorkspace } from '../../lib/types'; 4 import type { DragState, DropPosition } from '../../lib/dnd/types'; 5 import WorkspaceIcon from './WorkspaceIcon.svelte'; 6 import TranscendentBadge from './TranscendentBadge.svelte'; 7 8 interface Props { 9 workspace: Workspace; 10 selected?: boolean; 11 depth?: number; 12 expanded?: boolean; 13 draggable?: boolean; 14 dragState?: DragState | null; 15 isAudible?: boolean; 16 isOpen?: boolean; 17 openChildCount?: number; 18 onselect?: (workspace: Workspace) => void; 19 ontoggle?: (workspace: Workspace) => void; 20 oncontextmenu?: (e: MouseEvent, workspace: Workspace) => void; 21 ondragstart?: (workspace: Workspace) => void; 22 ondragover?: (workspace: Workspace, position: DropPosition) => void; 23 ondragleave?: () => void; 24 ondrop?: (workspace: Workspace) => void; 25 } 26 27 let { 28 workspace, 29 selected = false, 30 depth = 0, 31 expanded = false, 32 draggable = false, 33 dragState = null, 34 isAudible = false, 35 isOpen = false, 36 openChildCount = 0, 37 onselect, 38 ontoggle, 39 oncontextmenu, 40 ondragstart, 41 ondragover, 42 ondragleave, 43 ondrop, 44 }: Props = $props(); 45 46 // Determine drop feedback class based on drag state 47 const dropClass = $derived.by(() => { 48 if (!dragState || dragState.dropTargetId !== workspace.id) return ''; 49 switch (dragState.dropPosition) { 50 case 'before': return 'drop-indicator-before'; 51 case 'after': return 'drop-indicator-after'; 52 case 'inside': return 'drop-indicator-inside'; 53 default: return ''; 54 } 55 }); 56 57 const isDragging = $derived(dragState?.draggedId === workspace.id); 58 59 const tabCount = $derived( 60 isTabWorkspace(workspace) ? (workspace as TabWorkspace).tabs.length : 0 61 ); 62 63 function handleClick() { 64 onselect?.(workspace); 65 } 66 67 function handleToggle(e: MouseEvent) { 68 e.stopPropagation(); 69 ontoggle?.(workspace); 70 } 71 72 function handleContextMenu(e: MouseEvent) { 73 e.preventDefault(); 74 oncontextmenu?.(e, workspace); 75 } 76 77 function handleKeydown(e: KeyboardEvent) { 78 if (e.key === 'Enter' || e.key === ' ') { 79 e.preventDefault(); 80 onselect?.(workspace); 81 } 82 if (e.key === 'ArrowRight' && workspace.type === 'parent' && !expanded) { 83 ontoggle?.(workspace); 84 } 85 if (e.key === 'ArrowLeft' && workspace.type === 'parent' && expanded) { 86 ontoggle?.(workspace); 87 } 88 } 89 90 function handleDragStart(e: DragEvent) { 91 if (!draggable) { 92 e.preventDefault(); 93 return; 94 } 95 // Set drag data 96 e.dataTransfer?.setData('text/plain', workspace.id); 97 if (e.dataTransfer) { 98 e.dataTransfer.effectAllowed = 'move'; 99 } 100 ondragstart?.(workspace); 101 } 102 103 function handleDragOver(e: DragEvent) { 104 e.preventDefault(); 105 if (!e.dataTransfer) return; 106 e.dataTransfer.dropEffect = 'move'; 107 108 // Calculate position based on mouse Y relative to element 109 const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); 110 const y = e.clientY - rect.top; 111 const height = rect.height; 112 113 let position: DropPosition; 114 if (workspace.type === 'parent') { 115 // Parents have 3 zones: top 25% = before, middle 50% = inside, bottom 25% = after 116 if (y < height * 0.25) { 117 position = 'before'; 118 } else if (y > height * 0.75) { 119 position = 'after'; 120 } else { 121 position = 'inside'; 122 } 123 } else { 124 // Non-parents only have before/after 125 position = y < height / 2 ? 'before' : 'after'; 126 } 127 128 ondragover?.(workspace, position); 129 } 130 131 function handleDragLeave(e: DragEvent) { 132 // Only fire if actually leaving this element (not entering a child) 133 const relatedTarget = e.relatedTarget as HTMLElement; 134 const currentTarget = e.currentTarget as HTMLElement; 135 if (!relatedTarget || !currentTarget.contains(relatedTarget)) { 136 ondragleave?.(); 137 } 138 } 139 140 function handleDrop(e: DragEvent) { 141 e.preventDefault(); 142 ondrop?.(workspace); 143 } 144 145 function handleDragEnd() { 146 ondragleave?.(); 147 } 148 </script> 149 150 <div 151 class="workspace-item group flex items-center gap-2 pr-3 py-2 rounded cursor-pointer transition-all duration-150 152 {selected ? 'bg-surface-raised border-l-2 border-phosphor' : 'hover:bg-surface-raised/50 border-l-2 border-transparent'} 153 {isDragging ? 'opacity-50' : ''} 154 {dropClass}" 155 style="padding-left: {4 + depth * 8}px" 156 role="treeitem" 157 tabindex="0" 158 aria-selected={selected} 159 aria-expanded={workspace.type === 'parent' ? expanded : undefined} 160 draggable={draggable} 161 onclick={handleClick} 162 oncontextmenu={handleContextMenu} 163 onkeydown={handleKeydown} 164 ondragstart={handleDragStart} 165 ondragover={handleDragOver} 166 ondragleave={handleDragLeave} 167 ondrop={handleDrop} 168 ondragend={handleDragEnd} 169 > 170 <!-- Expand/collapse toggle for parents only --> 171 {#if workspace.type === 'parent'} 172 <button 173 class="flex-shrink-0 p-0.5 rounded hover:bg-phosphor/20 transition-colors" 174 onclick={handleToggle} 175 aria-label={expanded ? 'Collapse' : 'Expand'} 176 > 177 <svg 178 class="w-4 h-4 text-text-muted transition-transform duration-150 {expanded ? 'rotate-90' : ''}" 179 fill="none" 180 stroke="currentColor" 181 viewBox="0 0 24 24" 182 > 183 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> 184 </svg> 185 </button> 186 {/if} 187 188 <!-- Icon --> 189 <WorkspaceIcon 190 type={workspace.type} 191 isTranscendent={workspace.isTranscendent} 192 {expanded} 193 /> 194 195 <!-- Name and metadata --> 196 <div class="flex-1 min-w-0"> 197 <div class="flex items-center gap-2"> 198 {#if isOpen} 199 <span class="flex-shrink-0 w-2 h-2 rounded-full bg-status-success" title="Window open"></span> 200 {/if} 201 <span class="text-sm font-medium whitespace-nowrap {workspace.isTranscendent ? 'text-phosphor font-semibold' : (selected ? 'text-phosphor' : 'text-text-primary')}"> 202 {workspace.name} 203 </span> 204 {#if isAudible} 205 <span class="flex-shrink-0 text-status-success animate-pulse" title="Playing audio"> 206 <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> 207 <path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clip-rule="evenodd" /> 208 </svg> 209 </span> 210 {/if} 211 </div> 212 </div> 213 214 <!-- Tab count for child/standalone --> 215 {#if workspace.type !== 'parent' && tabCount > 0} 216 <span class="text-xs text-text-muted font-mono"> 217 {tabCount} tab{tabCount === 1 ? '' : 's'} 218 </span> 219 {/if} 220 221 <!-- Child count for parents --> 222 {#if workspace.type === 'parent'} 223 {@const childCount = (workspace as { children: string[] }).children.length} 224 <span class="text-xs text-text-muted font-mono"> 225 {openChildCount}/{childCount} children 226 </span> 227 {/if} 228 229 <!-- Action buttons (visible on hover) --> 230 <div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> 231 <button 232 class="p-1 rounded hover:bg-phosphor/20 text-text-muted hover:text-phosphor transition-colors" 233 onclick={(e) => { e.stopPropagation(); handleContextMenu(e); }} 234 title="More options" 235 > 236 <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> 237 <path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" /> 238 </svg> 239 </button> 240 </div> 241 </div> 242 243 <style> 244 /* Drop indicator styles */ 245 .drop-indicator-before { 246 box-shadow: 0 -2px 0 0 var(--color-phosphor); 247 } 248 249 .drop-indicator-after { 250 box-shadow: 0 2px 0 0 var(--color-phosphor); 251 } 252 253 .drop-indicator-inside { 254 background-color: rgba(217, 137, 46, 0.2); 255 outline: 2px dashed var(--color-phosphor); 256 outline-offset: -2px; 257 } 258 259 /* Ensure draggable cursor */ 260 .workspace-item[draggable="true"] { 261 cursor: grab; 262 } 263 264 .workspace-item[draggable="true"]:active { 265 cursor: grabbing; 266 } 267 </style>