initial public release.

This commit is contained in:
Xiaohan-Tian
2025-08-11 18:37:21 -07:00
commit de51967b49
186 changed files with 32322 additions and 0 deletions
+582
View File
@@ -0,0 +1,582 @@
import React, { useState, useRef, useEffect, memo } from 'react';
import { FaPlus, FaBan } from 'react-icons/fa';
import { UserMessage, AssistantMessage } from './chat';
import { AgentCore } from '../agent/core/AgentCore';
import { OpenAIProvider } from '../agent/llm/OpenAIProvider';
import { ClaudeProvider } from '../agent/llm/ClaudeProvider';
import { GeminiProvider } from '../agent/llm/GeminiProvider';
import { LLMProvider } from '../agent/llm/LLMProvider';
import { ConfigManager } from '../core/config/ConfigManager';
import { useProjectStore } from '../stores/projectStore';
import { XMLToolExecutor } from '../agent/core/XMLToolExecutor';
import { extractXMLFromString } from '../util/xmlUtil';
import { SystemPrompts } from '../agent/core/SystemPrompts';
import { clearChatHistoryAndUI, registerClearChatUICallback } from '../util/chatUtil';
import { processUserMessage } from '../util/messageFilter/UserMessageFilter';
// Module-level guard to avoid duplicate welcome in React StrictMode dev remounts
let hasShownWelcomeOnceInRuntime = false;
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
isStreaming?: boolean;
tokenCount?: number;
}
/**
* Create the appropriate LLM provider based on configuration
*/
const createLLMProvider = (): LLMProvider => {
const configManager = ConfigManager.instance();
const providerType = configManager.get('general.llm_provider') as string;
switch (providerType) {
case 'claude':
return new ClaudeProvider();
case 'gemini':
return new GeminiProvider();
case 'openai_compatible':
case 'openai':
default:
return new OpenAIProvider();
}
};
const ChatBox: React.FC = () => {
const [inputValue, setInputValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Initialize with empty messages
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [abortController, setAbortController] = useState<AbortController | null>(null);
const [lastUserMessage, setLastUserMessage] = useState<string>('');
// Tool execution state
const [isExecutingTools, setIsExecutingTools] = useState(false);
const [, setToolResults] = useState<string>(''); // placeholder for future display/use
const [, setCurrentToolIndex] = useState<number>(0); // placeholder for future display/use
// Track if this is the first message (for system prompt logging)
const [isFirstMessage, setIsFirstMessage] = useState(true);
// Initialize AgentCore with configured provider and register clear UI callback
useEffect(() => {
const initializeProvider = async () => {
const configManager = ConfigManager.instance();
// Ensure ConfigManager is initialized
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
const provider = createLLMProvider();
const agentCore = AgentCore.instance();
agentCore.setLLMProvider(provider);
console.log(`Switched to ${provider.name} provider`);
};
// Register the UI clear callback for external components to use
registerClearChatUICallback(clearChatUI);
initializeProvider();
// Auto-trigger welcome on first launch (guard against React StrictMode double-invoke only)
(async () => {
try {
if (hasShownWelcomeOnceInRuntime) return;
hasShownWelcomeOnceInRuntime = true;
const result = await processUserMessage('/welcome');
if (result.pseudoAssistantResponse) {
const pseudoId = generateMessageId();
setMessages(prev => [...prev, {
id: pseudoId,
role: 'assistant',
content: result.pseudoAssistantResponse!,
}]);
}
} catch {
// ignore
}
})();
}, []);
const generateMessageId = (): string => {
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
};
const handleAbort = () => {
if (abortController) {
abortController.abort();
setAbortController(null);
// Use AgentCore to clean up the data model and get the user message content
const agentCore = AgentCore.instance();
const userMessageContent = agentCore.abortCurrentRequest();
// Remove the last user message and assistant message from UI
setMessages(prev => prev.slice(0, -2));
// Restore the user's input (use the content from AgentCore if available)
setInputValue(userMessageContent || lastUserMessage);
setIsProcessing(false);
}
};
const clearChatUI = () => {
// Clear UI state
setMessages([]);
// Reset first message flag so system prompt will be logged again
setIsFirstMessage(true);
};
const handleClearCommand = () => {
const { setStatus } = useProjectStore.getState();
clearChatHistoryAndUI(setStatus);
};
const addToolResultMessage = (toolName: string, success: boolean, result: string) => {
const toolMsgId = generateMessageId();
const friendlyDisplay = `${success ? '✅' : '❌'} __**${toolName}**__ \n\n └── ${result}`;
setMessages(prev => [...prev, {
id: toolMsgId,
role: 'user',
content: friendlyDisplay
}]);
};
const executeToolsFromResponse = async (response: string): Promise<boolean> => {
try {
// Check if response contains XML tool invocations
const xmlBlocks = extractXMLFromString(response);
// Consider only actionable tools (exclude think/thinking)
const actionableBlocks = xmlBlocks.filter((block) => {
const match = block.match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/);
const name = match ? match[1].toLowerCase() : '';
return name !== 'think' && name !== 'thinking';
});
if (actionableBlocks.length === 0) {
// No actionable tools to execute, stop the loop
return false;
}
// Start tool execution phase
setIsExecutingTools(true);
setToolResults('');
setCurrentToolIndex(0);
const { setStatus } = useProjectStore.getState();
setStatus(`Executing ${actionableBlocks.length} tool(s)...`);
const executor = XMLToolExecutor.instance();
let accumulatedResults = '';
// Execute tools sequentially with real-time updates
for (let i = 0; i < actionableBlocks.length; i++) {
setCurrentToolIndex(i + 1);
setStatus(`Executing tool ${i + 1} of ${actionableBlocks.length}...`);
// Determine tool name from XML block
const toolNameMatch = actionableBlocks[i].match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/);
const toolName = toolNameMatch ? toolNameMatch[1] : 'unknown_tool';
try {
// Execute single XML block
const results = await executor.executeXMLTools(actionableBlocks[i]);
const result = results[0]; // Single block should give single result
if (result) {
// Add friendly display message
addToolResultMessage(toolName, result.success, result.result);
// Accumulate formatted result for LLM (skip thinking tools)
if (toolName !== 'thinking' && toolName !== 'think') {
const formattedResult = `tool: ${toolName}\nsuccess: ${result.success}\nresult:\n${result.result}\n------------\n`;
accumulatedResults += formattedResult;
}
}
} catch (error) {
// Handle individual tool error
addToolResultMessage(toolName, false, `Tool execution failed: ${error}`);
// Accumulate error result for LLM (skip thinking tools)
if (toolName !== 'thinking' && toolName !== 'think') {
const formattedResult = `tool: ${toolName}\nsuccess: false\nresult:\nTool execution failed: ${error}\n------------\n`;
accumulatedResults += formattedResult;
}
}
}
// Store accumulated results
setToolResults(accumulatedResults);
setIsExecutingTools(false);
// Check if agent is still working on task before sending results to LLM
const agentCore = AgentCore.instance();
const isStillWorkingOnTask = agentCore.getAgentState().getIsWorkingOnTask();
if (isStillWorkingOnTask) {
// Send tool results back to LLM
setStatus('Processing tool results...');
await sendToolResultsToLLM(accumulatedResults);
} else {
// Agent is no longer working on task, ignore results and return control to user
setStatus('Tool execution completed');
}
return true; // Tools were found and executed
} catch (error) {
console.error('Error executing tools:', error);
setIsExecutingTools(false);
const { setStatus } = useProjectStore.getState();
setStatus(`Tool execution failed: ${error}`);
return false; // Tool execution failed
}
};
const sendToolResultsToLLM = async (toolResultsString: string): Promise<void> => {
// Send tool results as hidden user input to LLM
setIsProcessing(true);
// Create abort controller for this request
const controller = new AbortController();
setAbortController(controller);
// Add streaming assistant message for the response
const assistantMsgId = generateMessageId();
setMessages(prev => [...prev, {
id: assistantMsgId,
role: 'assistant',
content: 'processing... 0 tokens received. click here to abort.',
isStreaming: true,
tokenCount: 0
}]);
try {
const agentCore = AgentCore.instance();
let assistantResponse = '';
let tokenCount = 0;
// Log the tool results being sent to LLM
console.log('------------ USER ------------');
console.log(toolResultsString);
console.log('------------------------------');
for await (const chunk of agentCore.processUserInput(toolResultsString)) {
// Check if request was aborted
if (controller.signal.aborted) {
return;
}
if (chunk.type === 'text') {
assistantResponse += chunk.content;
tokenCount++;
// Update streaming message with token count and abort link
setMessages(prev => prev.map(msg =>
msg.id === assistantMsgId
? { ...msg, content: `processing... ${tokenCount} tokens received. click here to abort.`, tokenCount }
: msg
));
} else if (chunk.type === 'done') {
// Replace with final response
setMessages(prev => prev.map(msg =>
msg.id === assistantMsgId
? { ...msg, content: assistantResponse, isStreaming: false, tokenCount: undefined }
: msg
));
// Log the complete assistant response
console.log('------------ ASSISTANT ------------');
console.log(assistantResponse);
console.log('-----------------------------------');
// Check if the new response contains more tools
const hasMoreTools = await executeToolsFromResponse(assistantResponse);
// If no more tools were found, set working flag to false
if (!hasMoreTools) {
const agentCore = AgentCore.instance();
agentCore.getAgentState().setIsWorkingOnTask(false);
}
break;
}
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// Request was aborted, don't show error
return;
}
console.error('Error processing tool results:', error);
// Update with error message
setMessages(prev => prev.map(msg =>
msg.id === assistantMsgId
? { ...msg, content: 'Error: Failed to process tool results', isStreaming: false, tokenCount: undefined }
: msg
));
} finally {
setAbortController(null);
setIsProcessing(false);
}
};
const handleSend = async () => {
if (inputValue.trim() && !isProcessing) {
const userMessage = inputValue.trim();
setLastUserMessage(userMessage);
setInputValue('');
// Run message through the filter system
const filterResult = await processUserMessage(userMessage);
// Conditionally show the user message bubble
if (filterResult.displayUserMessage) {
const userMsgId = generateMessageId();
setMessages(prev => [...prev, {
id: userMsgId,
role: 'user',
content: userMessage
}]);
}
// If we have a pseudo assistant response, show it immediately
if (filterResult.pseudoAssistantResponse) {
const pseudoId = generateMessageId();
setMessages(prev => [...prev, {
id: pseudoId,
role: 'assistant',
content: filterResult.pseudoAssistantResponse!,
}]);
}
// If we shouldn't send anything to LLM, stop here
if (!filterResult.sendToLLM || !filterResult.finalMessageForLLM) {
return;
}
setIsProcessing(true);
// Create abort controller for this request
const controller = new AbortController();
setAbortController(controller);
// Add streaming assistant message
const assistantMsgId = generateMessageId();
setMessages(prev => [...prev, {
id: assistantMsgId,
role: 'assistant',
content: 'processing... 0 tokens received. click here to abort.',
isStreaming: true,
tokenCount: 0
}]);
try {
const agentCore = AgentCore.instance();
let assistantResponse = '';
let tokenCount = 0;
// Set working on task flag when user sends a message
agentCore.getAgentState().setIsWorkingOnTask(true);
// Log system prompt only for first message or first message after clear
if (isFirstMessage) {
try {
const systemPrompt = await SystemPrompts.getSystemPromptWithContext();
console.log('------------ SYSTEM ------------');
console.log(systemPrompt);
console.log('--------------------------------');
} catch (error) {
console.error('Failed to log system prompt:', error);
}
// Mark that we've logged the system prompt for this conversation
setIsFirstMessage(false);
}
// Log the final user message being sent to LLM
console.log('------------ USER ------------');
console.log(filterResult.finalMessageForLLM);
console.log('------------------------------');
for await (const chunk of agentCore.processUserInput(filterResult.finalMessageForLLM)) {
// Check if request was aborted
if (controller.signal.aborted) {
return;
}
if (chunk.type === 'text') {
assistantResponse += chunk.content;
tokenCount++;
// Update streaming message with token count and abort link
setMessages(prev => prev.map(msg =>
msg.id === assistantMsgId
? { ...msg, content: `processing... ${tokenCount} tokens received. click here to abort.`, tokenCount }
: msg
));
} else if (chunk.type === 'done') {
// Replace with final response
setMessages(prev => prev.map(msg =>
msg.id === assistantMsgId
? { ...msg, content: assistantResponse, isStreaming: false, tokenCount: undefined }
: msg
));
// Log the complete assistant response
console.log('------------ ASSISTANT ------------');
console.log(assistantResponse);
console.log('-----------------------------------');
// Check if response contains tools to execute
const hasTools = await executeToolsFromResponse(assistantResponse);
// If no tools were found, set working flag to false and return control to user
if (!hasTools) {
agentCore.getAgentState().setIsWorkingOnTask(false);
}
break;
}
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// Request was aborted, don't show error
return;
}
console.error('Error processing message:', error);
// Update with error message
setMessages(prev => prev.map(msg =>
msg.id === assistantMsgId
? { ...msg, content: 'Error: Failed to process message', isStreaming: false, tokenCount: undefined }
: msg
));
} finally {
setAbortController(null);
setIsProcessing(false);
}
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
// Allow Shift+Enter for new lines (default textarea behavior)
};
const handleInputFocus = () => {
// Set a data attribute on the textarea to help global keyboard handler identify it
if (textareaRef.current) {
textareaRef.current.setAttribute('data-chatbox-input', 'true');
}
};
const handleInputBlur = () => {
// Remove the data attribute when losing focus
if (textareaRef.current) {
textareaRef.current.removeAttribute('data-chatbox-input');
}
};
// Auto-resize textarea based on content
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, [inputValue]);
// Auto-scroll to bottom when new messages arrive
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Auto-focus input when it becomes visible (when processing and tool execution complete)
useEffect(() => {
if (!isProcessing && !isExecutingTools && textareaRef.current) {
// Use a small delay to ensure the DOM has updated
setTimeout(() => {
textareaRef.current?.focus();
// Also scroll to bottom when input becomes visible after tool execution
// This ensures proper scroll position after layout changes from showing input box
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 0);
}
}, [isProcessing, isExecutingTools]);
return (
<div className="chatbox">
<div className="chatbox-header">
<h3>K.G.Studio Musician Assistant</h3>
<div className="chatbox-actions">
{isProcessing && (
<button
type="button"
title="Abort"
onClick={handleAbort}
className="chatbox-action-btn"
>
<FaBan />
</button>
)}
<button
type="button"
title="New Chat"
onClick={handleClearCommand}
className="chatbox-action-btn"
>
<FaPlus />
</button>
</div>
</div>
<div className="chatbox-messages">
{messages.map((message) => (
message.role === 'user' ? (
<UserMessage key={message.id} content={message.content} />
) : (
<AssistantMessage
key={message.id}
content={message.content}
isStreaming={message.isStreaming}
onAbort={message.isStreaming ? handleAbort : undefined}
/>
)
))}
<div ref={messagesEndRef} />
</div>
{!isProcessing && !isExecutingTools && (
<div className="chatbox-input-area">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
placeholder="Press Enter to send message, Shift + Enter for new line"
className="chatbox-input"
rows={1}
/>
</div>
)}
</div>
);
};
export default memo(ChatBox);
+110
View File
@@ -0,0 +1,110 @@
import React, { useMemo, useState, useEffect } from 'react';
import { useProjectStore } from '../stores/projectStore';
import { INSTRUMENT_GROUPS, FLUIDR3_INSTRUMENT_MAP } from '../constants/generalMidiConstants';
import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack';
const InstrumentSelection: React.FC = () => {
const {
tracks,
instrumentSelectionTrackId,
closeInstrumentSelection,
setTrackInstrument
} = useProjectStore();
const targetTrack = useMemo(() => {
return tracks.find(t => t.getId().toString() === instrumentSelectionTrackId) || null;
}, [tracks, instrumentSelectionTrackId]);
const currentInstrumentKey: InstrumentType = (targetTrack && targetTrack instanceof KGMidiTrack)
? (targetTrack.getInstrument() as InstrumentType)
: 'acoustic_grand_piano';
const currentInstrumentDef = FLUIDR3_INSTRUMENT_MAP[currentInstrumentKey] || FLUIDR3_INSTRUMENT_MAP['acoustic_grand_piano'];
// Maintain selected group in local state; selected instrument derives from model
const [selectedGroupKey, setSelectedGroupKey] = useState<string>(currentInstrumentDef?.group || 'PIANO_AND_KEYBOARDS');
useEffect(() => {
// Sync when the target track or its instrument changes
setSelectedGroupKey(currentInstrumentDef?.group || 'PIANO_AND_KEYBOARDS');
}, [instrumentSelectionTrackId, currentInstrumentKey, currentInstrumentDef]);
const groups = useMemo(() => Object.entries(INSTRUMENT_GROUPS) as Array<[string, string]>, []);
const instrumentsInGroup = useMemo(() => {
return Object.entries(FLUIDR3_INSTRUMENT_MAP)
.filter((entry) => entry[1].group === selectedGroupKey)
.map((entry) => ({ key: entry[0], label: entry[1].displayName }));
}, [selectedGroupKey]);
const handleSelectGroup = (groupKey: string) => {
setSelectedGroupKey(groupKey);
};
const handleSelectInstrument = async (instrumentKey: string) => {
const instrument = instrumentKey as InstrumentType;
if (!targetTrack || !(targetTrack instanceof KGMidiTrack)) return;
try {
await setTrackInstrument(targetTrack.getId(), instrument);
} catch (err) {
console.error('Failed to change instrument from panel:', err);
}
};
const previewImage = FLUIDR3_INSTRUMENT_MAP[currentInstrumentKey]?.image || 'piano.png';
const previewAlt = FLUIDR3_INSTRUMENT_MAP[currentInstrumentKey]?.displayName || currentInstrumentKey;
if (!targetTrack) return null;
return (
<div className="instrument-selection">
<div className="instrument-selection-header">
<h3>{`${previewAlt.toString()}`}</h3>
<button className="instrument-selection-close-btn" onClick={closeInstrumentSelection}></button>
</div>
<div className="instrument-selection-top">
<div className="instrument-preview">
<img
src={`/resources/instruments/${previewImage}`}
alt={previewAlt.toString()}
width={256}
height={256}
/>
</div>
<div className="instrument-name-overlay">{targetTrack.getName()}</div>
</div>
<div className="instrument-selection-bottom">
<div className="instrument-groups">
<div className="instrument-groups-list">
{groups.map(([key, label]) => (
<div
key={key}
className={`instrument-group-item${selectedGroupKey === key ? ' active' : ''}`}
onClick={() => handleSelectGroup(key)}
>
{label}
</div>
))}
</div>
</div>
<div className="instrument-list">
<div className="instrument-instruments-list">
{instrumentsInGroup.map((inst) => (
<div
key={inst.key}
className={`instrument-instrument-item${currentInstrumentKey === inst.key ? ' active' : ''}`}
onClick={() => handleSelectInstrument(inst.key)}
>
{inst.label}
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default InstrumentSelection;
+659
View File
@@ -0,0 +1,659 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useProjectStore } from '../stores/projectStore';
import { KGCore } from '../core/KGCore';
import { KGTrack } from '../core/track/KGTrack';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import TrackInfoPanel from './track/TrackInfoPanel';
import TrackGridPanel from './track/TrackGridPanel';
import PianoRoll from './piano-roll/PianoRoll';
import type { RegionUI } from './interfaces';
import { DEBUG_MODE } from '../constants';
import { useRegionOperations } from '../hooks/useRegionOperations';
import { regionDeleteManager } from '../util/regionDeleteUtil';
interface MainContentProps {
onTrackClick?: () => void;
}
const MainContent: React.FC<MainContentProps> = ({
onTrackClick = () => {} // Default to empty function if not provided
}) => {
const {
tracks,
maxBars,
reorderTracks,
updateTrack,
updateTrackProperties,
timeSignature,
setPlayheadPosition,
clearAllSelections,
setSelectedTrack,
showPianoRoll,
activeRegionId,
setShowPianoRoll,
setActiveRegionId
} = useProjectStore();
// State to store regions
const [regions, setRegions] = useState<RegionUI[]>([]);
// Drag state for track grid highlighting
const [draggedTrackIndex, setDraggedTrackIndex] = useState<number | null>(null);
const [dragOverTrackIndex, setDragOverTrackIndex] = useState<number | null>(null);
// Piano roll state is now managed by the store - removed local state
// Region selection state
const [selectedRegionId, setSelectedRegionId] = useState<string | null>(null);
// Use the region operations hook
const { deleteSelectedRegions } = useRegionOperations({
tracks,
updateTrack,
setRegions,
selectedRegionId,
setSelectedRegionId,
showPianoRoll,
setShowPianoRoll,
activeRegionId,
setActiveRegionId
});
// Register the delete function with the global manager
useEffect(() => {
regionDeleteManager.registerDeleteCallback(deleteSelectedRegions);
// Cleanup on unmount
return () => {
regionDeleteManager.unregisterDeleteCallback();
};
}, [deleteSelectedRegions]);
// Refs to track pending updates for verification
const pendingUpdates = useRef<Map<string, { trackId: string, regionId: string, startBeat: number, length: number }>>(new Map());
// Refs for bar numbers drag functionality
const isDraggingRef = useRef(false);
const barNumbersRef = useRef<HTMLDivElement | null>(null);
// Effect to verify track updates
useEffect(() => {
// Check for pending updates
if (pendingUpdates.current.size > 0) {
// Create a copy of the pending updates
const updates = new Map(pendingUpdates.current);
// Clear pending updates
pendingUpdates.current.clear();
// Check each update
updates.forEach((update, key) => {
const { trackId, regionId, startBeat, length } = update;
// Find the track
const track = tracks.find(t => t.getId().toString() === trackId);
if (track) {
// Find the region
const regions = track.getRegions();
const region = regions.find(r => r.getId() === regionId);
if (region && DEBUG_MODE.MAIN_CONTENT) {
console.log(`Verification - Region ${regionId} in track ${trackId}:`);
console.log(` Expected: startBeat=${startBeat}, length=${length}`);
console.log(` Actual: startBeat=${region.getStartFromBeat()}, length=${region.getLength()}, trackId=${region.getTrackId()}, trackIndex=${region.getTrackIndex()}`);
// Check if the update was successful
const success = region.getStartFromBeat() === startBeat && region.getLength() === length && region.getTrackId() === trackId;
console.log(` Update successful: ${success}`);
}
}
});
}
}, [tracks]);
// Effect to update regions when tracks change
useEffect(() => {
// Create a new array of RegionUI objects based on the current tracks
const updatedRegions: RegionUI[] = [];
// Iterate through all tracks
tracks.forEach(track => {
const trackId = track.getId().toString();
const trackIndex = track.getTrackIndex();
// Iterate through all regions in the track
track.getRegions().forEach(region => {
if (region instanceof KGMidiRegion) {
// Calculate bar number and length from beats
const beatsPerBar = timeSignature.numerator;
const barNumber = Math.floor(region.getStartFromBeat() / beatsPerBar) + 1;
const length = region.getLength() / beatsPerBar;
// Create a RegionUI object
updatedRegions.push({
id: region.getId(),
trackId,
trackIndex,
barNumber,
length,
name: region.getName()
});
}
});
});
// Update the regions state
setRegions(updatedRegions);
}, [tracks, timeSignature]);
// Handle track name edit
const handleTrackNameEdit = (track: KGTrack, newName: string) => {
// Use the command pattern to update track name with undo support
updateTrackProperties(track.getId(), { name: newName });
};
// Handle track reordering
const handleTracksReordered = (fromIndex: number, toIndex: number) => {
// Reorder tracks in the store - this will also update trackIndex in each KGTrack
reorderTracks(fromIndex, toIndex);
// Update regions to match the new track order
setRegions(prevRegions => {
return prevRegions.map(region => {
// If the region belongs to the dragged track, update its trackIndex
if (region.trackIndex === fromIndex) {
return { ...region, trackIndex: toIndex };
}
// If the region belongs to a track that was shifted due to the drag operation
else if (
(fromIndex < toIndex &&
region.trackIndex > fromIndex &&
region.trackIndex <= toIndex)
) {
// Shift up by 1
return { ...region, trackIndex: region.trackIndex - 1 };
}
else if (
(fromIndex > toIndex &&
region.trackIndex < fromIndex &&
region.trackIndex >= toIndex)
) {
// Shift down by 1
return { ...region, trackIndex: region.trackIndex + 1 };
}
// Otherwise leave it unchanged
return region;
});
});
// Update the grid drag state to match
setDraggedTrackIndex(null);
setDragOverTrackIndex(null);
};
// Handle region creation from TrackGridPanel
const handleRegionCreated = (trackIndex: number, regionUI: RegionUI, midiRegion: KGMidiRegion) => {
// Note: The region model is already created by the CreateRegionCommand
// We just need to update the UI state and handle selection
// Get the track for store updates
const track = tracks[trackIndex];
// Update the track in the store to reflect the command changes
updateTrack(track);
// Select the track that contains the new region
setSelectedTrack(track.getId().toString());
// Add the new region to the UI state and select it immediately
setRegions(prevRegions => {
const updatedRegions = [...prevRegions, regionUI];
// Select the region using the updated regions array
selectRegion(regionUI.id, updatedRegions);
// Manually trigger selection sync to ensure UI updates immediately
const { syncSelectionFromCore } = useProjectStore.getState();
syncSelectionFromCore();
// If piano roll is visible, set this region as the active region
if (showPianoRoll) {
setActiveRegionId(regionUI.id);
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Newly created region ${regionUI.id} set as active region in piano roll`);
}
}
return updatedRegions;
});
};
// Handle region updates (resize, move, etc.)
const handleRegionUpdated = (
regionId: string,
updates: Partial<RegionUI>,
expectedModelUpdates?: { startBeat: number, length: number }
) => {
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Updating region ${regionId} with:`, updates);
}
// Select the region when it's being updated (resize or move)
selectRegion(regionId);
// Find the region to determine which track to select
const updatedRegion = regions.find(r => r.id === regionId);
if (updatedRegion) {
// Use the updated trackId if available, otherwise use the existing trackId
const trackId = updates.trackId || updatedRegion.trackId;
const track = tracks.find(t => t.getId().toString() === trackId);
if (track) {
setSelectedTrack(track.getId().toString());
}
}
// Update the region in the UI state
setRegions(prevRegions => {
return prevRegions.map(region => {
if (region.id === regionId) {
return { ...region, ...updates };
}
return region;
});
});
// Find the region that was updated
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Check if the track ID is being updated (region moved to different track)
if (updates.trackId && updates.trackId !== region.trackId) {
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Region ${regionId} moved from track ${region.trackId} to track ${updates.trackId}`);
}
// Get the original track
const originalTrack = tracks.find(t => t.getId().toString() === region.trackId);
// Get the target track
const targetTrack = tracks.find(t => t.getId().toString() === updates.trackId);
if (originalTrack && targetTrack) {
// Select the target track that now contains the region
setSelectedTrack(targetTrack.getId().toString());
// Update both tracks in the store
updateTrack(originalTrack);
updateTrack(targetTrack);
// Add to pending updates for verification
if (expectedModelUpdates) {
const key = `${updates.trackId}-${regionId}-${Date.now()}`;
pendingUpdates.current.set(key, {
trackId: updates.trackId,
regionId,
startBeat: expectedModelUpdates.startBeat,
length: expectedModelUpdates.length
});
}
}
} else {
// Find the track that contains this region
const track = tracks.find(t => t.getId().toString() === region.trackId);
if (track) {
// Log the track's regions before updating the store
const trackRegions = track.getRegions();
const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
if (midiRegion) {
// If we have expected model updates, use those
if (expectedModelUpdates) {
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`MainContent - Expected model updates: startBeat=${expectedModelUpdates.startBeat}, length=${expectedModelUpdates.length}`);
}
// Add to pending updates for verification
const key = `${track.getId()}-${regionId}-${Date.now()}`;
pendingUpdates.current.set(key, {
trackId: track.getId().toString(),
regionId,
startBeat: expectedModelUpdates.startBeat,
length: expectedModelUpdates.length
});
} else {
// Otherwise use the current values (for backward compatibility)
const startBeat = midiRegion.getStartFromBeat();
const length = midiRegion.getLength();
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`MainContent - Region before store update: startBeat=${startBeat}, length=${length}`);
}
// Add to pending updates for verification
const key = `${track.getId()}-${regionId}-${Date.now()}`;
pendingUpdates.current.set(key, {
trackId: track.getId().toString(),
regionId,
startBeat,
length
});
}
}
// Update the track in the store to persist changes
updateTrack(track);
}
}
// If piano roll is visible, set this region as the active region
if (showPianoRoll) {
setActiveRegionId(regionId);
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Updated region ${regionId} set as active region in piano roll`);
}
}
};
// Helper function to select a region (clears previous selections)
const selectRegion = (regionId: string, regionsToSearch?: RegionUI[]) => {
// Clear any existing selections using store method
clearAllSelections();
// Find the region in the UI state (use provided regions or current state)
const regionsToUse = regionsToSearch || regions;
const region = regionsToUse.find(r => r.id === regionId);
if (!region) {
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Region not found in UI state: ${regionId}`);
}
return;
}
// Find the track that contains this region
const track = tracks.find(t => t.getId().toString() === region.trackId);
if (!track) {
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Track not found for region: ${regionId}`);
}
return;
}
// Find the region in the track's model
const trackRegions = track.getRegions();
const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
if (!midiRegion) {
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`MIDI region not found in track model: ${regionId}`);
}
return;
}
// Add the region to KGCore's selection
const core = KGCore.instance();
core.addSelectedItem(midiRegion);
// Update the region's internal selection state
midiRegion.select();
// Set the selected region (this might be redundant now, but keeping for compatibility)
setSelectedRegionId(regionId);
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Selected region: ${regionId} (added to KGCore selection)`);
}
};
// Handle region single click: selection only (no piano roll opening)
const handleRegionClick = (regionId: string) => {
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Region clicked in MainContent (selection only): ${regionId}`);
}
// Select the region
selectRegion(regionId);
// Also select the containing track
const region = regions.find(r => r.id === regionId);
if (!region) return;
const track = tracks.find(t => t.getId().toString() === region.trackId);
if (!track) return;
setSelectedTrack(track.getId().toString());
};
// Handle explicit pencil action: select region and open piano roll
const handleOpenPianoRoll = (regionId: string) => {
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Open piano roll via pencil for region: ${regionId}`);
}
// Reuse selection logic
handleRegionClick(regionId);
// Activate and show piano roll
setActiveRegionId(regionId);
setShowPianoRoll(true);
};
// Handle piano roll close
const handlePianoRollClose = () => {
setShowPianoRoll(false);
setActiveRegionId(null);
};
/**
* Add keyboard event listener for region deletion
* Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions
* Only processes deletion when not in the piano roll (piano roll has its own delete handler)
*/
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Skip if user is typing in an input field (including ChatBox)
const target = event.target as HTMLElement;
if (target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true' ||
target.hasAttribute('data-chatbox-input') ||
target.closest('.chatbox-input')
)) {
return;
}
// Handle delete key for selected regions (Backspace on Windows, Delete on Mac)
if (event.key === 'Backspace' || event.key === 'Delete') {
// Only handle if we're not in the piano roll (piano roll has its own delete handler)
const isInPianoRoll = document.querySelector('.piano-roll')?.contains(event.target as Node);
const isPianoRollOpen = showPianoRoll;
if (!isInPianoRoll && !isPianoRollOpen) {
const deleted = deleteSelectedRegions();
if (deleted) {
// Prevent default behavior only if regions were actually deleted
event.preventDefault();
}
}
}
};
// Add event listener
window.addEventListener('keydown', handleKeyDown);
// Remove event listener on cleanup
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [deleteSelectedRegions, showPianoRoll]); // Dependencies for the effect
// Utility function to calculate playhead position from mouse coordinates (bar-level snapping)
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
if (!barNumbersRef.current) return null;
const rect = barNumbersRef.current.getBoundingClientRect();
const relativeX = clientX - rect.left;
// Calculate the width of each bar
const barWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
) || 40;
// Find the closest bar start (using Math.round for nearest bar)
const barIndex = Math.round(relativeX / barWidth);
// Ensure we don't go below 0
const clampedBarIndex = Math.max(0, barIndex);
// Calculate destination beat position (start of the bar)
const beatsPerBar = timeSignature.numerator;
const destinationBeatPosition = clampedBarIndex * beatsPerBar;
return destinationBeatPosition;
}, [timeSignature]);
// Handle mouse down to start dragging
const handleBarNumbersMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
// Only handle left mouse button
if (e.button !== 0) return;
isDraggingRef.current = true;
// Calculate and set initial playhead position
const newPosition = calculatePlayheadFromMouse(e.clientX);
if (newPosition !== null) {
setPlayheadPosition(newPosition);
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Bar numbers drag started - Initial position: ${newPosition} (bar ${Math.floor(newPosition / timeSignature.numerator) + 1})`);
}
}
// Prevent text selection during drag
e.preventDefault();
};
// Handle click on bar numbers to move playhead (when not dragging)
const handleBarNumbersClick = (e: React.MouseEvent<HTMLDivElement>) => {
// If we were dragging, don't process as a click
if (isDraggingRef.current) {
return;
}
const newPosition = calculatePlayheadFromMouse(e.clientX);
if (newPosition !== null) {
const core = KGCore.instance();
const currentPlayheadPosition = core.getPlayheadPosition();
const beatsPerBar = timeSignature.numerator;
const currentBarNumber = Math.floor(currentPlayheadPosition / beatsPerBar) + 1; // 1-indexed
const destinationBarNumber = Math.floor(newPosition / beatsPerBar) + 1; // 1-indexed
// Debug logging
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Bar numbers click - Position: ${newPosition}`);
console.log(`Current bar: ${currentBarNumber} (beat ${currentPlayheadPosition})`);
console.log(`Destination bar: ${destinationBarNumber} (beat ${newPosition})`);
}
setPlayheadPosition(newPosition);
}
};
// Global mouse move and mouse up handlers for bar numbers drag functionality
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDraggingRef.current) return;
const newPosition = calculatePlayheadFromMouse(e.clientX);
if (newPosition !== null) {
setPlayheadPosition(newPosition);
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Bar numbers drag - Position: ${newPosition} (bar ${Math.floor(newPosition / timeSignature.numerator) + 1})`);
}
}
};
const handleMouseUp = () => {
if (isDraggingRef.current) {
isDraggingRef.current = false;
if (DEBUG_MODE.MAIN_CONTENT) {
console.log('Bar numbers drag ended');
}
}
};
// Add global event listeners for drag functionality
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// Cleanup event listeners on unmount
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [calculatePlayheadFromMouse, setPlayheadPosition, timeSignature]);
const { showInstrumentSelection } = useProjectStore();
return (
<div className={`main-content${showInstrumentSelection ? ' has-left-instrument' : ''}`}>
<div className="main-content-wrapper">
{/* Top-left spacer */}
<div className="top-left-spacer"></div>
{/* Bar numbers at the top */}
<div
className="bar-numbers"
ref={barNumbersRef}
onMouseDown={handleBarNumbersMouseDown}
onClick={handleBarNumbersClick}
>
{Array.from({ length: maxBars }, (_, i) => (
<div key={i} className="bar-number-cell">{i + 1}</div>
))}
</div>
<div className="main-content-body">
{/* Fixed left panel with track info */}
<TrackInfoPanel
tracks={tracks}
onTrackClick={onTrackClick}
onTrackNameEdit={handleTrackNameEdit}
onTracksReordered={handleTracksReordered}
/>
{/* Scrollable grid area */}
<TrackGridPanel
tracks={tracks}
regions={regions}
maxBars={maxBars}
timeSignature={timeSignature}
draggedTrackIndex={draggedTrackIndex}
dragOverTrackIndex={dragOverTrackIndex}
selectedRegionId={selectedRegionId}
onRegionCreated={handleRegionCreated}
onRegionUpdated={handleRegionUpdated}
onRegionClick={handleRegionClick}
onOpenPianoRoll={handleOpenPianoRoll}
/>
</div>
</div>
{/* Piano Roll - render using portal */}
{showPianoRoll && createPortal(
<PianoRoll
onClose={handlePianoRollClose}
regionId={activeRegionId}
/>,
document.body
)}
</div>
);
};
export default MainContent;
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import { useProjectStore } from '../stores/projectStore';
const StatusBar: React.FC = () => {
const { currentStatus } = useProjectStore();
return (
<div className="status-bar">
<div className="status-left">
{currentStatus}
</div>
<div className="status-right">
<span>K.G.Studio</span>
</div>
</div>
);
};
export default StatusBar;
+799
View File
@@ -0,0 +1,799 @@
import React from 'react';
import { saveProject } from '../util/saveUtil';
import { KGStorage } from '../core/io/KGStorage';
import { DB_CONSTANTS } from '../constants/coreConstants';
import { KGCore } from '../core/KGCore';
import { useProjectStore } from '../stores/projectStore';
import { DEBUG_MODE } from '../constants/uiConstants';
import { TIME_CONSTANTS } from '../constants/coreConstants';
import { parseTimeSignature, getTimeSignatureErrorMessage } from '../util/timeUtil';
import {
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
FaPlay, FaPause, FaComments,
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
FaCog
} from 'react-icons/fa';
import { KGProject, type KeySignature } from '../core/KGProject';
import { plainToClass, instanceToPlain } from 'class-transformer';
import { FaPencil, FaCopy, FaPaste, FaTrash } from 'react-icons/fa6';
import { KGMainContentState } from '../core/state/KGMainContentState';
import { regionDeleteManager } from '../util/regionDeleteUtil';
import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil';
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
import KGDropdown from './common/KGDropdown';
import FileImportModal from './common/FileImportModal';
import { clearChatHistoryAndUI } from '../util/chatUtil';
import PianoIcon from './common/icons/PianoIcon';
const Toolbar: React.FC = () => {
const {
projectName, setProjectName,
bpm, timeSignature, keySignature, setStatus,
isPlaying, startPlaying, stopPlaying, setPlayheadPosition,
currentTime, setBpm, setTimeSignature, setKeySignature,
maxBars, setMaxBars,
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
toggleChatBox, toggleSettings, cleanupProjectState,
// Piano roll state/actions
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
// Selection state
selectedRegionIds
} = useProjectStore();
// State for main content tools
const [activeMainTool, setActiveMainTool] = React.useState<'pointer' | 'pencil'>('pointer');
// State for key signature dropdown
const [showKeySignatureDropdown, setShowKeySignatureDropdown] = React.useState(false);
// State for export dropdown
const [showExportDropdown, setShowExportDropdown] = React.useState(false);
// State for import modal
const [showImportModal, setShowImportModal] = React.useState(false);
// Key signature options
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
// Export options
const exportOptions = ["Export to KGStudio JSON file", "Export to MIDI file"];
const handleProjectNameClick = () => {
const newName = prompt("Enter project name:", projectName);
if (newName) setProjectName(newName);
};
// Common project loading logic extracted for reuse
const loadProjectFromData = async (project: KGProject, sourceDescription: string) => {
try {
// Clean up UI state first
cleanupProjectState();
// Automatically clear chat history when loading a project
clearChatHistoryAndUI();
// Load the project using the store's loadProject method
const { loadProject: storeLoadProject } = useProjectStore.getState();
await storeLoadProject(project);
// Update status to indicate project loaded
setStatus(`${sourceDescription} loaded successfully`);
if (DEBUG_MODE.TOOLBAR) {
console.log(`project loaded successfully from ${sourceDescription}`);
}
} catch (error) {
console.error(`Error loading project from ${sourceDescription}:`, error);
setStatus(`Failed to load project: ${error}`);
window.alert(`An error occurred while loading the project: ${error}`);
}
};
// Handler functions for file operations
const handleNewProject = () => {
const confirmed = window.confirm("Are you sure you want to create a new project? Any unsaved changes will be lost.");
if (confirmed) {
if (DEBUG_MODE.TOOLBAR) {
console.log("user clicked new button");
}
// Clean up UI state first
cleanupProjectState();
// Automatically clear chat history when creating a new project
clearChatHistoryAndUI();
// Create a new project with default parameters
const newProject = new KGProject();
// Load the new project using the store's loadProject method
const { loadProject: storeLoadProject } = useProjectStore.getState();
storeLoadProject(newProject);
// Default track creation is handled centrally in the store's loadProject
// Update status to indicate new project created
setStatus(`New project "${newProject.getName()}" created`);
if (DEBUG_MODE.TOOLBAR) {
console.log("new project created successfully");
}
}
};
const handleLoadProject = async () => {
const confirmed = window.confirm("Are you sure you want to load another project? Any unsaved changes will be lost.");
if (confirmed) {
if (DEBUG_MODE.TOOLBAR) {
console.log("user clicked load button");
}
// Ask user for project name
const projectNameToLoad = window.prompt("Enter the project name to load:");
// Check if user input is empty or null (user cancelled)
if (!projectNameToLoad || projectNameToLoad.trim() === '') {
if (projectNameToLoad !== null) { // Only show error if user didn't cancel
window.alert("Project name cannot be empty. Please enter a valid project name.");
}
return;
}
try {
// Try to load the project from storage
const storage = KGStorage.getInstance();
const loadedProject = await storage.load(
DB_CONSTANTS.DB_NAME,
DB_CONSTANTS.PROJECTS_STORE_NAME,
projectNameToLoad.trim(),
KGProject,
DB_CONSTANTS.DB_VERSION
);
if (!loadedProject) {
window.alert(`Project "${projectNameToLoad}" not found. Please check the project name and try again.`);
return;
}
// Use common loading logic
await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`);
} catch (error) {
console.error("Error loading project:", error);
window.alert(`An error occurred while loading the project: ${error}`);
}
}
};
const handleSaveProject = async () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("user clicked save button");
}
await saveProject(projectName, setStatus);
};
const handleExportProject = (exportType: string) => {
if (DEBUG_MODE.TOOLBAR) {
console.log("user selected export option:", exportType);
}
if (exportType === "Export to KGStudio JSON file") {
handleExportKGStudioJSON();
} else if (exportType === "Export to MIDI file") {
handleExportMIDI();
}
setShowExportDropdown(false);
};
const handleExportKGStudioJSON = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("exporting to KGStudio JSON file");
}
try {
// Get the current project from KGCore
const currentProject = KGCore.instance().getCurrentProject();
// Serialize the project to JSON (same format as saved to IndexedDB)
// Use instanceToPlain to include type information for class-transformer
const projectData = JSON.stringify(instanceToPlain(currentProject), null, 2);
// Create a downloadable blob
const blob = new Blob([projectData], { type: 'application/json' });
// Create a temporary download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${projectName}.json`;
// Trigger download
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
URL.revokeObjectURL(url);
setStatus(`Project "${projectName}" exported as JSON file`);
if (DEBUG_MODE.TOOLBAR) {
console.log("KGStudio JSON export completed successfully");
}
} catch (error) {
console.error("Error exporting KGStudio JSON:", error);
setStatus(`Error exporting project: ${error}`);
window.alert(`Failed to export project as JSON: ${error}`);
}
};
const handleExportMIDI = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("exporting to MIDI file");
}
try {
// Get the current project from KGCore
const currentProject = KGCore.instance().getCurrentProject();
// Convert project to MIDI format
const midiData = convertProjectToMidi(currentProject);
// Create a downloadable blob
const blob = new Blob([midiData], { type: 'audio/midi' });
// Create a temporary download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${projectName}.mid`;
// Trigger download
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
URL.revokeObjectURL(url);
setStatus(`Project "${projectName}" exported as MIDI file`);
if (DEBUG_MODE.TOOLBAR) {
console.log("MIDI export completed successfully");
}
} catch (error) {
console.error("Error exporting MIDI:", error);
setStatus(`Error exporting MIDI: ${error}`);
window.alert(`Failed to export project as MIDI: ${error}`);
}
};
const handleImportProject = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("user clicked import button");
}
setShowImportModal(true);
};
const handleFileImport = async (file: File) => {
if (DEBUG_MODE.TOOLBAR) {
console.log("file selected for import:", file.name);
}
// Get file extension
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
try {
if (fileExtension === '.json') {
// Handle KGStudio JSON import
await handleKGStudioJSONImport(file);
} else if (fileExtension === '.mid' || fileExtension === '.midi') {
// Handle MIDI import
await handleMIDIImport(file);
} else {
throw new Error(`Unsupported file type: ${fileExtension}`);
}
} catch (error) {
console.error("Error importing file:", error);
setStatus(`Failed to import file: ${error}`);
window.alert(`Failed to import project file: ${error}`);
}
};
const handleKGStudioJSONImport = async (file: File) => {
try {
// Read the file content
const fileContent = await file.text();
const projectData = JSON.parse(fileContent);
// Deserialize the project data using class-transformer (same as KGStorage)
const deserializedResult = plainToClass(KGProject, projectData);
// Handle case where plainToClass might return an array
const deserializedProject = Array.isArray(deserializedResult)
? deserializedResult[0] || null
: deserializedResult;
if (!deserializedProject) {
throw new Error("Failed to deserialize project data");
}
// Load the project using common loading logic
await loadProjectFromData(deserializedProject, `File "${file.name}"`);
if (DEBUG_MODE.TOOLBAR) {
console.log("KGStudio JSON project imported successfully:", deserializedProject);
}
} catch (error) {
throw new Error(`Invalid KGStudio JSON file: ${error}`);
}
};
const handleMIDIImport = async (file: File) => {
try {
if (DEBUG_MODE.TOOLBAR) {
console.log("Starting MIDI file import:", file.name);
}
// Show loading status
setStatus(`Importing MIDI file "${file.name}"...`);
// Read the MIDI file as binary data
const arrayBuffer = await file.arrayBuffer();
const midiData = new Uint8Array(arrayBuffer);
if (DEBUG_MODE.TOOLBAR) {
console.log("MIDI file read successfully, size:", midiData.length, "bytes");
}
// Get current project to append MIDI tracks to it
const currentProject = KGCore.instance().getCurrentProject();
// Convert MIDI data and append to current project
const updatedProject = convertMidiToProject(midiData, currentProject);
if (DEBUG_MODE.TOOLBAR) {
console.log("MIDI conversion successful, tracks added to existing project");
}
// Load the updated project using common loading logic
await loadProjectFromData(updatedProject, `MIDI file "${file.name}"`);
if (DEBUG_MODE.TOOLBAR) {
console.log("MIDI file imported successfully:", file.name);
}
} catch (error) {
console.error("Error importing MIDI file:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
setStatus(`Failed to import MIDI file: ${errorMessage}`);
throw new Error(`Invalid MIDI file: ${errorMessage}`);
}
};
// Handler functions for playback control
const handlePlayClick = async () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("Play button clicked");
}
try {
await startPlaying();
} catch (error) {
console.error("Failed to start playback:", error);
setStatus("Playback failed to start");
}
};
const handlePauseClick = async () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("Pause button clicked");
}
try {
await stopPlaying();
} catch (error) {
console.error("Failed to stop playback:", error);
setStatus("Failed to stop playback");
}
};
const handleBackToBeginningClick = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("Back to beginning button clicked");
}
setPlayheadPosition(0);
};
// Prompt to change max bars when clicking on current-time display
const handleCurrentTimeClick = () => {
const MIN_BARS = 16;
const newMaxBarsStr = prompt(`Enter new max bars (>= ${MIN_BARS}):`, String(maxBars ?? 32));
if (newMaxBarsStr === null) {
return; // cancelled
}
const parsed = parseInt(newMaxBarsStr.trim(), 10);
if (isNaN(parsed)) {
alert('Invalid input. Please enter a valid number.');
return;
}
if (parsed < MIN_BARS) {
alert(`Invalid value. Please enter a number >= ${MIN_BARS}.`);
return;
}
setMaxBars(parsed);
setStatus(`Max bars changed to ${parsed}`);
};
const handleBpmClick = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("BPM clicked, current BPM:", bpm);
}
const newBpmStr = prompt(`Enter new BPM (${TIME_CONSTANTS.MIN_BPM}-${TIME_CONSTANTS.MAX_BPM}):`, bpm.toString());
// Check if user cancelled
if (newBpmStr === null) {
return;
}
// Validate input
const newBpm = parseInt(newBpmStr.trim());
// Check if it's a valid number
if (isNaN(newBpm)) {
alert("Invalid input. Please enter a valid number.");
return;
}
// Check if it's within valid range
if (newBpm <= TIME_CONSTANTS.MIN_BPM || newBpm >= TIME_CONSTANTS.MAX_BPM) {
alert(`Invalid BPM. Please enter a value between ${TIME_CONSTANTS.MIN_BPM} and ${TIME_CONSTANTS.MAX_BPM}.`);
return;
}
// Update BPM
setBpm(newBpm);
setStatus(`BPM changed to ${newBpm}`);
if (DEBUG_MODE.TOOLBAR) {
console.log(`BPM updated from ${bpm} to ${newBpm}`);
}
};
const handleTimeSignatureClick = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("Time signature clicked, current:", `${timeSignature.numerator}/${timeSignature.denominator}`);
}
const currentTimeSignatureStr = `${timeSignature.numerator}/${timeSignature.denominator}`;
const newTimeSignatureStr = prompt(`Enter new time signature (numerator/denominator):`, currentTimeSignatureStr);
// Check if user cancelled
if (newTimeSignatureStr === null) {
return;
}
// Parse and validate time signature
const newTimeSignature = parseTimeSignature(newTimeSignatureStr);
if (newTimeSignature === null) {
alert(getTimeSignatureErrorMessage());
return;
}
// Update time signature
setTimeSignature(newTimeSignature);
setStatus(`Time signature changed to ${newTimeSignature.numerator}/${newTimeSignature.denominator}`);
if (DEBUG_MODE.TOOLBAR) {
console.log(`Time signature updated from ${currentTimeSignatureStr} to ${newTimeSignature.numerator}/${newTimeSignature.denominator}`);
}
};
const handleKeySignatureChange = (newKeySignature: string) => {
if (DEBUG_MODE.TOOLBAR) {
console.log("Key signature changed from", keySignature, "to", newKeySignature);
}
setKeySignature(newKeySignature as KeySignature);
setStatus(`Key signature changed to ${newKeySignature}`);
setShowKeySignatureDropdown(false);
};
// Handle main content tool selection
const handleMainToolSelect = (tool: 'pointer' | 'pencil') => {
setActiveMainTool(tool);
KGMainContentState.instance().setActiveTool(tool);
if (DEBUG_MODE.TOOLBAR) {
console.log(`Selected main content tool: ${tool}`);
}
};
// Handle copy button click
const handleCopyClick = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("Copy button clicked");
}
const copied = handleCopyOperation();
if (copied) {
setStatus("Items copied to clipboard");
if (DEBUG_MODE.TOOLBAR) {
console.log("Items copied successfully");
}
} else {
setStatus("No items selected to copy");
if (DEBUG_MODE.TOOLBAR) {
console.log("No items were selected for copying");
}
}
};
// Handle paste button click
const handlePasteClick = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("Paste button clicked");
}
const pasted = handlePasteOperation();
if (pasted) {
setStatus("Items pasted from clipboard");
if (DEBUG_MODE.TOOLBAR) {
console.log("Items pasted successfully");
}
} else {
setStatus("Cannot paste - no valid clipboard content or context");
if (DEBUG_MODE.TOOLBAR) {
console.log("Paste operation failed or no valid context");
}
}
};
// Handle delete button click
const handleDeleteClick = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("Delete button clicked");
}
const deleted = regionDeleteManager.deleteSelectedRegions();
if (deleted) {
setStatus("Selected regions deleted");
if (DEBUG_MODE.TOOLBAR) {
console.log("Regions deleted successfully");
}
} else {
setStatus("No regions selected for deletion");
if (DEBUG_MODE.TOOLBAR) {
console.log("No regions were selected for deletion");
}
}
};
// Handle undo button click
const handleUndoClick = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("Undo button clicked");
}
if (!canUndo) {
alert("Nothing to undo");
return;
}
undo();
const description = undoDescription || "action";
setStatus(`Undid: ${description}`);
if (DEBUG_MODE.TOOLBAR) {
console.log(`Undo successful: ${description}`);
}
};
// Handle redo button click
const handleRedoClick = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("Redo button clicked");
}
if (!canRedo) {
alert("Nothing to redo");
return;
}
redo();
const description = redoDescription || "action";
setStatus(`Redid: ${description}`);
if (DEBUG_MODE.TOOLBAR) {
console.log(`Redo successful: ${description}`);
}
};
// Handle chat button click
const handleChatClick = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("Chat button clicked");
}
toggleChatBox();
setStatus("Chat toggled");
};
// Handle settings button click
const handleSettingsClick = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("Settings button clicked");
}
toggleSettings();
setStatus("Settings toggled");
};
// Handle Piano button click: open piano roll if closed, targeting active or selected region
const handlePianoButtonClick = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log('Piano button clicked');
}
// Only open if not already open
if (showPianoRoll) {
if (DEBUG_MODE.TOOLBAR) {
console.log('Piano roll already open; no action');
}
return;
}
// Prefer current active region; otherwise, first selected region
const candidateRegionId = activeRegionId || (selectedRegionIds && selectedRegionIds.length > 0 ? selectedRegionIds[0] : null);
if (!candidateRegionId) {
if (DEBUG_MODE.TOOLBAR) {
console.log('No active or selected region; piano roll will not open');
}
return;
}
setActiveRegionId(candidateRegionId);
setShowPianoRoll(true);
if (DEBUG_MODE.TOOLBAR) {
console.log(`Opening piano roll for region ${candidateRegionId}`);
}
};
return (
<>
<div className="toolbar">
<div className="toolbar-left">
<div className="logo-container">
<img src="/logo.png" alt="DAW Logo" className="logo" />
</div>
<div
className="project-name"
onClick={handleProjectNameClick}
>
{projectName}
</div>
</div>
<div className="toolbar-center">
<button title="New" onClick={handleNewProject}><FaPlus /></button>
<button title="Load" onClick={handleLoadProject}><FaFolderOpen /></button>
<button title="Save" onClick={handleSaveProject}><FaSave /></button>
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
title="Export"
onClick={() => setShowExportDropdown(!showExportDropdown)}
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
>
<FaDownload />
</button>
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}>
<KGDropdown
options={exportOptions}
value={exportOptions[0]}
onChange={handleExportProject}
label="Export"
hideButton={true}
isOpen={showExportDropdown}
onToggle={setShowExportDropdown}
className="export-dropdown"
/>
</div>
</div>
<button title="Import" onClick={handleImportProject}><FaUpload /></button>
<div className="toolbar-separator"></div>
<button title="Undo" onClick={handleUndoClick}><FaUndo /></button>
<button title="Redo" onClick={handleRedoClick}><FaRedo /></button>
<div className="toolbar-separator"></div>
<button
title="Select"
className={`tool-button ${activeMainTool === 'pointer' ? 'active' : ''}`}
onClick={() => handleMainToolSelect('pointer')}
>
<FaMousePointer />
</button>
<button
title="Pencil"
className={`tool-button ${activeMainTool === 'pencil' ? 'active' : ''}`}
onClick={() => handleMainToolSelect('pencil')}
>
<FaPencil />
</button>
<div className="toolbar-separator"></div>
<button title="Copy" onClick={handleCopyClick}><FaCopy /></button>
<button title="Paste" onClick={handlePasteClick}><FaPaste /></button>
<button title="Delete" onClick={handleDeleteClick}><FaTrash /></button>
<div className="toolbar-separator"></div>
<button title="Back to beginning" className="button-back-to-beginning" onClick={handleBackToBeginningClick}><FaStepBackward /></button>
{!isPlaying ? (
<button title="Play" className="button-play" onClick={handlePlayClick}><FaPlay /></button>
) : (
<button title="Pause" className="button-pause" onClick={handlePauseClick}><FaPause /></button>
)}
<div className="toolbar-separator"></div>
<button title="Piano" onClick={handlePianoButtonClick}><PianoIcon /></button>
{/* <button title="Record"><FaCircle className="record-btn" /></button>
<button title="Metronome">🎵</button> */}
</div>
<div className="toolbar-right">
<div className="transport-control">
<div className="transport-item">
<span className='current-time' onClick={handleCurrentTimeClick} style={{ cursor: 'pointer' }}>{currentTime}</span>
</div>
<div className="transport-item">
<span className='current-bpm' onClick={handleBpmClick} style={{ cursor: 'pointer' }}>{bpm}</span>
</div>
<div className="transport-item">
<span className='current-time-signature' onClick={handleTimeSignatureClick} style={{ cursor: 'pointer' }}>{timeSignature.numerator + "/" + timeSignature.denominator}</span>
</div>
<div className="transport-item" style={{ position: 'relative' }}>
<span
className='current-key-signature'
onClick={() => setShowKeySignatureDropdown(!showKeySignatureDropdown)}
style={{ cursor: 'pointer' }}
>
{keySignature}
</span>
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}>
<KGDropdown
options={keySignatureOptions}
value={keySignature}
onChange={handleKeySignatureChange}
label="Key Signature"
hideButton={true}
isOpen={showKeySignatureDropdown}
onToggle={setShowKeySignatureDropdown}
className="key-signature-dropdown"
/>
</div>
</div>
</div>
<button title="Settings" onClick={handleSettingsClick}><FaCog /></button>
<button title="Chat" onClick={handleChatClick}><FaComments /></button>
</div>
</div>
<FileImportModal
isVisible={showImportModal}
onClose={() => setShowImportModal(false)}
onFileImport={handleFileImport}
acceptedTypes={['.json', '.mid', '.midi']}
title="Import Project"
description="Drag and drop your project file here"
/>
</>
);
};
export default Toolbar;
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import { useProjectStore } from '../stores/projectStore';
const TrackControl: React.FC = () => {
const { addTrack } = useProjectStore();
const handleAddTrack = () => {
addTrack();
};
return (
<div className="track-control">
<button onClick={handleAddTrack}>+ Add track</button>
</div>
);
};
export default TrackControl;
+180
View File
@@ -0,0 +1,180 @@
import React, { memo, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { extractXMLFromString } from '../../util/xmlUtil';
interface ToolXMLExpanderProps {
toolName: string;
xmlContent: string;
}
const ToolXMLExpander: React.FC<ToolXMLExpanderProps> = ({ toolName, xmlContent }) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="tool-xml-expander">
<div
className="tool-xml-expander-header"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="tool-xml-expander-arrow">
{isExpanded ? '▼' : '▶'}
</span>
<span className="tool-xml-expander-title">
🔧 Tool: {toolName}
</span>
</div>
{isExpanded && (
<div className="tool-xml-expander-content">
{xmlContent}
</div>
)}
</div>
);
};
interface AssistantMessageProps {
content: string;
isStreaming?: boolean;
onAbort?: () => void;
}
// Memoized code component to prevent SyntaxHighlighter re-renders
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CodeComponent = memo(({ inline, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={vscDarkPlus}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
});
const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreaming, onAbort }) => {
// Function to process content and replace XML blocks with expanders
const processContentWithXMLExpanders = (text: string) => {
const xmlBlocks = extractXMLFromString(text);
if (xmlBlocks.length === 0) {
// No XML blocks found, return content as-is
return text;
}
let processedContent = text;
const expanders: React.ReactElement[] = [];
let expanderIndex = 0;
// Replace each XML block with a placeholder
xmlBlocks.forEach((xmlBlock) => {
const toolNameMatch = xmlBlock.match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/);
const toolName = toolNameMatch ? toolNameMatch[1] : 'unknown_tool';
const placeholder = `__XML_EXPANDER_${expanderIndex}__`;
processedContent = processedContent.replace(xmlBlock, placeholder);
expanders[expanderIndex] = (
<ToolXMLExpander
key={`xml-expander-${expanderIndex}`}
toolName={toolName}
xmlContent={xmlBlock}
/>
);
expanderIndex++;
});
// Split content by placeholders and interleave with expanders
const parts = processedContent.split(/__XML_EXPANDER_\d+__/);
const result: (string | React.ReactElement)[] = [];
for (let i = 0; i < parts.length; i++) {
if (parts[i]) {
result.push(parts[i]);
}
if (i < expanders.length) {
result.push(expanders[i]);
}
}
return result;
};
// Handle special abort link for streaming messages
const renderContent = () => {
if (isStreaming && onAbort && content.includes('click here to abort')) {
const parts = content.split('click here to abort');
return (
<span>
{parts[0]}
<button
onClick={onAbort}
className="abort-link"
>
click here to abort
</button>
{parts[1]}
</span>
);
}
const processedContent = processContentWithXMLExpanders(content);
// If we have mixed content (text + React elements), render them separately
if (Array.isArray(processedContent)) {
return (
<div>
{processedContent.map((item, index) => {
if (typeof item === 'string') {
return (
<ReactMarkdown
key={`text-${index}`}
remarkPlugins={[remarkGfm]}
components={{
code: CodeComponent,
}}
>
{item}
</ReactMarkdown>
);
} else {
return item; // React element (expander)
}
})}
</div>
);
}
// Plain text content, render with markdown
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code: CodeComponent,
}}
>
{processedContent as string}
</ReactMarkdown>
);
};
return (
<div className="message-container message-assistant">
<div className="message-content">
{renderContent()}
</div>
</div>
);
};
export default memo(AssistantMessage);
+19
View File
@@ -0,0 +1,19 @@
import React, { memo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface UserMessageProps {
content: string;
}
const UserMessage: React.FC<UserMessageProps> = ({ content }) => {
return (
<div className="message-container message-user">
<div className="message-content">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</div>
);
};
export default memo(UserMessage);
+2
View File
@@ -0,0 +1,2 @@
export { default as UserMessage } from './UserMessage';
export { default as AssistantMessage } from './AssistantMessage';
+130
View File
@@ -0,0 +1,130 @@
import React, { useCallback, useState } from 'react';
import { FaTimes } from 'react-icons/fa';
interface FileImportModalProps {
isVisible: boolean;
onClose: () => void;
onFileImport: (file: File) => void;
acceptedTypes?: string[];
title?: string;
description?: string;
}
const FileImportModal: React.FC<FileImportModalProps> = ({
isVisible,
onClose,
onFileImport,
acceptedTypes = ['.json'],
title = 'Import Project',
description = 'Drag and drop your project file here'
}) => {
const [isDragOver, setIsDragOver] = useState(false);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set drag over to false if we're leaving the drop zone entirely
if (e.currentTarget === e.target) {
setIsDragOver(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
const file = files[0];
// Check if file type is accepted
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
if (acceptedTypes.includes(fileExtension)) {
onFileImport(file);
onClose();
} else {
alert(`Invalid file type. Please select a file with one of these extensions: ${acceptedTypes.join(', ')}`);
}
}
}, [acceptedTypes, onFileImport, onClose]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
onFileImport(files[0]);
onClose();
}
}, [onFileImport, onClose]);
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
// Only close if clicking on the overlay itself, not the modal content
if (e.target === e.currentTarget) {
onClose();
}
}, [onClose]);
if (!isVisible) {
return null;
}
return (
<div className="file-import-overlay" onClick={handleOverlayClick}>
<div className="file-import-modal">
<div className="file-import-header">
<h3 className="file-import-title">{title}</h3>
<button
className="file-import-close-btn"
onClick={onClose}
aria-label="Close import modal"
>
<FaTimes />
</button>
</div>
<div
className={`file-import-drop-zone ${isDragOver ? 'drag-over' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="file-import-drop-content">
<div className="file-import-icon">📁</div>
<p className="file-import-description">{description}</p>
<p className="file-import-formats">
Supported formats: {acceptedTypes.join(', ')}
</p>
<div className="file-import-divider">
<span>or</span>
</div>
<label className="file-import-browse-btn">
Browse Files
<input
type="file"
accept={acceptedTypes.join(',')}
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
</label>
</div>
</div>
</div>
</div>
);
};
export default FileImportModal;
+107
View File
@@ -0,0 +1,107 @@
import React, { useState, useRef, useEffect } from 'react';
import { FaCaretDown } from 'react-icons/fa';
type DropdownOption = string | { label: string; value: string };
interface KGDropdownProps {
options: DropdownOption[];
value: string;
onChange: (value: string) => void;
label: string;
className?: string;
buttonClassName?: string;
optionClassName?: string;
showValueAsLabel?: boolean;
hideButton?: boolean;
isOpen?: boolean;
onToggle?: (open: boolean) => void;
}
const KGDropdown: React.FC<KGDropdownProps> = ({
options,
value,
onChange,
label,
className = '',
buttonClassName = '',
optionClassName = '',
showValueAsLabel = false,
hideButton = false,
isOpen: externalIsOpen,
onToggle
}) => {
const [internalIsOpen, setInternalIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Use external state if provided, otherwise use internal state
const isOpen = externalIsOpen !== undefined ? externalIsOpen : internalIsOpen;
const setIsOpen = onToggle || setInternalIsOpen;
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
isOpen &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Handle option selection
const handleSelect = (option: DropdownOption) => {
const value = typeof option === 'string' ? option : option.value;
onChange(value);
setIsOpen(false);
};
const resolveLabel = (option: DropdownOption) => (typeof option === 'string' ? option : option.label);
const resolveValue = (option: DropdownOption) => (typeof option === 'string' ? option : option.value);
const selectedLabel = (() => {
if (!showValueAsLabel) return label;
// Try to find the label for the current value
const match = options.find(opt => resolveValue(opt) === value);
return match ? resolveLabel(match) : value;
})();
const buttonText = showValueAsLabel ? selectedLabel : label;
return (
<div className={`quant-dropdown-container ${className}`} ref={dropdownRef}>
{!hideButton && (
<button
className={`quant-button ${buttonClassName}`}
onClick={() => setIsOpen(!isOpen)}
>
{buttonText} <FaCaretDown />
</button>
)}
{isOpen && (
<div className="quant-dropdown">
{options.map((option) => {
const optionValue = resolveValue(option);
return (
<div
key={optionValue}
className={`quant-option ${value === optionValue ? 'active' : ''} ${optionClassName}`}
onClick={() => handleSelect(option)}
>
{resolveLabel(option)}
</div>
);
})}
</div>
)}
</div>
);
};
export default KGDropdown;
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
interface LoadingOverlayProps {
visible: boolean;
message?: string;
}
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ visible, message = 'Loading ...' }) => {
if (!visible) return null;
return (
<div className="global-loading-overlay" role="status" aria-live="polite" aria-busy={true}>
<div className="global-loading-content">
<div className="global-loading-spinner" />
<div className="global-loading-text">{message}</div>
</div>
</div>
);
};
export default LoadingOverlay;
+82
View File
@@ -0,0 +1,82 @@
import React from 'react';
import { KGCore } from '../../core/KGCore';
import { useProjectStore } from '../../stores/projectStore';
interface PlayheadProps {
/** Context where the playhead is being rendered */
context: 'main-grid' | 'piano-roll';
/** For piano roll context, the region start beat offset */
regionStartBeat?: number;
}
const Playhead: React.FC<PlayheadProps> = ({ context, regionStartBeat = 0 }) => {
const { timeSignature, playheadPosition } = useProjectStore();
// Calculate the pixel position based on context
const getPixelPosition = (): number => {
if (context === 'main-grid') {
// In main grid, convert beats to bars, then bars to pixels
const beatsPerBar = timeSignature.numerator;
const barPosition = playheadPosition / beatsPerBar;
// Get bar width from CSS variable
const barWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
) || 40;
return barPosition * barWidth;
} else {
// In piano roll, use beat-based positioning
// Get beat width from CSS variable
const beatWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
) || 40;
return playheadPosition * beatWidth;
}
};
const pixelPosition = getPixelPosition();
// Don't render if position is negative (before region start in piano roll)
if (pixelPosition < 0) {
return null;
}
const playheadStyle: React.CSSProperties = {
position: 'absolute',
left: `${pixelPosition}px`,
top: 0,
bottom: 0,
width: '2px',
backgroundColor: '#4ECDC4', // Blue-green color similar to the reference image
zIndex: 1000,
pointerEvents: 'none', // Allow clicks to pass through
boxShadow: '0 0 4px rgba(78, 205, 196, 0.5)', // Subtle glow effect
};
// Triangle indicator style (only for main-grid context)
const triangleStyle: React.CSSProperties = {
position: 'absolute',
left: `${pixelPosition - 5}px`, // Center the triangle on the playhead line
top: '-2px', // Position slightly above the top
width: 0,
height: 0,
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderTop: '8px solid #4ECDC4',
zIndex: 1001,
pointerEvents: 'none',
};
return (
<>
<div className="playhead" style={playheadStyle} />
{context === 'main-grid' && (
<div className="playhead-triangle" style={triangleStyle} />
)}
</>
);
};
export default Playhead;
+46
View File
@@ -0,0 +1,46 @@
import React from 'react';
type PianoIconProps = React.SVGProps<SVGSVGElement>;
/**
* PianoIcon keyboard-style icon in a Font Awesome-like solid style
* - Uses currentColor
* - Scales with font size (1em)
* - viewBox matches FA dimensions
*/
const PianoIcon: React.FC<PianoIconProps> = (props) => (
<svg
viewBox="0 0 576 512"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
focusable="false"
{...props}
>
{/*
Build a frame with even-odd fill, then add inner black keys as filled bars.
Outer frame: 48,64 → 528x320
Inner hole: 96,112 → 384x224
Top slot: 112,128 → 352x32
Black keys: four bars centered
*/}
<path
fillRule="evenodd"
clipRule="evenodd"
d="
M48 64h480v320H48V64z
M96 112h384v224H96V112z
M112 128h352v32H112v-32z
M176 160h24v136h-24V160z
M240 160h24v136h-24V160z
M304 160h24v136h-24V160z
M368 160h24v136h-24V160z
"
/>
</svg>
);
export default PianoIcon;
+4
View File
@@ -0,0 +1,4 @@
export { default as KGDropdown } from './KGDropdown';
export { default as Playhead } from './Playhead';
export { default as FileImportModal } from './FileImportModal';
export { default as LoadingOverlay } from './LoadingOverlay';
+42
View File
@@ -0,0 +1,42 @@
// selectable interface
export interface Selectable {
getId(): string;
select(): void;
deselect(): void;
isSelected(): boolean;
getRootType(): string;
getCurrentType(): string;
}
// Define a Region interface for UI representation
export interface RegionUI {
id: string;
trackId: string;
trackIndex: number;
barNumber: number;
length: number;
name: string;
}
// Define resize action types
export type ResizeAction = 'none' | 'start' | 'end';
// Define region resize state
export interface RegionResizeState {
regionId: string;
isResizing: boolean;
resizeAction: ResizeAction;
initialX: number;
initialBarNumber: number;
initialLength: number;
}
// Define region drag state
export interface RegionDragState {
regionId: string;
isDragging: boolean;
initialX: number;
initialY: number;
initialBarNumber: number;
initialTrackIndex: number;
}
+164
View File
@@ -0,0 +1,164 @@
import React, { useState, useEffect } from 'react';
import type { MutableRefObject } from 'react';
import { Playhead } from '../common';
import SelectionBox from './SelectionBox';
import { isModifierKeyPressed } from '../../util/osUtil';
interface PianoGridProps {
gridRef: MutableRefObject<HTMLDivElement | null>;
children: React.ReactNode;
onDoubleClick: (e: React.MouseEvent) => void;
onClick: (e: React.MouseEvent) => void;
onMouseDown: (e: React.MouseEvent) => void;
isBoxSelecting: boolean;
selectionBox: {
startX: number;
startY: number;
endX: number;
endY: number;
};
regionStartBeat?: number;
}
interface CursorPosition {
beat: number;
pitch: number;
x: number;
y: number;
}
const PianoGrid: React.FC<PianoGridProps> = ({
gridRef,
children,
onDoubleClick,
onClick,
onMouseDown,
isBoxSelecting,
selectionBox,
regionStartBeat = 0
}) => {
const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(null);
const [isModifierPressed, setIsModifierPressed] = useState(false);
// Track modifier key state
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Skip if user is typing in an input field (including ChatBox)
const target = e.target as HTMLElement;
if (target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true' ||
target.hasAttribute('data-chatbox-input') ||
target.closest('.chatbox-input')
)) {
return;
}
if (isModifierKeyPressed(e)) {
setIsModifierPressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
// Skip if user is typing in an input field (including ChatBox)
const target = e.target as HTMLElement;
if (target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true' ||
target.hasAttribute('data-chatbox-input') ||
target.closest('.chatbox-input')
)) {
return;
}
if (!isModifierKeyPressed(e)) {
setIsModifierPressed(false);
}
};
// Add global event listeners
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
// Cleanup listeners on unmount
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
const handleMouseMove = (e: React.MouseEvent) => {
if (!gridRef.current) return;
const rect = gridRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Get CSS variables
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
// Calculate beat and pitch
const beat = Math.floor(x / beatWidth);
const pitch = 107 - Math.floor(y / noteHeight); // B7 = 107, reverse for display
// Only update if position changed and cursor is within valid range
if (beat >= 0 && pitch >= 0 && pitch <= 127) {
setCursorPosition({ beat, pitch, x, y });
}
};
const handleMouseLeave = () => {
setCursorPosition(null);
};
return (
<div className="piano-grid-container">
<div
className={`piano-grid ${isModifierPressed ? 'pencil-cursor' : ''}`}
ref={gridRef}
onDoubleClick={onDoubleClick}
onClick={onClick}
onMouseDown={(e) => onMouseDown(e)}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
{/* Cursor Highlights */}
{cursorPosition && (
<>
{/* Horizontal pitch row highlight */}
<div
className="piano-grid-pitch-highlight"
style={{
top: Math.floor(cursorPosition.y / (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20)) * (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20),
height: parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20
}}
/>
{/* Vertical beat column highlight */}
<div
className="piano-grid-beat-highlight"
style={{
left: cursorPosition.beat * (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40),
width: parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40
}}
/>
</>
)}
{/* Playhead */}
<Playhead context="piano-roll" regionStartBeat={regionStartBeat} />
{children}
<SelectionBox
isSelecting={isBoxSelecting}
selectionBox={selectionBox}
/>
</div>
</div>
);
};
export default PianoGrid;
@@ -0,0 +1,180 @@
import React, { useRef, useEffect, useCallback } from 'react';
import { KGCore } from '../../core/KGCore';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { useProjectStore } from '../../stores/projectStore';
import { DEBUG_MODE } from '../../constants';
interface PianoGridHeaderProps {
maxBars: number;
timeSignature?: { numerator: number; denominator: number };
}
const PianoGridHeader: React.FC<PianoGridHeaderProps> = ({
maxBars,
timeSignature = { numerator: 4, denominator: 4 } // Default to 4/4 if not provided
}) => {
// Get store access for playhead position updates
const { setPlayheadPosition } = useProjectStore();
// Refs for drag functionality
const isDraggingRef = useRef(false);
const headerElementRef = useRef<HTMLDivElement | null>(null);
// Utility function to calculate snapped beat position (based on useNoteOperations.ts)
const getSnappedBeatPosition = (beatPosition: number): number => {
const currentSnap = KGPianoRollState.instance().getCurrentSnap();
// If no snapping is enabled, return the original position
if (currentSnap === 'NO SNAP') {
return beatPosition;
}
// Parse the snap value (e.g., "1/4", "1/8", "1/16", "1/32")
const denominator = parseInt(currentSnap.split('/')[1]);
if (isNaN(denominator)) {
return beatPosition; // Fallback to no snapping if invalid
}
// Calculate the snap step in beats
// snapStep should ALWAYS be 4 / denominator regardless of time signature
const snapStep = 4 / denominator;
// Use round snapping for playhead positioning
const snappedPosition = Math.round(beatPosition / snapStep) * snapStep;
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Piano Grid Header Snapping: ${beatPosition} -> ${snappedPosition} (snap: ${currentSnap}, step: ${snapStep})`);
}
return snappedPosition;
};
// Utility function to calculate playhead position from mouse coordinates
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
if (!headerElementRef.current) return null;
const rect = headerElementRef.current.getBoundingClientRect();
const relativeX = clientX - rect.left;
// Account for the piano keys width offset
const pianoKeysWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 60;
const adjustedX = relativeX - pianoKeysWidth;
// If the click is in the piano keys area (left side), ignore it
if (adjustedX < 0) {
return null;
}
// Calculate the width of each beat
const beatWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
) || 40;
// Calculate the raw beat position using the adjusted X position
const rawBeatPosition = adjustedX / beatWidth;
// Apply quantization if enabled
return getSnappedBeatPosition(rawBeatPosition);
}, []);
// Handle mouse down to start dragging
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
// Only handle left mouse button
if (e.button !== 0) return;
isDraggingRef.current = true;
// Calculate and set initial playhead position
const newPosition = calculatePlayheadFromMouse(e.clientX);
if (newPosition !== null) {
setPlayheadPosition(newPosition);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Piano Grid Header drag started - Initial position: ${newPosition}`);
}
}
// Prevent text selection during drag
e.preventDefault();
};
// Handle click (when not dragging) - this will be the fallback for simple clicks
const handlePianoGridHeaderClick = (e: React.MouseEvent<HTMLDivElement>) => {
// If we were dragging, don't process as a click
if (isDraggingRef.current) {
return;
}
const newPosition = calculatePlayheadFromMouse(e.clientX);
if (newPosition !== null) {
const core = KGCore.instance();
const currentPlayheadPosition = core.getPlayheadPosition();
const beatsPerBar = timeSignature.numerator;
const currentBarNumber = Math.floor(currentPlayheadPosition / beatsPerBar) + 1; // 1-indexed
const destinationBarNumber = Math.floor(newPosition / beatsPerBar) + 1; // 1-indexed
// Debug logging
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Piano Grid Header click - Position: ${newPosition}`);
console.log(`Current bar: ${currentBarNumber} (beat ${currentPlayheadPosition})`);
console.log(`Destination bar: ${destinationBarNumber} (beat ${newPosition})`);
}
setPlayheadPosition(newPosition);
}
};
// Global mouse move and mouse up handlers
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDraggingRef.current) return;
const newPosition = calculatePlayheadFromMouse(e.clientX);
if (newPosition !== null) {
setPlayheadPosition(newPosition);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Piano Grid Header drag - Position: ${newPosition}`);
}
}
};
const handleMouseUp = () => {
if (isDraggingRef.current) {
isDraggingRef.current = false;
if (DEBUG_MODE.PIANO_ROLL) {
console.log('Piano Grid Header drag ended');
}
}
};
// Add global event listeners for drag functionality
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// Cleanup event listeners on unmount
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [calculatePlayheadFromMouse, setPlayheadPosition]);
return (
<div
className="piano-grid-header"
ref={headerElementRef}
onMouseDown={handleMouseDown}
onClick={handlePianoGridHeaderClick}
>
{Array.from({ length: maxBars }, (_, i) => (
<div key={i} className="piano-bar-number">{i + 1}</div>
))}
</div>
);
};
export default PianoGridHeader;
+178
View File
@@ -0,0 +1,178 @@
import React, { useState, useRef } from 'react';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { noteNameToPitch, midiPercussionKeyMap, pitchToNoteNameString } from '../../util/midiUtil';
import { useProjectStore } from '../../stores/projectStore';
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
interface PianoKeysProps {
activeRegion: KGMidiRegion | null;
}
const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
const [pressedKeys, setPressedKeys] = useState<Set<string>>(new Set());
const pressedKeysRef = useRef<Set<string>>(new Set());
const { tracks } = useProjectStore();
// Check if current active region belongs to a drum track
const isDrumTrack = React.useMemo(() => {
if (!activeRegion) return false;
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
return track instanceof KGMidiTrack && track.getInstrument() === 'standard';
}, [activeRegion, tracks]);
// Handle mouse down on piano key
const handleKeyMouseDown = (keyId: string) => {
// Prevent double pressing the same key
if (pressedKeysRef.current.has(keyId)) {
return;
}
// Get the track ID from active region
if (!activeRegion) {
console.warn('No active region, cannot play piano key');
return;
}
const trackId = activeRegion.getTrackId();
try {
// Convert note name to pitch (keyId is always a note name like "C4")
const pitch = noteNameToPitch(keyId);
// Get audio interface and start playing the note
const audioInterface = KGAudioInterface.instance();
if (audioInterface.getIsInitialized()) {
// Try to start audio context if not started yet
if (!audioInterface.getIsAudioContextStarted()) {
audioInterface.startAudioContext().catch(() => {
// Silently fail if still not allowed - browser policy
});
}
// Trigger note attack if audio context is ready
if (audioInterface.getIsAudioContextStarted()) {
audioInterface.triggerNoteAttack(trackId, pitch, 127);
// Update pressed keys state
const newPressedKeys = new Set(pressedKeysRef.current);
newPressedKeys.add(keyId);
pressedKeysRef.current = newPressedKeys;
setPressedKeys(newPressedKeys);
console.log(`Started playing piano key: ${keyId} (pitch ${pitch})`);
}
}
} catch (error) {
console.error(`Error playing piano key ${keyId}:`, error);
}
};
// Handle mouse up on piano key
const handleKeyMouseUp = (keyId: string) => {
// Only release if key was actually pressed
if (!pressedKeysRef.current.has(keyId)) {
return;
}
// Get the track ID from active region
if (!activeRegion) {
return;
}
const trackId = activeRegion.getTrackId();
try {
// Convert note name to pitch (keyId is always a note name like "C4")
const pitch = noteNameToPitch(keyId);
// Get audio interface and stop playing the note
const audioInterface = KGAudioInterface.instance();
if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
audioInterface.releaseNote(trackId, pitch);
// Update pressed keys state
const newPressedKeys = new Set(pressedKeysRef.current);
newPressedKeys.delete(keyId);
pressedKeysRef.current = newPressedKeys;
setPressedKeys(newPressedKeys);
console.log(`Stopped playing piano key: ${keyId} (pitch ${pitch})`);
}
} catch (error) {
console.error(`Error releasing piano key ${keyId}:`, error);
}
};
// Handle mouse leave to ensure keys are released
const handleKeyMouseLeave = (keyId: string) => {
handleKeyMouseUp(keyId);
};
// Generate piano keys (C0 to C7)
const generatePianoKeys = () => {
const octaves = [];
const notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
// Generate octaves from C0 to C7
for (let octave = 0; octave <= 7; octave++) {
const octaveKeys = [];
// Add keys in reverse order (B to C) for each octave
for (let i = notes.length - 1; i >= 0; i--) {
const note = notes[i];
const isSharp = note.includes('#');
const keyId = `${note}${octave}`;
const isPressed = pressedKeys.has(keyId);
const keyClass = `piano-key ${isSharp ? 'sharp' : 'natural'} ${isPressed ? 'pressed' : ''}`;
const isC = note === 'C';
// For drum tracks, show drum labels when available
let labelContent = null;
if (isDrumTrack) {
const pitch = noteNameToPitch(keyId);
const drumInfo = midiPercussionKeyMap[pitch];
if (drumInfo) {
labelContent = <span className="key-label">{drumInfo.shortName}</span>;
}
} else if (isC) {
labelContent = <span className="key-label">C{octave}</span>;
}
octaveKeys.push(
<div
key={keyId}
className={keyClass}
data-note={keyId}
onMouseDown={() => handleKeyMouseDown(keyId)}
onMouseUp={() => handleKeyMouseUp(keyId)}
onMouseLeave={() => handleKeyMouseLeave(keyId)}
style={{
cursor: 'pointer',
userSelect: 'none' // Prevent text selection
}}
>
{labelContent}
</div>
);
}
// Add each octave to the beginning of the array
octaves.unshift(
<div key={`octave-${octave}`} className="piano-octave">
{octaveKeys}
</div>
);
}
return octaves;
};
return (
<div className="piano-keys-container">
{generatePianoKeys()}
</div>
);
};
export default PianoKeys;
+268
View File
@@ -0,0 +1,268 @@
import React, { useState, useRef, useEffect } from 'react';
import { PIANO_ROLL_CONSTANTS, DEBUG_MODE } from '../../constants';
import { useProjectStore } from '../../stores/projectStore';
interface PianoNoteProps {
id: string;
index: number;
left: number;
top: number;
width: number;
height: number;
onResizeStart?: (noteId: string, resizeEdge: 'start' | 'end', initialX: number) => void;
onResize?: (noteId: string, resizeEdge: 'start' | 'end', deltaX: number) => void;
onResizeEnd?: (noteId: string, resizeEdge: 'start' | 'end') => void;
onDragStart?: (noteId: string, initialX: number, initialY: number) => void;
onDrag?: (noteId: string, deltaX: number, deltaY: number) => void;
onDragEnd?: (noteId: string) => void;
onClick?: (noteId: string, e: React.MouseEvent) => void;
}
const PianoNote: React.FC<PianoNoteProps> = ({
id,
index,
left,
top,
width,
height,
onResizeStart,
onResize,
onResizeEnd,
onDragStart,
onDrag,
onDragEnd,
onClick
}) => {
// Get selection state from store
const { selectedNoteIds } = useProjectStore();
const isSelected = selectedNoteIds.includes(id);
const [cursor, setCursor] = useState<string>('pointer');
const [resizeEdge, setResizeEdge] = useState<'none' | 'start' | 'end'>('none');
const [isResizing, setIsResizing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
// Use refs to track states for immediate access
const isResizingRef = useRef<boolean>(false);
const isDraggingRef = useRef<boolean>(false);
const initialMousePosRef = useRef<{x: number, y: number}>({x: 0, y: 0});
const hasMovedRef = useRef<boolean>(false);
// Handle mouse movement to detect edge proximity
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
// Skip if already resizing or dragging
if (isResizingRef.current || isDraggingRef.current) return;
const noteElement = e.currentTarget;
const rect = noteElement.getBoundingClientRect();
// Calculate distance from left and right edges
const distanceFromLeft = e.clientX - rect.left;
const distanceFromRight = rect.right - e.clientX;
// Use the edge threshold constant from constants file
const edgeThreshold = PIANO_ROLL_CONSTANTS.NOTE_EDGE_OFFSET;
if (distanceFromLeft <= edgeThreshold) {
// Near left edge - resize from start
setCursor('ew-resize');
setResizeEdge('start');
} else if (distanceFromRight <= edgeThreshold) {
// Near right edge - resize from end
setCursor('ew-resize');
setResizeEdge('end');
} else {
// Middle area - move
setCursor('grab');
setResizeEdge('none');
}
};
// Reset cursor when mouse leaves
const handleMouseLeave = () => {
if (!isResizingRef.current && !isDraggingRef.current) {
setCursor('default');
setResizeEdge('none');
}
};
// Handle mouse down for resize or drag
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
// Prevent text selection during resize/drag
e.preventDefault();
// Reset movement tracking
hasMovedRef.current = false;
// Store initial mouse position
initialMousePosRef.current = { x: e.clientX, y: e.clientY };
if (resizeEdge !== 'none') {
// Start resizing
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`NOTE RESIZE START: noteId=${id}, edge=${resizeEdge}`);
}
setIsResizing(true);
isResizingRef.current = true;
// Call the onResizeStart callback if provided
if (onResizeStart && (resizeEdge === 'start' || resizeEdge === 'end')) {
onResizeStart(id, resizeEdge, e.clientX);
}
} else {
// Start dragging
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`NOTE DRAG START: noteId=${id}`);
}
setIsDragging(true);
isDraggingRef.current = true;
// Change cursor to grabbing during drag
setCursor('grabbing');
// Call the onDragStart callback if provided
if (onDragStart) {
onDragStart(id, e.clientX, e.clientY);
}
}
// Add global event listeners for mouse move and up
document.addEventListener('mousemove', handleGlobalMouseMove);
document.addEventListener('mouseup', handleGlobalMouseUp);
};
// Handle global mouse move for resize or drag
const handleGlobalMouseMove = (e: MouseEvent) => {
// Set the hasMovedRef to true as soon as there's movement
hasMovedRef.current = true;
if (isResizingRef.current) {
// Handle resize
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`NOTE RESIZE MOVE: noteId=${id}, edge=${resizeEdge}`);
}
// Calculate delta from initial position
const deltaX = e.clientX - initialMousePosRef.current.x;
// Call the onResize callback if provided
if (onResize && (resizeEdge === 'start' || resizeEdge === 'end')) {
onResize(id, resizeEdge, deltaX);
}
} else if (isDraggingRef.current) {
// Handle drag
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`NOTE DRAG MOVE: noteId=${id}`);
}
// Calculate delta from initial position
const deltaX = e.clientX - initialMousePosRef.current.x;
const deltaY = e.clientY - initialMousePosRef.current.y;
// Call the onDrag callback if provided
if (onDrag) {
onDrag(id, deltaX, deltaY);
}
}
};
// Handle global mouse up to end resize or drag
const handleGlobalMouseUp = (e: MouseEvent) => {
if (isResizingRef.current) {
// End resizing
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`NOTE RESIZE END: noteId=${id}`);
}
setIsResizing(false);
isResizingRef.current = false;
// Call the onResizeEnd callback if provided
if (onResizeEnd && (resizeEdge === 'start' || resizeEdge === 'end')) {
onResizeEnd(id, resizeEdge);
}
} else if (isDraggingRef.current) {
// End dragging
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`NOTE DRAG END: noteId=${id}`);
}
setIsDragging(false);
isDraggingRef.current = false;
// Reset cursor after drag
setCursor('grab');
// Call the onDragEnd callback if provided
if (onDragEnd) {
onDragEnd(id);
}
// If there was no movement, treat it as a click
if (!hasMovedRef.current && onClick) {
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`NOTE CLICKED: noteId=${id}`);
}
// We can't pass the original event here since it's a MouseEvent, not a React.MouseEvent
// But we can create a synthetic event with the current mouse position
const clickEvent = {
clientX: e.clientX,
clientY: e.clientY,
target: e.target,
preventDefault: () => {},
stopPropagation: () => {},
shiftKey: e.shiftKey // Pass the shift key state
} as unknown as React.MouseEvent;
onClick(id, clickEvent);
}
}
// Remove global event listeners
document.removeEventListener('mousemove', handleGlobalMouseMove);
document.removeEventListener('mouseup', handleGlobalMouseUp);
};
// Clean up event listeners on unmount
useEffect(() => {
return () => {
document.removeEventListener('mousemove', handleGlobalMouseMove);
document.removeEventListener('mouseup', handleGlobalMouseUp);
};
}, []);
// Keep the refs in sync with the states
useEffect(() => {
isResizingRef.current = isResizing;
}, [isResizing]);
useEffect(() => {
isDraggingRef.current = isDragging;
}, [isDragging]);
return (
<div
className={`piano-note ${isDragging ? 'dragging' : ''} ${isResizing ? 'resizing' : ''} ${isSelected ? 'selected' : ''}`}
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
cursor: cursor
}}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onMouseDown={handleMouseDown}
id={id}
data-note-index={index}
data-resize-edge={resizeEdge}
data-is-resizing={isResizing}
data-is-dragging={isDragging}
data-is-selected={isSelected}
/>
);
};
export default PianoNote;
+793
View File
@@ -0,0 +1,793 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import type { MouseEvent } from 'react';
import { useProjectStore } from '../../stores/projectStore';
import { FaGripLines } from 'react-icons/fa';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { DEBUG_MODE, PIANO_ROLL_CONSTANTS } from '../../constants';
import PianoRollHeader from './PianoRollHeader';
import PianoRollToolbar from './PianoRollToolbar';
import PianoRollContent from './PianoRollContent';
import { KGCore } from '../../core/KGCore';
import { KGMidiNote } from '../../core/midi/KGMidiNote';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { ConfigManager } from '../../core/config/ConfigManager';
import { beatsToBar } from '../../util/midiUtil';
import { UpdateRegionCommand } from '../../core/commands';
interface PianoRollProps {
onClose: () => void;
regionId: string | null;
initialPosition?: { x: number; y: number };
initialSize?: { width: number; height: number };
}
const PianoRoll: React.FC<PianoRollProps> = ({
onClose,
regionId,
initialPosition,
initialSize
}) => {
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection } = useProjectStore();
// Tool state for piano roll
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
// Quantization state
const [quantPosition, setQuantPosition] = useState<string>('1/8');
const [quantLength, setQuantLength] = useState<string>('1/8');
// Snapping state
const [snapping, setSnapping] = useState<string>('NO SNAP');
// Piano roll state with temporary initial values
const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 });
// Blink effect state for toolbar button feedback
const [blinkButton, setBlinkButton] = useState<string | null>(null);
const [size, setSize] = useState(initialSize || { width: 800, height: PIANO_ROLL_CONSTANTS.PIANO_ROLL_HEIGHT });
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [activeRegion, setActiveRegion] = useState<KGMidiRegion | null>(null);
const pianoRollRef = useRef<HTMLDivElement>(null);
const pianoRollContentRef = useRef<HTMLDivElement>(null);
const pianoGridRef = useRef<HTMLDivElement>(null);
const wasDraggingRef = useRef<boolean>(false);
// Ref for storing the setNoteUpdateCounter function
const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
// Ref for storing the deleteSelectedNotes function
const deleteSelectedNotesRef = useRef<(() => boolean) | null>(null);
// Calculate initial position and size once on mount
useEffect(() => {
// Skip if initialPosition or initialSize were provided as props
if (!initialPosition || !initialSize) {
// Calculate initial position based on window dimensions
const calculateInitialPosition = () => {
// Dynamically get heights from CSS computed styles
const statusBarElement = document.querySelector('.status-bar');
const trackControlElement = document.querySelector('.track-control');
// Get actual heights from DOM elements, or use fallback values if elements don't exist yet
const statusBarHeight = statusBarElement ? statusBarElement.clientHeight : 30;
const trackControlHeight = trackControlElement ? trackControlElement.clientHeight : 30;
const pianoRollHeight = PIANO_ROLL_CONSTANTS.PIANO_ROLL_HEIGHT;
// Compute left offset when instrument selection panel is open
const rootStyles = getComputedStyle(document.documentElement);
const instrumentPanelWidthStr = rootStyles.getPropertyValue('--instrument-selection-width') || '300px';
const instrumentPanelWidth = parseInt(instrumentPanelWidthStr, 10) || 300;
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Positioning piano roll with heights - statusBar: ${statusBarHeight}px, trackControl: ${trackControlHeight}px, pianoRoll: ${pianoRollHeight}px`);
}
return {
x: showInstrumentSelection ? instrumentPanelWidth : 0,
y: window.innerHeight - statusBarHeight - trackControlHeight - pianoRollHeight
};
};
const calculateInitialSize = () => {
const rootStyles = getComputedStyle(document.documentElement);
const chatBoxWidthStr = rootStyles.getPropertyValue('--chat-box-width') || '350px';
const instrumentPanelWidthStr = rootStyles.getPropertyValue('--instrument-selection-width') || '300px';
const chatBoxWidth = parseInt(chatBoxWidthStr, 10) || 350;
const instrumentPanelWidth = parseInt(instrumentPanelWidthStr, 10) || 300;
let availableWidth = window.innerWidth;
if (showChatBox) availableWidth -= chatBoxWidth;
if (showInstrumentSelection) availableWidth -= instrumentPanelWidth;
// Ensure a sensible minimum starting width
const clampedWidth = Math.max(400, availableWidth);
return {
width: clampedWidth,
height: PIANO_ROLL_CONSTANTS.PIANO_ROLL_HEIGHT
};
};
// Set position and size only if not provided as props
if (!initialPosition) {
setPosition(calculateInitialPosition());
}
if (!initialSize) {
setSize(calculateInitialSize());
}
}
// Intentionally run once on mount to capture layout at open time
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty dependency array means this runs once on mount
// Find and set the active region when regionId changes
useEffect(() => {
if (!regionId) {
setActiveRegion(null);
return;
}
// Find the region in the tracks
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === regionId);
if (region && region instanceof KGMidiRegion) {
setActiveRegion(region);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Active region set in PianoRoll: ${region.getId()}`);
console.log(`Region details: name=${region.getName()}, trackId=${region.getTrackId()}, trackIndex=${region.getTrackIndex()}`);
}
break;
}
}
}, [regionId, tracks]);
// Sync local state with KGPianoRollState on mount
useEffect(() => {
const pianoRollState = KGPianoRollState.instance();
// Sync snapping state
const currentSnap = pianoRollState.getCurrentSnap();
setSnapping(currentSnap);
// Sync tool state
const currentTool = pianoRollState.getActiveTool() as 'pointer' | 'pencil';
setActiveTool(currentTool);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Synced piano roll state on mount - snap: ${currentSnap}, tool: ${currentTool}`);
}
}, []); // Empty dependency array means this runs once on mount
// Add keyboard event listener for Escape
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Close on Escape key
if (event.key === 'Escape') {
if (DEBUG_MODE.PIANO_ROLL) {
console.log('Closing piano roll with ESC key');
}
onClose();
}
};
// Add event listener
window.addEventListener('keydown', handleKeyDown);
// Remove event listener on cleanup
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
// Handle mouse events for dragging and resizing
const handleMouseDown = (e: React.MouseEvent, action: 'drag' | 'resize') => {
if (action === 'drag') {
setIsDragging(true);
wasDraggingRef.current = false; // Reset the dragging flag
if (pianoRollRef.current) {
const rect = pianoRollRef.current.getBoundingClientRect();
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top
});
}
} else if (action === 'resize') {
setIsResizing(true);
e.preventDefault();
}
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
// Set the flag to true as soon as any movement happens
wasDraggingRef.current = true;
setPosition({
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y
});
} else if (isResizing) {
setSize({
width: Math.max(400, e.clientX - position.x),
height: Math.max(300, e.clientY - position.y)
});
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
// We keep wasDraggingRef.current as is - it will be used in handleTitleClick
// and reset on the next mousedown
};
if (isDragging || isResizing) {
document.addEventListener('mousemove', handleMouseMove as unknown as EventListener);
document.addEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove as unknown as EventListener);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, isResizing, dragOffset, position]);
// Handle title click to rename the region
const handleTitleClick = () => {
// If we were just dragging, don't show the rename dialog
if (wasDraggingRef.current) {
if (DEBUG_MODE.PIANO_ROLL) {
console.log("Skipping rename dialog because the window was just dragged");
}
return;
}
if (!activeRegion) return;
// Show a prompt to get the new name
const newName = window.prompt("Enter a new name for the region:", activeRegion.getName());
// If the user clicked Cancel or entered an empty string, do nothing
if (!newName || newName.trim() === '' || newName === activeRegion.getName()) return;
// Use command pattern to update the region name with undo support
try {
const command = new UpdateRegionCommand(activeRegion.getId(), { name: newName.trim() });
KGCore.instance().executeCommand(command);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Executed UpdateRegionCommand: renamed region ${activeRegion.getId()} to "${newName}" using command pattern`);
}
// Update the store to trigger re-render
const updatedTracks = [...tracks];
useProjectStore.setState({ tracks: updatedTracks });
} catch (error) {
console.error('Error renaming region:', error);
// Optionally show user-friendly error message
alert('Failed to rename region. Please try again.');
}
};
// Handle tool selection
const handleToolSelect = (tool: 'pointer' | 'pencil') => {
setActiveTool(tool);
KGPianoRollState.instance().setActiveTool(tool);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Selected tool: ${tool}`);
}
};
// Handle snapping selection
const handleSnappingSelect = useCallback((value: string) => {
setSnapping(value);
KGPianoRollState.instance().setCurrentSnap(value);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Selected snapping: ${value}`);
}
}, []);
// Handler for receiving the setNoteUpdateCounter function from PianoRollContent
const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => {
triggerNoteUpdateRef.current = setNoteFn;
};
// Handler for receiving the deleteSelectedNotes function from PianoRollContent
const handleSetDeleteNotesTrigger = (deleteFn: () => boolean) => {
deleteSelectedNotesRef.current = deleteFn;
};
// Quantize selected notes based on the selected quantization value
const quantizeSelectedNotes = useCallback((quantValue: string) => {
if (!activeRegion) return;
// Get the KGCore instance
const core = KGCore.instance();
// Get all selected notes
const selectedItems = core.getSelectedItems();
const selectedNotes = selectedItems.filter(item =>
item instanceof KGMidiNote &&
activeRegion.getNotes().some(note => note.getId() === item.getId())
) as KGMidiNote[];
if (selectedNotes.length === 0) {
if (DEBUG_MODE.PIANO_ROLL) {
console.log('No notes selected for quantization');
}
return;
}
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Quantizing ${selectedNotes.length} selected notes with value: ${quantValue}`);
}
// Parse the quantization value (e.g., "1/4", "1/8", "1/16", "1/32")
const denominator = parseInt(quantValue.split('/')[1]);
if (isNaN(denominator)) {
console.error(`Invalid quantization value: ${quantValue}`);
return;
}
// Calculate the quantization step in beats
// In a 4/4 time signature, a quarter note (1/4) is 1 beat
// In a 6/8 time signature, an eighth note (1/8) is 1 beat
const { numerator, denominator: timeSigDenominator } = timeSignature;
// Calculate beats per whole note based on time signature
// In 4/4, a whole note is 4 beats
// In 6/8, a whole note is 6 beats (because each beat is an eighth note)
const beatsPerWholeNote = numerator * (4 / timeSigDenominator);
// Calculate the quantization step in beats
// quantizationStep should ALWAYS be 4 / denominator regardless of time signature
const quantizationStep = 4 / denominator;
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Time signature: ${numerator}/${timeSigDenominator}`);
console.log(`Beats per whole note: ${beatsPerWholeNote}`);
console.log(`Quantization step: ${quantizationStep} beats`);
}
// Apply quantization to each selected note
selectedNotes.forEach(note => {
// Get the current start beat
const currentStartBeat = note.getStartBeat();
// Calculate the quantized start beat
const quantizedStartBeat = Math.round(currentStartBeat / quantizationStep) * quantizationStep;
// Calculate the duration of the note
const duration = note.getEndBeat() - currentStartBeat;
// Set the new start beat and maintain the duration
note.setStartBeat(quantizedStartBeat);
note.setEndBeat(quantizedStartBeat + duration);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Quantized note ${note.getId()}: ${currentStartBeat} -> ${quantizedStartBeat}`);
}
});
// Find the track that contains this region and update it
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
if (track) {
updateTrack(track);
}
// Trigger a re-render by incrementing the note update counter
if (triggerNoteUpdateRef.current) {
triggerNoteUpdateRef.current(prev => prev + 1);
if (DEBUG_MODE.PIANO_ROLL) {
console.log('Triggered note update to re-render quantized notes');
}
}
}, [activeRegion, timeSignature, updateTrack, tracks]);
// Quantize selected notes length based on the selected quantization value
const quantizeNoteLength = useCallback((quantValue: string) => {
if (!activeRegion) return;
// Get the KGCore instance
const core = KGCore.instance();
// Get all selected notes
const selectedItems = core.getSelectedItems();
const selectedNotes = selectedItems.filter(item =>
item instanceof KGMidiNote &&
activeRegion.getNotes().some(note => note.getId() === item.getId())
) as KGMidiNote[];
if (selectedNotes.length === 0) {
if (DEBUG_MODE.PIANO_ROLL) {
console.log('No notes selected for length quantization');
}
return;
}
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Quantizing length of ${selectedNotes.length} selected notes with value: ${quantValue}`);
}
// Parse the quantization value (e.g., "1/1", "1/2", "1/4", "1/8", "1/16", "1/32")
const denominator = parseInt(quantValue.split('/')[1]);
if (isNaN(denominator)) {
console.error(`Invalid quantization value: ${quantValue}`);
return;
}
// Calculate the quantization step in beats
// In a 4/4 time signature, a quarter note (1/4) is 1 beat
// In a 6/8 time signature, an eighth note (1/8) is 1 beat
const { numerator, denominator: timeSigDenominator } = timeSignature;
// Calculate beats per whole note based on time signature
// In 4/4, a whole note is 4 beats
// In 6/8, a whole note is 6 beats (because each beat is an eighth note)
const beatsPerWholeNote = numerator * (4 / timeSigDenominator);
// Calculate the quantization step in beats
// quantizationStep should ALWAYS be 4 / denominator regardless of time signature
const quantizationStep = 4 / denominator;
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Time signature: ${numerator}/${timeSigDenominator}`);
console.log(`Beats per whole note: ${beatsPerWholeNote}`);
console.log(`Length quantization step: ${quantizationStep} beats`);
}
// Apply quantization to each selected note
selectedNotes.forEach(note => {
// Get the current start and end beats
const startBeat = note.getStartBeat();
const currentEndBeat = note.getEndBeat();
// Calculate the current duration
const currentDuration = currentEndBeat - startBeat;
// Calculate the quantized duration
// If the current duration is less than the quantization step,
// extend it to match the quantization step exactly
// Otherwise, round to the nearest multiple of quantizationStep
let quantizedDuration;
if (currentDuration < quantizationStep) {
// For notes shorter than the quantization step, extend to exactly one step
quantizedDuration = quantizationStep;
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Extending short note ${note.getId()} from ${currentDuration} to ${quantizedDuration}`);
}
} else {
// For longer notes, round to nearest multiple of quantizationStep
quantizedDuration = Math.round(currentDuration / quantizationStep) * quantizationStep;
}
// Ensure minimum note length
quantizedDuration = Math.max(PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH, quantizedDuration);
// Set the new end beat while maintaining the start beat
note.setEndBeat(startBeat + quantizedDuration);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Quantized note length ${note.getId()}: ${currentDuration} -> ${quantizedDuration}`);
}
});
// Find the track that contains this region and update it
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
if (track) {
updateTrack(track);
}
// Trigger a re-render by incrementing the note update counter
if (triggerNoteUpdateRef.current) {
triggerNoteUpdateRef.current(prev => prev + 1);
if (DEBUG_MODE.PIANO_ROLL) {
console.log('Triggered note update to re-render quantized note lengths');
}
}
}, [activeRegion, timeSignature, updateTrack, tracks]);
// Handle quantization selection
const handleQuantSelect = useCallback((type: 'position' | 'length', value: string) => {
if (type === 'position') {
setQuantPosition(value);
// Apply quantization immediately when position quantization is changed
quantizeSelectedNotes(value);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`quant-position selected: ${value}`);
}
} else {
setQuantLength(value);
// Apply length quantization immediately when length quantization is changed
quantizeNoteLength(value);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`quant-length selected: ${value}`);
}
}
}, [quantizeSelectedNotes, quantizeNoteLength]);
// Calculate C4 position and scroll to it when piano roll opens
useEffect(() => {
if (pianoRollContentRef.current) {
// Calculate position of C4
// We have 8 octaves (0-7), and C4 is in the middle
// Each octave has 12 notes, each note is piano key height
// C4 is in octave 4, and C is the first note in each octave
// Calculate from the bottom:
// - Octaves 0-3 = 4 octaves = 4 * 12 * piano key height
// - Within octave 4, C is the first note (from bottom), so 0px additional
const keyHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
const c4Position = 4 * 12 * keyHeight; // pixels from bottom
// Total height of all notes (8 octaves * 12 notes * piano key height)
const totalHeight = 8 * 12 * keyHeight;
// Get the viewport height of the piano roll content
const viewportHeight = pianoRollContentRef.current.clientHeight;
// Calculate scroll position to center C4
// We need to scroll from the top, so we calculate:
// (total height - C4 position) - (viewport height / 2)
const scrollPosition = (totalHeight - c4Position) - (viewportHeight / 2);
// Scroll to the calculated position
pianoRollContentRef.current.scrollTop = Math.max(0, scrollPosition);
}
}, []);
// Scroll horizontally to the active region's starting bar
useEffect(() => {
if (pianoRollContentRef.current && activeRegion) {
// Get the starting beat of the region
const startBeat = activeRegion.getStartFromBeat();
// Get the time signature to calculate beats per bar
const beatsPerBar = timeSignature.numerator;
// Calculate the bar number (0-indexed)
const barNumber = Math.floor(startBeat / beatsPerBar);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Scrolling to region's starting bar: ${barNumber + 1} (startBeat: ${startBeat}, beatsPerBar: ${beatsPerBar})`);
}
// Calculate the pixel position (each bar is --region-grid-bar-width wide, which is 160px by default)
const barWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-bar-width')) || 160;
// Calculate the scroll position to scroll to the starting bar
const scrollPosition = barNumber * barWidth;
// Scroll to the calculated position
pianoRollContentRef.current.scrollLeft = Math.max(0, scrollPosition);
}
}, [activeRegion, timeSignature]);
// Add keyboard event listener for piano roll hotkeys (snapping and quantization)
useEffect(() => {
const handlePianoRollKeyDown = (event: KeyboardEvent) => {
// Skip if user is typing in an input field (including ChatBox)
const target = event.target as HTMLElement;
if (target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true' ||
target.hasAttribute('data-chatbox-input') ||
target.closest('.chatbox-input')
)) {
return;
}
// Handle delete key for selected notes
if (event.key === 'Backspace' || event.key === 'Delete') {
if (deleteSelectedNotesRef.current) {
const deleted = deleteSelectedNotesRef.current();
if (deleted) {
// Prevent default behavior only if notes were actually deleted
event.preventDefault();
}
}
return;
}
// Handle piano roll hotkeys
const configManager = ConfigManager.instance();
if (configManager.getIsInitialized()) {
// Snapping hotkeys
const snap_none_key = configManager.get('hotkeys.piano_roll.snap_none') as string;
const snap_1_4_key = configManager.get('hotkeys.piano_roll.snap_1_4') as string;
const snap_1_8_key = configManager.get('hotkeys.piano_roll.snap_1_8') as string;
const snap_1_16_key = configManager.get('hotkeys.piano_roll.snap_1_16') as string;
// Quantize position hotkeys
const qua_pos_1_4_key = configManager.get('hotkeys.piano_roll.qua_pos_1_4') as string;
const qua_pos_1_8_key = configManager.get('hotkeys.piano_roll.qua_pos_1_8') as string;
const qua_pos_1_16_key = configManager.get('hotkeys.piano_roll.qua_pos_1_16') as string;
// Quantize length hotkeys
const qua_len_1_4_key = configManager.get('hotkeys.piano_roll.qua_len_1_4') as string;
const qua_len_1_8_key = configManager.get('hotkeys.piano_roll.qua_len_1_8') as string;
const qua_len_1_16_key = configManager.get('hotkeys.piano_roll.qua_len_1_16') as string;
let actionType: 'snap' | 'quantize' | null = null;
let actionValue: string | null = null;
let quantType: 'position' | 'length' | null = null;
// Check snapping hotkeys
if (event.key === snap_none_key) {
actionType = 'snap';
actionValue = 'NO SNAP';
} else if (event.key === snap_1_4_key) {
actionType = 'snap';
actionValue = '1/4';
} else if (event.key === snap_1_8_key) {
actionType = 'snap';
actionValue = '1/8';
} else if (event.key === snap_1_16_key) {
actionType = 'snap';
actionValue = '1/16';
}
// Check quantize position hotkeys
else if (event.key === qua_pos_1_4_key) {
actionType = 'quantize';
actionValue = '1/4';
quantType = 'position';
} else if (event.key === qua_pos_1_8_key) {
actionType = 'quantize';
actionValue = '1/8';
quantType = 'position';
} else if (event.key === qua_pos_1_16_key) {
actionType = 'quantize';
actionValue = '1/16';
quantType = 'position';
}
// Check quantize length hotkeys
else if (event.key === qua_len_1_4_key) {
actionType = 'quantize';
actionValue = '1/4';
quantType = 'length';
} else if (event.key === qua_len_1_8_key) {
actionType = 'quantize';
actionValue = '1/8';
quantType = 'length';
} else if (event.key === qua_len_1_16_key) {
actionType = 'quantize';
actionValue = '1/16';
quantType = 'length';
}
if (actionType && actionValue) {
// Prevent default behavior
event.preventDefault();
if (actionType === 'snap') {
// Validate the snap value exists in snap options
if (KGPianoRollState.SNAP_OPTIONS.includes(actionValue)) {
// Change snapping value
handleSnappingSelect(actionValue);
// Trigger blink effect for visual feedback
setBlinkButton('snapping');
setTimeout(() => setBlinkButton(null), 200);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Snap hotkey triggered: ${event.key}${actionValue}`);
}
}
} else if (actionType === 'quantize' && quantType) {
// Validate the quantValue exists in the appropriate options
const validOptions = quantType === 'length' ? KGPianoRollState.QUANT_LEN_OPTIONS : KGPianoRollState.QUANT_POS_OPTIONS;
if (validOptions.includes(actionValue)) {
// Apply quantization
handleQuantSelect(quantType, actionValue);
// Trigger blink effect for visual feedback
const buttonName = quantType === 'length' ? 'quant-length' : 'quant-position';
setBlinkButton(buttonName);
setTimeout(() => setBlinkButton(null), 200);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Quantize ${quantType} hotkey triggered: ${event.key}${actionValue}`);
}
}
}
}
}
};
// Add event listener
window.addEventListener('keydown', handlePianoRollKeyDown);
// Remove event listener on cleanup
return () => {
window.removeEventListener('keydown', handlePianoRollKeyDown);
};
}, [handleQuantSelect, handleSnappingSelect]);
// Get the title for the piano roll based on the active region
const getPianoRollTitle = () => {
if (!activeRegion) return "EDIT NOTE CLIP";
// Calculate the bar and beat position of the region
const startBeat = activeRegion.getStartFromBeat();
const { bar, beatInBar } = beatsToBar(startBeat, timeSignature);
// Format as 1-indexed bar and beat (bar + 1, beatInBar + 1)
const barNumber = bar + 1;
const beatNumber = beatInBar + 1;
return `${activeRegion.getName()} (at ${barNumber}:${beatNumber})`;
};
return (
<div
className="piano-roll-panel"
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: `${size.width}px`,
height: `${size.height}px`,
zIndex: 2000
}}
ref={pianoRollRef}
>
<PianoRollHeader
onClose={onClose}
title={getPianoRollTitle()}
onTitleClick={handleTitleClick}
onMouseDown={(e) => handleMouseDown(e, 'drag')}
/>
<PianoRollToolbar
activeTool={activeTool}
onToolSelect={handleToolSelect}
quantPosition={quantPosition}
quantLength={quantLength}
onQuantSelect={handleQuantSelect}
snapping={snapping}
onSnappingSelect={handleSnappingSelect}
blinkButton={blinkButton}
/>
<PianoRollContent
contentRef={pianoRollContentRef}
pianoGridRef={pianoGridRef}
maxBars={maxBars}
timeSignature={timeSignature}
activeRegion={activeRegion}
updateTrack={updateTrack}
tracks={tracks}
onSetNoteUpdateTrigger={handleSetNoteUpdateTrigger}
onSetDeleteNotesTrigger={handleSetDeleteNotesTrigger}
/>
<div
className="resize-handle"
onMouseDown={(e) => handleMouseDown(e, 'resize')}
>
<FaGripLines />
</div>
</div>
);
};
export default PianoRoll;
@@ -0,0 +1,238 @@
import React, { useMemo, useState, useRef, useEffect } from 'react';
import { DEBUG_MODE } from '../../constants';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { KGMidiNote } from '../../core/midi/KGMidiNote';
import { KGTrack } from '../../core/track/KGTrack';
import { KGCore } from '../../core/KGCore';
import PianoNote from './PianoNote';
import PianoKeys from './PianoKeys';
import PianoGridHeader from './PianoGridHeader';
import PianoGrid from './PianoGrid';
import { useNoteOperations } from '../../hooks/useNoteOperations';
import { useNoteSelection } from '../../hooks/useNoteSelection';
interface PianoRollContentProps {
contentRef: React.MutableRefObject<HTMLDivElement | null>;
pianoGridRef: React.MutableRefObject<HTMLDivElement | null>;
maxBars: number;
timeSignature: { numerator: number; denominator: number };
activeRegion: KGMidiRegion | null;
updateTrack: (track: KGTrack) => void;
tracks: KGTrack[];
onSetNoteUpdateTrigger?: (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => void;
onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void;
}
const PianoRollContent: React.FC<PianoRollContentProps> = ({
contentRef,
pianoGridRef,
maxBars,
timeSignature,
activeRegion,
updateTrack,
tracks,
onSetNoteUpdateTrigger,
onSetDeleteNotesTrigger
}) => {
// Get KGCore instance
const core = KGCore.instance();
// Use the note operations hook for resize and drag functionality
const {
resizingNoteId,
draggingNoteId,
tempNoteStyles,
noteUpdateCounter,
setNoteUpdateCounter,
handleGridDoubleClick,
handleGridClick,
handleNoteResizeStart,
handleNoteResize,
handleNoteResizeEnd,
handleNoteDragStart,
handleNoteDrag,
handleNoteDragEnd,
deleteSelectedNotes
} = useNoteOperations({
activeRegion,
timeSignature,
updateTrack,
tracks,
pianoGridRef
});
// Expose setNoteUpdateCounter to parent component
useEffect(() => {
if (onSetNoteUpdateTrigger) {
onSetNoteUpdateTrigger(setNoteUpdateCounter);
}
}, [onSetNoteUpdateTrigger, setNoteUpdateCounter]);
// Expose deleteSelectedNotes to parent component
useEffect(() => {
if (onSetDeleteNotesTrigger) {
onSetDeleteNotesTrigger(deleteSelectedNotes);
}
}, [onSetDeleteNotesTrigger, deleteSelectedNotes]);
// Use the note selection hook for selection functionality
const {
selectedNoteIds,
isBoxSelectingRef,
selectionBoxRef,
selectionBoxRender,
handleNoteClick,
handleBackgroundClick,
handleBackgroundMouseDown,
cleanupSelectionListeners
} = useNoteSelection({
activeRegion,
updateTrack,
tracks
});
// Combined click handler for both pointer and pencil modes
const handleCombinedClick = (e: React.MouseEvent) => {
// Handle selection click (pointer mode)
handleBackgroundClick(e);
// Handle pencil mode note creation
handleGridClick(e);
};
// Sync selected notes with KGCore on mount and when selection changes
useEffect(() => {
if (!activeRegion) return;
// Get currently selected items from KGCore
const selectedItems = core.getSelectedItems();
// Filter for KGMidiNote items that belong to this region
const selectedNotes = selectedItems.filter(item =>
item instanceof KGMidiNote &&
activeRegion.getNotes().some(note => note.getId() === item.getId())
) as KGMidiNote[];
// Make sure the note objects have the correct selection state
activeRegion.getNotes().forEach(note => {
const isSelected = selectedNotes.some(selectedNote => selectedNote.getId() === note.getId());
if (isSelected && !note.isSelected()) {
note.select();
} else if (!isSelected && note.isSelected()) {
note.deselect();
}
});
}, [activeRegion]);
// Clean up event listeners on unmount
useEffect(() => {
return () => {
cleanupSelectionListeners();
};
}, []);
// Memoize the notes rendering to prevent unnecessary recalculations
const memoizedNotes = useMemo(() => {
if (!activeRegion) return null;
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Rendering notes for region: ${activeRegion.getId()}`);
console.log(`Number of notes: ${activeRegion.getNotes().length}`);
console.log(`Note update counter: ${noteUpdateCounter}`);
console.log(`Selected notes: ${Array.from(selectedNoteIds).join(', ')}`);
}
const notes = activeRegion.getNotes();
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
const regionStartBeat = activeRegion.getStartFromBeat();
return notes.map((note, index) => {
// Calculate position and size
const startBeat = note.getStartBeat() + regionStartBeat; // Absolute beat position
const endBeat = note.getEndBeat() + regionStartBeat; // Absolute beat position
const pitch = note.getPitch();
// Convert pitch to y position (higher notes have lower y values)
// We need to find the index of the pitch in our piano roll
const pitchIndex = 107 - pitch; // Reverse the pitch to get the index (B7 is 107)
// Calculate position and dimensions
const left = startBeat * beatWidth;
const top = pitchIndex * noteHeight;
const width = (endBeat - startBeat) * beatWidth;
const noteId = note.getId();
// Check if this note is being resized or dragged and has a temporary style
if ((resizingNoteId === noteId || draggingNoteId === noteId) && tempNoteStyles[noteId]) {
// Use the temporary style for position and size
const tempStyle = tempNoteStyles[noteId];
return (
<PianoNote
key={`note-${noteId}`}
id={noteId}
index={index}
left={parseFloat(tempStyle.left as string)}
top={parseFloat(tempStyle.top as string)}
width={parseFloat(tempStyle.width as string)}
height={noteHeight}
onResizeStart={handleNoteResizeStart}
onResize={handleNoteResize}
onResizeEnd={handleNoteResizeEnd}
onDragStart={handleNoteDragStart}
onDrag={handleNoteDrag}
onDragEnd={handleNoteDragEnd}
onClick={handleNoteClick}
/>
);
}
return (
<PianoNote
key={`note-${noteId}`}
id={noteId}
index={index}
left={left}
top={top}
width={width}
height={noteHeight}
onResizeStart={handleNoteResizeStart}
onResize={handleNoteResize}
onResizeEnd={handleNoteResizeEnd}
onDragStart={handleNoteDragStart}
onDrag={handleNoteDrag}
onDragEnd={handleNoteDragEnd}
onClick={handleNoteClick}
/>
);
});
}, [activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks]);
return (
<div
className="piano-roll-content"
ref={contentRef}
>
<PianoGridHeader maxBars={maxBars} timeSignature={timeSignature} />
<div className="piano-roll-body">
<PianoKeys activeRegion={activeRegion} />
<PianoGrid
gridRef={pianoGridRef}
onDoubleClick={handleGridDoubleClick}
onClick={handleCombinedClick}
onMouseDown={handleBackgroundMouseDown}
isBoxSelecting={isBoxSelectingRef.current}
selectionBox={selectionBoxRef.current}
regionStartBeat={activeRegion?.getStartFromBeat() || 0}
>
{memoizedNotes}
</PianoGrid>
</div>
</div>
);
};
export default PianoRollContent;
@@ -0,0 +1,39 @@
import React from 'react';
import { FaTimes } from 'react-icons/fa';
interface PianoRollHeaderProps {
onClose: () => void;
title: string;
onTitleClick: () => void;
onMouseDown: (e: React.MouseEvent) => void;
}
const PianoRollHeader: React.FC<PianoRollHeaderProps> = ({
onClose,
title,
onTitleClick,
onMouseDown
}) => {
return (
<div
className="piano-roll-header"
onMouseDown={onMouseDown}
>
<button
className="close-button"
onClick={onClose}
>
<FaTimes />
</button>
<div
className="piano-roll-title"
onClick={onTitleClick}
title="Click to rename region"
>
{title}
</div>
</div>
);
};
export default PianoRollHeader;
@@ -0,0 +1,82 @@
import React from 'react';
import { FaMousePointer, FaPencilAlt } from 'react-icons/fa';
import { KGDropdown } from '../common';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
interface PianoRollToolbarProps {
activeTool: 'pointer' | 'pencil';
onToolSelect: (tool: 'pointer' | 'pencil') => void;
quantPosition: string;
quantLength: string;
onQuantSelect: (type: 'position' | 'length', value: string) => void;
snapping: string;
onSnappingSelect: (value: string) => void;
blinkButton?: string | null;
}
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
activeTool,
onToolSelect,
quantPosition,
quantLength,
onQuantSelect,
snapping,
onSnappingSelect,
blinkButton = null
}) => {
return (
<div className="piano-roll-toolbar">
<div className="toolbar-left">
{/* Left section - can add more tools later */}
</div>
<div className="toolbar-center">
{/* Center section with pointer and pencil tools */}
<button
className={`tool-button ${activeTool === 'pointer' ? 'active' : ''}`}
onClick={() => onToolSelect('pointer')}
title="Pointer Tool"
>
<FaMousePointer />
</button>
<button
className={`tool-button ${activeTool === 'pencil' ? 'active' : ''}`}
onClick={() => onToolSelect('pencil')}
title="Pencil Tool"
>
<FaPencilAlt />
</button>
</div>
<div className="toolbar-right">
{/* Right section with quantization options */}
<KGDropdown
options={KGPianoRollState.SNAP_OPTIONS}
value={snapping}
onChange={(value) => onSnappingSelect(value)}
label="Snap"
buttonClassName="snapping"
showValueAsLabel={true}
/>
<KGDropdown
options={KGPianoRollState.QUANT_POS_OPTIONS}
value={quantPosition}
onChange={(value) => onQuantSelect('position', value)}
label="Qua. Pos."
buttonClassName={`quant-position ${blinkButton === 'quant-position' ? 'button-blink' : ''}`}
/>
<KGDropdown
options={KGPianoRollState.QUANT_LEN_OPTIONS}
value={quantLength}
onChange={(value) => onQuantSelect('length', value)}
label="Qua. Len."
buttonClassName={`quant-length ${blinkButton === 'quant-length' ? 'button-blink' : ''}`}
/>
</div>
</div>
);
};
export default PianoRollToolbar;
@@ -0,0 +1,41 @@
import React from 'react';
interface SelectionBoxProps {
isSelecting: boolean;
selectionBox: {
startX: number;
startY: number;
endX: number;
endY: number;
};
}
const SelectionBox: React.FC<SelectionBoxProps> = ({ isSelecting, selectionBox }) => {
if (!isSelecting) return null;
// Calculate the normalized coordinates (top-left to bottom-right)
const { startX, startY, endX, endY } = selectionBox;
const left = Math.min(startX, endX);
const top = Math.min(startY, endY);
const width = Math.abs(endX - startX);
const height = Math.abs(endY - startY);
return (
<div
className="selection-box"
style={{
position: 'absolute',
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
border: '1px solid white',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
pointerEvents: 'none', // Allow clicks to pass through
zIndex: 50
}}
/>
);
};
export default SelectionBox;
+45
View File
@@ -0,0 +1,45 @@
import React, { useState } from 'react';
import SettingsSidebar from './SettingsSidebar';
import GeneralSettings from './sections/GeneralSettings';
import BehaviorSettings from './sections/BehaviorSettings';
import TemplatesSettings from './sections/TemplatesSettings';
export type SettingsSection = 'general' | 'behavior' | 'templates';
interface SettingsPanelProps {
onClose: () => void;
}
const SettingsPanel: React.FC<SettingsPanelProps> = ({ onClose }) => {
const [activeSection, setActiveSection] = useState<SettingsSection>('general');
const renderActiveSection = () => {
switch (activeSection) {
case 'general':
return <GeneralSettings />;
case 'behavior':
return <BehaviorSettings />;
case 'templates':
return <TemplatesSettings />;
default:
return <GeneralSettings />;
}
};
return (
<div className="settings-panel">
<div className="settings-container">
<SettingsSidebar
activeSection={activeSection}
onSectionChange={setActiveSection}
onClose={onClose}
/>
<div className="settings-content">
{renderActiveSection()}
</div>
</div>
</div>
);
};
export default SettingsPanel;
@@ -0,0 +1,50 @@
import React from 'react';
import { FaTimes } from 'react-icons/fa';
import type { SettingsSection } from './SettingsPanel';
interface SettingsSidebarProps {
activeSection: SettingsSection;
onSectionChange: (section: SettingsSection) => void;
onClose: () => void;
}
const SettingsSidebar: React.FC<SettingsSidebarProps> = ({
activeSection,
onSectionChange,
onClose
}) => {
const sections = [
{ id: 'general' as SettingsSection, label: 'General' },
{ id: 'behavior' as SettingsSection, label: 'Behavior' },
{ id: 'templates' as SettingsSection, label: 'Templates' }
];
return (
<div className="settings-sidebar">
<div className="settings-sidebar-header">
<h2>Settings</h2>
<button
className="settings-close-btn"
onClick={onClose}
title="Close Settings"
>
<FaTimes />
</button>
</div>
<nav className="settings-nav">
{sections.map((section) => (
<button
key={section.id}
className={`settings-nav-item ${activeSection === section.id ? 'active' : ''}`}
onClick={() => onSectionChange(section.id)}
>
{section.label}
</button>
))}
</nav>
</div>
);
};
export default SettingsSidebar;
+5
View File
@@ -0,0 +1,5 @@
export { default as SettingsPanel } from './SettingsPanel.tsx';
export { default as SettingsSidebar } from './SettingsSidebar.tsx';
export { default as GeneralSettings } from './sections/GeneralSettings.tsx';
export { default as BehaviorSettings } from './sections/BehaviorSettings.tsx';
export { default as TemplatesSettings } from './sections/TemplatesSettings.tsx';
@@ -0,0 +1,58 @@
import React, { useState, useEffect } from 'react';
import { ConfigManager } from '../../../core/config/ConfigManager';
const BehaviorSettings: React.FC = () => {
const [chatboxDefaultOpen, setChatboxDefaultOpen] = useState<boolean>(true);
const configManager = ConfigManager.instance();
// Load configuration values on component mount
useEffect(() => {
const loadConfig = async () => {
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
setChatboxDefaultOpen((configManager.get('chatbox.default_open') as boolean) ?? true);
};
loadConfig();
}, [configManager]);
// Save configuration when values change
const handleChatboxDefaultOpenChange = async (value: string) => {
const boolValue = value === 'yes';
setChatboxDefaultOpen(boolValue);
await configManager.set('chatbox.default_open', boolValue);
};
return (
<div className="settings-section">
<div className="settings-section-header">
<h3>Behavior</h3>
</div>
<div className="settings-section-content">
<div className="settings-group">
<h4>Chat Box</h4>
<div className="settings-item">
<label className="settings-label">
Open at Start Up
</label>
<select
className="settings-select"
value={chatboxDefaultOpen ? 'yes' : 'no'}
onChange={(e) => handleChatboxDefaultOpenChange(e.target.value)}
>
<option value="no">No</option>
<option value="yes">Yes</option>
</select>
</div>
</div>
</div>
</div>
);
};
export default BehaviorSettings;
@@ -0,0 +1,359 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { ConfigManager } from '../../../core/config/ConfigManager';
const GeneralSettings: React.FC = () => {
const [llmProvider, setLlmProvider] = useState<string>('openai');
const [openaiKey, setOpenaiKey] = useState<string>('');
const [openaiModel, setOpenaiModel] = useState<string>('');
const [geminiKey, setGeminiKey] = useState<string>('');
const [geminiModel, setGeminiModel] = useState<string>('');
const [claudeKey, setClaudeKey] = useState<string>('');
const [claudeModel, setClaudeModel] = useState<string>('');
const [openaiFlex, setOpenaiFlex] = useState<boolean>(false);
const [compatibleKey, setCompatibleKey] = useState<string>('');
const [compatibleBaseUrl, setCompatibleBaseUrl] = useState<string>('');
const [compatibleModel, setCompatibleModel] = useState<string>('');
const [soundfontBaseUrl, setSoundfontBaseUrl] = useState<string>('');
const configManager = ConfigManager.instance();
const isLocalEnvironment = useMemo(() => {
try {
if (typeof window === 'undefined' || typeof window.location === 'undefined') {
return false;
}
const { protocol, hostname } = window.location;
if (protocol === 'file:') return true;
const localHosts = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
return localHosts.has(hostname);
} catch {
return false;
}
}, []);
// Load configuration values on component mount
useEffect(() => {
const loadConfig = async () => {
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
setLlmProvider((configManager.get('general.llm_provider') as string) || 'openai');
setOpenaiKey((configManager.get('general.openai.api_key') as string) || '');
setOpenaiModel((configManager.get('general.openai.model') as string) || '');
setOpenaiFlex((configManager.get('general.openai.flex') as boolean) ?? false);
setGeminiKey((configManager.get('general.gemini.api_key') as string) || '');
setGeminiModel((configManager.get('general.gemini.model') as string) || '');
setClaudeKey((configManager.get('general.claude.api_key') as string) || '');
setClaudeModel((configManager.get('general.claude.model') as string) || '');
setCompatibleKey((configManager.get('general.openai_compatible.api_key') as string) || '');
setCompatibleBaseUrl((configManager.get('general.openai_compatible.base_url') as string) || '');
setCompatibleModel((configManager.get('general.openai_compatible.model') as string) || '');
setSoundfontBaseUrl((configManager.get('general.soundfont.base_url') as string) || '');
};
loadConfig();
}, [configManager]);
// Debounced save function for text inputs
const debouncedSave = useCallback((key: string, value: string) => {
const timeoutId = setTimeout(async () => {
try {
await configManager.set(key, value);
console.log(`Settings saved: ${key} = ${value}`);
} catch (error) {
console.error(`Failed to save setting ${key}:`, error);
}
}, 500); // 500ms debounce
return () => clearTimeout(timeoutId);
}, [configManager]);
// Save configuration when values change
const handleLlmProviderChange = async (value: string) => {
setLlmProvider(value);
try {
await configManager.set('general.llm_provider', value);
console.log('LLM provider changed to:', value);
} catch (error) {
console.error('Failed to save LLM provider:', error);
}
};
const handleOpenaiKeyChange = (value: string) => {
setOpenaiKey(value);
debouncedSave('general.openai.api_key', value);
};
const handleOpenaiModelChange = (value: string) => {
setOpenaiModel(value);
debouncedSave('general.openai.model', value);
};
const handleOpenaiFlexChange = async (value: string) => {
const boolValue = value === 'yes';
setOpenaiFlex(boolValue);
try {
await configManager.set('general.openai.flex', boolValue);
console.log('OpenAI Flex Mode changed to:', boolValue);
} catch (error) {
console.error('Failed to save OpenAI Flex Mode:', error);
}
};
const handleGeminiKeyChange = (value: string) => {
setGeminiKey(value);
debouncedSave('general.gemini.api_key', value);
};
const handleGeminiModelChange = (value: string) => {
setGeminiModel(value);
debouncedSave('general.gemini.model', value);
};
const handleClaudeKeyChange = (value: string) => {
setClaudeKey(value);
debouncedSave('general.claude.api_key', value);
};
const handleClaudeModelChange = (value: string) => {
setClaudeModel(value);
debouncedSave('general.claude.model', value);
};
const handleCompatibleKeyChange = (value: string) => {
setCompatibleKey(value);
debouncedSave('general.openai_compatible.api_key', value);
};
const handleCompatibleBaseUrlChange = (value: string) => {
setCompatibleBaseUrl(value);
debouncedSave('general.openai_compatible.base_url', value);
};
const handleCompatibleModelChange = (value: string) => {
setCompatibleModel(value);
debouncedSave('general.openai_compatible.model', value);
};
const handleSoundfontBaseUrlChange = (value: string) => {
setSoundfontBaseUrl(value);
debouncedSave('general.soundfont.base_url', value);
};
// NOTE: Gemini and Claude are not supported yet due to CORS issues.
return (
<div className="settings-section">
<div className="settings-section-header">
<h3>General</h3>
</div>
<div className="settings-section-content">
<div className="settings-group">
<h4>LLM Provider</h4>
<div className="settings-item">
<label className="settings-label">
LLM Provider
</label>
<select
className="settings-select"
value={llmProvider}
onChange={(e) => handleLlmProviderChange(e.target.value)}
>
<option value="openai">OpenAI</option>
{/* <option value="gemini">Gemini</option>
<option value="claude">Claude</option> */}
<option value="openai_compatible">OpenAI Compatible (e.g. OpenRouter, Ollama)</option>
</select>
</div>
</div>
<div className="settings-group">
<h4>OpenAI</h4>
<div className="settings-item">
<label className="settings-label">
Key
</label>
<input
type="password"
className="settings-input"
placeholder="Enter your OpenAI API key"
value={openaiKey}
onChange={(e) => handleOpenaiKeyChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{isLocalEnvironment
? 'Keys are persisted locally (the IndexedDB in your browser).'
: 'For security, keys are not persisted on non-local hosts and are kept in-memory for this session.'}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Model
</label>
<select
className="settings-select"
value={openaiModel}
onChange={(e) => handleOpenaiModelChange(e.target.value)}
>
{/* <option value="gpt-5">gpt-5</option>
<option value="gpt-5-mini">gpt-5-mini</option>
<option value="gpt-5-nano">gpt-5-nano</option> */}
<option value="gpt-4o">gpt-4o</option>
</select>
</div>
<div className="settings-item">
<label className="settings-label">
Flex Mode
</label>
<select
className="settings-select"
value={openaiFlex ? 'yes' : 'no'}
onChange={(e) => handleOpenaiFlexChange(e.target.value)}
>
<option value="no">No</option>
<option value="yes">Yes</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Flex Mode uses OpenAI's flexible service tier. Pros: potential cost savings and higher throughput during busy periods. Cons: variable latency and possible queueing/deprioritization. Applies only to the OpenAI provider; no effect for OpenAI Compatible servers.
</div>
</div>
</div>
{/* <div className="settings-group">
<h4>Gemini</h4>
<div className="settings-item">
<label className="settings-label">
Key
</label>
<input
type="password"
className="settings-input"
placeholder="Enter your Gemini API key"
value={geminiKey}
onChange={(e) => handleGeminiKeyChange(e.target.value)}
/>
</div>
<div className="settings-item">
<label className="settings-label">
Model
</label>
<input
type="text"
className="settings-input"
placeholder="e.g. gemini-2.5-flash"
value={geminiModel}
onChange={(e) => handleGeminiModelChange(e.target.value)}
/>
</div>
</div>
<div className="settings-group">
<h4>Claude</h4>
<div className="settings-item">
<label className="settings-label">
Key
</label>
<input
type="password"
className="settings-input"
placeholder="Enter your Claude API key"
value={claudeKey}
onChange={(e) => handleClaudeKeyChange(e.target.value)}
/>
</div>
<div className="settings-item">
<label className="settings-label">
Model
</label>
<input
type="text"
className="settings-input"
placeholder="e.g. claude-sonnet-4-0"
value={claudeModel}
onChange={(e) => handleClaudeModelChange(e.target.value)}
/>
</div>
</div> */}
<div className="settings-group">
<h4>OpenAI Compatible Server</h4>
<div className="settings-item">
<label className="settings-label">
Key
</label>
<input
type="password"
className="settings-input"
placeholder="Enter your API key"
value={compatibleKey}
onChange={(e) => handleCompatibleKeyChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{isLocalEnvironment
? 'Keys are persisted locally (the IndexedDB in your browser).'
: 'For security, keys are not persisted on non-local hosts and are kept in-memory for this session.'}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Base URL
</label>
<input
type="text"
className="settings-input"
placeholder="e.g. https://api.openrouter.ai/v1"
value={compatibleBaseUrl}
onChange={(e) => handleCompatibleBaseUrlChange(e.target.value)}
/>
</div>
<div className="settings-item">
<label className="settings-label">
Model
</label>
<input
type="text"
className="settings-input"
placeholder="e.g. qwen3:30b"
value={compatibleModel}
onChange={(e) => handleCompatibleModelChange(e.target.value)}
/>
</div>
</div>
<div className="settings-group">
<h4>Soundfont Settings</h4>
<div className="settings-item">
<label className="settings-label">
Base URL
</label>
<input
type="text"
className="settings-input"
placeholder="e.g. https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/"
value={soundfontBaseUrl}
onChange={(e) => handleSoundfontBaseUrlChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Changing this URL to an incompatible soundfont source may cause some instruments to sound wrong or not play.
</div>
</div>
</div>
</div>
</div>
);
};
export default GeneralSettings;
@@ -0,0 +1,67 @@
import React, { useState, useEffect, useCallback } from 'react';
import { ConfigManager } from '../../../core/config/ConfigManager';
const TemplatesSettings: React.FC = () => {
const [customInstructions, setCustomInstructions] = useState<string>('');
const configManager = ConfigManager.instance();
// Load configuration values on component mount
useEffect(() => {
const loadConfig = async () => {
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
setCustomInstructions((configManager.get('templates.custom_instructions') as string) || '');
};
loadConfig();
}, [configManager]);
// Debounced save function for textarea
const debouncedSave = useCallback((value: string) => {
const timeoutId = setTimeout(async () => {
try {
await configManager.set('templates.custom_instructions', value);
console.log('Custom instructions saved');
} catch (error) {
console.error('Failed to save custom instructions:', error);
}
}, 1000); // 1 second debounce for longer text
return () => clearTimeout(timeoutId);
}, [configManager]);
// Save configuration when value changes
const handleCustomInstructionsChange = (value: string) => {
setCustomInstructions(value);
debouncedSave(value);
};
return (
<div className="settings-section">
<div className="settings-section-header">
<h3>Templates</h3>
</div>
<div className="settings-section-content">
<div className="settings-group">
<h4>Custom Instructions</h4>
<div className="settings-item">
<textarea
className="settings-textarea"
placeholder="Please input your custom instructions for the K.G.Studio Musician Assistant"
rows={8}
value={customInstructions}
onChange={(e) => handleCustomInstructionsChange(e.target.value)}
/>
</div>
</div>
</div>
</div>
);
};
export default TemplatesSettings;
+498
View File
@@ -0,0 +1,498 @@
import React, { useState, useRef, useEffect } from 'react';
import { FaPencilAlt } from 'react-icons/fa';
import type { ResizeAction } from '../interfaces';
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { useProjectStore } from '../../stores/projectStore';
import { KGMainContentState } from '../../core/state/KGMainContentState';
interface RegionItemProps {
id: string;
name: string;
style: React.CSSProperties;
// Additional props that will be needed for resize functionality
barNumber?: number;
length?: number;
trackIndex?: number;
onResizeStart?: (regionId: string, resizeAction: ResizeAction, initialX: number) => void;
onResize?: (regionId: string, resizeAction: ResizeAction, deltaX: number) => void;
onResizeEnd?: (regionId: string, resizeAction: ResizeAction) => void;
// Drag props
onDragStart?: (regionId: string, initialX: number, initialY: number) => void;
onDrag?: (regionId: string, deltaX: number, deltaY: number) => void;
onDragEnd?: (regionId: string) => void;
// Click prop
onClick?: (regionId: string) => void;
// Explicit open piano roll action from header pencil icon
onOpenPianoRoll?: (regionId: string) => void;
// MIDI region data for rendering notes
midiRegion?: KGMidiRegion;
}
const RegionItem: React.FC<RegionItemProps> = ({
id,
name,
style,
barNumber,
length,
trackIndex,
onResizeStart,
onResize,
onResizeEnd,
onDragStart,
onDrag,
onDragEnd,
onClick,
onOpenPianoRoll,
midiRegion
}) => {
// Get selection state and time signature from store
const { selectedRegionIds, timeSignature } = useProjectStore();
const isSelected = selectedRegionIds.includes(id);
const [cursor, setCursor] = useState<string>('pointer');
const [resizeEdge, setResizeEdge] = useState<ResizeAction>('none');
const [isResizing, setIsResizing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const initialMousePosRef = useRef<{x: number, y: number}>({x: 0, y: 0});
// Use refs to track states for immediate access
const isResizingRef = useRef<boolean>(false);
const isDraggingRef = useRef<boolean>(false);
const hasMovedRef = useRef<boolean>(false);
// Canvas ref for note visualization
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const regionContentRef = useRef<HTMLDivElement | null>(null);
// Function to render notes on canvas
const renderNotesOnCanvas = () => {
if (!canvasRef.current || !regionContentRef.current || !midiRegion) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Get the current dimensions of the region content
const contentRect = regionContentRef.current.getBoundingClientRect();
const width = contentRect.width;
const height = contentRect.height;
// Set canvas size to match the region content
canvas.width = width;
canvas.height = height;
// Clear the canvas
ctx.clearRect(0, 0, width, height);
// Get notes from the MIDI region
const notes = midiRegion.getNotes();
if (notes.length === 0) {
// No notes to render, but we can draw a reference grid or just return
return;
}
// Calculate note dimensions and positioning
const regionLengthInBeats = midiRegion.getLength();
const beatsPerBar = timeSignature.numerator;
const regionLengthInBars = regionLengthInBeats / beatsPerBar;
// Calculate beats per pixel
const beatsPerPixel = regionLengthInBeats / width;
// Analyze the pitch range of notes in the region
const notePitches = notes.map(note => note.getPitch());
const minNotePitch = Math.min(...notePitches);
const maxNotePitch = Math.max(...notePitches);
const averagePitch = notePitches.reduce((sum, pitch) => sum + pitch, 0) / notePitches.length;
const noteRange = maxNotePitch - minNotePitch;
// Define display parameters
const c4Pitch = 60; // C4 reference
const defaultPitchSpacing = 2; // pixels per semitone
const minPitchSpacing = 1; // minimum pixels per semitone
const paddingSemitones = 6; // padding above and below note range
// Calculate optimal pitch range and centering
let displayMinPitch, displayMaxPitch, centerPitch, pitchSpacing;
if (noteRange === 0) {
// Single note or all notes have same pitch
centerPitch = averagePitch;
// Show 2 octaves around the note
displayMinPitch = Math.max(0, centerPitch - 12);
displayMaxPitch = Math.min(127, centerPitch + 12);
pitchSpacing = defaultPitchSpacing;
} else {
// Multiple notes with different pitches
const expandedMinPitch = Math.max(0, minNotePitch - paddingSemitones);
const expandedMaxPitch = Math.min(127, maxNotePitch + paddingSemitones);
const expandedRange = expandedMaxPitch - expandedMinPitch;
// Check if we can fit all notes with default spacing
const requiredHeight = expandedRange * defaultPitchSpacing;
if (requiredHeight <= height) {
// Notes fit with default spacing, center around average pitch
displayMinPitch = expandedMinPitch;
displayMaxPitch = expandedMaxPitch;
centerPitch = averagePitch;
pitchSpacing = defaultPitchSpacing;
} else {
// Notes don't fit, need to compress spacing
displayMinPitch = expandedMinPitch;
displayMaxPitch = expandedMaxPitch;
centerPitch = averagePitch;
pitchSpacing = Math.max(minPitchSpacing, height / expandedRange);
if (DEBUG_MODE.REGION_ITEM) {
console.log(`Compressing pitch spacing to ${pitchSpacing.toFixed(2)}px per semitone to fit range ${expandedRange}`);
}
}
}
// If the note range is small, fall back to centering around C4 if it's reasonable
const displayRange = displayMaxPitch - displayMinPitch;
if (displayRange < 24 && Math.abs(averagePitch - c4Pitch) > 12) {
// Note range is small but far from C4, use a compromise
const compromiseCenter = averagePitch > c4Pitch ?
Math.min(averagePitch, c4Pitch + 12) :
Math.max(averagePitch, c4Pitch - 12);
displayMinPitch = Math.max(0, compromiseCenter - 12);
displayMaxPitch = Math.min(127, compromiseCenter + 12);
centerPitch = compromiseCenter;
if (DEBUG_MODE.REGION_ITEM) {
console.log(`Using compromise center ${compromiseCenter} between notes (${averagePitch.toFixed(1)}) and C4 (${c4Pitch})`);
}
}
const finalPitchRange = displayMaxPitch - displayMinPitch;
if (DEBUG_MODE.REGION_ITEM) {
console.log(`Pitch analysis: notes=${minNotePitch}-${maxNotePitch} (avg=${averagePitch.toFixed(1)}), display=${displayMinPitch}-${displayMaxPitch}, spacing=${pitchSpacing.toFixed(2)}px`);
}
// Set note rendering style
ctx.fillStyle = 'white';
const noteHeight = 2; // Fixed 2px height as requested
// Render each note
notes.forEach(note => {
const startBeat = note.getStartBeat();
const endBeat = note.getEndBeat();
const pitch = note.getPitch();
// Only render notes within our display pitch range
if (pitch < displayMinPitch || pitch > displayMaxPitch) return;
// Calculate note position and size
const noteStartX = startBeat / beatsPerPixel;
const noteWidth = (endBeat - startBeat) / beatsPerPixel;
// Calculate Y position based on pitch using dynamic spacing
// Higher pitches should be at the top (lower Y values)
const pitchIndex = pitch - displayMinPitch;
const noteY = height - (pitchIndex * pitchSpacing) - (noteHeight / 2);
// Draw the note as a white horizontal line
ctx.fillRect(noteStartX, noteY, noteWidth, noteHeight);
});
if (DEBUG_MODE.REGION_ITEM) {
console.log(`Rendered ${notes.length} notes on canvas for region ${id}`);
}
};
// Create a stable reference to track note changes
const notesRef = useRef<string>('');
const [noteUpdateTrigger, setNoteUpdateTrigger] = useState(0);
// Check for changes in notes and trigger re-render when needed
useEffect(() => {
if (!midiRegion) return;
// Create a signature of all notes for change detection
const notesSignature = midiRegion.getNotes()
.map(n => `${n.getId()}-${n.getStartBeat()}-${n.getEndBeat()}-${n.getPitch()}`)
.join(',');
// If notes have changed, trigger a re-render
if (notesRef.current !== notesSignature) {
notesRef.current = notesSignature;
setNoteUpdateTrigger(prev => prev + 1);
}
});
// Set up canvas when component mounts or updates
useEffect(() => {
renderNotesOnCanvas();
}, [midiRegion, timeSignature, id, noteUpdateTrigger]);
// Re-render canvas when region content size changes
useEffect(() => {
if (!regionContentRef.current) return;
const resizeObserver = new ResizeObserver(() => {
renderNotesOnCanvas();
});
resizeObserver.observe(regionContentRef.current);
return () => {
if (regionContentRef.current) {
resizeObserver.unobserve(regionContentRef.current);
}
};
}, [midiRegion, timeSignature]);
// Handle mouse movement to detect edge proximity
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
// Skip if already resizing or dragging
if (isResizingRef.current || isDraggingRef.current) return;
// Disable move and resize when pencil tool is active
const activeTool = KGMainContentState.instance().getActiveTool();
if (activeTool === 'pencil') {
setCursor('pointer');
setResizeEdge('none');
return;
}
const regionElement = e.currentTarget;
const rect = regionElement.getBoundingClientRect();
// Calculate distance from left and right edges
const distanceFromLeft = e.clientX - rect.left;
const distanceFromRight = rect.right - e.clientX;
// Use the edge threshold constant from constants file
const edgeThreshold = REGION_CONSTANTS.EDGE_THRESHOLD;
if (distanceFromLeft <= edgeThreshold) {
// Near left edge - resize from start
setCursor('ew-resize');
setResizeEdge('start');
} else if (distanceFromRight <= edgeThreshold) {
// Near right edge - resize from end
setCursor('ew-resize');
setResizeEdge('end');
} else {
// Middle area - move
setCursor('grab');
setResizeEdge('none');
}
};
// Reset cursor when mouse leaves
const handleMouseLeave = () => {
if (!isResizingRef.current && !isDraggingRef.current) {
setCursor('default');
setResizeEdge('none');
}
};
// Handle mouse down for resize or drag
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
// Disable move and resize when pencil tool is active
const activeTool = KGMainContentState.instance().getActiveTool();
if (activeTool === 'pencil') {
// Still allow click events to pass through for region selection
if (!hasMovedRef.current && onClick) {
if (DEBUG_MODE.REGION_ITEM) {
console.log(`REGION CLICKED (pencil mode): regionId=${id}`);
}
onClick(id);
}
return;
}
// Prevent text selection during resize/drag
e.preventDefault();
// Reset movement tracking
hasMovedRef.current = false;
// Store initial mouse position
initialMousePosRef.current = { x: e.clientX, y: e.clientY };
if (resizeEdge !== 'none') {
// Start resizing
if (DEBUG_MODE.REGION_ITEM) {
console.log(`RESIZE START: regionId=${id}, edge=${resizeEdge}`);
}
setIsResizing(true);
isResizingRef.current = true;
// Call the onResizeStart callback if provided
if (onResizeStart) {
onResizeStart(id, resizeEdge, e.clientX);
}
} else {
// Start dragging
if (DEBUG_MODE.REGION_ITEM) {
console.log(`DRAG START: regionId=${id}`);
}
setIsDragging(true);
isDraggingRef.current = true;
// Change cursor to grabbing during drag
setCursor('grabbing');
// Call the onDragStart callback if provided
if (onDragStart) {
onDragStart(id, e.clientX, e.clientY);
}
}
// Add global event listeners for mouse move and up
document.addEventListener('mousemove', handleGlobalMouseMove);
document.addEventListener('mouseup', handleGlobalMouseUp);
};
// Handle global mouse move for resize or drag
const handleGlobalMouseMove = (e: MouseEvent) => {
// Set the hasMovedRef to true as soon as there's movement
hasMovedRef.current = true;
if (isResizingRef.current) {
// Handle resize
if (DEBUG_MODE.REGION_ITEM) {
console.log(`RESIZE MOVE: regionId=${id}, edge=${resizeEdge}`);
}
// Calculate delta from initial position
const deltaX = e.clientX - initialMousePosRef.current.x;
// Call the onResize callback if provided
if (onResize) {
onResize(id, resizeEdge, deltaX);
}
} else if (isDraggingRef.current) {
// Handle drag
if (DEBUG_MODE.REGION_ITEM) {
console.log(`DRAG MOVE: regionId=${id}, trackIndex=${trackIndex}`);
}
// Calculate delta from initial position
const deltaX = e.clientX - initialMousePosRef.current.x;
const deltaY = e.clientY - initialMousePosRef.current.y;
// Call the onDrag callback if provided
if (onDrag) {
onDrag(id, deltaX, deltaY);
}
}
};
// Handle global mouse up to end resize or drag
const handleGlobalMouseUp = (e: MouseEvent) => {
if (isResizingRef.current) {
// End resizing
if (DEBUG_MODE.REGION_ITEM) {
console.log(`RESIZE END: regionId=${id}`);
}
setIsResizing(false);
isResizingRef.current = false;
// Call the onResizeEnd callback if provided
if (onResizeEnd) {
onResizeEnd(id, resizeEdge);
}
} else if (isDraggingRef.current) {
// End dragging
if (DEBUG_MODE.REGION_ITEM) {
console.log(`DRAG END: regionId=${id}`);
}
setIsDragging(false);
isDraggingRef.current = false;
// Reset cursor after drag
setCursor('grab');
// Call the onDragEnd callback if provided
if (onDragEnd) {
onDragEnd(id);
}
// If there was no movement, treat it as a click
if (!hasMovedRef.current && onClick) {
if (DEBUG_MODE.REGION_ITEM) {
console.log(`REGION CLICKED: regionId=${id}`);
}
onClick(id);
}
}
// Remove global event listeners
document.removeEventListener('mousemove', handleGlobalMouseMove);
document.removeEventListener('mouseup', handleGlobalMouseUp);
};
// Clean up event listeners on unmount
useEffect(() => {
return () => {
document.removeEventListener('mousemove', handleGlobalMouseMove);
document.removeEventListener('mouseup', handleGlobalMouseUp);
};
}, []);
// Keep the refs in sync with the states
useEffect(() => {
isResizingRef.current = isResizing;
}, [isResizing]);
useEffect(() => {
isDraggingRef.current = isDragging;
}, [isDragging]);
return (
<div
key={id}
className={`track-region ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''}`}
style={{ ...style, cursor }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onMouseDown={handleMouseDown}
data-region-id={id}
data-resize-edge={resizeEdge}
data-is-resizing={isResizing}
data-is-dragging={isDragging}
>
<div className="region-header">
{name}
</div>
<div className="region-content" ref={regionContentRef}>
<button
className="region-pencil-btn"
title="Edit notes"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (DEBUG_MODE.REGION_ITEM) {
console.log(`Pencil clicked: open piano roll for region ${id}`);
}
if (onOpenPianoRoll) {
onOpenPianoRoll(id);
} else if (onClick) {
onClick(id);
}
}}
aria-label="Open piano roll"
>
<FaPencilAlt size={10} />
</button>
<canvas ref={canvasRef} />
</div>
</div>
);
};
export default RegionItem;
+546
View File
@@ -0,0 +1,546 @@
import React, { useEffect, useState, useRef } from 'react';
import { KGTrack } from '../../core/track/KGTrack';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import RegionItem from './RegionItem';
import type { RegionUI, ResizeAction } from '../interfaces';
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
import { KGMainContentState } from '../../core/state/KGMainContentState';
import { isModifierKeyPressed } from '../../util/osUtil';
interface TrackGridItemProps {
track: KGTrack;
index: number;
isDragging: boolean;
isDragOver: boolean;
regions: RegionUI[];
maxBars: number;
selectedRegionId: string | null;
gridContainerRef: React.RefObject<HTMLDivElement | null>;
onDoubleClick: (e: React.MouseEvent<HTMLDivElement>, index: number) => void;
onClick?: (e: React.MouseEvent<HTMLDivElement>, index: number) => void;
onRegionResize?: (regionId: string, newBarNumber: number, newLength: number) => void;
onRegionResizeEnd?: (regionId: string, finalBarNumber: number, finalLength: number) => void;
onRegionDrag?: (regionId: string, newBarNumber: number, newTrackIndex: number) => void;
onRegionDragEnd?: (regionId: string, finalBarNumber: number, finalTrackIndex: number) => void;
onRegionClick?: (regionId: string) => void;
onOpenPianoRoll?: (regionId: string) => void;
allTracks?: KGTrack[]; // Added to access all tracks for drag operations
}
const TrackGridItem: React.FC<TrackGridItemProps> = ({
track,
index,
isDragging,
isDragOver,
regions,
maxBars,
selectedRegionId,
gridContainerRef,
onDoubleClick,
onClick,
onRegionResize,
onRegionResizeEnd,
onRegionDrag,
onRegionDragEnd,
onRegionClick,
onOpenPianoRoll,
allTracks
}) => {
const [containerWidth, setContainerWidth] = useState(0);
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
const [draggingRegion, setDraggingRegion] = useState<string | null>(null);
const [tempRegionStyles, setTempRegionStyles] = useState<Record<string, React.CSSProperties>>({});
const [isModifierPressed, setIsModifierPressed] = useState(false);
// Refs for resize operations
const mouseMoved = useRef(false);
const currentResizeWidth = useRef<number | null>(null);
const currentResizeLeft = useRef<number | null>(null);
const currentResizeRegion = useRef<RegionUI | null>(null);
const initialBarNumberRef = useRef<number | null>(null);
const initialLengthRef = useRef<number | null>(null);
// Refs for drag operations
const currentDragLeft = useRef<number | null>(null);
const currentDragTop = useRef<number | null>(null);
const currentDragRegion = useRef<RegionUI | null>(null);
const trackElementRef = useRef<HTMLDivElement | null>(null);
// Update container width when the grid container changes size
useEffect(() => {
if (!gridContainerRef.current) return;
setContainerWidth(gridContainerRef.current.clientWidth);
const resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
resizeObserver.observe(gridContainerRef.current);
return () => {
if (gridContainerRef.current) {
resizeObserver.unobserve(gridContainerRef.current);
}
};
}, [gridContainerRef]);
// Track modifier key state for cursor feedback
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isModifierKeyPressed(e)) {
setIsModifierPressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!isModifierKeyPressed(e)) {
setIsModifierPressed(false);
}
};
// Add global event listeners
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
// Cleanup listeners on unmount
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
// Calculate region position and style
const getRegionStyle = (region: RegionUI) => {
// Check if there's a temporary style for this region during resize or drag
if ((resizingRegion === region.id || draggingRegion === region.id) && tempRegionStyles[region.id]) {
return tempRegionStyles[region.id];
}
// Calculate the width of each bar
const barWidth = containerWidth / maxBars;
// Calculate left position (0-indexed bar number)
const left = (region.barNumber - 1) * barWidth;
// Calculate width based on region length
const width = region.length * barWidth;
return {
left: `${left}px`,
width: `${width}px`,
position: 'absolute' as const, // Fixed: Use const assertion
};
};
// Handle region resize start
const handleRegionResizeStart = (regionId: string, resizeAction: ResizeAction, initialX: number) => {
// Disable resizing in pencil mode
if (KGMainContentState.instance().getActiveTool() === 'pencil') {
return;
}
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`RESIZE START: regionId=${regionId}, action=${resizeAction}`);
}
setResizingRegion(regionId);
// Reset the mouse moved flag
mouseMoved.current = false;
// Find the region being resized
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Store the region for reference
currentResizeRegion.current = region;
initialBarNumberRef.current = region.barNumber;
initialLengthRef.current = region.length;
// Calculate the width of each bar
const barWidth = containerWidth / maxBars;
// Store the initial width and left position
currentResizeWidth.current = region.length * barWidth;
currentResizeLeft.current = (region.barNumber - 1) * barWidth;
// Set initial style to current position/size
const initialStyle = {
left: `${currentResizeLeft.current}px`,
width: `${currentResizeWidth.current}px`,
position: 'absolute' as const, // Fixed: Use const assertion
};
setTempRegionStyles(prev => ({
...prev,
[regionId]: initialStyle
}));
};
// Handle region resize
const handleRegionResize = (regionId: string, resizeAction: ResizeAction, deltaX: number) => {
// Set the mouse moved flag to true
mouseMoved.current = true;
// Find the region being resized
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Calculate the width of each bar
const barWidth = containerWidth / maxBars;
// Get initial values
const originalWidth = initialLengthRef.current! * barWidth;
const originalLeft = (initialBarNumberRef.current! - 1) * barWidth;
let newLeft = originalLeft;
let newWidth = originalWidth;
if (resizeAction === 'end') {
// End resize: adjust width only
newWidth = Math.max(barWidth * REGION_CONSTANTS.MIN_REGION_LENGTH, originalWidth + deltaX);
} else if (resizeAction === 'start') {
// Start resize: adjust both left position and width
// Calculate maximum delta to prevent negative width
const maxDelta = originalWidth - barWidth * REGION_CONSTANTS.MIN_REGION_LENGTH;
const clampedDeltaX = Math.min(maxDelta, deltaX);
// Adjust left position and width
newLeft = originalLeft + clampedDeltaX;
newWidth = originalWidth - clampedDeltaX;
}
// Store the current values in refs
currentResizeWidth.current = newWidth;
currentResizeLeft.current = newLeft;
// Calculate the new bar number and length (not rounded yet, for smooth resizing)
const newBarNumber = (newLeft / barWidth) + 1;
const newLength = newWidth / barWidth;
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`RESIZE: regionId=${regionId}, action=${resizeAction}, deltaX=${deltaX}, newBarNumber=${newBarNumber}, newLength=${newLength}`);
}
// Update the temporary style for this region
const newStyle = {
left: `${newLeft}px`,
width: `${newWidth}px`,
position: 'absolute' as const, // Fixed: Use const assertion
};
setTempRegionStyles(prev => ({
...prev,
[regionId]: newStyle
}));
// Notify parent about resize
if (onRegionResize) {
onRegionResize(regionId, newBarNumber, newLength);
}
};
// Handle region resize end
const handleRegionResizeEnd = (regionId: string, resizeAction: ResizeAction) => {
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`RESIZE END: regionId=${regionId}, action=${resizeAction}, mouseMoved=${mouseMoved.current}`);
console.log('Current resize width from ref:', currentResizeWidth.current);
console.log('Current resize left from ref:', currentResizeLeft.current);
}
// Find the region being resized
const region = regions.find(r => r.id === regionId) || currentResizeRegion.current;
if (!region) {
console.error(`Region not found: ${regionId}`);
return;
}
// Calculate the width of each bar
const barWidth = containerWidth / maxBars;
let newBarNumber = region.barNumber; // Default to current bar number
let newLength = region.length; // Default to current length
// If the mouse was moved and we have current values, calculate the new values
if (mouseMoved.current && currentResizeWidth.current !== null && currentResizeLeft.current !== null) {
if (resizeAction === 'end') {
// End resize: round length to nearest bar
newLength = Math.max(REGION_CONSTANTS.MIN_REGION_LENGTH, Math.round(currentResizeWidth.current / barWidth));
} else if (resizeAction === 'start') {
// Start resize: round bar number and adjust length accordingly
const rawBarNumber = currentResizeLeft.current / barWidth + 1;
newBarNumber = Math.max(1, Math.round(rawBarNumber));
// Calculate the difference from the initial position
const barDiff = initialBarNumberRef.current! - newBarNumber;
// Adjust length to maintain the end position
newLength = initialLengthRef.current! + barDiff;
// Ensure minimum length
if (newLength < REGION_CONSTANTS.MIN_REGION_LENGTH) {
newLength = REGION_CONSTANTS.MIN_REGION_LENGTH;
newBarNumber = initialBarNumberRef.current! + initialLengthRef.current! - REGION_CONSTANTS.MIN_REGION_LENGTH;
}
}
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`Calculated new values: barNumber=${newBarNumber}, length=${newLength}`);
}
} else if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`Using existing values: barNumber=${newBarNumber}, length=${newLength}`);
}
// Clear resizing state
setResizingRegion(null);
setTempRegionStyles(prev => {
const updated = { ...prev };
delete updated[regionId];
return updated;
});
currentResizeWidth.current = null;
currentResizeLeft.current = null;
currentResizeRegion.current = null;
initialBarNumberRef.current = null;
initialLengthRef.current = null;
// Notify parent about resize end with rounded values
if (onRegionResizeEnd) {
onRegionResizeEnd(regionId, newBarNumber, newLength);
}
};
// Handle region drag start
const handleRegionDragStart = (regionId: string, initialX: number, initialY: number) => {
// Disable dragging in pencil mode
if (KGMainContentState.instance().getActiveTool() === 'pencil') {
return;
}
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`DRAG START: regionId=${regionId}`);
}
setDraggingRegion(regionId);
// Reset the mouse moved flag
mouseMoved.current = false;
// Find the region being dragged
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Store the region for reference
currentDragRegion.current = region;
// Calculate the width of each bar
const barWidth = containerWidth / maxBars;
// Calculate the left position
const left = (region.barNumber - 1) * barWidth;
const width = region.length * barWidth;
// Store the initial position
currentDragLeft.current = left;
currentDragTop.current = 0; // Initially at the top of the current track
// Set initial style
const initialStyle = {
left: `${left}px`,
width: `${width}px`,
position: 'absolute' as const, // Fixed: Use const assertion
zIndex: 100, // Bring to front during drag
};
setTempRegionStyles(prev => ({
...prev,
[regionId]: initialStyle
}));
};
// Handle region drag
const handleRegionDrag = (regionId: string, deltaX: number, deltaY: number) => {
// Set the mouse moved flag to true
mouseMoved.current = true;
// Find the region being dragged
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Calculate the width of each bar
const barWidth = containerWidth / maxBars;
// Get the initial left position
const initialLeft = (region.barNumber - 1) * barWidth;
// Calculate new left position
const newLeft = initialLeft + deltaX;
// Calculate the new bar number (not rounded yet, for smooth dragging)
const newBarNumber = (newLeft / barWidth) + 1;
// Store the current drag position for use in handleRegionDragEnd
currentDragLeft.current = newLeft;
currentDragTop.current = deltaY;
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`DRAG: regionId=${regionId}, deltaX=${deltaX}, deltaY=${deltaY}, newBarNumber=${newBarNumber}`);
}
// Update the temporary style for this region
const newStyle = {
left: `${newLeft}px`,
width: `${region.length * barWidth}px`,
position: 'absolute' as const,
zIndex: 100, // Keep on top during drag
transform: `translateY(${deltaY}px)`,
};
setTempRegionStyles(prev => ({
...prev,
[regionId]: newStyle
}));
// We'll calculate the track index on drag end, but still notify parent about the drag
if (onRegionDrag) {
onRegionDrag(regionId, newBarNumber, region.trackIndex);
}
};
// Handle region drag end
const handleRegionDragEnd = (regionId: string) => {
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`DRAG END: regionId=${regionId}, mouseMoved=${mouseMoved.current}`);
}
// Find the region being dragged
const region = regions.find(r => r.id === regionId) || currentDragRegion.current;
if (!region) {
console.error(`Region not found: ${regionId}`);
return;
}
// Calculate the width of each bar
const barWidth = containerWidth / maxBars;
// Default to current position
let finalBarNumber = region.barNumber;
let finalTrackIndex = region.trackIndex;
// If the mouse was moved, calculate the final position
if (mouseMoved.current && currentDragLeft.current !== null && currentDragTop.current !== null) {
// Calculate the new bar number and round to nearest integer
const rawBarNumber = (currentDragLeft.current / barWidth) + 1;
finalBarNumber = Math.max(1, Math.round(rawBarNumber));
// Calculate the closest track based on vertical position
if (allTracks && allTracks.length > 0 && gridContainerRef.current) {
const trackHeight = gridContainerRef.current.clientHeight / allTracks.length;
// Calculate the absolute vertical position
const originTrackTop = region.trackIndex * trackHeight;
const absoluteY = originTrackTop + currentDragTop.current;
// Find the closest track index
const closestTrackIndex = Math.max(0, Math.min(
allTracks.length - 1,
Math.round(absoluteY / trackHeight)
));
finalTrackIndex = closestTrackIndex;
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`Calculated closest track: ${finalTrackIndex} (from Y=${currentDragTop.current}, absoluteY=${absoluteY}, trackHeight=${trackHeight})`);
if (finalTrackIndex !== region.trackIndex) {
console.log(`Track change: from trackIndex=${region.trackIndex} (trackId=${region.trackId}) to trackIndex=${finalTrackIndex} (trackId=${allTracks[finalTrackIndex].getId()})`);
}
}
}
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`Final position: barNumber=${finalBarNumber}, trackIndex=${finalTrackIndex}`);
}
}
// Clear dragging state
setDraggingRegion(null);
setTempRegionStyles(prev => {
const updated = { ...prev };
delete updated[regionId];
return updated;
});
currentDragLeft.current = null;
currentDragTop.current = null;
currentDragRegion.current = null;
// Notify parent about drag end with final values
if (onRegionDragEnd) {
onRegionDragEnd(regionId, finalBarNumber, finalTrackIndex);
}
};
// Handle region click
const handleRegionClick = (regionId: string) => {
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`Region clicked: ${regionId}`);
}
if (onRegionClick) {
onRegionClick(regionId);
}
};
// Filter regions for this track
const trackRegions = regions.filter(region => region.trackIndex === index);
return (
<div
className={`track-grid ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isModifierPressed ? 'pencil-cursor' : ''}`}
data-test-id={`track-grid-${track.getId()}`}
onDoubleClick={(e) => onDoubleClick(e, index)}
onClick={(e) => onClick && onClick(e, index)}
ref={trackElementRef}
>
{/* Render regions for this track */}
{trackRegions.map(region => {
// Find the corresponding KGMidiRegion in the track
const midiRegion = track.getRegions().find(r => r.getId() === region.id) as KGMidiRegion | undefined;
return (
<RegionItem
key={region.id}
id={region.id}
name={region.name}
style={getRegionStyle(region)}
barNumber={region.barNumber}
length={region.length}
trackIndex={index}
onResizeStart={handleRegionResizeStart}
onResize={handleRegionResize}
onResizeEnd={handleRegionResizeEnd}
onDragStart={handleRegionDragStart}
onDrag={handleRegionDrag}
onDragEnd={handleRegionDragEnd}
// Keep onClick for selection-only logic if needed by parent
onClick={handleRegionClick}
// New explicit pencil action
onOpenPianoRoll={(regionId) => {
if (onOpenPianoRoll) {
onOpenPianoRoll(regionId);
} else if (onRegionClick) {
// Fallback to legacy behavior
onRegionClick(regionId);
}
}}
midiRegion={midiRegion}
/>
);
})}
</div>
);
};
export default TrackGridItem;
+332
View File
@@ -0,0 +1,332 @@
import React, { useRef } from 'react';
import { KGTrack } from '../../core/track/KGTrack';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import TrackGridItem from './TrackGridItem';
import { Playhead } from '../common';
import type { RegionUI } from '../interfaces';
import { DEBUG_MODE } from '../../constants';
import { KGMainContentState } from '../../core/state/KGMainContentState';
import { isModifierKeyPressed } from '../../util/osUtil';
import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand } from '../../core/commands';
import { KGCore } from '../../core/KGCore';
interface TrackGridPanelProps {
tracks: KGTrack[];
regions: RegionUI[];
maxBars: number;
timeSignature: { numerator: number; denominator: number };
draggedTrackIndex: number | null;
dragOverTrackIndex: number | null;
selectedRegionId: string | null;
onRegionCreated: (trackIndex: number, region: RegionUI, midiRegion: KGMidiRegion) => void;
onRegionUpdated?: (regionId: string, updates: Partial<RegionUI>, expectedModelUpdates?: { startBeat: number, length: number }) => void;
onRegionClick?: (regionId: string) => void;
onOpenPianoRoll?: (regionId: string) => void;
}
const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
tracks,
regions,
maxBars,
timeSignature,
draggedTrackIndex,
dragOverTrackIndex,
selectedRegionId,
onRegionCreated,
onRegionUpdated,
onRegionClick,
onOpenPianoRoll
}) => {
const gridContainerRef = useRef<HTMLDivElement>(null);
// Utility function to create a region at a specific position
const createRegionAtPosition = (e: React.MouseEvent<HTMLDivElement>, trackIndex: number) => {
// Get the grid container element
const gridContainer = e.currentTarget.closest('.grid-container');
if (!gridContainer) return;
// Get the grid container's bounding rectangle
const gridRect = gridContainer.getBoundingClientRect();
// Calculate the relative X position within the grid
const relativeX = e.clientX - gridRect.left;
// Calculate the width of each bar
const barWidth = gridContainer.clientWidth / maxBars;
// Calculate which bar was clicked (0-indexed)
const barIndex = Math.floor(relativeX / barWidth);
// Add 1 to convert to 1-indexed bar number
const barNumber = barIndex + 1;
// Get the track and its ID
const track = tracks[trackIndex];
const trackId = track.getId().toString();
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Creating region on track ${trackIndex + 1}, bar ${barNumber}`);
}
// Get beats per bar from the time signature
const beatsPerBar = timeSignature.numerator;
// Create and execute the region creation command
const command = CreateRegionCommand.fromBarCoordinates(
trackId,
trackIndex,
barNumber,
1, // Default to 1 bar length
beatsPerBar,
`${track.getName()} Region`
);
KGCore.instance().executeCommand(command);
// Get the created region for the UI callback
const createdRegion = command.getCreatedRegion();
if (createdRegion) {
// Create the region UI object for the parent component
const newRegionUI: RegionUI = {
id: createdRegion.getId(),
trackId: trackId,
trackIndex,
barNumber,
length: 1,
name: createdRegion.getName()
};
// Notify parent about the new region (for UI state updates)
onRegionCreated(trackIndex, newRegionUI, createdRegion);
}
};
// Handle double click on track grid to create region
const handleTrackGridDoubleClick = (e: React.MouseEvent<HTMLDivElement>, trackIndex: number) => {
// Only allow double-click creation in pointer mode
if (KGMainContentState.instance().getActiveTool() === 'pencil') {
return;
}
createRegionAtPosition(e, trackIndex);
};
// Handle single click on track grid for pencil mode or modifier+click
const handleTrackGridClick = (e: React.MouseEvent<HTMLDivElement>, trackIndex: number) => {
// Create region on single click in pencil mode OR when modifier key is pressed
if (KGMainContentState.instance().getActiveTool() === 'pencil' || isModifierKeyPressed(e)) {
createRegionAtPosition(e, trackIndex);
}
};
// Handle region resize during drag
const handleRegionResize = (regionId: string, newBarNumber: number, newLength: number) => {
// This is just for live visual updates, we don't update the model yet
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Resizing region ${regionId} to barNumber ${newBarNumber}, length ${newLength}`);
}
};
// Handle region resize end
const handleRegionResizeEnd = (regionId: string, finalBarNumber: number, finalLength: number) => {
// Now we update the model with the final rounded values
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Finished resizing region ${regionId} to barNumber ${finalBarNumber}, length ${finalLength}`);
}
// Find the region
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Calculate new start and length in beats
const beatsPerBar = timeSignature.numerator;
const newStartBeat = (finalBarNumber - 1) * beatsPerBar;
const newLengthInBeats = finalLength * beatsPerBar;
// Find the track that contains this region
const track = tracks.find(t => t.getId().toString() === region.trackId);
if (!track) return;
// Update the region in the track's model
const trackRegions = track.getRegions();
const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
if (midiRegion) {
const oldStartBeat = midiRegion.getStartFromBeat();
const oldBarNumber = region.barNumber;
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Updating KGRegion model - Before: startBeat=${oldStartBeat}, length=${midiRegion.getLength()}`);
console.log(`Bar numbers - old: ${oldBarNumber}, new: ${finalBarNumber}`);
}
// Use command pattern to update the region position and length (note adjustments handled inside command)
try {
const command = ResizeRegionCommand.fromBarCoordinates(
regionId,
finalBarNumber,
finalLength,
timeSignature
);
KGCore.instance().executeCommand(command);
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Executed ResizeRegionCommand: region ${regionId} resized using command pattern`);
// Verify the command worked
const updatedRegion = track.getRegions().find(r => r.getId() === regionId);
console.log(`Verified region in track: ${updatedRegion ? 'found' : 'not found'}, startBeat=${updatedRegion?.getStartFromBeat()}, length=${updatedRegion?.getLength()}`);
}
} catch (error) {
console.error('Error resizing region:', error);
return;
}
}
// Update the region in the parent component with expected model values
if (onRegionUpdated) {
onRegionUpdated(
regionId,
{ barNumber: finalBarNumber, length: finalLength },
{ startBeat: newStartBeat, length: newLengthInBeats }
);
}
};
// Handle region drag during movement
const handleRegionDrag = (regionId: string, newBarNumber: number, trackIndex: number) => {
// This is just for live visual updates, we don't update the model yet
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Dragging region ${regionId} to barNumber ${newBarNumber}, trackIndex ${trackIndex}`);
}
// We don't need to update any temporary state in the parent component anymore
// The region will follow the mouse directly using transform in the TrackGridItem component
};
// Handle region drag end
const handleRegionDragEnd = (regionId: string, finalBarNumber: number, finalTrackIndex: number) => {
// Now we update the model with the final rounded values
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Finished dragging region ${regionId} to barNumber ${finalBarNumber}, trackIndex ${finalTrackIndex}`);
}
// Find the region
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Get the target track
const targetTrack = tracks[finalTrackIndex];
if (!targetTrack) return;
// Use command pattern to move the region
try {
const command = MoveRegionCommand.fromBarCoordinates(
regionId,
finalBarNumber,
targetTrack.getId().toString(),
finalTrackIndex,
timeSignature
);
KGCore.instance().executeCommand(command);
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Executed MoveRegionCommand: region ${regionId} moved using command pattern`);
// Verify the command worked
const movedRegion = command.getTargetRegion();
console.log(`Verified region: ${movedRegion ? 'found' : 'not found'}, startBeat=${movedRegion?.getStartFromBeat()}, trackId=${movedRegion?.getTrackId()}`);
}
} catch (error) {
console.error('Error moving region:', error);
return;
}
// Calculate new start in beats for UI update
const beatsPerBar = timeSignature.numerator;
const startBeat = (finalBarNumber - 1) * beatsPerBar;
// Update the region in the parent component with expected model values
if (onRegionUpdated) {
// Find the updated region to get its length
const updatedTrack = tracks[finalTrackIndex];
const updatedRegions = updatedTrack.getRegions();
const updatedRegion = updatedRegions.find(r => r.getId() === regionId);
onRegionUpdated(
regionId,
{
trackId: targetTrack.getId().toString(),
trackIndex: finalTrackIndex,
barNumber: finalBarNumber
},
{
startBeat,
length: updatedRegion ? updatedRegion.getLength() : 0
}
);
}
};
// Handle region click
const handleRegionClick = (regionId: string) => {
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Region clicked in panel: ${regionId}`);
}
// Find the region
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Find the track that contains this region
const track = tracks.find(t => t.getId().toString() === region.trackId);
if (!track) return;
// Find the region in the track's model
const trackRegions = track.getRegions();
const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
if (midiRegion && DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Found region in model: ${midiRegion.getId()}, trackId=${midiRegion.getTrackId()}, name=${midiRegion.getName()}`);
}
// Notify parent about the click
if (onRegionClick) {
onRegionClick(regionId);
}
};
return (
<div className="grid-container" ref={gridContainerRef}>
{/* Playhead */}
<Playhead context="main-grid" />
{/* Track grids */}
{tracks.map((track, index) => (
<TrackGridItem
key={track.getId()}
track={track}
index={index}
isDragging={draggedTrackIndex === index}
isDragOver={dragOverTrackIndex === index}
regions={regions}
maxBars={maxBars}
selectedRegionId={selectedRegionId}
gridContainerRef={gridContainerRef}
onDoubleClick={handleTrackGridDoubleClick}
onClick={handleTrackGridClick}
onRegionResize={handleRegionResize}
onRegionResizeEnd={handleRegionResizeEnd}
onRegionDrag={handleRegionDrag}
onRegionDragEnd={handleRegionDragEnd}
onRegionClick={handleRegionClick}
onOpenPianoRoll={onOpenPianoRoll}
allTracks={tracks}
/>
))}
</div>
);
};
export default TrackGridPanel;
+319
View File
@@ -0,0 +1,319 @@
import React, { useState, useRef, useEffect } from 'react';
import { KGTrack } from '../../core/track/KGTrack';
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
import { useProjectStore } from '../../stores/projectStore';
import { TbPiano } from 'react-icons/tb';
import { TbSettings } from 'react-icons/tb';
import KGDropdown from '../common/KGDropdown';
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
import { DEBUG_MODE } from '../../constants/uiConstants';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
interface TrackInfoItemProps {
track: KGTrack;
index: number;
isDragging: boolean;
isDragOver: boolean;
onTrackClick?: () => void;
onTrackNameEdit: (track: KGTrack, newName: string) => void; // eslint-disable-line no-unused-vars
onDragStart: (e: React.DragEvent<HTMLDivElement>, index: number) => void; // eslint-disable-line no-unused-vars
onDragOver: (e: React.DragEvent<HTMLDivElement>, index: number) => void; // eslint-disable-line no-unused-vars
onDrop: (e: React.DragEvent<HTMLDivElement>) => void; // eslint-disable-line no-unused-vars
onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void; // eslint-disable-line no-unused-vars
}
const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
track,
index,
isDragging,
isDragOver,
onTrackClick,
onTrackNameEdit,
onDragStart,
onDragOver,
onDrop,
onDragEnd
}) => {
const { selectedTrackId, setSelectedTrack, removeTrack, toggleInstrumentSelectionForTrack, tracks: allTracks } = useProjectStore();
const isSelected = selectedTrackId === track.getId().toString();
// Inline instrument dropdown removed; use InstrumentSelection panel instead
// Initialize current instrument from track data
const getTrackInstrument = () => {
if (track instanceof KGMidiTrack) {
return track.getInstrument();
}
return 'acoustic_grand_piano'; // Default fallback
};
const [currentInstrument, setCurrentInstrument] = useState(getTrackInstrument());
const [showSettingsDropdown, setShowSettingsDropdown] = useState(false);
const settingsDropdownRef = useRef<HTMLDivElement>(null);
const suppressDragRef = useRef(false);
const [volume, setVolume] = useState(track.getVolume());
// Local flag to track slider interaction; not used for rendering
const isAdjustingVolumeRef = useRef(false);
const [muted, setMuted] = useState(false);
const [solo, setSolo] = useState(false);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
showSettingsDropdown &&
settingsDropdownRef.current &&
!settingsDropdownRef.current.contains(event.target as Node)
) {
setShowSettingsDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showSettingsDropdown]);
// Sync currentInstrument state with actual track instrument value
const instrumentFromTrack = track instanceof KGMidiTrack ? track.getInstrument() : 'acoustic_grand_piano';
useEffect(() => {
setCurrentInstrument(instrumentFromTrack);
}, [instrumentFromTrack]);
// Sync volume UI with model when tracks state changes (e.g., load, undo/redo, external updates)
useEffect(() => {
setVolume(track.getVolume());
}, [allTracks, track]);
// Handle track name edit within the component
const handleTrackNameClick = (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent opening piano roll when clicking track name
const newName = prompt("Enter track name:", track.getName());
if (newName) {
// Call the parent handler with the new name
onTrackNameEdit(track, newName);
}
};
// Prevent drag reordering when interacting with interactive controls
const handleMouseDownCapture = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
const isInteractive = !!target.closest(
'input, button, .volume-slider, .instrument-dropdown, .settings-dropdown'
);
suppressDragRef.current = isInteractive;
};
const handleDragStartWrapper = (e: React.DragEvent<HTMLDivElement>) => {
if (suppressDragRef.current) {
e.preventDefault();
e.stopPropagation();
suppressDragRef.current = false;
return;
}
onDragStart(e, index);
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
const next = Number(e.target.value) / 100;
isAdjustingVolumeRef.current = true;
setVolume(next);
try {
// Live preview: update audio only while sliding
KGAudioInterface.instance().setTrackVolume(track.getId().toString(), next);
} catch (err) {
console.error('Failed to update live volume:', err);
}
};
const commitVolumeChange = () => {
// Only commit if value actually changed from model to avoid extra commands
const modelVolume = track.getVolume();
if (Math.abs(modelVolume - volume) < 1e-6) {
isAdjustingVolumeRef.current = false;
return;
}
try {
useProjectStore.getState().updateTrackProperties(track.getId(), { volume });
} catch (err) {
console.error('Failed to persist volume:', err);
} finally {
isAdjustingVolumeRef.current = false;
}
};
const handleResetVolume = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const defaultVolume = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
setVolume(defaultVolume);
try {
useProjectStore.getState().updateTrackProperties(track.getId(), { volume: defaultVolume });
} catch (err) {
console.error('Failed to reset volume:', err);
}
};
const handleToggleMute = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const next = !muted;
setMuted(next);
try {
KGAudioInterface.instance().setTrackMute(track.getId().toString(), next);
} catch (err) {
console.error('Failed to toggle mute:', err);
}
};
const handleToggleSolo = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const next = !solo;
setSolo(next);
try {
KGAudioInterface.instance().setTrackSolo(track.getId().toString(), next);
} catch (err) {
console.error('Failed to toggle solo:', err);
}
};
// Handle track click
const handleTrackClick = () => {
// Select this track when clicked
setSelectedTrack(track.getId().toString());
if (onTrackClick) {
onTrackClick();
}
};
// Inline instrument change removed; handled by InstrumentSelection panel
// Handle piano button click
const handlePianoButtonClick = (e: React.MouseEvent) => {
e.stopPropagation();
// Select this track as active when opening instrument panel
setSelectedTrack(track.getId().toString());
// Toggle global InstrumentSelection panel for this track
toggleInstrumentSelectionForTrack(track.getId().toString());
};
// Handle settings button click
const handleSettingsButtonClick = (e: React.MouseEvent) => {
e.stopPropagation();
setShowSettingsDropdown(!showSettingsDropdown);
};
// Handle settings action
const handleSettingsAction = async (action: string) => {
if (action === 'Delete Track') {
const confirmed = window.confirm(`Are you sure you want to delete track "${track.getName()}"?`);
if (confirmed) {
try {
if (DEBUG_MODE.TRACK_INFO) {
console.log('Delete track confirmed for:', track.getName());
}
// Clear selection if this track is selected
if (selectedTrackId === track.getId().toString()) {
setSelectedTrack(null);
}
// Delete the track using the command system
await removeTrack(track.getId());
// Close the settings dropdown
setShowSettingsDropdown(false);
} catch (error) {
console.error('Failed to delete track:', error);
alert('Failed to delete track. Please try again.');
}
}
}
};
return (
<div
className={`track-info ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''}`}
data-test-id={`track-info-${track.getId()}`}
onClick={handleTrackClick}
onMouseDownCapture={handleMouseDownCapture}
draggable={true}
onDragStart={handleDragStartWrapper}
onDragOver={(e) => onDragOver(e, index)}
onDrop={onDrop}
onDragEnd={onDragEnd}
>
<div className="track-controls">
<div className="track-name-and-volume">
<div className="instrument-image">
<img
src={`/resources/instruments/${String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.image || 'piano.png')}`}
alt={String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.displayName || currentInstrument)}
width="64"
height="64"
/>
</div>
<div className="track-name-and-controls">
<div
className="track-name"
onClick={handleTrackNameClick}
>
{track.getName()}
</div>
<div className="volume-slider">
<input
type="range"
min="0"
max="100"
value={Math.round(volume * 100)}
onChange={handleVolumeChange}
onMouseDown={(e) => { e.stopPropagation(); isAdjustingVolumeRef.current = true; }}
onMouseUp={(e) => { e.stopPropagation(); commitVolumeChange(); }}
onTouchStart={(e) => { e.stopPropagation(); isAdjustingVolumeRef.current = true; }}
onTouchEnd={(e) => { e.stopPropagation(); commitVolumeChange(); }}
onBlur={commitVolumeChange}
onClick={(e) => e.stopPropagation()}
/>
<button
className="reset-volume"
title="Reset volume"
aria-label="Reset volume"
onClick={handleResetVolume}
>
</button>
</div>
</div>
</div>
<div className="pan-controls">
<button className={`solo${solo ? ' active' : ''}`} onClick={handleToggleSolo}>S</button>
<button className={`mute${muted ? ' active' : ''}`} onClick={handleToggleMute}>M</button>
<div>
<button className="instrument" onClick={handlePianoButtonClick}>
<TbPiano />
</button>
</div>
<div style={{ position: 'relative' }} ref={settingsDropdownRef}>
<button className="settings" onClick={handleSettingsButtonClick}>
<TbSettings />
</button>
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}>
<KGDropdown
options={['Delete Track']}
value={''}
onChange={handleSettingsAction}
label="Settings"
hideButton={true}
isOpen={showSettingsDropdown}
onToggle={setShowSettingsDropdown}
className="settings-dropdown"
/>
</div>
</div>
</div>
</div>
</div>
);
};
export default TrackInfoItem;
+107
View File
@@ -0,0 +1,107 @@
import React, { useState } from 'react';
import { KGTrack } from '../../core/track/KGTrack';
import { useProjectStore } from '../../stores/projectStore';
import TrackInfoItem from './TrackInfoItem';
interface TrackInfoPanelProps {
tracks: KGTrack[];
onTrackClick?: () => void;
onTrackNameEdit: (track: KGTrack, newName: string) => void;
onTracksReordered: (fromIndex: number, toIndex: number) => void;
}
const TrackInfoPanel: React.FC<TrackInfoPanelProps> = ({
tracks,
onTrackClick,
onTrackNameEdit,
onTracksReordered
}) => {
const { setSelectedTrack } = useProjectStore();
// Drag state for track reordering
const [draggedTrackIndex, setDraggedTrackIndex] = useState<number | null>(null);
const [dragOverTrackIndex, setDragOverTrackIndex] = useState<number | null>(null);
// Handle track drag events
const handleTrackDragStart = (e: React.DragEvent<HTMLDivElement>, index: number) => {
setDraggedTrackIndex(index);
// Set a custom drag image or data if needed
e.dataTransfer.setData('text/plain', index.toString());
e.dataTransfer.effectAllowed = 'move';
// Add a class to the dragged element - store a reference to avoid null issues
const element = e.currentTarget;
if (element) {
// Add class immediately instead of using setTimeout
element.classList.add('dragging');
}
};
const handleTrackDragOver = (e: React.DragEvent<HTMLDivElement>, index: number) => {
e.preventDefault(); // Necessary to allow dropping
// Only update if the drag over index has changed
if (dragOverTrackIndex !== index) {
setDragOverTrackIndex(index);
}
e.dataTransfer.dropEffect = 'move';
};
const handleTrackDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (draggedTrackIndex !== null && dragOverTrackIndex !== null && draggedTrackIndex !== dragOverTrackIndex) {
// Notify parent component about the reordering
onTracksReordered(draggedTrackIndex, dragOverTrackIndex);
// Select the track that was moved (it's now at the dragOverTrackIndex position)
const movedTrack = tracks[draggedTrackIndex];
if (movedTrack) {
setSelectedTrack(movedTrack.getId().toString());
}
}
// Reset drag state
setDraggedTrackIndex(null);
setDragOverTrackIndex(null);
// Remove the dragging class from all elements
document.querySelectorAll('.track-info.dragging').forEach(el => {
el.classList.remove('dragging');
});
};
const handleTrackDragEnd = (e: React.DragEvent<HTMLDivElement>) => {
// Reset drag state
setDraggedTrackIndex(null);
setDragOverTrackIndex(null);
// Remove the dragging class
if (e.currentTarget) {
e.currentTarget.classList.remove('dragging');
}
};
return (
<div className="info-container">
{tracks.map((track, index) => (
<TrackInfoItem
key={track.getId()}
track={track}
index={index}
isDragging={draggedTrackIndex === index}
isDragOver={dragOverTrackIndex === index}
onTrackClick={onTrackClick}
onTrackNameEdit={onTrackNameEdit}
onDragStart={handleTrackDragStart}
onDragOver={handleTrackDragOver}
onDrop={handleTrackDrop}
onDragEnd={handleTrackDragEnd}
/>
))}
</div>
);
};
export default TrackInfoPanel;