refactor: split MainContent into focused hooks and fix audio debug build
This commit is contained in:
+149
-1865
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { FaPlus } from 'react-icons/fa';
|
||||||
|
import GlobalChordLane from './GlobalChordLane';
|
||||||
|
import GlobalKeySignatureLane from './GlobalKeySignatureLane';
|
||||||
|
import GlobalMarkerLane from './GlobalMarkerLane';
|
||||||
|
import GlobalTempoLane from './GlobalTempoLane';
|
||||||
|
|
||||||
|
interface GlobalTrackDefinition {
|
||||||
|
id: 'marker' | 'tempo' | 'signature' | 'chord';
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLOBAL_TRACKS: GlobalTrackDefinition[] = [
|
||||||
|
{ id: 'marker', label: 'Marker' },
|
||||||
|
{ id: 'tempo', label: 'Tempo' },
|
||||||
|
{ id: 'signature', label: 'Key Signature' },
|
||||||
|
{ id: 'chord', label: 'Chord' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MainContentGlobalTracksSectionProps {
|
||||||
|
visible: boolean;
|
||||||
|
onAddMarker: () => void;
|
||||||
|
onAddTempo: () => void;
|
||||||
|
onAddKeySignature: () => void;
|
||||||
|
onAddChord: () => void;
|
||||||
|
markerLaneProps: React.ComponentProps<typeof GlobalMarkerLane>;
|
||||||
|
tempoLaneProps: React.ComponentProps<typeof GlobalTempoLane>;
|
||||||
|
keySignatureLaneProps: React.ComponentProps<typeof GlobalKeySignatureLane>;
|
||||||
|
chordLaneProps: React.ComponentProps<typeof GlobalChordLane>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MainContentGlobalTracksSection: React.FC<MainContentGlobalTracksSectionProps> = ({
|
||||||
|
visible,
|
||||||
|
onAddMarker,
|
||||||
|
onAddTempo,
|
||||||
|
onAddKeySignature,
|
||||||
|
onAddChord,
|
||||||
|
markerLaneProps,
|
||||||
|
tempoLaneProps,
|
||||||
|
keySignatureLaneProps,
|
||||||
|
chordLaneProps,
|
||||||
|
}) => {
|
||||||
|
const [shouldRender, setShouldRender] = useState(visible);
|
||||||
|
const [isAnimated, setIsAnimated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setIsAnimated(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShouldRender(true);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldRender || !visible || isAnimated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
setIsAnimated(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [isAnimated, shouldRender, visible]);
|
||||||
|
|
||||||
|
if (!shouldRender) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="global-tracks-section"
|
||||||
|
aria-label="Global tracks"
|
||||||
|
aria-hidden={!visible}
|
||||||
|
style={{ ['--global-track-count' as string]: String(GLOBAL_TRACKS.length) }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`global-tracks-info-shell${isAnimated ? ' expanded' : ' collapsed'}`}
|
||||||
|
onTransitionEnd={() => {
|
||||||
|
if (!visible) {
|
||||||
|
setShouldRender(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="global-tracks-info">
|
||||||
|
{GLOBAL_TRACKS.map(track => (
|
||||||
|
<div key={track.id} className="global-track-info-row">
|
||||||
|
<span className="global-track-name">{track.label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="global-track-add-button"
|
||||||
|
aria-label={`Add ${track.label} global track item`}
|
||||||
|
title={`Add ${track.label} global track item`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (track.id === 'marker') {
|
||||||
|
onAddMarker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.id === 'tempo') {
|
||||||
|
onAddTempo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.id === 'signature') {
|
||||||
|
onAddKeySignature();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onAddChord();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaPlus />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`global-tracks-grid-shell${isAnimated ? ' expanded' : ' collapsed'}`}>
|
||||||
|
<div className="global-tracks-grid" aria-hidden="true">
|
||||||
|
<GlobalMarkerLane {...markerLaneProps} />
|
||||||
|
<GlobalTempoLane {...tempoLaneProps} />
|
||||||
|
<GlobalKeySignatureLane {...keySignatureLaneProps} />
|
||||||
|
<GlobalChordLane {...chordLaneProps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainContentGlobalTracksSection;
|
||||||
@@ -58,7 +58,7 @@ const PianoRollHeader: React.FC<PianoRollHeaderProps> = ({
|
|||||||
<div
|
<div
|
||||||
className="piano-roll-title"
|
className="piano-roll-title"
|
||||||
onClick={onTitleClick}
|
onClick={onTitleClick}
|
||||||
title="Click to rename region"
|
title={title}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,654 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import type { KeySignature } from '../core/KGProject';
|
||||||
|
import { KGCore } from '../core/KGCore';
|
||||||
|
import { GlobalTrackType, KGGlobalTrack } from '../core/global-track';
|
||||||
|
import { KGRegion } from '../core/region/KGRegion';
|
||||||
|
import { KGChordRegion } from '../core/region/KGChordRegion';
|
||||||
|
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
||||||
|
import { KGTempoRegion } from '../core/region/KGTempoRegion';
|
||||||
|
import { KGMarkerRegion } from '../core/region/KGMarkerRegion';
|
||||||
|
import {
|
||||||
|
CreateChordRegionCommand,
|
||||||
|
CreateGlobalMarkerRegionCommand,
|
||||||
|
CreateKeySignatureRegionCommand,
|
||||||
|
CreateTempoRegionCommand,
|
||||||
|
DeleteKeySignatureRegionCommand,
|
||||||
|
DeleteMultipleGlobalRegionsCommand,
|
||||||
|
DeleteMultipleKeySignatureRegionsCommand,
|
||||||
|
DeleteMultipleTempoRegionsCommand,
|
||||||
|
DeleteTempoRegionCommand,
|
||||||
|
InsertChordRegionAtBeatCommand,
|
||||||
|
MoveGlobalRegionCommand,
|
||||||
|
ResizeGlobalRegionCommand,
|
||||||
|
ResizeKeySignatureRegionCommand,
|
||||||
|
ResizeTempoRegionCommand,
|
||||||
|
UpdateChordRegionCommand,
|
||||||
|
UpdateGlobalRegionTextCommand,
|
||||||
|
UpdateKeySignatureRegionCommand,
|
||||||
|
UpdateTempoRegionCommand,
|
||||||
|
} from '../core/commands';
|
||||||
|
import type { RegionClickOptions } from '../components/interfaces';
|
||||||
|
import { TIME_CONSTANTS } from '../constants/coreConstants';
|
||||||
|
import {
|
||||||
|
DEFAULT_MARKER_REGION_NAME,
|
||||||
|
getSortedKeySignatureRegions,
|
||||||
|
getSortedTempoRegions,
|
||||||
|
} from '../util/globalTrackUtil';
|
||||||
|
import type MainContentGlobalTracksSection from '../components/global-track/MainContentGlobalTracksSection';
|
||||||
|
|
||||||
|
interface UseMainContentGlobalTracksParams {
|
||||||
|
globalTracks: KGGlobalTrack[];
|
||||||
|
selectedRegionIds: string[];
|
||||||
|
timeSignature: { numerator: number; denominator: number };
|
||||||
|
barWidthMultiplier: number;
|
||||||
|
maxBars: number;
|
||||||
|
playheadPosition: number;
|
||||||
|
refreshProjectState: () => void;
|
||||||
|
bumpAudioWaveformRedrawVersion: () => void;
|
||||||
|
findProjectRegionById: (regionId: string) => KGRegion | null;
|
||||||
|
isGlobalRegionId: (regionId: string) => boolean;
|
||||||
|
selectGlobalRegion: (regionId: string, options?: RegionClickOptions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalTracksSectionProps = React.ComponentProps<typeof MainContentGlobalTracksSection>;
|
||||||
|
type MarkerLaneProps = GlobalTracksSectionProps['markerLaneProps'];
|
||||||
|
type TempoLaneProps = GlobalTracksSectionProps['tempoLaneProps'];
|
||||||
|
type KeySignatureLaneProps = GlobalTracksSectionProps['keySignatureLaneProps'];
|
||||||
|
type ChordLaneProps = GlobalTracksSectionProps['chordLaneProps'];
|
||||||
|
|
||||||
|
const DEFAULT_REGION_CLICK_OPTIONS: RegionClickOptions = {
|
||||||
|
shiftKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UseMainContentGlobalTracksResult {
|
||||||
|
deleteSelectedGlobalRegions: () => boolean;
|
||||||
|
editingRegionIds: string[];
|
||||||
|
sectionProps: Omit<GlobalTracksSectionProps, 'visible'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMainContentGlobalTracks({
|
||||||
|
globalTracks,
|
||||||
|
selectedRegionIds,
|
||||||
|
timeSignature,
|
||||||
|
barWidthMultiplier,
|
||||||
|
maxBars,
|
||||||
|
playheadPosition,
|
||||||
|
refreshProjectState,
|
||||||
|
bumpAudioWaveformRedrawVersion,
|
||||||
|
findProjectRegionById,
|
||||||
|
isGlobalRegionId,
|
||||||
|
selectGlobalRegion,
|
||||||
|
}: UseMainContentGlobalTracksParams): UseMainContentGlobalTracksResult {
|
||||||
|
const [editingGlobalRegionId, setEditingGlobalRegionId] = useState<string | null>(null);
|
||||||
|
const [editingGlobalRegionText, setEditingGlobalRegionText] = useState('');
|
||||||
|
const [editingKeySignatureRegionId, setEditingKeySignatureRegionId] = useState<string | null>(null);
|
||||||
|
const [editingTempoRegionId, setEditingTempoRegionId] = useState<string | null>(null);
|
||||||
|
const [editingTempoText, setEditingTempoText] = useState('');
|
||||||
|
const [editingChordRegionId, setEditingChordRegionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const markerTrack = useMemo(
|
||||||
|
() => globalTracks.find(track => track.getType() === GlobalTrackType.Marker) ?? null,
|
||||||
|
[globalTracks]
|
||||||
|
);
|
||||||
|
const markerRegions = useMemo(
|
||||||
|
() => (markerTrack?.getRegions() ?? []).filter((region): region is KGMarkerRegion => region instanceof KGMarkerRegion),
|
||||||
|
[markerTrack]
|
||||||
|
);
|
||||||
|
const signatureTrack = useMemo(
|
||||||
|
() => globalTracks.find(track => track.getType() === GlobalTrackType.Signature) ?? null,
|
||||||
|
[globalTracks]
|
||||||
|
);
|
||||||
|
const signatureRegions = useMemo(
|
||||||
|
() => (signatureTrack ? getSortedKeySignatureRegions(signatureTrack, timeSignature.numerator) : []),
|
||||||
|
[signatureTrack, timeSignature.numerator]
|
||||||
|
);
|
||||||
|
const tempoTrack = useMemo(
|
||||||
|
() => globalTracks.find(track => track.getType() === GlobalTrackType.Tempo) ?? null,
|
||||||
|
[globalTracks]
|
||||||
|
);
|
||||||
|
const tempoRegions = useMemo(
|
||||||
|
() => (tempoTrack ? getSortedTempoRegions(tempoTrack, timeSignature.numerator) : []),
|
||||||
|
[tempoTrack, timeSignature.numerator]
|
||||||
|
);
|
||||||
|
const chordTrack = useMemo(
|
||||||
|
() => globalTracks.find(track => track.getType() === GlobalTrackType.Chord) ?? null,
|
||||||
|
[globalTracks]
|
||||||
|
);
|
||||||
|
const chordRegions = useMemo(
|
||||||
|
() => (chordTrack?.getRegions() ?? []).filter((region): region is KGChordRegion => region instanceof KGChordRegion),
|
||||||
|
[chordTrack]
|
||||||
|
);
|
||||||
|
|
||||||
|
const beginEditingGlobalRegion = useCallback((regionId: string) => {
|
||||||
|
const region = findProjectRegionById(regionId);
|
||||||
|
if (!(region instanceof KGMarkerRegion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingGlobalRegionId(regionId);
|
||||||
|
setEditingGlobalRegionText(region.getName());
|
||||||
|
}, [findProjectRegionById]);
|
||||||
|
|
||||||
|
const beginEditingKeySignatureRegion = useCallback((regionId: string) => {
|
||||||
|
const region = findProjectRegionById(regionId);
|
||||||
|
if (region instanceof KGKeySignatureRegion) {
|
||||||
|
setEditingKeySignatureRegionId(regionId);
|
||||||
|
}
|
||||||
|
}, [findProjectRegionById]);
|
||||||
|
|
||||||
|
const beginEditingTempoRegion = useCallback((regionId: string) => {
|
||||||
|
const region = findProjectRegionById(regionId);
|
||||||
|
if (!(region instanceof KGTempoRegion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingTempoRegionId(regionId);
|
||||||
|
setEditingTempoText(region.getBpm().toString());
|
||||||
|
}, [findProjectRegionById]);
|
||||||
|
|
||||||
|
const beginEditingChordRegion = useCallback((regionId: string) => {
|
||||||
|
const region = findProjectRegionById(regionId);
|
||||||
|
if (region instanceof KGChordRegion) {
|
||||||
|
setEditingChordRegionId(regionId);
|
||||||
|
}
|
||||||
|
}, [findProjectRegionById]);
|
||||||
|
|
||||||
|
const commitGlobalRegionEdit = useCallback((regionId: string) => {
|
||||||
|
const region = findProjectRegionById(regionId);
|
||||||
|
if (!(region instanceof KGMarkerRegion)) {
|
||||||
|
setEditingGlobalRegionId(null);
|
||||||
|
setEditingGlobalRegionText('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedText = editingGlobalRegionText.replace(/\r?\n/g, ' ').trim();
|
||||||
|
setEditingGlobalRegionId(null);
|
||||||
|
setEditingGlobalRegionText('');
|
||||||
|
|
||||||
|
if (!trimmedText || trimmedText === region.getName()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new UpdateGlobalRegionTextCommand(regionId, trimmedText));
|
||||||
|
refreshProjectState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating marker text:', error);
|
||||||
|
}
|
||||||
|
}, [editingGlobalRegionText, findProjectRegionById, refreshProjectState]);
|
||||||
|
|
||||||
|
const createMarkerAtBeat = useCallback((requestedStartBeat: number) => {
|
||||||
|
const normalizedStartBeat = Math.max(0, Math.round(requestedStartBeat));
|
||||||
|
const occupiedRegion = markerRegions.find(region => region.getStartFromBeat() === normalizedStartBeat);
|
||||||
|
if (occupiedRegion) {
|
||||||
|
selectGlobalRegion(occupiedRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
|
||||||
|
beginEditingGlobalRegion(occupiedRegion.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = new CreateGlobalMarkerRegionCommand(
|
||||||
|
normalizedStartBeat,
|
||||||
|
timeSignature.numerator,
|
||||||
|
DEFAULT_MARKER_REGION_NAME
|
||||||
|
);
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
refreshProjectState();
|
||||||
|
|
||||||
|
const createdRegion = command.getCreatedRegion();
|
||||||
|
if (!createdRegion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectGlobalRegion(createdRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
|
||||||
|
setEditingGlobalRegionId(createdRegion.getId());
|
||||||
|
setEditingGlobalRegionText(createdRegion.getName());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating marker region:', error);
|
||||||
|
}
|
||||||
|
}, [beginEditingGlobalRegion, markerRegions, refreshProjectState, selectGlobalRegion, timeSignature.numerator]);
|
||||||
|
|
||||||
|
const createMarkerAtPlayheadBar = useCallback(() => {
|
||||||
|
const beatsPerBar = timeSignature.numerator;
|
||||||
|
const startBeat = Math.floor(playheadPosition / beatsPerBar) * beatsPerBar;
|
||||||
|
createMarkerAtBeat(startBeat);
|
||||||
|
}, [createMarkerAtBeat, playheadPosition, timeSignature.numerator]);
|
||||||
|
|
||||||
|
const moveGlobalMarkerRegion = useCallback((regionId: string, startBeat: number) => {
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new MoveGlobalRegionCommand(regionId, Math.round(startBeat)));
|
||||||
|
refreshProjectState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error moving marker region:', error);
|
||||||
|
}
|
||||||
|
}, [refreshProjectState]);
|
||||||
|
|
||||||
|
const resizeGlobalMarkerRegion = useCallback((regionId: string, edge: 'start' | 'end', beat: number) => {
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new ResizeGlobalRegionCommand(regionId, edge, Math.round(beat)));
|
||||||
|
refreshProjectState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resizing marker region:', error);
|
||||||
|
}
|
||||||
|
}, [refreshProjectState]);
|
||||||
|
|
||||||
|
const createKeySignatureAtBar = useCallback((requestedStartBar: number) => {
|
||||||
|
const normalizedStartBar = Math.max(0, Math.min(requestedStartBar, maxBars - 1));
|
||||||
|
const existingRegionAtStart = signatureRegions.find(region => region.getStartBar() === normalizedStartBar);
|
||||||
|
if (existingRegionAtStart) {
|
||||||
|
selectGlobalRegion(existingRegionAtStart.getId(), DEFAULT_REGION_CLICK_OPTIONS);
|
||||||
|
beginEditingKeySignatureRegion(existingRegionAtStart.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = new CreateKeySignatureRegionCommand(normalizedStartBar);
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
refreshProjectState();
|
||||||
|
|
||||||
|
const createdRegion = command.getCreatedRegion();
|
||||||
|
if (!createdRegion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectGlobalRegion(createdRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
|
||||||
|
setEditingKeySignatureRegionId(createdRegion.getId());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating key signature region:', error);
|
||||||
|
}
|
||||||
|
}, [beginEditingKeySignatureRegion, maxBars, refreshProjectState, selectGlobalRegion, signatureRegions]);
|
||||||
|
|
||||||
|
const createKeySignatureAtPlayheadBar = useCallback(() => {
|
||||||
|
const beatsPerBar = timeSignature.numerator;
|
||||||
|
const startBar = Math.floor(playheadPosition / beatsPerBar);
|
||||||
|
createKeySignatureAtBar(startBar);
|
||||||
|
}, [createKeySignatureAtBar, playheadPosition, timeSignature.numerator]);
|
||||||
|
|
||||||
|
const resizeKeySignatureRegion = useCallback((regionId: string, edge: 'start' | 'end', bar: number) => {
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new ResizeKeySignatureRegionCommand(regionId, edge, Math.round(bar)));
|
||||||
|
refreshProjectState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resizing key signature region:', error);
|
||||||
|
}
|
||||||
|
}, [refreshProjectState]);
|
||||||
|
|
||||||
|
const updateKeySignatureRegion = useCallback((regionId: string, keySignature: KeySignature) => {
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new UpdateKeySignatureRegionCommand(regionId, keySignature));
|
||||||
|
setEditingKeySignatureRegionId(null);
|
||||||
|
refreshProjectState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating key signature region:', error);
|
||||||
|
}
|
||||||
|
}, [refreshProjectState]);
|
||||||
|
|
||||||
|
const commitTempoRegionEdit = useCallback((regionId: string) => {
|
||||||
|
const region = findProjectRegionById(regionId);
|
||||||
|
if (!(region instanceof KGTempoRegion)) {
|
||||||
|
setEditingTempoRegionId(null);
|
||||||
|
setEditingTempoText('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = editingTempoText.trim();
|
||||||
|
setEditingTempoRegionId(null);
|
||||||
|
setEditingTempoText('');
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextBpm = parseInt(trimmed, 10);
|
||||||
|
if (Number.isNaN(nextBpm)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextBpm <= TIME_CONSTANTS.MIN_BPM || nextBpm >= TIME_CONSTANTS.MAX_BPM || nextBpm === region.getBpm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new UpdateTempoRegionCommand(regionId, nextBpm));
|
||||||
|
bumpAudioWaveformRedrawVersion();
|
||||||
|
refreshProjectState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating tempo region:', error);
|
||||||
|
}
|
||||||
|
}, [bumpAudioWaveformRedrawVersion, editingTempoText, findProjectRegionById, refreshProjectState]);
|
||||||
|
|
||||||
|
const createTempoAtBar = useCallback((requestedStartBar: number) => {
|
||||||
|
const normalizedStartBar = Math.max(0, Math.min(requestedStartBar, maxBars - 1));
|
||||||
|
const existingRegionAtStart = tempoRegions.find(region => region.getStartBar() === normalizedStartBar);
|
||||||
|
if (existingRegionAtStart) {
|
||||||
|
selectGlobalRegion(existingRegionAtStart.getId(), DEFAULT_REGION_CLICK_OPTIONS);
|
||||||
|
beginEditingTempoRegion(existingRegionAtStart.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = new CreateTempoRegionCommand(normalizedStartBar);
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
bumpAudioWaveformRedrawVersion();
|
||||||
|
refreshProjectState();
|
||||||
|
|
||||||
|
const createdRegion = command.getCreatedRegion();
|
||||||
|
if (!createdRegion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectGlobalRegion(createdRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
|
||||||
|
setEditingTempoRegionId(createdRegion.getId());
|
||||||
|
setEditingTempoText(createdRegion.getBpm().toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating tempo region:', error);
|
||||||
|
}
|
||||||
|
}, [beginEditingTempoRegion, bumpAudioWaveformRedrawVersion, maxBars, refreshProjectState, selectGlobalRegion, tempoRegions]);
|
||||||
|
|
||||||
|
const createTempoAtPlayheadBar = useCallback(() => {
|
||||||
|
const beatsPerBar = timeSignature.numerator;
|
||||||
|
const startBar = Math.floor(playheadPosition / beatsPerBar);
|
||||||
|
createTempoAtBar(startBar);
|
||||||
|
}, [createTempoAtBar, playheadPosition, timeSignature.numerator]);
|
||||||
|
|
||||||
|
const resizeTempoRegion = useCallback((regionId: string, edge: 'start' | 'end', bar: number) => {
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new ResizeTempoRegionCommand(regionId, edge, Math.round(bar)));
|
||||||
|
bumpAudioWaveformRedrawVersion();
|
||||||
|
refreshProjectState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resizing tempo region:', error);
|
||||||
|
}
|
||||||
|
}, [bumpAudioWaveformRedrawVersion, refreshProjectState]);
|
||||||
|
|
||||||
|
const createChordAtBeat = useCallback((requestedStartBeat: number) => {
|
||||||
|
const normalizedStartBeat = Math.max(0, Math.round(requestedStartBeat));
|
||||||
|
const occupiedRegion = chordRegions.find(region => (
|
||||||
|
normalizedStartBeat >= region.getStartFromBeat()
|
||||||
|
&& normalizedStartBeat < region.getStartFromBeat() + region.getLength()
|
||||||
|
));
|
||||||
|
if (occupiedRegion) {
|
||||||
|
selectGlobalRegion(occupiedRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
|
||||||
|
beginEditingChordRegion(occupiedRegion.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = new CreateChordRegionCommand(normalizedStartBeat, timeSignature.numerator, 'C');
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
refreshProjectState();
|
||||||
|
|
||||||
|
const createdRegion = command.getCreatedRegion();
|
||||||
|
if (!createdRegion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectGlobalRegion(createdRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
|
||||||
|
setEditingChordRegionId(createdRegion.getId());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating chord region:', error);
|
||||||
|
}
|
||||||
|
}, [beginEditingChordRegion, chordRegions, refreshProjectState, selectGlobalRegion, timeSignature.numerator]);
|
||||||
|
|
||||||
|
const createChordAtExactBeat = useCallback((requestedStartBeat: number) => {
|
||||||
|
const normalizedStartBeat = Math.max(0, Math.round(requestedStartBeat));
|
||||||
|
const occupiedRegion = chordRegions.find(region => (
|
||||||
|
normalizedStartBeat > region.getStartFromBeat()
|
||||||
|
&& normalizedStartBeat < region.getStartFromBeat() + region.getLength()
|
||||||
|
));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = occupiedRegion
|
||||||
|
? new InsertChordRegionAtBeatCommand(normalizedStartBeat, 'C')
|
||||||
|
: new CreateChordRegionCommand(normalizedStartBeat, timeSignature.numerator, 'C');
|
||||||
|
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
refreshProjectState();
|
||||||
|
|
||||||
|
const createdRegion = command.getCreatedRegion();
|
||||||
|
if (!createdRegion) {
|
||||||
|
if (occupiedRegion) {
|
||||||
|
selectGlobalRegion(occupiedRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
|
||||||
|
beginEditingChordRegion(occupiedRegion.getId());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectGlobalRegion(createdRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
|
||||||
|
setEditingChordRegionId(createdRegion.getId());
|
||||||
|
return createdRegion;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating chord region at exact beat:', error);
|
||||||
|
if (occupiedRegion) {
|
||||||
|
selectGlobalRegion(occupiedRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
|
||||||
|
beginEditingChordRegion(occupiedRegion.getId());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [beginEditingChordRegion, chordRegions, refreshProjectState, selectGlobalRegion, timeSignature.numerator]);
|
||||||
|
|
||||||
|
const createChordAtPlayheadBeat = useCallback(() => {
|
||||||
|
createChordAtExactBeat(playheadPosition);
|
||||||
|
}, [createChordAtExactBeat, playheadPosition]);
|
||||||
|
|
||||||
|
const navigateChordPopupByBar = useCallback((currentRegionId: string, direction: 'forward' | 'backward') => {
|
||||||
|
const currentRegion = chordRegions.find(region => region.getId() === currentRegionId);
|
||||||
|
if (!currentRegion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beatsPerBar = timeSignature.numerator;
|
||||||
|
const currentStartBeat = currentRegion.getStartFromBeat();
|
||||||
|
const currentBarBeat = Math.floor(currentStartBeat / beatsPerBar) * beatsPerBar;
|
||||||
|
const targetBarBeat = direction === 'forward'
|
||||||
|
? currentBarBeat + beatsPerBar
|
||||||
|
: currentBarBeat - beatsPerBar;
|
||||||
|
const songEndBeat = maxBars * beatsPerBar;
|
||||||
|
if (targetBarBeat < 0 || targetBarBeat >= songEndBeat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedRegions = [...chordRegions].sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
|
||||||
|
const targetRegion = direction === 'forward'
|
||||||
|
? sortedRegions.find(region => region.getStartFromBeat() > currentStartBeat && region.getStartFromBeat() <= targetBarBeat)
|
||||||
|
: [...sortedRegions].reverse().find(region => region.getStartFromBeat() < currentStartBeat);
|
||||||
|
|
||||||
|
if (targetRegion) {
|
||||||
|
selectGlobalRegion(targetRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
|
||||||
|
setEditingChordRegionId(targetRegion.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === 'forward') {
|
||||||
|
createChordAtExactBeat(targetBarBeat);
|
||||||
|
}
|
||||||
|
}, [chordRegions, createChordAtExactBeat, maxBars, selectGlobalRegion, timeSignature.numerator]);
|
||||||
|
|
||||||
|
const moveGlobalChordRegion = useCallback((regionId: string, startBeat: number) => {
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new MoveGlobalRegionCommand(regionId, Math.round(startBeat)));
|
||||||
|
refreshProjectState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error moving chord region:', error);
|
||||||
|
}
|
||||||
|
}, [refreshProjectState]);
|
||||||
|
|
||||||
|
const resizeGlobalChordRegion = useCallback((regionId: string, edge: 'start' | 'end', beat: number) => {
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new ResizeGlobalRegionCommand(regionId, edge, Math.round(beat)));
|
||||||
|
refreshProjectState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resizing chord region:', error);
|
||||||
|
}
|
||||||
|
}, [refreshProjectState]);
|
||||||
|
|
||||||
|
const updateChordRegion = useCallback((regionId: string, symbol: string) => {
|
||||||
|
const region = findProjectRegionById(regionId);
|
||||||
|
if (!(region instanceof KGChordRegion) || region.getSymbol() === symbol) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new UpdateChordRegionCommand(regionId, symbol));
|
||||||
|
refreshProjectState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating chord region:', error);
|
||||||
|
}
|
||||||
|
}, [findProjectRegionById, refreshProjectState]);
|
||||||
|
|
||||||
|
const deleteSelectedGlobalRegions = useCallback((): boolean => {
|
||||||
|
const selectedGlobalRegionIds = selectedRegionIds.filter(regionId => isGlobalRegionId(regionId));
|
||||||
|
if (selectedGlobalRegionIds.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const signatureRegionIds = selectedGlobalRegionIds.filter(regionId => findProjectRegionById(regionId) instanceof KGKeySignatureRegion);
|
||||||
|
const tempoRegionIds = selectedGlobalRegionIds.filter(regionId => findProjectRegionById(regionId) instanceof KGTempoRegion);
|
||||||
|
const markerRegionIds = selectedGlobalRegionIds.filter(regionId => findProjectRegionById(regionId) instanceof KGMarkerRegion);
|
||||||
|
|
||||||
|
if (signatureRegionIds.length > 0 && markerRegionIds.length === 0 && tempoRegionIds.length === 0) {
|
||||||
|
KGCore.instance().executeCommand(
|
||||||
|
signatureRegionIds.length === 1
|
||||||
|
? new DeleteKeySignatureRegionCommand(signatureRegionIds[0])
|
||||||
|
: new DeleteMultipleKeySignatureRegionsCommand(signatureRegionIds)
|
||||||
|
);
|
||||||
|
} else if (tempoRegionIds.length > 0 && markerRegionIds.length === 0 && signatureRegionIds.length === 0) {
|
||||||
|
KGCore.instance().executeCommand(
|
||||||
|
tempoRegionIds.length === 1
|
||||||
|
? new DeleteTempoRegionCommand(tempoRegionIds[0])
|
||||||
|
: new DeleteMultipleTempoRegionsCommand(tempoRegionIds)
|
||||||
|
);
|
||||||
|
bumpAudioWaveformRedrawVersion();
|
||||||
|
} else if (markerRegionIds.length > 0 && signatureRegionIds.length === 0) {
|
||||||
|
KGCore.instance().executeCommand(new DeleteMultipleGlobalRegionsCommand(markerRegionIds));
|
||||||
|
} else {
|
||||||
|
KGCore.instance().executeCommand(new DeleteMultipleGlobalRegionsCommand(selectedGlobalRegionIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingGlobalRegionId && selectedGlobalRegionIds.includes(editingGlobalRegionId)) {
|
||||||
|
setEditingGlobalRegionId(null);
|
||||||
|
setEditingGlobalRegionText('');
|
||||||
|
}
|
||||||
|
if (editingKeySignatureRegionId && selectedGlobalRegionIds.includes(editingKeySignatureRegionId)) {
|
||||||
|
setEditingKeySignatureRegionId(null);
|
||||||
|
}
|
||||||
|
if (editingTempoRegionId && selectedGlobalRegionIds.includes(editingTempoRegionId)) {
|
||||||
|
setEditingTempoRegionId(null);
|
||||||
|
setEditingTempoText('');
|
||||||
|
}
|
||||||
|
if (editingChordRegionId && selectedGlobalRegionIds.includes(editingChordRegionId)) {
|
||||||
|
setEditingChordRegionId(null);
|
||||||
|
}
|
||||||
|
refreshProjectState();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting global marker regions:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
bumpAudioWaveformRedrawVersion,
|
||||||
|
editingChordRegionId,
|
||||||
|
editingGlobalRegionId,
|
||||||
|
editingKeySignatureRegionId,
|
||||||
|
editingTempoRegionId,
|
||||||
|
findProjectRegionById,
|
||||||
|
isGlobalRegionId,
|
||||||
|
refreshProjectState,
|
||||||
|
selectedRegionIds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const markerLaneProps: MarkerLaneProps = {
|
||||||
|
markerRegions,
|
||||||
|
maxBars,
|
||||||
|
barWidthMultiplier,
|
||||||
|
timeSignature,
|
||||||
|
selectedRegionIds,
|
||||||
|
editingRegionId: editingGlobalRegionId,
|
||||||
|
editingText: editingGlobalRegionText,
|
||||||
|
onEditingTextChange: setEditingGlobalRegionText,
|
||||||
|
onCommitEdit: commitGlobalRegionEdit,
|
||||||
|
onCancelEdit: () => {
|
||||||
|
setEditingGlobalRegionId(null);
|
||||||
|
setEditingGlobalRegionText('');
|
||||||
|
},
|
||||||
|
onBeginEdit: beginEditingGlobalRegion,
|
||||||
|
onSelectRegion: selectGlobalRegion,
|
||||||
|
onCreateAtBeat: createMarkerAtBeat,
|
||||||
|
onMoveRegion: moveGlobalMarkerRegion,
|
||||||
|
onResizeRegion: resizeGlobalMarkerRegion,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tempoLaneProps: TempoLaneProps = {
|
||||||
|
tempoRegions,
|
||||||
|
maxBars,
|
||||||
|
barWidthMultiplier,
|
||||||
|
selectedRegionIds,
|
||||||
|
editingRegionId: editingTempoRegionId,
|
||||||
|
editingText: editingTempoText,
|
||||||
|
onEditingTextChange: setEditingTempoText,
|
||||||
|
onCommitEdit: commitTempoRegionEdit,
|
||||||
|
onCancelEdit: () => {
|
||||||
|
setEditingTempoRegionId(null);
|
||||||
|
setEditingTempoText('');
|
||||||
|
},
|
||||||
|
onBeginEdit: beginEditingTempoRegion,
|
||||||
|
onSelectRegion: selectGlobalRegion,
|
||||||
|
onCreateAtBar: createTempoAtBar,
|
||||||
|
onResizeRegion: resizeTempoRegion,
|
||||||
|
};
|
||||||
|
|
||||||
|
const keySignatureLaneProps: KeySignatureLaneProps = {
|
||||||
|
signatureRegions,
|
||||||
|
maxBars,
|
||||||
|
barWidthMultiplier,
|
||||||
|
timeSignature,
|
||||||
|
selectedRegionIds,
|
||||||
|
pickerRegionId: editingKeySignatureRegionId,
|
||||||
|
onClosePicker: () => setEditingKeySignatureRegionId(null),
|
||||||
|
onSelectRegion: selectGlobalRegion,
|
||||||
|
onCreateAtBar: createKeySignatureAtBar,
|
||||||
|
onResizeRegion: resizeKeySignatureRegion,
|
||||||
|
onChangeKeySignature: updateKeySignatureRegion,
|
||||||
|
onOpenPicker: beginEditingKeySignatureRegion,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chordLaneProps: ChordLaneProps = {
|
||||||
|
chordRegions,
|
||||||
|
maxBars,
|
||||||
|
barWidthMultiplier,
|
||||||
|
timeSignature,
|
||||||
|
selectedRegionIds,
|
||||||
|
popupRegionId: editingChordRegionId,
|
||||||
|
onClosePopup: () => setEditingChordRegionId(null),
|
||||||
|
onSelectRegion: selectGlobalRegion,
|
||||||
|
onCreateAtBeat: createChordAtBeat,
|
||||||
|
onMoveRegion: moveGlobalChordRegion,
|
||||||
|
onResizeRegion: resizeGlobalChordRegion,
|
||||||
|
onChangeChord: updateChordRegion,
|
||||||
|
onOpenPopup: beginEditingChordRegion,
|
||||||
|
onTabNavigate: navigateChordPopupByBar,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleteSelectedGlobalRegions,
|
||||||
|
editingRegionIds: [
|
||||||
|
editingGlobalRegionId,
|
||||||
|
editingTempoRegionId,
|
||||||
|
editingKeySignatureRegionId,
|
||||||
|
editingChordRegionId,
|
||||||
|
].filter((regionId): regionId is string => Boolean(regionId)),
|
||||||
|
sectionProps: {
|
||||||
|
onAddMarker: createMarkerAtPlayheadBar,
|
||||||
|
onAddTempo: createTempoAtPlayheadBar,
|
||||||
|
onAddKeySignature: createKeySignatureAtPlayheadBar,
|
||||||
|
onAddChord: createChordAtPlayheadBeat,
|
||||||
|
markerLaneProps,
|
||||||
|
tempoLaneProps,
|
||||||
|
keySignatureLaneProps,
|
||||||
|
chordLaneProps,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,765 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { KGCore } from '../core/KGCore';
|
||||||
|
import { KGGlobalTrack } from '../core/global-track';
|
||||||
|
import { KGTrack } from '../core/track/KGTrack';
|
||||||
|
import { KGRegion } from '../core/region/KGRegion';
|
||||||
|
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
|
||||||
|
import { KGChordRegion } from '../core/region/KGChordRegion';
|
||||||
|
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
||||||
|
import { KGTempoRegion } from '../core/region/KGTempoRegion';
|
||||||
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
|
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||||
|
import { KGMarkerRegion } from '../core/region/KGMarkerRegion';
|
||||||
|
import type { RegionClickOptions, RegionUI } from '../components/interfaces';
|
||||||
|
import { DEBUG_MODE } from '../constants';
|
||||||
|
import { useProjectStore } from '../stores/projectStore';
|
||||||
|
import { useRegionOperations } from './useRegionOperations';
|
||||||
|
import { getAudioRegionDisplayLengthBeats } from '../util/globalTrackUtil';
|
||||||
|
|
||||||
|
const DEFAULT_REGION_CLICK_OPTIONS: RegionClickOptions = {
|
||||||
|
shiftKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseMainContentRegionsParams {
|
||||||
|
tracks: KGTrack[];
|
||||||
|
globalTracks: KGGlobalTrack[];
|
||||||
|
timeSignature: { numerator: number; denominator: number };
|
||||||
|
selectedRegionIds: string[];
|
||||||
|
clearAllSelections: () => void;
|
||||||
|
setSelectedTrack: (trackId: string) => void;
|
||||||
|
showPianoRoll: boolean;
|
||||||
|
activeRegionId: string | null;
|
||||||
|
setShowPianoRoll: (show: boolean) => void;
|
||||||
|
setActiveRegionId: (regionId: string | null) => void;
|
||||||
|
openMidiPianoRoll: (regionId: string) => void;
|
||||||
|
openSpectrogramViewer: (regionId: string) => void;
|
||||||
|
openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
|
||||||
|
pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid';
|
||||||
|
updateTrack: (track: KGTrack) => void;
|
||||||
|
maxBars: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MainContentSelectionState {
|
||||||
|
selectedRegionId: string | null;
|
||||||
|
findProjectRegionById: (regionId: string) => KGRegion | null;
|
||||||
|
isGlobalRegionId: (regionId: string) => boolean;
|
||||||
|
selectGlobalRegion: (regionId: string, options?: RegionClickOptions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseMainContentRegionsResult {
|
||||||
|
regions: RegionUI[];
|
||||||
|
deleteSelectedRegions: () => boolean;
|
||||||
|
selection: MainContentSelectionState;
|
||||||
|
handleRegionCreated: (trackIndex: number, regionUI: RegionUI, midiRegion: KGMidiRegion) => void;
|
||||||
|
handleExternalDropComplete: (trackIndex: number, regionUI: RegionUI) => void;
|
||||||
|
handleRegionUpdated: (
|
||||||
|
regionId: string,
|
||||||
|
updates: Partial<RegionUI>,
|
||||||
|
expectedModelUpdates?: { startBeat: number; length: number }
|
||||||
|
) => void;
|
||||||
|
handleRegionClick: (regionId: string, options?: RegionClickOptions) => void;
|
||||||
|
handleRegionLassoSelection: (regionIds: string[], options?: RegionClickOptions) => void;
|
||||||
|
handleRegionLassoCommit: () => void;
|
||||||
|
handleEmptyMainContentClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
|
handleOpenPianoRoll: (regionId: string) => void;
|
||||||
|
handleOpenSpectrogram: (regionId: string) => void;
|
||||||
|
handleOpenHybrid: (regionId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMainContentRegions({
|
||||||
|
tracks,
|
||||||
|
globalTracks,
|
||||||
|
timeSignature,
|
||||||
|
selectedRegionIds,
|
||||||
|
clearAllSelections,
|
||||||
|
setSelectedTrack,
|
||||||
|
showPianoRoll,
|
||||||
|
activeRegionId,
|
||||||
|
setShowPianoRoll,
|
||||||
|
setActiveRegionId,
|
||||||
|
openMidiPianoRoll,
|
||||||
|
openSpectrogramViewer,
|
||||||
|
openHybridMode,
|
||||||
|
pianoRollMode,
|
||||||
|
updateTrack,
|
||||||
|
maxBars,
|
||||||
|
}: UseMainContentRegionsParams): UseMainContentRegionsResult {
|
||||||
|
const [regions, setRegions] = useState<RegionUI[]>([]);
|
||||||
|
const [selectedRegionId, setSelectedRegionId] = useState<string | null>(null);
|
||||||
|
const pendingUpdates = useRef<Map<string, { trackId: string; regionId: string; startBeat: number; length: number }>>(new Map());
|
||||||
|
const preventEmptyMainContentDeselectRef = useRef(false);
|
||||||
|
const pendingAutoSelectionRegionIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const { deleteSelectedRegions } = useRegionOperations({
|
||||||
|
tracks,
|
||||||
|
updateTrack,
|
||||||
|
setRegions,
|
||||||
|
selectedRegionId,
|
||||||
|
setSelectedRegionId,
|
||||||
|
showPianoRoll,
|
||||||
|
setShowPianoRoll,
|
||||||
|
activeRegionId,
|
||||||
|
setActiveRegionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findProjectRegionById = useCallback((regionId: string): KGRegion | null => {
|
||||||
|
for (const track of tracks) {
|
||||||
|
const region = track.getRegions().find(candidate => candidate.getId() === regionId);
|
||||||
|
if (region) {
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const globalTrack of globalTracks) {
|
||||||
|
const region = globalTrack.getRegions().find(candidate => candidate.getId() === regionId);
|
||||||
|
if (region) {
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [globalTracks, tracks]);
|
||||||
|
|
||||||
|
const isGlobalRegionId = useCallback((regionId: string) => {
|
||||||
|
const region = findProjectRegionById(regionId);
|
||||||
|
return region instanceof KGGlobalRegion;
|
||||||
|
}, [findProjectRegionById]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingUpdates.current.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = new Map(pendingUpdates.current);
|
||||||
|
pendingUpdates.current.clear();
|
||||||
|
|
||||||
|
updates.forEach(update => {
|
||||||
|
const { trackId, regionId, startBeat, length } = update;
|
||||||
|
const track = tracks.find(candidate => candidate.getId().toString() === trackId);
|
||||||
|
if (!track) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = track.getRegions().find(candidate => candidate.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()}`);
|
||||||
|
const success = region.getStartFromBeat() === startBeat && region.getLength() === length && region.getTrackId() === trackId;
|
||||||
|
console.log(` Update successful: ${success}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updatedRegions: RegionUI[] = [];
|
||||||
|
|
||||||
|
tracks.forEach(track => {
|
||||||
|
const trackId = track.getId().toString();
|
||||||
|
const trackIndex = track.getTrackIndex();
|
||||||
|
|
||||||
|
track.getRegions().forEach(region => {
|
||||||
|
if (region instanceof KGMidiRegion || region instanceof KGAudioRegion) {
|
||||||
|
const beatsPerBar = timeSignature.numerator;
|
||||||
|
const barNumber = (region.getStartFromBeat() / beatsPerBar) + 1;
|
||||||
|
const lengthBeats = region instanceof KGAudioRegion
|
||||||
|
? getAudioRegionDisplayLengthBeats(KGCore.instance().getCurrentProject(), region)
|
||||||
|
: region.getLength();
|
||||||
|
const length = lengthBeats / beatsPerBar;
|
||||||
|
|
||||||
|
updatedRegions.push({
|
||||||
|
id: region.getId(),
|
||||||
|
trackId,
|
||||||
|
trackIndex,
|
||||||
|
barNumber,
|
||||||
|
length,
|
||||||
|
name: region.getName(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setRegions(updatedRegions);
|
||||||
|
}, [globalTracks, timeSignature, tracks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPianoRoll || !activeRegionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeRegionStillExists = tracks.some(track =>
|
||||||
|
track.getRegions().some(region => region.getId() === activeRegionId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!activeRegionStillExists) {
|
||||||
|
setShowPianoRoll(false);
|
||||||
|
setActiveRegionId(null);
|
||||||
|
}
|
||||||
|
}, [tracks, showPianoRoll, activeRegionId, setShowPianoRoll, setActiveRegionId]);
|
||||||
|
|
||||||
|
const applyRegionSelection = useCallback((orderedSelectionIds: string[]) => {
|
||||||
|
const core = KGCore.instance();
|
||||||
|
const allRegions = [
|
||||||
|
...tracks.flatMap(projectTrack => projectTrack.getRegions()),
|
||||||
|
...globalTracks.flatMap(globalTrack => globalTrack.getRegions()),
|
||||||
|
];
|
||||||
|
|
||||||
|
allRegions.forEach(projectRegion => projectRegion.deselect());
|
||||||
|
clearAllSelections();
|
||||||
|
|
||||||
|
const selectedRegions: KGRegion[] = orderedSelectionIds
|
||||||
|
.map(selectedId => allRegions.find(region => region.getId() === selectedId) ?? null)
|
||||||
|
.filter((selectedRegion): selectedRegion is KGRegion => selectedRegion !== null);
|
||||||
|
|
||||||
|
selectedRegions.forEach(selectedRegion => selectedRegion.select());
|
||||||
|
|
||||||
|
if (selectedRegions.length > 0) {
|
||||||
|
core.addSelectedItems(selectedRegions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSelectedRegion = selectedRegions.length > 0
|
||||||
|
? selectedRegions[selectedRegions.length - 1]
|
||||||
|
: null;
|
||||||
|
const lastSelectedRegionId = lastSelectedRegion?.getId() ?? null;
|
||||||
|
|
||||||
|
setSelectedRegionId(lastSelectedRegionId);
|
||||||
|
if (lastSelectedRegionId && lastSelectedRegion && !(lastSelectedRegion instanceof KGGlobalRegion)) {
|
||||||
|
setActiveRegionId(lastSelectedRegionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
console.log(`Selected regions: ${selectedRegions.map(selectedRegion => selectedRegion.getId()).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showPianoRoll || !lastSelectedRegionId || !lastSelectedRegion || lastSelectedRegion instanceof KGGlobalRegion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastSelectedRegion instanceof KGAudioRegion) {
|
||||||
|
openSpectrogramViewer(lastSelectedRegionId);
|
||||||
|
} else if (lastSelectedRegion instanceof KGMidiRegion) {
|
||||||
|
openMidiPianoRoll(lastSelectedRegionId);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
clearAllSelections,
|
||||||
|
globalTracks,
|
||||||
|
openMidiPianoRoll,
|
||||||
|
openSpectrogramViewer,
|
||||||
|
setActiveRegionId,
|
||||||
|
showPianoRoll,
|
||||||
|
tracks,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isAdditiveSelection = useCallback((options: RegionClickOptions) => options.metaKey || options.ctrlKey, []);
|
||||||
|
|
||||||
|
const getOrderedTrackRegionIds = useCallback((trackId: string) => (
|
||||||
|
regions
|
||||||
|
.filter(region => region.trackId === trackId)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.barNumber !== right.barNumber) {
|
||||||
|
return left.barNumber - right.barNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
return left.length - right.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.id.localeCompare(right.id);
|
||||||
|
})
|
||||||
|
.map(region => region.id)
|
||||||
|
), [regions]);
|
||||||
|
|
||||||
|
const getOrderedGlobalRegionIds = useCallback((regionType: 'marker' | 'tempo' | 'signature' | 'chord') => (
|
||||||
|
globalTracks
|
||||||
|
.flatMap(globalTrack => globalTrack.getRegions())
|
||||||
|
.filter((region): region is KGGlobalRegion => {
|
||||||
|
if (regionType === 'tempo') {
|
||||||
|
return region instanceof KGTempoRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionType === 'signature') {
|
||||||
|
return region instanceof KGKeySignatureRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionType === 'chord') {
|
||||||
|
return region instanceof KGChordRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
return region instanceof KGMarkerRegion;
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftStart = left instanceof KGTempoRegion || left instanceof KGKeySignatureRegion
|
||||||
|
? left.getStartBar()
|
||||||
|
: Math.round(left.getStartFromBeat() / timeSignature.numerator);
|
||||||
|
const rightStart = right instanceof KGTempoRegion || right instanceof KGKeySignatureRegion
|
||||||
|
? right.getStartBar()
|
||||||
|
: Math.round(right.getStartFromBeat() / timeSignature.numerator);
|
||||||
|
|
||||||
|
if (leftStart !== rightStart) {
|
||||||
|
return leftStart - rightStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftLength = left instanceof KGTempoRegion || left instanceof KGKeySignatureRegion
|
||||||
|
? left.getLengthBars()
|
||||||
|
: left.getLength();
|
||||||
|
const rightLength = right instanceof KGTempoRegion || right instanceof KGKeySignatureRegion
|
||||||
|
? right.getLengthBars()
|
||||||
|
: right.getLength();
|
||||||
|
|
||||||
|
if (leftLength !== rightLength) {
|
||||||
|
return leftLength - rightLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.getId().localeCompare(right.getId());
|
||||||
|
})
|
||||||
|
.map(region => region.getId())
|
||||||
|
), [globalTracks, timeSignature.numerator]);
|
||||||
|
|
||||||
|
const appendPrimarySelection = useCallback((orderedIds: string[], primaryRegionId: string) => {
|
||||||
|
const deduped = orderedIds.filter(id => id !== primaryRegionId);
|
||||||
|
return [...deduped, primaryRegionId];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildSameTrackRangeSelection = useCallback((
|
||||||
|
existingRegularSelectionIds: string[],
|
||||||
|
anchorRegionId: string,
|
||||||
|
targetRegionId: string,
|
||||||
|
orderedTrackRegionIds: string[]
|
||||||
|
) => {
|
||||||
|
const anchorIndex = orderedTrackRegionIds.indexOf(anchorRegionId);
|
||||||
|
const targetIndex = orderedTrackRegionIds.indexOf(targetRegionId);
|
||||||
|
if (anchorIndex === -1 || targetIndex === -1) {
|
||||||
|
return [targetRegionId];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [startIndex, endIndex] = anchorIndex <= targetIndex
|
||||||
|
? [anchorIndex, targetIndex]
|
||||||
|
: [targetIndex, anchorIndex];
|
||||||
|
const rangeIds = orderedTrackRegionIds.slice(startIndex, endIndex + 1);
|
||||||
|
const rangeIdSet = new Set(rangeIds);
|
||||||
|
const preservedOtherTrackIds = existingRegularSelectionIds.filter(selectedId => !rangeIdSet.has(selectedId));
|
||||||
|
return appendPrimarySelection([...preservedOtherTrackIds, ...rangeIds], targetRegionId);
|
||||||
|
}, [appendPrimarySelection]);
|
||||||
|
|
||||||
|
const buildSameLaneRangeSelection = useCallback((
|
||||||
|
existingGlobalSelectionIds: string[],
|
||||||
|
anchorRegionId: string,
|
||||||
|
targetRegionId: string,
|
||||||
|
orderedLaneRegionIds: string[]
|
||||||
|
) => {
|
||||||
|
const anchorIndex = orderedLaneRegionIds.indexOf(anchorRegionId);
|
||||||
|
const targetIndex = orderedLaneRegionIds.indexOf(targetRegionId);
|
||||||
|
if (anchorIndex === -1 || targetIndex === -1) {
|
||||||
|
return [targetRegionId];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [startIndex, endIndex] = anchorIndex <= targetIndex
|
||||||
|
? [anchorIndex, targetIndex]
|
||||||
|
: [targetIndex, anchorIndex];
|
||||||
|
const rangeIds = orderedLaneRegionIds.slice(startIndex, endIndex + 1);
|
||||||
|
const laneIdSet = new Set(orderedLaneRegionIds);
|
||||||
|
const preservedOtherLaneIds = existingGlobalSelectionIds.filter(selectedId => !laneIdSet.has(selectedId));
|
||||||
|
return appendPrimarySelection([...preservedOtherLaneIds, ...rangeIds], targetRegionId);
|
||||||
|
}, [appendPrimarySelection]);
|
||||||
|
|
||||||
|
const getGlobalRegionLaneType = useCallback((region: KGGlobalRegion): 'marker' | 'tempo' | 'signature' | 'chord' => {
|
||||||
|
if (region instanceof KGTempoRegion) {
|
||||||
|
return 'tempo';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (region instanceof KGKeySignatureRegion) {
|
||||||
|
return 'signature';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (region instanceof KGChordRegion) {
|
||||||
|
return 'chord';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'marker';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectRegion = useCallback((
|
||||||
|
regionId: string,
|
||||||
|
options: RegionClickOptions = DEFAULT_REGION_CLICK_OPTIONS,
|
||||||
|
regionsToSearch?: RegionUI[]
|
||||||
|
) => {
|
||||||
|
const regionsToUse = regionsToSearch || regions;
|
||||||
|
const region = regionsToUse.find(candidate => candidate.id === regionId);
|
||||||
|
if (!region) {
|
||||||
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
console.log(`Region not found in UI state: ${regionId}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = tracks.find(candidate => candidate.getId().toString() === region.trackId);
|
||||||
|
if (!track) {
|
||||||
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
console.log(`Track not found for region: ${regionId}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreRegion = track.getRegions().find(candidate => candidate.getId() === regionId);
|
||||||
|
if (!coreRegion) {
|
||||||
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
console.log(`Region not found in track model: ${regionId}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regularSelectedRegionIds = selectedRegionIds.filter(selectedId => !isGlobalRegionId(selectedId));
|
||||||
|
const additiveSelection = isAdditiveSelection(options);
|
||||||
|
let orderedSelection: string[];
|
||||||
|
|
||||||
|
if (additiveSelection) {
|
||||||
|
orderedSelection = regularSelectedRegionIds.includes(regionId)
|
||||||
|
? regularSelectedRegionIds.filter(id => id !== regionId)
|
||||||
|
: appendPrimarySelection([...regularSelectedRegionIds, regionId], regionId);
|
||||||
|
} else if (options.shiftKey) {
|
||||||
|
const sameTrackSelectedIds = regularSelectedRegionIds.filter(selectedId => {
|
||||||
|
const selectedRegion = regionsToUse.find(candidate => candidate.id === selectedId);
|
||||||
|
return selectedRegion?.trackId === region.trackId;
|
||||||
|
});
|
||||||
|
const anchorRegionId = [...sameTrackSelectedIds].reverse().find(selectedId => selectedId !== regionId) ?? null;
|
||||||
|
|
||||||
|
if (!anchorRegionId) {
|
||||||
|
orderedSelection = [regionId];
|
||||||
|
} else {
|
||||||
|
orderedSelection = buildSameTrackRangeSelection(
|
||||||
|
regularSelectedRegionIds,
|
||||||
|
anchorRegionId,
|
||||||
|
regionId,
|
||||||
|
getOrderedTrackRegionIds(region.trackId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
orderedSelection = [regionId];
|
||||||
|
}
|
||||||
|
|
||||||
|
applyRegionSelection(orderedSelection);
|
||||||
|
}, [
|
||||||
|
appendPrimarySelection,
|
||||||
|
applyRegionSelection,
|
||||||
|
buildSameTrackRangeSelection,
|
||||||
|
getOrderedTrackRegionIds,
|
||||||
|
isAdditiveSelection,
|
||||||
|
isGlobalRegionId,
|
||||||
|
regions,
|
||||||
|
selectedRegionIds,
|
||||||
|
tracks,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectGlobalRegion = useCallback((regionId: string, options: RegionClickOptions = DEFAULT_REGION_CLICK_OPTIONS) => {
|
||||||
|
const globalSelectedRegionIds = selectedRegionIds.filter(selectedId => isGlobalRegionId(selectedId));
|
||||||
|
const globalRegion = findProjectRegionById(regionId);
|
||||||
|
if (!(globalRegion instanceof KGGlobalRegion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLaneType = getGlobalRegionLaneType(globalRegion);
|
||||||
|
const sameLaneSelectedRegionIds = globalSelectedRegionIds.filter(selectedId => {
|
||||||
|
const selectedRegion = findProjectRegionById(selectedId);
|
||||||
|
return selectedRegion instanceof KGGlobalRegion && getGlobalRegionLaneType(selectedRegion) === targetLaneType;
|
||||||
|
});
|
||||||
|
|
||||||
|
const additiveSelection = isAdditiveSelection(options);
|
||||||
|
let orderedSelection: string[];
|
||||||
|
|
||||||
|
if (additiveSelection) {
|
||||||
|
orderedSelection = sameLaneSelectedRegionIds.includes(regionId)
|
||||||
|
? sameLaneSelectedRegionIds.filter(id => id !== regionId)
|
||||||
|
: appendPrimarySelection([...sameLaneSelectedRegionIds, regionId], regionId);
|
||||||
|
} else if (options.shiftKey) {
|
||||||
|
const orderedLaneRegionIds = getOrderedGlobalRegionIds(targetLaneType);
|
||||||
|
const laneRegionIdSet = new Set(orderedLaneRegionIds);
|
||||||
|
const sameLaneSelectedIds = sameLaneSelectedRegionIds.filter(selectedId => laneRegionIdSet.has(selectedId));
|
||||||
|
const anchorRegionId = [...sameLaneSelectedIds].reverse().find(selectedId => selectedId !== regionId) ?? null;
|
||||||
|
|
||||||
|
if (!anchorRegionId) {
|
||||||
|
orderedSelection = [regionId];
|
||||||
|
} else {
|
||||||
|
orderedSelection = buildSameLaneRangeSelection(
|
||||||
|
sameLaneSelectedRegionIds,
|
||||||
|
anchorRegionId,
|
||||||
|
regionId,
|
||||||
|
orderedLaneRegionIds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
orderedSelection = [regionId];
|
||||||
|
}
|
||||||
|
|
||||||
|
applyRegionSelection(orderedSelection);
|
||||||
|
}, [
|
||||||
|
appendPrimarySelection,
|
||||||
|
applyRegionSelection,
|
||||||
|
buildSameLaneRangeSelection,
|
||||||
|
findProjectRegionById,
|
||||||
|
getGlobalRegionLaneType,
|
||||||
|
getOrderedGlobalRegionIds,
|
||||||
|
isAdditiveSelection,
|
||||||
|
isGlobalRegionId,
|
||||||
|
selectedRegionIds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleRegionLassoSelection = useCallback((regionIds: string[], options: RegionClickOptions = DEFAULT_REGION_CLICK_OPTIONS) => {
|
||||||
|
const orderedRegionIds = regionIds.filter(regionId => regions.some(region => region.id === regionId));
|
||||||
|
const regularSelectedRegionIds = selectedRegionIds.filter(selectedId => !isGlobalRegionId(selectedId));
|
||||||
|
const additiveSelection = isAdditiveSelection(options);
|
||||||
|
const orderedSelection = additiveSelection
|
||||||
|
? orderedRegionIds.reduce<string[]>((nextSelection, regionId) => {
|
||||||
|
if (nextSelection.includes(regionId)) {
|
||||||
|
return nextSelection.filter(id => id !== regionId);
|
||||||
|
}
|
||||||
|
return appendPrimarySelection([...nextSelection, regionId], regionId);
|
||||||
|
}, [...regularSelectedRegionIds])
|
||||||
|
: orderedRegionIds;
|
||||||
|
|
||||||
|
applyRegionSelection(orderedSelection);
|
||||||
|
}, [appendPrimarySelection, applyRegionSelection, isAdditiveSelection, isGlobalRegionId, regions, selectedRegionIds]);
|
||||||
|
|
||||||
|
const handleEmptyMainContentClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (event.target !== event.currentTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preventEmptyMainContentDeselectRef.current) {
|
||||||
|
preventEmptyMainContentDeselectRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRegionLassoSelection([], DEFAULT_REGION_CLICK_OPTIONS);
|
||||||
|
}, [handleRegionLassoSelection]);
|
||||||
|
|
||||||
|
const handleRegionLassoCommit = useCallback(() => {
|
||||||
|
preventEmptyMainContentDeselectRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pendingRegionId = pendingAutoSelectionRegionIdRef.current;
|
||||||
|
if (!pendingRegionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regionExists = regions.some(region => region.id === pendingRegionId);
|
||||||
|
if (!regionExists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingAutoSelectionRegionIdRef.current = null;
|
||||||
|
selectRegion(pendingRegionId, DEFAULT_REGION_CLICK_OPTIONS, regions);
|
||||||
|
}, [regions, selectRegion]);
|
||||||
|
|
||||||
|
const handleRegionCreated = useCallback((trackIndex: number, regionUI: RegionUI) => {
|
||||||
|
const track = tracks[trackIndex];
|
||||||
|
updateTrack(track);
|
||||||
|
setSelectedTrack(track.getId().toString());
|
||||||
|
|
||||||
|
pendingAutoSelectionRegionIdRef.current = regionUI.id;
|
||||||
|
setRegions(previousRegions => [...previousRegions, regionUI]);
|
||||||
|
}, [setSelectedTrack, tracks, updateTrack]);
|
||||||
|
|
||||||
|
const handleExternalDropComplete = useCallback((trackIndex: number, regionUI: RegionUI) => {
|
||||||
|
const track = tracks[trackIndex];
|
||||||
|
if (!track) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTrack(track);
|
||||||
|
setSelectedTrack(track.getId().toString());
|
||||||
|
|
||||||
|
const coreMaxBars = KGCore.instance().getCurrentProject().getMaxBars();
|
||||||
|
if (coreMaxBars > maxBars) {
|
||||||
|
useProjectStore.setState({ maxBars: coreMaxBars });
|
||||||
|
document.documentElement.style.setProperty('--max-number-of-bars', coreMaxBars.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingAutoSelectionRegionIdRef.current = regionUI.id;
|
||||||
|
setRegions(previousRegions => [...previousRegions, regionUI]);
|
||||||
|
}, [maxBars, setSelectedTrack, tracks, updateTrack]);
|
||||||
|
|
||||||
|
const handleRegionUpdated = useCallback((
|
||||||
|
regionId: string,
|
||||||
|
updates: Partial<RegionUI>,
|
||||||
|
expectedModelUpdates?: { startBeat: number; length: number }
|
||||||
|
) => {
|
||||||
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
console.log(`Updating region ${regionId} with:`, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectRegion(regionId);
|
||||||
|
|
||||||
|
const updatedRegion = regions.find(region => region.id === regionId);
|
||||||
|
if (updatedRegion) {
|
||||||
|
const trackId = updates.trackId || updatedRegion.trackId;
|
||||||
|
const track = tracks.find(candidate => candidate.getId().toString() === trackId);
|
||||||
|
if (track) {
|
||||||
|
setSelectedTrack(track.getId().toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setRegions(previousRegions => previousRegions.map(region => (
|
||||||
|
region.id === regionId ? { ...region, ...updates } : region
|
||||||
|
)));
|
||||||
|
|
||||||
|
const region = regions.find(candidate => candidate.id === regionId);
|
||||||
|
if (!region) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalTrack = tracks.find(candidate => candidate.getId().toString() === region.trackId);
|
||||||
|
const targetTrack = tracks.find(candidate => candidate.getId().toString() === updates.trackId);
|
||||||
|
|
||||||
|
if (originalTrack && targetTrack) {
|
||||||
|
setSelectedTrack(targetTrack.getId().toString());
|
||||||
|
updateTrack(originalTrack);
|
||||||
|
updateTrack(targetTrack);
|
||||||
|
|
||||||
|
if (expectedModelUpdates) {
|
||||||
|
const key = `${updates.trackId}-${regionId}-${Date.now()}`;
|
||||||
|
pendingUpdates.current.set(key, {
|
||||||
|
trackId: updates.trackId,
|
||||||
|
regionId,
|
||||||
|
startBeat: expectedModelUpdates.startBeat,
|
||||||
|
length: expectedModelUpdates.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const track = tracks.find(candidate => candidate.getId().toString() === region.trackId);
|
||||||
|
if (track) {
|
||||||
|
const trackRegions = track.getRegions();
|
||||||
|
const midiRegion = trackRegions.find(candidate => candidate.getId() === regionId) as KGMidiRegion | undefined;
|
||||||
|
|
||||||
|
if (midiRegion) {
|
||||||
|
if (expectedModelUpdates) {
|
||||||
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
console.log(`MainContent - Expected model updates: startBeat=${expectedModelUpdates.startBeat}, length=${expectedModelUpdates.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${track.getId()}-${regionId}-${Date.now()}`;
|
||||||
|
pendingUpdates.current.set(key, {
|
||||||
|
trackId: track.getId().toString(),
|
||||||
|
regionId,
|
||||||
|
startBeat: expectedModelUpdates.startBeat,
|
||||||
|
length: expectedModelUpdates.length,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const startBeat = midiRegion.getStartFromBeat();
|
||||||
|
const length = midiRegion.getLength();
|
||||||
|
|
||||||
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
console.log(`MainContent - Region before store update: startBeat=${startBeat}, length=${length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${track.getId()}-${regionId}-${Date.now()}`;
|
||||||
|
pendingUpdates.current.set(key, {
|
||||||
|
trackId: track.getId().toString(),
|
||||||
|
regionId,
|
||||||
|
startBeat,
|
||||||
|
length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTrack(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showPianoRoll && activeRegionId === regionId) {
|
||||||
|
setActiveRegionId(regionId);
|
||||||
|
|
||||||
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
console.log(`Updated region ${regionId} set as active region in piano roll`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
activeRegionId,
|
||||||
|
regions,
|
||||||
|
selectRegion,
|
||||||
|
setActiveRegionId,
|
||||||
|
setSelectedTrack,
|
||||||
|
showPianoRoll,
|
||||||
|
tracks,
|
||||||
|
updateTrack,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleRegionClick = useCallback((regionId: string, options: RegionClickOptions = DEFAULT_REGION_CLICK_OPTIONS) => {
|
||||||
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
console.log(`Region clicked in MainContent (selection only): ${regionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectRegion(regionId, options);
|
||||||
|
|
||||||
|
const region = regions.find(candidate => candidate.id === regionId);
|
||||||
|
if (!region) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = tracks.find(candidate => candidate.getId().toString() === region.trackId);
|
||||||
|
if (!track) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedTrack(track.getId().toString());
|
||||||
|
}, [regions, selectRegion, setSelectedTrack, tracks]);
|
||||||
|
|
||||||
|
const handleOpenPianoRoll = useCallback((regionId: string) => {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
for (const track of project.getTracks()) {
|
||||||
|
const region = track.getRegions().find(candidate => candidate.getId() === regionId);
|
||||||
|
if (region && region.getCurrentType() === 'KGAudioRegion') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
console.log(`Open piano roll via pencil for region: ${regionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRegionClick(regionId, DEFAULT_REGION_CLICK_OPTIONS);
|
||||||
|
openMidiPianoRoll(regionId);
|
||||||
|
}, [handleRegionClick, openMidiPianoRoll]);
|
||||||
|
|
||||||
|
const handleOpenSpectrogram = useCallback((regionId: string) => {
|
||||||
|
handleRegionClick(regionId, DEFAULT_REGION_CLICK_OPTIONS);
|
||||||
|
openSpectrogramViewer(regionId);
|
||||||
|
}, [handleRegionClick, openSpectrogramViewer]);
|
||||||
|
|
||||||
|
const handleOpenHybrid = useCallback((regionId: string) => {
|
||||||
|
if (pianoRollMode === 'midi-edit' && activeRegionId) {
|
||||||
|
openHybridMode(activeRegionId, regionId);
|
||||||
|
} else if (pianoRollMode === 'spectrogram' && activeRegionId) {
|
||||||
|
openHybridMode(regionId, activeRegionId);
|
||||||
|
}
|
||||||
|
}, [activeRegionId, openHybridMode, pianoRollMode]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
regions,
|
||||||
|
deleteSelectedRegions,
|
||||||
|
selection: {
|
||||||
|
selectedRegionId,
|
||||||
|
findProjectRegionById,
|
||||||
|
isGlobalRegionId,
|
||||||
|
selectGlobalRegion,
|
||||||
|
},
|
||||||
|
handleRegionCreated,
|
||||||
|
handleExternalDropComplete,
|
||||||
|
handleRegionUpdated,
|
||||||
|
handleRegionClick,
|
||||||
|
handleRegionLassoSelection,
|
||||||
|
handleRegionLassoCommit,
|
||||||
|
handleEmptyMainContentClick,
|
||||||
|
handleOpenPianoRoll,
|
||||||
|
handleOpenSpectrogram,
|
||||||
|
handleOpenHybrid,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||||
|
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
|
||||||
|
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
||||||
|
import { KGTempoRegion } from '../core/region/KGTempoRegion';
|
||||||
|
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||||
|
import { BAR_NUMBERS_CONSTANTS, DEBUG_MODE, TOOLBAR_CONSTANTS } from '../constants';
|
||||||
|
import { ChangeLoopSettingsCommand } from '../core/commands';
|
||||||
|
import { KGCore } from '../core/KGCore';
|
||||||
|
import { useProjectStore } from '../stores/projectStore';
|
||||||
|
|
||||||
|
interface UseMainContentViewportParams {
|
||||||
|
barWidthMultiplier: number;
|
||||||
|
timeSignature: { numerator: number; denominator: number };
|
||||||
|
isPlaying: boolean;
|
||||||
|
autoScrollEnabled: boolean;
|
||||||
|
playheadPosition: number;
|
||||||
|
mainContentScrollRequest: number | null;
|
||||||
|
maxBars: number;
|
||||||
|
isLooping: boolean;
|
||||||
|
loopingRange: [number, number];
|
||||||
|
setPlayheadPosition: (beatPosition: number) => void;
|
||||||
|
requestPianoRollScroll: (beatPosition: number) => void;
|
||||||
|
editingRegionIds: string[];
|
||||||
|
findProjectRegionById: (regionId: string) => KGGlobalRegion | import('../core/region/KGRegion').KGRegion | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseMainContentViewportResult {
|
||||||
|
mainContentRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
barNumbersRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
handleBarNumbersMouseDown: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
|
isBarInLoopRange: (barIndex: number) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMainContentViewport({
|
||||||
|
barWidthMultiplier,
|
||||||
|
timeSignature,
|
||||||
|
isPlaying,
|
||||||
|
autoScrollEnabled,
|
||||||
|
playheadPosition,
|
||||||
|
mainContentScrollRequest,
|
||||||
|
maxBars,
|
||||||
|
isLooping,
|
||||||
|
loopingRange,
|
||||||
|
setPlayheadPosition,
|
||||||
|
requestPianoRollScroll,
|
||||||
|
editingRegionIds,
|
||||||
|
findProjectRegionById,
|
||||||
|
}: UseMainContentViewportParams): UseMainContentViewportResult {
|
||||||
|
const mainContentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const expectedScrollLeftRef = useRef<number>(-1);
|
||||||
|
const isPlayingRef = useRef(false);
|
||||||
|
const previousBarWidthMultiplierRef = useRef(barWidthMultiplier);
|
||||||
|
const barNumbersRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const isLoopDraggingRef = useRef(false);
|
||||||
|
const loopDragStartBarRef = useRef<number | null>(null);
|
||||||
|
const loopDragStartXRef = useRef<number | null>(null);
|
||||||
|
const loopDragOriginalSettingsRef = useRef<{ isLooping: boolean; loopingRange: [number, number] } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isPlayingRef.current = isPlaying;
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const previousMultiplier = previousBarWidthMultiplierRef.current;
|
||||||
|
if (previousMultiplier === barWidthMultiplier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousBarWidthMultiplierRef.current = barWidthMultiplier;
|
||||||
|
|
||||||
|
const container = mainContentRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoWidth = parseInt(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--track-info-panel-width'),
|
||||||
|
10
|
||||||
|
) || 200;
|
||||||
|
const visibleMusicWidth = Math.max(0, container.clientWidth - infoWidth);
|
||||||
|
const previousBarWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * previousMultiplier;
|
||||||
|
const nextBarWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier;
|
||||||
|
|
||||||
|
if (visibleMusicWidth === 0 || previousBarWidth === 0 || nextBarWidth === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const centerPixelBeforeZoom = container.scrollLeft + visibleMusicWidth / 2;
|
||||||
|
const anchorBeat = (centerPixelBeforeZoom / previousBarWidth) * timeSignature.numerator;
|
||||||
|
const targetPixel = (anchorBeat / timeSignature.numerator) * nextBarWidth;
|
||||||
|
const targetScrollLeft = targetPixel - visibleMusicWidth / 2;
|
||||||
|
const clampedScrollLeft = Math.max(0, Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth));
|
||||||
|
|
||||||
|
expectedScrollLeftRef.current = clampedScrollLeft;
|
||||||
|
container.scrollLeft = clampedScrollLeft;
|
||||||
|
}, [barWidthMultiplier, timeSignature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = mainContentRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!isPlayingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Math.abs(container.scrollLeft - expectedScrollLeftRef.current) < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
useProjectStore.getState().setAutoScrollEnabled(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener('scroll', handleScroll);
|
||||||
|
return () => container.removeEventListener('scroll', handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || !autoScrollEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = mainContentRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beatsPerBar = timeSignature.numerator;
|
||||||
|
const barPosition = playheadPosition / beatsPerBar;
|
||||||
|
const barWidth = parseInt(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width'),
|
||||||
|
10
|
||||||
|
) || 40;
|
||||||
|
const playheadPixel = barPosition * barWidth;
|
||||||
|
const infoWidth = parseInt(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--track-info-panel-width'),
|
||||||
|
10
|
||||||
|
) || 200;
|
||||||
|
const targetScrollLeft = playheadPixel - (container.clientWidth - infoWidth) / 2;
|
||||||
|
const clampedScrollLeft = Math.max(0, Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth));
|
||||||
|
|
||||||
|
expectedScrollLeftRef.current = clampedScrollLeft;
|
||||||
|
container.scrollLeft = clampedScrollLeft;
|
||||||
|
}, [autoScrollEnabled, isPlaying, playheadPosition, timeSignature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mainContentScrollRequest === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = mainContentRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beatsPerBar = timeSignature.numerator;
|
||||||
|
const barPosition = mainContentScrollRequest / beatsPerBar;
|
||||||
|
const barWidth = parseInt(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width'),
|
||||||
|
10
|
||||||
|
) || 40;
|
||||||
|
const playheadPixel = barPosition * barWidth;
|
||||||
|
const infoWidth = parseInt(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--track-info-panel-width'),
|
||||||
|
10
|
||||||
|
) || 200;
|
||||||
|
const targetScrollLeft = playheadPixel - (container.clientWidth - infoWidth) / 2;
|
||||||
|
const clampedScrollLeft = Math.max(0, Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth));
|
||||||
|
|
||||||
|
container.scrollLeft = clampedScrollLeft;
|
||||||
|
useProjectStore.setState({ mainContentScrollRequest: null });
|
||||||
|
}, [mainContentScrollRequest, timeSignature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editingRegionId = editingRegionIds[0] ?? null;
|
||||||
|
if (!editingRegionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = mainContentRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editingRegion = findProjectRegionById(editingRegionId);
|
||||||
|
if (!(editingRegion instanceof KGGlobalRegion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const barWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier;
|
||||||
|
const leftInset = (parseInt(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--track-info-panel-width'),
|
||||||
|
10
|
||||||
|
) || 200) + 12;
|
||||||
|
const regionStartBeat = editingRegion instanceof KGTempoRegion || editingRegion instanceof KGKeySignatureRegion
|
||||||
|
? editingRegion.getStartBar() * timeSignature.numerator
|
||||||
|
: editingRegion.getStartFromBeat();
|
||||||
|
const regionStartPixel = (regionStartBeat / timeSignature.numerator) * barWidth;
|
||||||
|
const minimumVisiblePixel = container.scrollLeft + leftInset;
|
||||||
|
|
||||||
|
if (regionStartPixel >= minimumVisiblePixel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetScrollLeft = Math.max(0, regionStartPixel - leftInset);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (mainContentRef.current) {
|
||||||
|
mainContentRef.current.scrollLeft = targetScrollLeft;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [barWidthMultiplier, editingRegionIds, findProjectRegionById, timeSignature.numerator]);
|
||||||
|
|
||||||
|
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
|
||||||
|
if (!barNumbersRef.current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = barNumbersRef.current.getBoundingClientRect();
|
||||||
|
const relativeX = clientX - rect.left;
|
||||||
|
const barWidth = parseInt(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width'),
|
||||||
|
10
|
||||||
|
) || 40;
|
||||||
|
const snap = KGMainContentState.instance().isSnappingEnabled();
|
||||||
|
const barIndex = snap ? Math.round(relativeX / barWidth) : relativeX / barWidth;
|
||||||
|
const clampedBarIndex = Math.max(0, barIndex);
|
||||||
|
return clampedBarIndex * timeSignature.numerator;
|
||||||
|
}, [timeSignature]);
|
||||||
|
|
||||||
|
const calculateBarIndexFromMouse = useCallback((clientX: number): number | null => {
|
||||||
|
if (!barNumbersRef.current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = barNumbersRef.current.getBoundingClientRect();
|
||||||
|
const relativeX = clientX - rect.left;
|
||||||
|
const barWidth = parseInt(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width'),
|
||||||
|
10
|
||||||
|
) || 40;
|
||||||
|
const barIndex = Math.floor(relativeX / barWidth);
|
||||||
|
return Math.max(0, Math.min(barIndex, maxBars - 1));
|
||||||
|
}, [maxBars]);
|
||||||
|
|
||||||
|
const handleBarNumbersMouseDown = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startBarIndex = calculateBarIndexFromMouse(event.clientX);
|
||||||
|
if (startBarIndex === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoopDraggingRef.current = true;
|
||||||
|
loopDragStartBarRef.current = startBarIndex;
|
||||||
|
loopDragStartXRef.current = event.clientX;
|
||||||
|
loopDragOriginalSettingsRef.current = {
|
||||||
|
isLooping,
|
||||||
|
loopingRange: [...loopingRange] as [number, number],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
console.log(`Bar numbers mouse down - Start bar: ${startBarIndex} (displayed as bar ${startBarIndex + 1})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
}, [calculateBarIndexFromMouse, isLooping, loopingRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
|
if (!isLoopDraggingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loopDragStartBarRef.current === null || loopDragStartXRef.current === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distanceMoved = Math.abs(event.clientX - loopDragStartXRef.current);
|
||||||
|
if (distanceMoved < BAR_NUMBERS_CONSTANTS.DRAG_THRESHOLD) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBarIndex = calculateBarIndexFromMouse(event.clientX);
|
||||||
|
if (currentBarIndex === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startBar = loopDragStartBarRef.current;
|
||||||
|
const loopStart = Math.min(startBar, currentBarIndex);
|
||||||
|
const loopEnd = Math.max(startBar, currentBarIndex);
|
||||||
|
const newLoopRange: [number, number] = [loopStart, loopEnd];
|
||||||
|
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
project.setLoopingRange(newLoopRange);
|
||||||
|
project.setIsLooping(true);
|
||||||
|
useProjectStore.setState({ loopingRange: newLoopRange, isLooping: true });
|
||||||
|
|
||||||
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
console.log(`Loop range drag - Range: [${loopStart}, ${loopEnd}] (bars ${loopStart + 1}-${loopEnd + 1})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = (event: MouseEvent) => {
|
||||||
|
if (!isLoopDraggingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loopDragStartXRef.current !== null) {
|
||||||
|
const distanceMoved = Math.abs(event.clientX - loopDragStartXRef.current);
|
||||||
|
|
||||||
|
if (distanceMoved >= BAR_NUMBERS_CONSTANTS.DRAG_THRESHOLD) {
|
||||||
|
const core = KGCore.instance();
|
||||||
|
const currentIsLooping = core.getCurrentProject().getIsLooping();
|
||||||
|
const currentLoopingRange = core.getCurrentProject().getLoopingRange();
|
||||||
|
|
||||||
|
if (loopDragOriginalSettingsRef.current) {
|
||||||
|
const originalSettings = loopDragOriginalSettingsRef.current;
|
||||||
|
const settingsChanged =
|
||||||
|
originalSettings.isLooping !== currentIsLooping ||
|
||||||
|
originalSettings.loopingRange[0] !== currentLoopingRange[0] ||
|
||||||
|
originalSettings.loopingRange[1] !== currentLoopingRange[1];
|
||||||
|
|
||||||
|
if (settingsChanged) {
|
||||||
|
const { isPlaying: currentIsPlaying, stopPlaying } = useProjectStore.getState();
|
||||||
|
if (currentIsPlaying) {
|
||||||
|
stopPlaying();
|
||||||
|
}
|
||||||
|
|
||||||
|
core.getCurrentProject().setIsLooping(originalSettings.isLooping);
|
||||||
|
core.getCurrentProject().setLoopingRange(originalSettings.loopingRange);
|
||||||
|
core.executeCommand(new ChangeLoopSettingsCommand({
|
||||||
|
isLooping: currentIsLooping,
|
||||||
|
loopingRange: currentLoopingRange,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
console.log('Loop range drag ended - Command executed for undo/redo');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const clickPosition = calculatePlayheadFromMouse(event.clientX);
|
||||||
|
if (clickPosition !== null) {
|
||||||
|
setPlayheadPosition(clickPosition);
|
||||||
|
requestPianoRollScroll(clickPosition);
|
||||||
|
|
||||||
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
console.log(`Single click on bar numbers - Set playhead to: ${clickPosition}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoopDraggingRef.current = false;
|
||||||
|
loopDragStartBarRef.current = null;
|
||||||
|
loopDragStartXRef.current = null;
|
||||||
|
loopDragOriginalSettingsRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [calculateBarIndexFromMouse, calculatePlayheadFromMouse, requestPianoRollScroll, setPlayheadPosition]);
|
||||||
|
|
||||||
|
const isBarInLoopRange = useCallback((barIndex: number) => {
|
||||||
|
if (!isLooping) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return barIndex >= loopingRange[0] && barIndex <= loopingRange[1];
|
||||||
|
}, [isLooping, loopingRange]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mainContentRef,
|
||||||
|
barNumbersRef,
|
||||||
|
handleBarNumbersMouseDown,
|
||||||
|
isBarInLoopRange,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user