1232 lines
40 KiB
TypeScript
1232 lines
40 KiB
TypeScript
import React from 'react';
|
|
import './Toolbar.css';
|
|
import { saveProject } from '../util/saveUtil';
|
|
import { KGProjectStorage } from '../core/io/KGProjectStorage';
|
|
import { isValidProjectName, isReservedProjectName, RESERVED_PROJECT_NAME } from '../util/projectNameUtil';
|
|
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, FaSync,
|
|
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
|
|
FaCog, FaMagnet, FaCut, FaCircle, FaCompress
|
|
} from 'react-icons/fa';
|
|
import { KGProject, type KeySignature } from '../core/KGProject';
|
|
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
|
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
|
import { plainToInstance } from 'class-transformer';
|
|
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles, FaListUl } 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 { KGOfflineRenderer } from '../core/audio-interface/KGOfflineRenderer';
|
|
import KGDropdown from './common/KGDropdown';
|
|
import FloatingPopup from './common/FloatingPopup';
|
|
import FileImportModal from './common/FileImportModal';
|
|
import LoadingOverlay from './common/LoadingOverlay';
|
|
import OpenProjectModal from './common/OpenProjectModal';
|
|
import KeySignaturePickerPopup from './KeySignaturePickerPopup';
|
|
import { clearChatHistoryAndUI } from '../util/chatUtil';
|
|
import PianoIcon from './common/icons/PianoIcon';
|
|
import MetronomeIcon from './common/icons/MetronomeIcon';
|
|
import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil';
|
|
import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
|
|
|
|
const Toolbar: React.FC = () => {
|
|
const {
|
|
projectName, setProjectName,
|
|
savedProjectName, setSavedProjectName,
|
|
bpm, timeSignature, keySignature, setStatus,
|
|
isPlaying, isPreparingPlayback, startPlaying, stopTransport, setPlayheadPosition,
|
|
currentTime, setBpm, setTimeSignature, setKeySignature,
|
|
maxBars, setMaxBars,
|
|
barWidthMultiplier, setBarWidthMultiplier,
|
|
isLooping, toggleLoop,
|
|
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
|
|
toggleChatBox, toggleSettings, toggleKGOnePanel, toggleEventListPanel, activateSidePanel, showKGOnePanel, showEventListPanel, showChatBox, showSettings, cleanupProjectState, toggleMetronome, isMetronomeEnabled,
|
|
isRecording, startRecording, stopRecording,
|
|
// Piano roll state/actions
|
|
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
|
|
// Selection state
|
|
selectedRegionIds, selectedTrackId,
|
|
// Playhead and refresh
|
|
playheadPosition, refreshProjectState,
|
|
requestMainContentScroll, requestPianoRollScroll
|
|
} = useProjectStore();
|
|
|
|
// State for main content tools
|
|
const [activeMainTool, setActiveMainTool] = React.useState<'pointer' | 'pencil'>('pointer');
|
|
const [isSnapping, setIsSnapping] = React.useState(true);
|
|
|
|
// 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);
|
|
|
|
// State for zoom slider popup
|
|
const [showZoomSlider, setShowZoomSlider] = React.useState(false);
|
|
const zoomSliderRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
// State for open project modal
|
|
const [showOpenProject, setShowOpenProject] = React.useState(false);
|
|
const [isOpeningProject, setIsOpeningProject] = React.useState(false);
|
|
|
|
// Close zoom slider on click outside
|
|
React.useEffect(() => {
|
|
if (!showZoomSlider) return;
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (zoomSliderRef.current && !zoomSliderRef.current.contains(e.target as Node)) {
|
|
setShowZoomSlider(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, [showZoomSlider]);
|
|
|
|
// Export options
|
|
const exportOptions = ["Export to KGStudio file", "Export to MIDI file", "Export to WAV", "Export to MP3"];
|
|
const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null;
|
|
|
|
const handleProjectNameClick = async () => {
|
|
const newName = await showPrompt("Enter project name:", projectName);
|
|
if (!newName) return;
|
|
if (!isValidProjectName(newName)) {
|
|
await showAlert("Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.");
|
|
return;
|
|
}
|
|
if (isReservedProjectName(newName)) {
|
|
await showAlert(`"${RESERVED_PROJECT_NAME}" is a reserved project name. Please choose a different name.`);
|
|
return;
|
|
}
|
|
|
|
// If the name hasn't changed from the saved state, just save without prompting
|
|
if (newName === savedProjectName) {
|
|
setProjectName(newName);
|
|
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
|
|
setSavedProjectName(finalName);
|
|
if (finalName !== newName) setProjectName(finalName);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Untitled Project is ephemeral — no existing save to preserve, so rename directly
|
|
if (savedProjectName === RESERVED_PROJECT_NAME) {
|
|
const storage = KGProjectStorage.getInstance();
|
|
const exists = await storage.exists(newName);
|
|
if (exists) {
|
|
const confirmed = await showConfirm(
|
|
`Project "${newName}" already exists. Do you want to overwrite it?`
|
|
);
|
|
if (!confirmed) return;
|
|
setProjectName(newName);
|
|
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
|
|
setSavedProjectName(finalName);
|
|
if (finalName !== newName) setProjectName(finalName);
|
|
}, true /* forceOverwrite */);
|
|
return;
|
|
}
|
|
setProjectName(newName);
|
|
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
|
|
setSavedProjectName(finalName);
|
|
if (finalName !== newName) setProjectName(finalName);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Ask whether the user wants to rename or save as a copy
|
|
const choice = await showChoice(
|
|
"Would you like to rename this project, or save it as a new copy?",
|
|
[
|
|
{ label: 'Save as Copy', value: 'saveas' },
|
|
{ label: 'Rename', value: 'rename' },
|
|
]
|
|
);
|
|
if (!choice) return;
|
|
|
|
if (choice === 'rename') {
|
|
// Conflict check: only relevant when targeting a different OPFS folder
|
|
const storage = KGProjectStorage.getInstance();
|
|
const exists = await storage.exists(newName);
|
|
if (exists) {
|
|
const confirmed = await showConfirm(
|
|
`Project "${newName}" already exists. Do you want to overwrite it?`
|
|
);
|
|
if (!confirmed) return;
|
|
setProjectName(newName);
|
|
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
|
|
setSavedProjectName(finalName);
|
|
if (finalName !== newName) setProjectName(finalName);
|
|
}, true /* forceOverwrite */);
|
|
return;
|
|
}
|
|
setProjectName(newName);
|
|
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
|
|
setSavedProjectName(finalName);
|
|
if (finalName !== newName) setProjectName(finalName);
|
|
});
|
|
} else {
|
|
// Save as Copy: save current state under a unique new name, then switch to it
|
|
const storage = KGProjectStorage.getInstance();
|
|
const finalName = await storage.resolveUniqueName(newName);
|
|
try {
|
|
await storage.saveAs(savedProjectName, finalName, KGCore.instance().getCurrentProject());
|
|
setProjectName(finalName);
|
|
setSavedProjectName(finalName);
|
|
setStatus(`Saved as "${finalName}"`);
|
|
} catch (error) {
|
|
console.error('Error saving project as copy:', error);
|
|
await showAlert(`An error occurred while saving: ${error}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Common project loading logic extracted for reuse
|
|
const loadProjectFromData = async (project: KGProject, sourceDescription: string, savedName?: 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, savedName);
|
|
|
|
// Clean up orphan media files (only on open, not on save)
|
|
if (savedName) {
|
|
const storage = KGProjectStorage.getInstance();
|
|
await storage.cleanupOrphanMedia(savedName, 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}`);
|
|
await showAlert(`An error occurred while loading the project: ${error}`);
|
|
}
|
|
};
|
|
|
|
// Core logic for creating a new project (no confirmation dialog)
|
|
const createNewProject = () => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("creating new project");
|
|
}
|
|
|
|
cleanupProjectState();
|
|
clearChatHistoryAndUI();
|
|
|
|
const newProject = new KGProject();
|
|
const { loadProject: storeLoadProject } = useProjectStore.getState();
|
|
storeLoadProject(newProject);
|
|
|
|
setStatus(`New project "${newProject.getName()}" created`);
|
|
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("new project created successfully");
|
|
}
|
|
};
|
|
|
|
// Handler functions for file operations
|
|
const handleNewProject = async () => {
|
|
const confirmed = await showConfirm("Are you sure you want to create a new project? Any unsaved changes will be lost.");
|
|
if (confirmed) {
|
|
createNewProject();
|
|
}
|
|
};
|
|
|
|
const handleLoadProject = () => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("user clicked load button");
|
|
}
|
|
setShowOpenProject(true);
|
|
};
|
|
|
|
const handleOpenProjectSelect = async (projectNameToLoad: string) => {
|
|
setIsOpeningProject(true);
|
|
|
|
try {
|
|
const storage = KGProjectStorage.getInstance();
|
|
const loadedProject = await storage.load(projectNameToLoad);
|
|
|
|
if (!loadedProject) {
|
|
await showAlert(`Project "${projectNameToLoad}" not found.`);
|
|
return;
|
|
}
|
|
|
|
await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`, projectNameToLoad);
|
|
} catch (error) {
|
|
console.error("Error loading project:", error);
|
|
await showAlert(`An error occurred while loading the project: ${error}`);
|
|
} finally {
|
|
setIsOpeningProject(false);
|
|
}
|
|
};
|
|
|
|
const handleConfirmOpenProject = async () => {
|
|
const confirmed = await showConfirm(
|
|
'Open this project? Any unsaved changes in the current project will be lost.'
|
|
);
|
|
return confirmed;
|
|
};
|
|
|
|
const handleSaveProject = async () => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("user clicked save button");
|
|
}
|
|
|
|
await saveProject(projectName, savedProjectName, setStatus, (finalName) => {
|
|
setSavedProjectName(finalName);
|
|
if (finalName !== projectName) {
|
|
setProjectName(finalName);
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleExportProject = (exportType: string) => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("user selected export option:", exportType);
|
|
}
|
|
|
|
if (exportType === "Export to KGStudio file") {
|
|
handleExportKGStudio();
|
|
} else if (exportType === "Export to MIDI file") {
|
|
handleExportMIDI();
|
|
} else if (exportType === "Export to WAV") {
|
|
handleBounceToWav();
|
|
} else if (exportType === "Export to MP3") {
|
|
handleBounceToMp3();
|
|
}
|
|
|
|
setShowExportDropdown(false);
|
|
};
|
|
|
|
const handleExportKGStudio = async () => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("exporting to KGStudio file");
|
|
}
|
|
|
|
try {
|
|
// First save the current project to OPFS so the export reflects the latest state
|
|
const storage = KGProjectStorage.getInstance();
|
|
await storage.save(projectName, KGCore.instance().getCurrentProject(), true);
|
|
|
|
// Bundle the project folder into a zip
|
|
const blob = await storage.exportAsZip(projectName);
|
|
|
|
// Trigger download
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `${projectName}.kgstudio`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
|
|
setStatus(`Project "${projectName}" exported as KGStudio file`);
|
|
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("KGStudio export completed successfully");
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error("Error exporting KGStudio file:", error);
|
|
setStatus(`Error exporting project: ${error}`);
|
|
await showAlert(`Failed to export project: ${error}`);
|
|
}
|
|
};
|
|
|
|
const handleExportMIDI = async () => {
|
|
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.buffer as ArrayBuffer], { 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}`);
|
|
await showAlert(`Failed to export project as MIDI: ${error}`);
|
|
}
|
|
};
|
|
|
|
const handleBounceToWav = async () => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("bouncing to WAV");
|
|
}
|
|
|
|
try {
|
|
const currentProject = KGCore.instance().getCurrentProject();
|
|
await KGOfflineRenderer.instance().bounceToWav(currentProject, projectName);
|
|
setStatus(`Project "${projectName}" exported as WAV file`);
|
|
} catch (error) {
|
|
console.error("Error bouncing to WAV:", error);
|
|
setStatus(`Error exporting WAV: ${error}`);
|
|
await showAlert(`Failed to export project as WAV: ${error}`);
|
|
}
|
|
};
|
|
|
|
const handleBounceToMp3 = async () => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("bouncing to MP3");
|
|
}
|
|
|
|
try {
|
|
const currentProject = KGCore.instance().getCurrentProject();
|
|
await KGOfflineRenderer.instance().bounceToMp3(currentProject, projectName);
|
|
setStatus(`Project "${projectName}" exported as MP3 file`);
|
|
} catch (error) {
|
|
console.error("Error bouncing to MP3:", error);
|
|
setStatus(`Error exporting MP3: ${error}`);
|
|
await showAlert(`Failed to export project as MP3: ${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 === '.kgstudio') {
|
|
// Handle KGStudio bundle import
|
|
await handleKGStudioFileImport(file);
|
|
} else if (fileExtension === '.json') {
|
|
// Handle legacy 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}`);
|
|
await showAlert(`Failed to import project file: ${error}`);
|
|
}
|
|
};
|
|
|
|
const handleKGStudioFileImport = async (file: File) => {
|
|
const storage = KGProjectStorage.getInstance();
|
|
|
|
try {
|
|
const projectName = await storage.importFromZip(file);
|
|
|
|
// Load the project from OPFS (this runs the upgrader)
|
|
const loaded = await storage.load(projectName);
|
|
if (!loaded) {
|
|
throw new Error('Failed to load imported project');
|
|
}
|
|
|
|
await loadProjectFromData(loaded, `KGStudio file "${file.name}"`, projectName);
|
|
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("KGStudio file imported successfully:", projectName);
|
|
}
|
|
} catch (error) {
|
|
await showAlert(`The .kgstudio file is corrupted or invalid: ${error}`);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const handleKGStudioJSONImport = async (file: File) => {
|
|
try {
|
|
const fileContent = await file.text();
|
|
const projectData = JSON.parse(fileContent);
|
|
|
|
// Deserialize and handle potential array return
|
|
const deserializedResult = plainToInstance(KGProject, projectData);
|
|
const project = Array.isArray(deserializedResult)
|
|
? deserializedResult[0] || null
|
|
: deserializedResult;
|
|
|
|
if (!project) {
|
|
throw new Error("Failed to deserialize project data");
|
|
}
|
|
|
|
// Use the project's name for the OPFS folder, auto-rename if it already exists
|
|
const storage = KGProjectStorage.getInstance();
|
|
const importedName = await storage.resolveUniqueName(project.getName() || 'Imported Project');
|
|
|
|
// Save to OPFS as a proper folder-based project
|
|
project.setName(importedName);
|
|
await storage.save(importedName, project, false);
|
|
|
|
// Load back from OPFS so the upgrader runs
|
|
const loaded = await storage.load(importedName);
|
|
if (!loaded) {
|
|
throw new Error('Failed to load imported project from storage');
|
|
}
|
|
|
|
await loadProjectFromData(loaded, `JSON file "${file.name}"`, importedName);
|
|
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("KGStudio JSON project imported and saved to OPFS:", importedName);
|
|
}
|
|
|
|
} 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 stopTransport();
|
|
if (isRecording) {
|
|
setStatus("Recording stopped — notes committed");
|
|
}
|
|
} 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);
|
|
requestMainContentScroll(0);
|
|
requestPianoRollScroll(0);
|
|
};
|
|
|
|
const handleLoopToggle = () => {
|
|
toggleLoop();
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("Loop toggle clicked");
|
|
}
|
|
};
|
|
|
|
// Prompt to change max bars when clicking on current-time display
|
|
const handleCurrentTimeClick = async () => {
|
|
const MIN_BARS = 16;
|
|
const newMaxBarsStr = await showPrompt(`Enter new max bars (>= ${MIN_BARS}):`, String(maxBars ?? 32));
|
|
if (newMaxBarsStr === null) {
|
|
return; // cancelled
|
|
}
|
|
const parsed = parseInt(newMaxBarsStr.trim(), 10);
|
|
if (isNaN(parsed)) {
|
|
await showAlert('Invalid input. Please enter a valid number.');
|
|
return;
|
|
}
|
|
if (parsed < MIN_BARS) {
|
|
await showAlert(`Invalid value. Please enter a number >= ${MIN_BARS}.`);
|
|
return;
|
|
}
|
|
setMaxBars(parsed);
|
|
setStatus(`Max bars changed to ${parsed}`);
|
|
};
|
|
|
|
const handleBpmClick = async () => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("BPM clicked, current BPM:", bpm);
|
|
}
|
|
|
|
const newBpmStr = await showPrompt(`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)) {
|
|
await showAlert("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) {
|
|
await showAlert(`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 = async () => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("Time signature clicked, current:", `${timeSignature.numerator}/${timeSignature.denominator}`);
|
|
}
|
|
|
|
const result = await showTimeSigPrompt('Set the time signature:', timeSignature);
|
|
if (result === null) return;
|
|
|
|
const newTimeSignature = parseTimeSignature(`${result.numerator}/${result.denominator}`);
|
|
if (newTimeSignature === null) {
|
|
await showAlert(getTimeSignatureErrorMessage());
|
|
return;
|
|
}
|
|
|
|
setTimeSignature(newTimeSignature);
|
|
setStatus(`Time signature changed to ${newTimeSignature.numerator}/${newTimeSignature.denominator}`);
|
|
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log(`Time signature updated 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 snapping toggle
|
|
const handleSnappingToggle = () => {
|
|
const newValue = !isSnapping;
|
|
setIsSnapping(newValue);
|
|
KGMainContentState.instance().setSnapping(newValue);
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log(`Snapping ${newValue ? 'enabled' : 'disabled'}`);
|
|
}
|
|
};
|
|
|
|
// 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 split region button click
|
|
const handleSplitClick = async () => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("Split button clicked");
|
|
}
|
|
|
|
const status = await splitSelectedRegionAtPlayhead({
|
|
selectedRegionIds,
|
|
playheadPosition,
|
|
refreshProjectState,
|
|
});
|
|
if (!status) {
|
|
return;
|
|
}
|
|
|
|
setStatus(status);
|
|
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log(`Split selected region at beat ${playheadPosition}`);
|
|
}
|
|
};
|
|
|
|
const handleMergeClick = async () => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log('Merge button clicked');
|
|
}
|
|
|
|
const status = await mergeSelectedMidiRegions({
|
|
selectedRegionIds,
|
|
refreshProjectState,
|
|
});
|
|
if (!status) {
|
|
return;
|
|
}
|
|
|
|
setStatus(status);
|
|
};
|
|
|
|
// Handle undo button click
|
|
const handleUndoClick = async () => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("Undo button clicked");
|
|
}
|
|
|
|
if (!canUndo) {
|
|
await showAlert("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 = async () => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("Redo button clicked");
|
|
}
|
|
|
|
if (!canRedo) {
|
|
await showAlert("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");
|
|
}
|
|
|
|
if (showSettings) {
|
|
activateSidePanel('chat');
|
|
} else {
|
|
toggleChatBox();
|
|
}
|
|
setStatus("Chat toggled");
|
|
};
|
|
|
|
// Handle settings button click
|
|
const handleSettingsClick = () => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("Settings button clicked");
|
|
}
|
|
|
|
toggleSettings();
|
|
setStatus("Settings toggled");
|
|
};
|
|
|
|
// K.G.One panel toggle
|
|
const handleKGOneClick = () => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("K.G.One button clicked");
|
|
}
|
|
|
|
if (showSettings) {
|
|
activateSidePanel('kgone');
|
|
return;
|
|
}
|
|
|
|
toggleKGOnePanel();
|
|
};
|
|
|
|
const handleEventListClick = () => {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log("Event List button clicked");
|
|
}
|
|
|
|
if (showSettings) {
|
|
activateSidePanel('eventList');
|
|
return;
|
|
}
|
|
|
|
toggleEventListPanel();
|
|
};
|
|
|
|
// Handle Piano button click: open piano roll if closed, targeting active or selected region
|
|
const handlePianoButtonClick = async () => {
|
|
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 || lastSelectedRegionId;
|
|
|
|
if (!candidateRegionId) {
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log('No active or selected region; piano roll will not open');
|
|
}
|
|
await showAlert('Please select a MIDI region to open the Piano Roll.');
|
|
return;
|
|
}
|
|
|
|
setActiveRegionId(candidateRegionId);
|
|
setShowPianoRoll(true);
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log(`Opening piano roll for region ${candidateRegionId}`);
|
|
}
|
|
};
|
|
|
|
const handleMetronomeToggle = () => {
|
|
toggleMetronome();
|
|
if (DEBUG_MODE.TOOLBAR) {
|
|
console.log('Metronome toggled');
|
|
}
|
|
};
|
|
|
|
const handleRecordClick = async () => {
|
|
if (isRecording) {
|
|
await stopRecording();
|
|
setStatus("Recording stopped");
|
|
return;
|
|
}
|
|
|
|
const selectedTrack = useProjectStore.getState().tracks.find(track => track.getId().toString() === selectedTrackId) ?? null;
|
|
if (selectedTrack instanceof KGAudioTrack) {
|
|
await startRecording();
|
|
setStatus("Audio recording started...");
|
|
return;
|
|
}
|
|
|
|
// Require an active or selected MIDI region
|
|
const candidateId = activeRegionId ?? lastSelectedRegionId;
|
|
if (!candidateId) {
|
|
await showAlert("Please open a MIDI region in the Piano Roll before starting recording.");
|
|
return;
|
|
}
|
|
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
|
let isMidi = false;
|
|
for (const track of tracks) {
|
|
const region = track.getRegions().find(r => r.getId() === candidateId);
|
|
if (region) { isMidi = region instanceof KGMidiRegion; break; }
|
|
}
|
|
if (!isMidi) {
|
|
await showAlert("Please select a MIDI region before starting recording.");
|
|
return;
|
|
}
|
|
|
|
// Require at least one connected MIDI device
|
|
if (KGMidiInput.instance().getConnectedInputCount() === 0) {
|
|
await showAlert("No MIDI device detected. Please connect a MIDI keyboard and try again.");
|
|
return;
|
|
}
|
|
|
|
// Ensure the piano roll is open showing the target region
|
|
if (!activeRegionId) {
|
|
setActiveRegionId(candidateId);
|
|
setShowPianoRoll(true);
|
|
}
|
|
|
|
await startRecording();
|
|
setStatus("Recording started...");
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="toolbar">
|
|
<div className="toolbar-left">
|
|
<div className="logo-container">
|
|
<img src={`${import.meta.env.BASE_URL}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>
|
|
<button
|
|
title="Settings"
|
|
className={`tool-button ${showSettings ? 'active' : ''}`}
|
|
onClick={handleSettingsClick}
|
|
>
|
|
<FaCog />
|
|
</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>
|
|
<button
|
|
title="Split Region at Playhead"
|
|
onClick={handleSplitClick}
|
|
>
|
|
<FaCut />
|
|
</button>
|
|
<button
|
|
title="Merge Selected MIDI Regions"
|
|
onClick={handleMergeClick}
|
|
>
|
|
<FaCompress />
|
|
</button>
|
|
<button
|
|
title="Snap to Grid"
|
|
className={`tool-button ${isSnapping ? 'active' : ''}`}
|
|
onClick={handleSnappingToggle}
|
|
>
|
|
<FaMagnet />
|
|
</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} disabled={isPreparingPlayback}><FaPlay /></button>
|
|
) : (
|
|
<button title="Pause" className="tool-button button-pause active" onClick={handlePauseClick}><FaPause /></button>
|
|
)}
|
|
<button
|
|
title={isRecording ? "Stop Recording" : "Record"}
|
|
className={`tool-button record-button ${isRecording ? 'active' : ''}`}
|
|
onClick={handleRecordClick}
|
|
>
|
|
<FaCircle />
|
|
</button>
|
|
<button
|
|
title="Loop"
|
|
className={`tool-button ${isLooping ? 'active' : ''}`}
|
|
onClick={handleLoopToggle}
|
|
>
|
|
<FaSync />
|
|
</button>
|
|
<div className="toolbar-separator"></div>
|
|
<button
|
|
title="Metronome"
|
|
className={`tool-button ${isMetronomeEnabled ? 'active' : ''}`}
|
|
onClick={handleMetronomeToggle}
|
|
>
|
|
<MetronomeIcon />
|
|
</button>
|
|
<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" style={{ position: 'relative' }} ref={zoomSliderRef}>
|
|
<span
|
|
className='current-zoom'
|
|
onClick={() => setShowZoomSlider(!showZoomSlider)}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
{barWidthMultiplier}x
|
|
</span>
|
|
{showZoomSlider && (
|
|
<div className="zoom-slider-popup">
|
|
<input
|
|
type="range"
|
|
min="1"
|
|
max="8"
|
|
step="1"
|
|
value={barWidthMultiplier}
|
|
onChange={(e) => setBarWidthMultiplier(parseInt(e.target.value))}
|
|
/>
|
|
<span className="zoom-slider-label">{barWidthMultiplier}x</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<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' }}>
|
|
<FloatingPopup
|
|
isOpen={showKeySignatureDropdown}
|
|
onClose={() => setShowKeySignatureDropdown(false)}
|
|
placement="bottom"
|
|
className="key-signature-popup-anchor"
|
|
contentClassName="key-signature-popup-surface"
|
|
panelClassName="key-signature-popup-panel"
|
|
arrowClassName="key-signature-popup-arrow"
|
|
trigger={(
|
|
<button
|
|
type="button"
|
|
className='current-key-signature'
|
|
onClick={() => setShowKeySignatureDropdown((current) => !current)}
|
|
aria-haspopup="dialog"
|
|
aria-expanded={showKeySignatureDropdown}
|
|
aria-label={`Choose key signature, current ${keySignature}`}
|
|
>
|
|
{keySignature}
|
|
</button>
|
|
)}
|
|
>
|
|
<KeySignaturePickerPopup value={keySignature} onChange={handleKeySignatureChange} />
|
|
</FloatingPopup>
|
|
</div>
|
|
</div>
|
|
<button
|
|
title="K.G.One Music Generator"
|
|
onClick={handleKGOneClick}
|
|
className={!showSettings && showKGOnePanel ? 'active' : ''}
|
|
>
|
|
<FaWandMagicSparkles />
|
|
</button>
|
|
<button
|
|
title="Chat"
|
|
onClick={handleChatClick}
|
|
className={!showSettings && showChatBox ? 'active' : ''}
|
|
>
|
|
<FaComments />
|
|
</button>
|
|
<button
|
|
title="Event List Editor"
|
|
onClick={handleEventListClick}
|
|
className={!showSettings && showEventListPanel ? 'active' : ''}
|
|
>
|
|
<FaListUl />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<FileImportModal
|
|
isVisible={showImportModal}
|
|
onClose={() => setShowImportModal(false)}
|
|
onFileImport={handleFileImport}
|
|
acceptedTypes={['.kgstudio', '.json', '.mid', '.midi']}
|
|
title="Import Project"
|
|
description="Drag and drop your project file here"
|
|
/>
|
|
|
|
<LoadingOverlay
|
|
visible={isOpeningProject}
|
|
message="Opening project..."
|
|
/>
|
|
|
|
{showOpenProject && (
|
|
<OpenProjectModal
|
|
onClose={() => setShowOpenProject(false)}
|
|
onConfirmOpenProject={handleConfirmOpenProject}
|
|
onOpenProject={handleOpenProjectSelect}
|
|
currentProjectName={savedProjectName}
|
|
onCreateNewProject={createNewProject}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default Toolbar;
|