refactor: move automation controls to left sidebar, simplify layout
Major automation UX improvements: - Moved all automation controls to left sidebar (180px, matching track controls) - Parameter dropdown selector - Automation mode button (R/W/T/L with color coding) - Height adjustment buttons (+/-) - Automation canvas now fills right side (matching waveform width exactly) - Removed AutomationHeader component (no longer needed) - Removed eye icon (automation visibility controlled by "A" button on track) Two-column layout consistency: - Left: 180px sidebar with all controls - Right: Flexible canvas area matching waveform width - Perfect vertical alignment between waveform and automation Simplified AutomationLane component: - Now only renders the canvas area with points - All controls handled in parent Track component - Cleaner, more maintainable code structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import type { AutomationLane as AutomationLaneType, AutomationPoint as AutomationPointType, AutomationMode } from '@/types/automation';
|
import type { AutomationLane as AutomationLaneType, AutomationPoint as AutomationPointType } from '@/types/automation';
|
||||||
import { AutomationHeader } from './AutomationHeader';
|
|
||||||
import { AutomationPoint } from './AutomationPoint';
|
import { AutomationPoint } from './AutomationPoint';
|
||||||
|
|
||||||
export interface AutomationLaneProps {
|
export interface AutomationLaneProps {
|
||||||
@@ -16,10 +15,6 @@ export interface AutomationLaneProps {
|
|||||||
onUpdatePoint?: (pointId: string, updates: Partial<AutomationPointType>) => void;
|
onUpdatePoint?: (pointId: string, updates: Partial<AutomationPointType>) => void;
|
||||||
onRemovePoint?: (pointId: string) => void;
|
onRemovePoint?: (pointId: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
// Parameter selection
|
|
||||||
availableParameters?: Array<{ id: string; name: string }>;
|
|
||||||
selectedParameterId?: string;
|
|
||||||
onParameterChange?: (parameterId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutomationLane({
|
export function AutomationLane({
|
||||||
@@ -32,9 +27,6 @@ export function AutomationLane({
|
|||||||
onUpdatePoint,
|
onUpdatePoint,
|
||||||
onRemovePoint,
|
onRemovePoint,
|
||||||
className,
|
className,
|
||||||
availableParameters,
|
|
||||||
selectedParameterId,
|
|
||||||
onParameterChange,
|
|
||||||
}: AutomationLaneProps) {
|
}: AutomationLaneProps) {
|
||||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -294,54 +286,32 @@ export function AutomationLane({
|
|||||||
if (!lane.visible) return null;
|
if (!lane.visible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)} style={{ height: lane.height + 30 }}>
|
<div
|
||||||
{/* Header */}
|
ref={containerRef}
|
||||||
<AutomationHeader
|
className={cn('relative bg-background/30 overflow-hidden cursor-crosshair', className)}
|
||||||
parameterName={lane.parameterName}
|
style={{ height: lane.height }}
|
||||||
currentValue={getCurrentValue()}
|
>
|
||||||
visible={lane.visible}
|
<canvas
|
||||||
mode={lane.mode}
|
ref={canvasRef}
|
||||||
color={lane.color}
|
className="absolute inset-0 w-full h-full"
|
||||||
onToggleVisible={() => onUpdateLane?.({ visible: !lane.visible })}
|
onClick={handleCanvasClick}
|
||||||
onModeChange={(mode: AutomationMode) => onUpdateLane?.({ mode })}
|
|
||||||
onHeightChange={(delta) => {
|
|
||||||
const newHeight = Math.max(60, Math.min(180, lane.height + delta));
|
|
||||||
onUpdateLane?.({ height: newHeight });
|
|
||||||
}}
|
|
||||||
formatter={lane.valueRange.formatter}
|
|
||||||
availableParameters={availableParameters}
|
|
||||||
selectedParameterId={selectedParameterId}
|
|
||||||
onParameterChange={onParameterChange}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Lane canvas area */}
|
{/* Automation points */}
|
||||||
<div
|
{lane.points.map((point) => (
|
||||||
ref={containerRef}
|
<AutomationPoint
|
||||||
className="relative flex-1 bg-background/30 overflow-hidden cursor-crosshair"
|
key={point.id}
|
||||||
style={{ height: lane.height }}
|
point={point}
|
||||||
>
|
x={timeToX(point.time)}
|
||||||
<canvas
|
y={valueToY(point.value)}
|
||||||
ref={canvasRef}
|
isSelected={selectedPointId === point.id}
|
||||||
className="absolute inset-0 w-full h-full"
|
onDragStart={handlePointDragStart}
|
||||||
onClick={handleCanvasClick}
|
onDrag={handlePointDrag}
|
||||||
|
onDragEnd={handlePointDragEnd}
|
||||||
|
onClick={handlePointClick}
|
||||||
|
onDoubleClick={handlePointDoubleClick}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
{/* Automation points */}
|
|
||||||
{lane.points.map((point) => (
|
|
||||||
<AutomationPoint
|
|
||||||
key={point.id}
|
|
||||||
point={point}
|
|
||||||
x={timeToX(point.time)}
|
|
||||||
y={valueToY(point.value)}
|
|
||||||
isSelected={selectedPointId === point.id}
|
|
||||||
onDragStart={handlePointDragStart}
|
|
||||||
onDrag={handlePointDrag}
|
|
||||||
onDragEnd={handlePointDragEnd}
|
|
||||||
onClick={handlePointClick}
|
|
||||||
onDoubleClick={handlePointDoubleClick}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Volume2, VolumeX, Headphones, Trash2, ChevronDown, ChevronRight, UnfoldHorizontal, Upload, Mic, Gauge, Circle, Sparkles } from 'lucide-react';
|
import { Volume2, VolumeX, Headphones, Trash2, ChevronDown, ChevronRight, ChevronUp, UnfoldHorizontal, Upload, Mic, Gauge, Circle, Sparkles } from 'lucide-react';
|
||||||
import type { Track as TrackType } from '@/types/track';
|
import type { Track as TrackType } from '@/types/track';
|
||||||
import { COLLAPSED_TRACK_HEIGHT, MIN_TRACK_HEIGHT, MAX_TRACK_HEIGHT } from '@/types/track';
|
import { COLLAPSED_TRACK_HEIGHT, MIN_TRACK_HEIGHT, MAX_TRACK_HEIGHT } from '@/types/track';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -765,26 +765,99 @@ export function Track({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modes: Array<{ value: string; label: string; color: string }> = [
|
||||||
|
{ value: 'read', label: 'R', color: 'text-muted-foreground' },
|
||||||
|
{ value: 'write', label: 'W', color: 'text-red-500' },
|
||||||
|
{ value: 'touch', label: 'T', color: 'text-yellow-500' },
|
||||||
|
{ value: 'latch', label: 'L', color: 'text-orange-500' },
|
||||||
|
];
|
||||||
|
const currentModeIndex = modes.findIndex(m => m.value === selectedLane?.mode);
|
||||||
|
|
||||||
return selectedLane ? (
|
return selectedLane ? (
|
||||||
<div className="flex border-b border-border">
|
<div className="flex border-b border-border">
|
||||||
{/* Left: Sidebar spacer to align with track controls */}
|
{/* Left: Automation Controls (matching track controls width) */}
|
||||||
<div className="w-[180px] flex-shrink-0 bg-background/30" />
|
<div className="w-[180px] flex-shrink-0 bg-muted/30 border-r border-border/50 p-2 flex flex-col gap-2">
|
||||||
|
{/* Parameter selector dropdown */}
|
||||||
|
<select
|
||||||
|
value={selectedParameterId}
|
||||||
|
onChange={(e) => {
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, selectedParameterId: e.target.value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full text-xs font-medium text-foreground bg-background/80 border border-border/30 rounded px-2 py-1 hover:bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
>
|
||||||
|
{availableParameters.map((param) => (
|
||||||
|
<option key={param.id} value={param.id}>
|
||||||
|
{param.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
{/* Right: Automation lane matching waveform width */}
|
{/* Automation mode cycle button */}
|
||||||
<div className="flex-1">
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const nextIndex = (currentModeIndex + 1) % modes.length;
|
||||||
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
|
l.id === selectedLane.id ? { ...l, mode: modes[nextIndex].value as any } : l
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, lanes: updatedLanes },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-2 py-1 text-xs font-bold rounded transition-colors border border-border/30',
|
||||||
|
'bg-background/50 hover:bg-background',
|
||||||
|
modes[currentModeIndex]?.color
|
||||||
|
)}
|
||||||
|
title={`Mode: ${selectedLane.mode} (click to cycle)`}
|
||||||
|
>
|
||||||
|
{modes[currentModeIndex]?.label} - {selectedLane.mode.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Height controls */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newHeight = Math.max(60, Math.min(180, selectedLane.height + 20));
|
||||||
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
|
l.id === selectedLane.id ? { ...l, height: newHeight } : l
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, lanes: updatedLanes },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex-1 px-2 py-1 text-xs bg-background/50 hover:bg-background border border-border/30 rounded transition-colors"
|
||||||
|
title="Increase lane height"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-3 w-3 mx-auto" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newHeight = Math.max(60, Math.min(180, selectedLane.height - 20));
|
||||||
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
|
l.id === selectedLane.id ? { ...l, height: newHeight } : l
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, lanes: updatedLanes },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex-1 px-2 py-1 text-xs bg-background/50 hover:bg-background border border-border/30 rounded transition-colors"
|
||||||
|
title="Decrease lane height"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3 w-3 mx-auto" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Automation Lane Canvas (matching waveform width) */}
|
||||||
|
<div className="flex-1 border-l border-border/50">
|
||||||
<AutomationLane
|
<AutomationLane
|
||||||
key={selectedLane.id}
|
key={selectedLane.id}
|
||||||
lane={selectedLane}
|
lane={selectedLane}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
availableParameters={availableParameters}
|
|
||||||
selectedParameterId={selectedParameterId}
|
|
||||||
onParameterChange={(parameterId) => {
|
|
||||||
onUpdateTrack(track.id, {
|
|
||||||
automation: { ...track.automation, selectedParameterId: parameterId },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onUpdateLane={(updates) => {
|
onUpdateLane={(updates) => {
|
||||||
const updatedLanes = track.automation.lanes.map((l) =>
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
l.id === selectedLane.id ? { ...l, ...updates } : l
|
l.id === selectedLane.id ? { ...l, ...updates } : l
|
||||||
|
|||||||
Reference in New Issue
Block a user