initial public release.
This commit is contained in:
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as UserMessage } from './UserMessage';
|
||||
export { default as AssistantMessage } from './AssistantMessage';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user