feat: add advanced audio effects and improve UI
Phase 6.5 Advanced Effects: - Add Pitch Shifter with semitones and cents adjustment - Add Time Stretch with pitch preservation using overlap-add - Add Distortion with soft/hard/tube types and tone control - Add Bitcrusher with bit depth and sample rate reduction - Add AdvancedParameterDialog with real-time waveform visualization - Add 4 professional presets per effect type Improvements: - Fix undefined parameter errors by adding nullish coalescing operators - Add global custom scrollbar styling with color-mix transparency - Add custom-scrollbar utility class for side panel - Improve theme-aware scrollbar appearance in light/dark modes - Fix parameter initialization when switching effect types Integration: - All advanced effects support undo/redo via EffectCommand - Effects accessible via command palette and side panel - Selection-based processing support - Toast notifications for all effects 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
515
components/layout/SidePanel.tsx
Normal file
515
components/layout/SidePanel.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
FileAudio,
|
||||
History,
|
||||
Info,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Upload,
|
||||
Download,
|
||||
X,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatDuration } from '@/lib/audio/decoder';
|
||||
import type { Selection } from '@/types/selection';
|
||||
import type { HistoryState } from '@/lib/history/history-manager';
|
||||
|
||||
export interface SidePanelProps {
|
||||
// File info
|
||||
fileName: string | null;
|
||||
audioBuffer: AudioBuffer | null;
|
||||
onFileSelect: (file: File) => void;
|
||||
onClear: () => void;
|
||||
|
||||
// Selection info
|
||||
selection: Selection | null;
|
||||
|
||||
// History info
|
||||
historyState: HistoryState;
|
||||
|
||||
// Effects handlers
|
||||
onNormalize: () => void;
|
||||
onFadeIn: () => void;
|
||||
onFadeOut: () => void;
|
||||
onReverse: () => void;
|
||||
onLowPassFilter: () => void;
|
||||
onHighPassFilter: () => void;
|
||||
onBandPassFilter: () => void;
|
||||
onCompressor: () => void;
|
||||
onLimiter: () => void;
|
||||
onGate: () => void;
|
||||
onDelay: () => void;
|
||||
onReverb: () => void;
|
||||
onChorus: () => void;
|
||||
onFlanger: () => void;
|
||||
onPhaser: () => void;
|
||||
onPitchShift: () => void;
|
||||
onTimeStretch: () => void;
|
||||
onDistortion: () => void;
|
||||
onBitcrusher: () => void;
|
||||
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SidePanel({
|
||||
fileName,
|
||||
audioBuffer,
|
||||
onFileSelect,
|
||||
onClear,
|
||||
selection,
|
||||
historyState,
|
||||
onNormalize,
|
||||
onFadeIn,
|
||||
onFadeOut,
|
||||
onReverse,
|
||||
onLowPassFilter,
|
||||
onHighPassFilter,
|
||||
onBandPassFilter,
|
||||
onCompressor,
|
||||
onLimiter,
|
||||
onGate,
|
||||
onDelay,
|
||||
onReverb,
|
||||
onChorus,
|
||||
onFlanger,
|
||||
onPhaser,
|
||||
onPitchShift,
|
||||
onTimeStretch,
|
||||
onDistortion,
|
||||
onBitcrusher,
|
||||
className,
|
||||
}: SidePanelProps) {
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(false);
|
||||
const [activeTab, setActiveTab] = React.useState<'file' | 'history' | 'info' | 'effects'>('file');
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onFileSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-12 bg-card border-r border-border flex flex-col items-center py-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
title="Expand Side Panel"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-64 bg-card border-r border-border flex flex-col', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-2 border-b border-border">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={activeTab === 'file' ? 'secondary' : 'ghost'}
|
||||
size="icon-sm"
|
||||
onClick={() => setActiveTab('file')}
|
||||
title="File"
|
||||
>
|
||||
<FileAudio className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'effects' ? 'secondary' : 'ghost'}
|
||||
size="icon-sm"
|
||||
onClick={() => setActiveTab('effects')}
|
||||
title="Effects"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'history' ? 'secondary' : 'ghost'}
|
||||
size="icon-sm"
|
||||
onClick={() => setActiveTab('history')}
|
||||
title="History"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'info' ? 'secondary' : 'ghost'}
|
||||
size="icon-sm"
|
||||
onClick={() => setActiveTab('info')}
|
||||
title="Info"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
title="Collapse Side Panel"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3 custom-scrollbar">
|
||||
{activeTab === 'file' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Audio File
|
||||
</h3>
|
||||
{audioBuffer ? (
|
||||
<div className="space-y-2">
|
||||
<div className="p-2 bg-secondary/30 rounded text-xs">
|
||||
<div className="font-medium text-foreground truncate" title={fileName || 'Unknown'}>
|
||||
{fileName || 'Unknown'}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1">
|
||||
Duration: {formatDuration(audioBuffer.duration)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Channels: {audioBuffer.numberOfChannels}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Sample Rate: {audioBuffer.sampleRate} Hz
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
className="w-full"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 mr-1.5" />
|
||||
Clear File
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFileClick}
|
||||
className="w-full"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5 mr-1.5" />
|
||||
Load Audio File
|
||||
</Button>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Or drag and drop an audio file onto the waveform area.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Edit History
|
||||
</h3>
|
||||
{historyState.historySize > 0 ? (
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="p-2 bg-secondary/30 rounded">
|
||||
<div className="text-foreground">
|
||||
{historyState.historySize} action{historyState.historySize !== 1 ? 's' : ''}
|
||||
</div>
|
||||
{historyState.undoDescription && (
|
||||
<div className="text-muted-foreground mt-1">
|
||||
Next undo: {historyState.undoDescription}
|
||||
</div>
|
||||
)}
|
||||
{historyState.redoDescription && (
|
||||
<div className="text-muted-foreground mt-1">
|
||||
Next redo: {historyState.redoDescription}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No history available. Edit operations will appear here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'info' && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Selection Info
|
||||
</h3>
|
||||
{selection ? (
|
||||
<div className="p-2 bg-secondary/30 rounded text-xs">
|
||||
<div className="text-foreground font-medium">Selection Active</div>
|
||||
<div className="text-muted-foreground mt-1">
|
||||
Duration: {formatDuration(selection.end - selection.start)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Start: {formatDuration(selection.start)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
End: {formatDuration(selection.end)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No selection. Drag on the waveform to select a region.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'effects' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Basic Effects
|
||||
</h3>
|
||||
{audioBuffer ? (
|
||||
<div className="space-y-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onNormalize}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Normalize
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onFadeIn}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Fade In
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onFadeOut}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Fade Out
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onReverse}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Reverse
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Load an audio file to apply effects.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Filters
|
||||
</h3>
|
||||
{audioBuffer ? (
|
||||
<div className="space-y-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onLowPassFilter}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Low-Pass Filter
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onHighPassFilter}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
High-Pass Filter
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onBandPassFilter}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Band-Pass Filter
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Load an audio file to apply filters.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Dynamics Processing
|
||||
</h3>
|
||||
{audioBuffer ? (
|
||||
<div className="space-y-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCompressor}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Compressor
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onLimiter}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Limiter
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onGate}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Gate/Expander
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Load an audio file to apply dynamics processing.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Time-Based Effects
|
||||
</h3>
|
||||
{audioBuffer ? (
|
||||
<div className="space-y-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDelay}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Delay/Echo
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onReverb}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Reverb
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onChorus}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Chorus
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onFlanger}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Flanger
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onPhaser}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Phaser
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Load an audio file to apply time-based effects.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Advanced Effects
|
||||
</h3>
|
||||
{audioBuffer ? (
|
||||
<div className="space-y-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onPitchShift}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Pitch Shifter
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onTimeStretch}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Time Stretch
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDistortion}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Distortion
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onBitcrusher}
|
||||
className="w-full justify-start text-xs"
|
||||
>
|
||||
Bitcrusher
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Load an audio file to apply advanced effects.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
components/layout/Toolbar.tsx
Normal file
237
components/layout/Toolbar.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
SkipBack,
|
||||
Scissors,
|
||||
Copy,
|
||||
Clipboard,
|
||||
Trash2,
|
||||
CropIcon,
|
||||
Undo2,
|
||||
Redo2,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface ToolbarProps {
|
||||
// Playback
|
||||
isPlaying: boolean;
|
||||
isPaused: boolean;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onStop: () => void;
|
||||
|
||||
// Edit
|
||||
hasSelection: boolean;
|
||||
hasClipboard: boolean;
|
||||
onCut: () => void;
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onDelete: () => void;
|
||||
onTrim: () => void;
|
||||
|
||||
// History
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
|
||||
// Zoom
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onFitToView: () => void;
|
||||
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Toolbar({
|
||||
isPlaying,
|
||||
isPaused,
|
||||
onPlay,
|
||||
onPause,
|
||||
onStop,
|
||||
hasSelection,
|
||||
hasClipboard,
|
||||
onCut,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onDelete,
|
||||
onTrim,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onFitToView,
|
||||
disabled = false,
|
||||
className,
|
||||
}: ToolbarProps) {
|
||||
const handlePlayPause = () => {
|
||||
if (isPlaying) {
|
||||
onPause();
|
||||
} else {
|
||||
onPlay();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1.5 bg-card border-b border-border',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Transport Controls */}
|
||||
<div className="flex items-center gap-0.5 pr-2 border-r border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onStop}
|
||||
disabled={disabled || (!isPlaying && !isPaused)}
|
||||
title="Stop"
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isPlaying ? 'default' : 'ghost'}
|
||||
size="icon-sm"
|
||||
onClick={handlePlayPause}
|
||||
disabled={disabled}
|
||||
title={isPlaying ? 'Pause (Space)' : 'Play (Space)'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 ml-0.5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onStop}
|
||||
disabled={disabled || (!isPlaying && !isPaused)}
|
||||
title="Stop"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Edit Tools */}
|
||||
<div className="flex items-center gap-0.5 px-2 border-r border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onCut}
|
||||
disabled={!hasSelection}
|
||||
title="Cut (Ctrl+X)"
|
||||
>
|
||||
<Scissors className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onCopy}
|
||||
disabled={!hasSelection}
|
||||
title="Copy (Ctrl+C)"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onPaste}
|
||||
disabled={!hasClipboard}
|
||||
title="Paste (Ctrl+V)"
|
||||
>
|
||||
<Clipboard className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
disabled={!hasSelection}
|
||||
title="Delete (Del)"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onTrim}
|
||||
disabled={!hasSelection}
|
||||
title="Trim to Selection"
|
||||
>
|
||||
<CropIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
<div className="flex items-center gap-0.5 px-2 border-r border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
title="Redo (Ctrl+Y)"
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="flex items-center gap-0.5 px-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onZoomOut}
|
||||
title="Zoom Out"
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onZoomIn}
|
||||
title="Zoom In"
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onFitToView}
|
||||
title="Fit to View"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user