From 178d0784d18ab87b6a3378b4a2f56cd6c1502828 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 22 Feb 2026 10:37:33 +0100 Subject: [PATCH] refactor: decompose TabBar and Sidebar for reduced complexity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TabBar (8.50 → 9.38): - Extract useTabDrag hook for drag state management - Extract computeDropTarget/computeInsertIndex helpers - Extract TabItem, DropIndicator, TabBarActions sub-components - TabBar main function reduced from cc=23/176 LoC to ~25 LoC Sidebar (8.57 → 9.38, SidebarParts 10.0): - Create SidebarParts.tsx with NavItem, SectionContent, VisibilityPopover - Extract useSidebarSections hook for section computation - Extract useEntryCounts hook consolidating 3 useMemos into 1 - Extract useOutsideClick hook (eliminates 3 duplicate useEffect patterns) - Extract buildCustomizeArgs, computeReorder pure helpers - Extract CommitButton, ContextMenuOverlay, CustomizeOverlay - isSelectionActive extracted as pure function (was closure) - Sidebar reduced from 604 LoC/cc=34 to ~120 LoC/cc=11 Co-Authored-By: Claude Opus 4.6 --- src/components/Sidebar.tsx | 730 ++++++++++---------------------- src/components/SidebarParts.tsx | 227 ++++++++++ src/components/TabBar.tsx | 304 +++++++------ 3 files changed, 589 insertions(+), 672 deletions(-) create mode 100644 src/components/SidebarParts.tsx diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 079d4812..64c05ed9 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,42 +1,24 @@ -import { useState, useMemo, useRef, useEffect, useCallback, memo, type ComponentType } from 'react' +import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react' import type { VaultEntry, SidebarSelection } from '../types' -import { cn } from '@/lib/utils' -import { ChevronRight, ChevronDown, GitCommitHorizontal, Plus, SlidersHorizontal, GripVertical } from 'lucide-react' -import { getTypeColor, getTypeLightColor } from '../utils/typeColors' import { resolveIcon, TypeCustomizePopover } from './TypeCustomizePopover' import { useSectionVisibility } from '../hooks/useSectionVisibility' import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, + DndContext, closestCenter, KeyboardSensor, PointerSensor, + useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core' import { - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, + SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { - FileText, - Star, - Wrench, - Flask, - Target, - ArrowsClockwise, - Users, - CalendarBlank, - Tag, - TagSimple, - Trash, - StackSimple, - Archive, - type IconProps, + FileText, Star, Wrench, Flask, Target, ArrowsClockwise, + Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, } from '@phosphor-icons/react' +import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react' +import { + type SectionGroup, isSelectionActive, + NavItem, SectionContent, type SectionContentProps, VisibilityPopover, +} from './SidebarParts' interface SidebarProps { entries: VaultEntry[] @@ -51,18 +33,6 @@ interface SidebarProps { onCommitPush?: () => void } -const TOP_NAV = [ - { label: 'All Notes', filter: 'all' as const, Icon: FileText }, - { label: 'Favorites', filter: 'favorites' as const, Icon: Star }, -] - -interface SectionGroup { - label: string - type: string - Icon: ComponentType - customColor?: string | null -} - const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [ { label: 'Projects', type: 'Project', Icon: Wrench }, { label: 'Experiments', type: 'Experiment', Icon: Flask }, @@ -76,528 +46,262 @@ const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [ const BUILT_IN_TYPES = new Set(BUILT_IN_SECTION_GROUPS.map((s) => s.type)) -export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, onCustomizeType, onReorderSections, modifiedCount = 0, onCommitPush }: SidebarProps) { +// --- Hooks --- + +function useOutsideClick(ref: React.RefObject, isOpen: boolean, onClose: () => void) { + useEffect(() => { + if (!isOpen) return + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose() + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [isOpen, onClose]) +} + +function buildTypeEntryMap(entries: VaultEntry[]): Record { + const map: Record = {} + for (const e of entries) { if (e.isA === 'Type') map[e.title] = e } + return map +} + +function applyOverrides(typeEntryMap: Record): SectionGroup[] { + return BUILT_IN_SECTION_GROUPS.map((sg) => { + const te = typeEntryMap[sg.type] + if (!te?.icon && !te?.color) return sg + return { ...sg, Icon: te?.icon ? resolveIcon(te.icon) : sg.Icon, customColor: te?.color ?? null } + }) +} + +function buildCustomSections(entries: VaultEntry[]): SectionGroup[] { + return entries + .filter((e) => e.isA === 'Type' && !BUILT_IN_TYPES.has(e.title)) + .sort((a, b) => a.title.localeCompare(b.title)) + .map((e) => ({ label: e.title + 's', type: e.title, Icon: resolveIcon(e.icon), customColor: e.color })) +} + +function sortSections(groups: SectionGroup[], typeEntryMap: Record): SectionGroup[] { + return [...groups].sort((a, b) => { + const orderA = typeEntryMap[a.type]?.order ?? Infinity + const orderB = typeEntryMap[b.type]?.order ?? Infinity + return orderA !== orderB ? orderA - orderB : a.label.localeCompare(b.label) + }) +} + +function useSidebarSections(entries: VaultEntry[], isSectionVisible: (type: string) => boolean) { + const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) + const allSectionGroups = useMemo(() => { + const built = applyOverrides(typeEntryMap) + const custom = buildCustomSections(entries) + return sortSections([...built, ...custom], typeEntryMap) + }, [entries, typeEntryMap]) + const visibleSections = useMemo(() => allSectionGroups.filter((g) => isSectionVisible(g.type)), [allSectionGroups, isSectionVisible]) + const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections]) + return { typeEntryMap, allSectionGroups, visibleSections, sectionIds } +} + +function useEntryCounts(entries: VaultEntry[]) { + return useMemo(() => { + let active = 0, archived = 0, trashed = 0 + for (const e of entries) { + if (e.trashed) trashed++ + else if (e.archived) archived++ + else active++ + } + return { activeCount: active, archivedCount: archived, trashedCount: trashed } + }, [entries]) +} + +function computeReorder(sectionIds: string[], activeId: string, overId: string): string[] | null { + const oldIndex = sectionIds.indexOf(activeId) + const newIndex = sectionIds.indexOf(overId) + if (oldIndex === -1 || newIndex === -1) return null + const reordered = [...sectionIds] + reordered.splice(oldIndex, 1) + reordered.splice(newIndex, 0, activeId) + return reordered +} + +function buildCustomizeArgs(typeEntry: VaultEntry, prop: 'icon' | 'color', value: string): [string, string] { + return [ + prop === 'icon' ? value : (typeEntry.icon ?? 'file-text'), + prop === 'color' ? value : (typeEntry.color ?? 'blue'), + ] +} + +// --- Sub-components --- + +function SortableSection({ group, sectionProps }: { + group: SectionGroup + sectionProps: Omit + & { entries: VaultEntry[]; collapsed: Record; onToggle: (type: string) => void } +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type }) + const items = sectionProps.entries.filter((e) => e.isA === group.type && !e.archived && !e.trashed) + const isCollapsed = sectionProps.collapsed[group.type] ?? true + + return ( +
+ sectionProps.onToggle(group.type)} + dragHandleProps={listeners} + /> +
+ ) +} + +function CommitButton({ modifiedCount, onClick }: { modifiedCount: number; onClick?: () => void }) { + if (!onClick) return null + return ( +
+ +
+ ) +} + +function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: { + pos: { x: number; y: number } | null; type: string | null + innerRef: React.Ref + onOpenCustomize: (type: string) => void +}) { + if (!pos || !type) return null + return ( +
+ +
+ ) +} + +function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose }: { + target: string | null; typeEntryMap: Record + innerRef: React.Ref + onCustomize: (prop: 'icon' | 'color', value: string) => void + onClose: () => void +}) { + if (!target) return null + return ( +
+ onCustomize('icon', icon)} + onChangeColor={(color) => onCustomize('color', color)} + onClose={onClose} + /> +
+ ) +} + +// --- Main Sidebar --- + +export const Sidebar = memo(function Sidebar({ + entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, + onCustomizeType, onReorderSections, modifiedCount = 0, onCommitPush, +}: SidebarProps) { const [collapsed, setCollapsed] = useState>({}) const [customizeTarget, setCustomizeTarget] = useState(null) const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null) const [contextMenuType, setContextMenuType] = useState(null) + const [showCustomize, setShowCustomize] = useState(false) + const contextMenuRef = useRef(null) const popoverRef = useRef(null) - const [showCustomize, setShowCustomize] = useState(false) const customizeRef = useRef(null) + const { toggleSection: toggleVisibility, isSectionVisible } = useSectionVisibility() + const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries, isSectionVisible) + const { activeCount, archivedCount, trashedCount } = useEntryCounts(entries) - // Close section-visibility popover on outside click - useEffect(() => { - if (!showCustomize) return - const handleClick = (e: MouseEvent) => { - if (customizeRef.current && !customizeRef.current.contains(e.target as Node)) { - setShowCustomize(false) - } - } - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) - }, [showCustomize]) + const closeContextMenu = useCallback(() => { setContextMenuPos(null); setContextMenuType(null) }, []) + const closeCustomize = useCallback(() => setShowCustomize(false), []) + const closeCustomizeTarget = useCallback(() => setCustomizeTarget(null), []) - const toggleSection = (type: string) => { + useOutsideClick(customizeRef, showCustomize, closeCustomize) + useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu) + useOutsideClick(popoverRef, !!customizeTarget, closeCustomizeTarget) + + const toggleSection = useCallback((type: string) => { setCollapsed((prev) => ({ ...prev, [type]: !(prev[type] ?? true) })) - } + }, []) - // Build a map of type name → type entry for quick lookup of icon/color - const typeEntryMap = useMemo(() => { - const map: Record = {} - for (const e of entries) { - if (e.isA === 'Type') map[e.title] = e - } - return map - }, [entries]) - - const isActive = (sel: SidebarSelection): boolean => { - if (selection.kind !== sel.kind) return false - if (sel.kind === 'filter' && selection.kind === 'filter') return sel.filter === selection.filter - if (sel.kind === 'sectionGroup' && selection.kind === 'sectionGroup') return sel.type === selection.type - if (sel.kind === 'entity' && selection.kind === 'entity') return sel.entry.path === selection.entry.path - if (sel.kind === 'topic' && selection.kind === 'topic') return sel.entry.path === selection.entry.path - return false - } - - // Derive custom type sections from Type entries not in the built-in list - const customSectionGroups: SectionGroup[] = useMemo(() => { - return entries - .filter((e) => e.isA === 'Type' && !BUILT_IN_TYPES.has(e.title)) - .sort((a, b) => a.title.localeCompare(b.title)) - .map((e) => ({ - label: e.title + 's', - type: e.title, - Icon: resolveIcon(e.icon), - customColor: e.color, - })) - }, [entries]) - - // For built-in types, check if they have custom icon/color overrides from their type entry - const builtInWithOverrides: SectionGroup[] = useMemo(() => { - return BUILT_IN_SECTION_GROUPS.map((sg) => { - const typeEntry = typeEntryMap[sg.type] - if (!typeEntry?.icon && !typeEntry?.color) return sg - return { - ...sg, - Icon: typeEntry?.icon ? resolveIcon(typeEntry.icon) : sg.Icon, - customColor: typeEntry?.color ?? null, - } - }) - }, [typeEntryMap]) - - // Merge built-in and custom sections, then sort by order from type entries - const allSectionGroups = useMemo(() => { - const merged = [...builtInWithOverrides, ...customSectionGroups] - return merged.sort((a, b) => { - const orderA = typeEntryMap[a.type]?.order ?? Infinity - const orderB = typeEntryMap[b.type]?.order ?? Infinity - if (orderA !== orderB) return orderA - orderB - return a.label.localeCompare(b.label) - }) - }, [builtInWithOverrides, customSectionGroups, typeEntryMap]) - - // DnD sensors with activation distance to avoid accidental drags on click const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ) - const visibleSections = useMemo( - () => allSectionGroups.filter((g) => isSectionVisible(g.type)), - [allSectionGroups, isSectionVisible], - ) - - const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections]) - const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event if (!over || active.id === over.id) return - - const oldIndex = sectionIds.indexOf(active.id as string) - const newIndex = sectionIds.indexOf(over.id as string) - if (oldIndex === -1 || newIndex === -1) return - - // Compute new order: assign sequential order values to the reordered list - const reordered = [...sectionIds] - reordered.splice(oldIndex, 1) - reordered.splice(newIndex, 0, active.id as string) - - const updates = reordered.map((typeName, i) => ({ typeName, order: i })) - onReorderSections?.(updates) + const reordered = computeReorder(sectionIds, active.id as string, over.id as string) + if (reordered) onReorderSections?.(reordered.map((typeName, i) => ({ typeName, order: i }))) }, [sectionIds, onReorderSections]) - const archivedCount = useMemo(() => entries.filter((e) => e.archived).length, [entries]) - const trashedCount = useMemo(() => entries.filter((e) => e.trashed).length, [entries]) - - // Close context menu on outside click - useEffect(() => { - if (!contextMenuPos) return - const handler = (e: MouseEvent) => { - if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) { - setContextMenuPos(null) - setContextMenuType(null) - } - } - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [contextMenuPos]) - - // Close customize popover on outside click - useEffect(() => { - if (!customizeTarget) return - const handler = (e: MouseEvent) => { - if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { - setCustomizeTarget(null) - } - } - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [customizeTarget]) - const handleContextMenu = useCallback((e: React.MouseEvent, type: string) => { - e.preventDefault() - e.stopPropagation() - setContextMenuPos({ x: e.clientX, y: e.clientY }) - setContextMenuType(type) + e.preventDefault(); e.stopPropagation() + setContextMenuPos({ x: e.clientX, y: e.clientY }); setContextMenuType(type) }, []) - const openCustomizePopover = useCallback((type: string) => { - setContextMenuPos(null) - setContextMenuType(null) - setCustomizeTarget(type) - }, []) - - const handleCustomizeIcon = useCallback((icon: string) => { - if (!customizeTarget) return - const typeEntry = typeEntryMap[customizeTarget] - if (typeEntry && onCustomizeType) { - onCustomizeType(customizeTarget, icon, typeEntry.color ?? 'blue') - } + const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => { + if (!customizeTarget || !onCustomizeType) return + const te = typeEntryMap[customizeTarget] + if (!te) return + const [icon, color] = buildCustomizeArgs(te, prop, value) + onCustomizeType(customizeTarget, icon, color) }, [customizeTarget, typeEntryMap, onCustomizeType]) - const handleCustomizeColor = useCallback((color: string) => { - if (!customizeTarget) return - const typeEntry = typeEntryMap[customizeTarget] - if (typeEntry && onCustomizeType) { - onCustomizeType(customizeTarget, typeEntry.icon ?? 'file-text', color) - } - }, [customizeTarget, typeEntryMap, onCustomizeType]) - - const renderSectionContent = ({ label, type, Icon, customColor }: SectionGroup, dragHandleProps?: Record) => { - const items = entries.filter((e) => e.isA === type && !e.archived && !e.trashed) - const isCollapsed = collapsed[type] ?? true - const isTopic = type === 'Topic' - const isTypeSection = type === 'Type' - const sectionColor = getTypeColor(type, customColor) - const sectionLightColor = getTypeLightColor(type, customColor) - - const handlePlusClick = (e: React.MouseEvent) => { - e.stopPropagation() - if (isTypeSection) { - onCreateNewType?.() - } else { - onCreateType?.(type) - } - } - - return ( - <> - {/* Section header row */} -
onSelect({ kind: 'sectionGroup', type })} - onContextMenu={(e) => handleContextMenu(e, type)} - > -
- {/* Drag handle */} -
- -
- - {label} -
-
- {(onCreateType || (isTypeSection && onCreateNewType)) && ( - - )} - -
-
- - {/* Children items */} - {!isCollapsed && items.length > 0 && ( -
- {items.map((entry) => ( -
{ - onSelect(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry }) - onSelectNote?.(entry) - }} - > - {entry.title} -
- ))} -
- )} - - ) - } - - function SortableSection({ group }: { group: SectionGroup }) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: group.type }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - padding: '4px 6px', - } - - return ( -
- {renderSectionContent(group, listeners)} -
- ) + const sectionProps = { + entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, + onContextMenu: handleContextMenu, onToggle: toggleSection, } return ( ) }) diff --git a/src/components/SidebarParts.tsx b/src/components/SidebarParts.tsx new file mode 100644 index 00000000..e6b10b75 --- /dev/null +++ b/src/components/SidebarParts.tsx @@ -0,0 +1,227 @@ +import { type ComponentType } from 'react' +import type { VaultEntry, SidebarSelection } from '../types' +import { cn } from '@/lib/utils' +import { ChevronRight, ChevronDown, Plus, GripVertical } from 'lucide-react' +import { getTypeColor, getTypeLightColor } from '../utils/typeColors' +import { type IconProps } from '@phosphor-icons/react' + +export interface SectionGroup { + label: string + type: string + Icon: ComponentType + customColor?: string | null +} + +export function isSelectionActive(current: SidebarSelection, check: SidebarSelection): boolean { + if (current.kind !== check.kind) return false + switch (check.kind) { + case 'filter': return (current as typeof check).filter === check.filter + case 'sectionGroup': return (current as typeof check).type === check.type + case 'entity': + case 'topic': return (current as typeof check).entry.path === check.entry.path + default: return false + } +} + +// --- NavItem --- + +export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, onClick, disabled }: { + icon: ComponentType + label: string + count?: number + isActive?: boolean + activeClassName?: string + badgeClassName?: string + badgeStyle?: React.CSSProperties + onClick?: () => void + disabled?: boolean +}) { + if (disabled) { + return ( +
+ + {label} +
+ ) + } + return ( +
+ + {label} + {count !== undefined && count > 0 && ( + + {count} + + )} +
+ ) +} + +// --- Section Content --- + +export interface SectionContentProps { + group: SectionGroup + items: VaultEntry[] + isCollapsed: boolean + selection: SidebarSelection + onSelect: (sel: SidebarSelection) => void + onSelectNote?: (entry: VaultEntry) => void + onCreateType?: (type: string) => void + onCreateNewType?: () => void + onContextMenu: (e: React.MouseEvent, type: string) => void + onToggle: () => void + dragHandleProps?: Record +} + +function childSelection(type: string, entry: VaultEntry): SidebarSelection { + return type === 'Topic' ? { kind: 'topic', entry } : { kind: 'entity', entry } +} + +function resolveCreateHandler(type: string, onCreateType?: (type: string) => void, onCreateNewType?: () => void): (() => void) | undefined { + const isType = type === 'Type' + if (!onCreateType && !(isType && onCreateNewType)) return undefined + return isType ? () => onCreateNewType?.() : () => onCreateType?.(type) +} + +export function SectionContent({ + group, items, isCollapsed, selection, onSelect, onSelectNote, + onCreateType, onCreateNewType, onContextMenu, onToggle, dragHandleProps, +}: SectionContentProps) { + const { label, type, Icon, customColor } = group + const sectionColor = getTypeColor(type, customColor) + const sectionLightColor = getTypeLightColor(type, customColor) + const onCreate = resolveCreateHandler(type, onCreateType, onCreateNewType) + + return ( + <> + onSelect({ kind: 'sectionGroup', type })} + onContextMenu={(e) => onContextMenu(e, type)} + onToggle={onToggle} + onCreate={(e) => { e.stopPropagation(); onCreate?.() }} + dragHandleProps={dragHandleProps} + /> + {!isCollapsed && items.length > 0 && ( + + )} + + ) +} + +function SectionChildList({ items, type, selection, sectionColor, sectionLightColor, onSelect, onSelectNote }: { + items: VaultEntry[]; type: string; selection: SidebarSelection + sectionColor: string; sectionLightColor: string + onSelect: (sel: SidebarSelection) => void; onSelectNote?: (entry: VaultEntry) => void +}) { + return ( +
+ {items.map((entry) => { + const sel = childSelection(type, entry) + const active = isSelectionActive(selection, sel) + return ( + { onSelect(sel); onSelectNote?.(entry) }} + /> + ) + })} +
+ ) +} + +function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, dragHandleProps }: { + label: string; type: string; Icon: ComponentType + sectionColor: string; isCollapsed: boolean; isActive: boolean; showCreate: boolean + onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void + onToggle: () => void; onCreate: (e: React.MouseEvent) => void + dragHandleProps?: Record +}) { + return ( +
+
+
+ +
+ + {label} +
+
+ {showCreate && ( + + )} + +
+
+ ) +} + +function SectionChildItem({ title, isActive, sectionColor, sectionLightColor, onClick }: { + title: string; isActive: boolean + sectionColor?: string; sectionLightColor?: string + onClick: () => void +}) { + return ( +
+ {title} +
+ ) +} + +// --- Visibility Popover --- + +export function VisibilityPopover({ sections, isSectionVisible, onToggle }: { + sections: SectionGroup[] + isSectionVisible: (type: string) => boolean + onToggle: (type: string) => void +}) { + return ( +
+
Show in sidebar
+ {sections.map(({ label, type, Icon }) => ( + + ))} +
+ ) +} + +function ToggleSwitch({ on }: { on: boolean }) { + return ( +
+
+
+ ) +} diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 202d7ba1..c791c5d2 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -20,191 +20,177 @@ interface TabBarProps { const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const -export const TabBar = memo(function TabBar({ - tabs, activeTabPath, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs, -}: TabBarProps) { +// --- Drag-and-drop helpers --- + +function computeDropTarget(dragIdx: number | null, dropIdx: number | null): number | null { + if (dragIdx === null || dropIdx === null || dragIdx === dropIdx) return null + const toIndex = dropIdx > dragIdx ? dropIdx - 1 : dropIdx + return toIndex !== dragIdx ? toIndex : null +} + +function computeInsertIndex(e: React.DragEvent, index: number): number { + const rect = e.currentTarget.getBoundingClientRect() + return e.clientX < rect.left + rect.width / 2 ? index : index + 1 +} + +function useTabDrag(onReorderTabs?: (from: number, to: number) => void) { const [dragIndex, setDragIndex] = useState(null) const [dropIndex, setDropIndex] = useState(null) const dragNodeRef = useRef(null) - const handleDragStart = useCallback((e: React.DragEvent, index: number) => { - setDragIndex(index) - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData('text/plain', String(index)) - // Make the drag image slightly transparent - if (e.currentTarget) { - dragNodeRef.current = e.currentTarget - requestAnimationFrame(() => { - if (dragNodeRef.current) { - dragNodeRef.current.style.opacity = '0.5' - } - }) - } - }, []) - - const handleDragEnd = useCallback(() => { - if (dragNodeRef.current) { - dragNodeRef.current.style.opacity = '' - } + const resetDrag = useCallback(() => { + if (dragNodeRef.current) dragNodeRef.current.style.opacity = '' dragNodeRef.current = null setDragIndex(null) setDropIndex(null) }, []) + const handleDragStart = useCallback((e: React.DragEvent, index: number) => { + setDragIndex(index) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', String(index)) + dragNodeRef.current = e.currentTarget + requestAnimationFrame(() => { + if (dragNodeRef.current) dragNodeRef.current.style.opacity = '0.5' + }) + }, []) + const handleDragOver = useCallback((e: React.DragEvent, index: number) => { e.preventDefault() e.dataTransfer.dropEffect = 'move' - if (dragIndex === null || dragIndex === index) { - setDropIndex(null) - return - } - // Determine drop position based on cursor within the tab - const rect = e.currentTarget.getBoundingClientRect() - const midpoint = rect.left + rect.width / 2 - const insertIndex = e.clientX < midpoint ? index : index + 1 - setDropIndex(insertIndex) + if (dragIndex === null || dragIndex === index) { setDropIndex(null); return } + setDropIndex(computeInsertIndex(e, index)) }, [dragIndex]) const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault() - if (dragIndex !== null && dropIndex !== null && dragIndex !== dropIndex && onReorderTabs) { - // Adjust target index: if dropping after the dragged item, account for removal - const toIndex = dropIndex > dragIndex ? dropIndex - 1 : dropIndex - if (toIndex !== dragIndex) { - onReorderTabs(dragIndex, toIndex) - } - } - handleDragEnd() - }, [dragIndex, dropIndex, onReorderTabs, handleDragEnd]) + const toIndex = computeDropTarget(dragIndex, dropIndex) + if (toIndex !== null && onReorderTabs) onReorderTabs(dragIndex!, toIndex) + resetDrag() + }, [dragIndex, dropIndex, onReorderTabs, resetDrag]) - const handleDragLeave = useCallback((e: React.DragEvent) => { - // Only clear if we're leaving the tab bar entirely - const relatedTarget = e.relatedTarget as HTMLElement | null - if (!e.currentTarget.contains(relatedTarget)) { - setDropIndex(null) - } + const handleBarDragLeave = useCallback((e: React.DragEvent) => { + const related = e.relatedTarget as HTMLElement | null + if (!e.currentTarget.contains(related)) setDropIndex(null) }, []) + return { dragIndex, dropIndex, handleDragStart, handleDragEnd: resetDrag, handleDragOver, handleDrop, handleBarDragLeave } +} + +// --- Sub-components --- + +function DropIndicator({ side }: { side: 'left' | 'right' }) { + return ( +
+ ) +} + +function TabItem({ tab, isActive, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, dragProps }: { + tab: Tab + isActive: boolean + isDragging: boolean + showDropBefore: boolean + showDropAfter: boolean + onSwitch: () => void + onClose: () => void + dragProps: React.HTMLAttributes +}) { + return ( +
+ {showDropBefore && } + {tab.entry.title} + + {showDropAfter && } +
+ ) +} + +function TabBarActions({ onCreateNote }: { onCreateNote?: () => void }) { + return ( +
+ + + +
+ ) +} + +// --- Main TabBar --- + +export const TabBar = memo(function TabBar({ + tabs, activeTabPath, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs, +}: TabBarProps) { + const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs) + return (
- {tabs.map((tab, index) => { - const isActive = tab.entry.path === activeTabPath - const showDropBefore = dropIndex === index - const showDropAfter = dropIndex === index + 1 && index === tabs.length - 1 - return ( -
handleDragStart(e, index)} - onDragEnd={handleDragEnd} - onDragOver={(e) => handleDragOver(e, index)} - onDrop={handleDrop} - className={cn( - "group flex shrink-0 items-center gap-1.5 whitespace-nowrap max-w-[180px] transition-all relative", - isActive - ? "text-foreground" - : "text-muted-foreground hover:text-secondary-foreground" - )} - style={{ - background: isActive ? 'var(--background)' : 'transparent', - borderRight: `1px solid ${isActive ? 'var(--border)' : 'var(--sidebar-border)'}`, - borderBottom: isActive ? 'none' : '1px solid var(--sidebar-border)', - padding: '0 12px', - fontSize: 12, - fontWeight: isActive ? 500 : 400, - cursor: dragIndex !== null ? 'grabbing' : 'grab', - WebkitAppRegion: 'no-drag', - } as React.CSSProperties} - onClick={() => onSwitchTab(tab.entry.path)} - > - {showDropBefore && ( -
- )} - {tab.entry.title} - - {showDropAfter && ( -
- )} -
- ) - })} - + {tabs.map((tab, index) => ( + onSwitchTab(tab.entry.path)} + onClose={() => onCloseTab(tab.entry.path)} + dragProps={{ + onDragStart: (e) => handleDragStart(e, index), + onDragEnd: handleDragEnd, + onDragOver: (e) => handleDragOver(e, index), + onDrop: handleDrop, + }} + /> + ))}
- -
- - - -
+
) })