feat: implemented global marker track
This commit is contained in:
@@ -287,6 +287,81 @@
|
||||
);
|
||||
}
|
||||
|
||||
.global-marker-lane {
|
||||
position: relative;
|
||||
height: var(--global-track-height);
|
||||
min-width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width));
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
background-color: #2d2d2d;
|
||||
background-size: var(--track-grid-bar-width) var(--global-track-height);
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
transparent calc(var(--track-grid-bar-width) - 1px),
|
||||
#3a3a3a calc(var(--track-grid-bar-width) - 1px),
|
||||
#3a3a3a var(--track-grid-bar-width)
|
||||
);
|
||||
}
|
||||
|
||||
.global-marker-lane.pencil-cursor {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.global-marker-region {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
height: calc(var(--global-track-height) - 4px);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: #e0e0e0;
|
||||
color: #2d2d2d;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
transition: box-shadow 0.1s ease, border-color 0.1s ease;
|
||||
}
|
||||
|
||||
.global-marker-region:hover {
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.global-marker-region.selected {
|
||||
border-color: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.global-marker-region:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.global-marker-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.global-marker-input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
letter-spacing: inherit;
|
||||
font-family: inherit;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.info-container {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
|
||||
@@ -5,6 +5,7 @@ import MainContent from './MainContent';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||
import { createDefaultGlobalTracks } from '../core/global-track';
|
||||
import { createMockMidiTrack } from '../test/utils/mock-data';
|
||||
|
||||
const midiRegion = new KGMidiRegion('region-1', '1', 0, 'Region 1', 0, 4);
|
||||
@@ -17,6 +18,7 @@ audioTrack.setRegions([audioRegion]);
|
||||
|
||||
const storeState = {
|
||||
tracks: [midiTrack, audioTrack],
|
||||
globalTracks: createDefaultGlobalTracks(),
|
||||
maxBars: 8,
|
||||
barWidthMultiplier: 1,
|
||||
reorderTracks: vi.fn(),
|
||||
@@ -328,19 +330,18 @@ describe('MainContent', () => {
|
||||
expect(screen.queryByText('Chord')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders visual-only add buttons for mock global tracks without mutating track state', () => {
|
||||
it('keeps the non-marker global track add buttons as visual-only controls', () => {
|
||||
render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
|
||||
const addButtons = [
|
||||
screen.getByRole('button', { name: 'Add Marker global track item' }),
|
||||
screen.getByRole('button', { name: 'Add Tempo global track item' }),
|
||||
screen.getByRole('button', { name: 'Add Signature global track item' }),
|
||||
screen.getByRole('button', { name: 'Add Chord global track item' }),
|
||||
];
|
||||
|
||||
expect(addButtons).toHaveLength(4);
|
||||
expect(addButtons).toHaveLength(3);
|
||||
|
||||
addButtons.forEach(button => {
|
||||
fireEvent.click(button);
|
||||
|
||||
+224
-28
@@ -3,12 +3,16 @@ import './MainContent.css';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import { GlobalTrackType } from '../core/global-track';
|
||||
import { KGTrack } from '../core/track/KGTrack';
|
||||
import { KGRegion } from '../core/region/KGRegion';
|
||||
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||
import { KGMarkerRegion } from '../core/region/KGMarkerRegion';
|
||||
import TrackInfoPanel from './track/TrackInfoPanel';
|
||||
import TrackGridPanel from './track/TrackGridPanel';
|
||||
import GlobalMarkerLane from './global-track/GlobalMarkerLane';
|
||||
import PianoRoll from './piano-roll/PianoRoll';
|
||||
import { TrackCreateDialog } from './common';
|
||||
import type { RegionClickOptions, RegionUI } from './interfaces';
|
||||
@@ -16,8 +20,16 @@ import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS, TOOLBAR_CONSTANTS } from '../constan
|
||||
import { useRegionOperations } from '../hooks/useRegionOperations';
|
||||
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
||||
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||
import { ChangeLoopSettingsCommand } from '../core/commands';
|
||||
import { DeleteTrackAutomationPointsCommand } from '../core/commands';
|
||||
import {
|
||||
ChangeLoopSettingsCommand,
|
||||
CreateGlobalMarkerRegionCommand,
|
||||
DeleteMultipleGlobalRegionsCommand,
|
||||
DeleteTrackAutomationPointsCommand,
|
||||
MoveGlobalRegionCommand,
|
||||
ResizeGlobalRegionCommand,
|
||||
UpdateGlobalRegionTextCommand,
|
||||
} from '../core/commands';
|
||||
import { DEFAULT_MARKER_REGION_NAME } from '../util/globalTrackUtil';
|
||||
import { FaPlus } from 'react-icons/fa';
|
||||
import { FaSquareArrowUpRight } from 'react-icons/fa6';
|
||||
|
||||
@@ -42,6 +54,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
}) => {
|
||||
const {
|
||||
tracks,
|
||||
globalTracks,
|
||||
maxBars,
|
||||
barWidthMultiplier,
|
||||
reorderTracks,
|
||||
@@ -95,6 +108,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
const [showGlobalTracksMock, setShowGlobalTracksMock] = useState(false);
|
||||
const [renderGlobalTracksMock, setRenderGlobalTracksMock] = useState(false);
|
||||
const [animateGlobalTracksMock, setAnimateGlobalTracksMock] = useState(false);
|
||||
const [editingGlobalRegionId, setEditingGlobalRegionId] = useState<string | null>(null);
|
||||
const [editingGlobalRegionText, setEditingGlobalRegionText] = useState('');
|
||||
|
||||
// Use the region operations hook
|
||||
const { deleteSelectedRegions } = useRegionOperations({
|
||||
@@ -143,9 +158,60 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
refreshProjectState,
|
||||
]);
|
||||
|
||||
const markerTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Marker) ?? null;
|
||||
const markerRegions = (markerTrack?.getRegions() ?? []).filter(
|
||||
(region): region is KGMarkerRegion => region instanceof KGMarkerRegion
|
||||
);
|
||||
|
||||
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]);
|
||||
|
||||
const deleteSelectedGlobalRegions = useCallback((): boolean => {
|
||||
const selectedGlobalRegionIds = selectedRegionIds.filter(regionId => isGlobalRegionId(regionId));
|
||||
if (selectedGlobalRegionIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
KGCore.instance().executeCommand(new DeleteMultipleGlobalRegionsCommand(selectedGlobalRegionIds));
|
||||
if (editingGlobalRegionId && selectedGlobalRegionIds.includes(editingGlobalRegionId)) {
|
||||
setEditingGlobalRegionId(null);
|
||||
setEditingGlobalRegionText('');
|
||||
}
|
||||
refreshProjectState();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting global marker regions:', error);
|
||||
return false;
|
||||
}
|
||||
}, [editingGlobalRegionId, isGlobalRegionId, refreshProjectState, selectedRegionIds]);
|
||||
|
||||
// Register the delete function with the global manager
|
||||
useEffect(() => {
|
||||
regionDeleteManager.registerDeleteCallback(() => {
|
||||
if (deleteSelectedGlobalRegions()) {
|
||||
return true;
|
||||
}
|
||||
if (deleteSelectedTrackAutomationPoints()) {
|
||||
return true;
|
||||
}
|
||||
@@ -156,7 +222,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
return () => {
|
||||
regionDeleteManager.unregisterDeleteCallback();
|
||||
};
|
||||
}, [deleteSelectedRegions, deleteSelectedTrackAutomationPoints]);
|
||||
}, [deleteSelectedGlobalRegions, deleteSelectedRegions, deleteSelectedTrackAutomationPoints]);
|
||||
|
||||
// Refs to track pending updates for verification
|
||||
const pendingUpdates = useRef<Map<string, { trackId: string, regionId: string, startBeat: number, length: number }>>(new Map());
|
||||
@@ -594,26 +660,21 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
// Helper function to select a region (clears previous selections)
|
||||
const applyRegionSelection = (orderedSelectionIds: string[]) => {
|
||||
const core = KGCore.instance();
|
||||
const allRegions = [
|
||||
...tracks.flatMap(projectTrack => projectTrack.getRegions()),
|
||||
...globalTracks.flatMap(globalTrack => globalTrack.getRegions()),
|
||||
];
|
||||
|
||||
tracks.forEach(projectTrack => {
|
||||
projectTrack.getRegions().forEach(projectRegion => projectRegion.deselect());
|
||||
});
|
||||
allRegions.forEach(projectRegion => projectRegion.deselect());
|
||||
|
||||
clearAllSelections();
|
||||
|
||||
const selectedRegions: KGRegion[] = orderedSelectionIds
|
||||
.map(selectedId => {
|
||||
for (const projectTrack of tracks) {
|
||||
const selectedRegion = projectTrack.getRegions().find(r => r.getId() === selectedId);
|
||||
if (selectedRegion) {
|
||||
selectedRegion.select();
|
||||
return selectedRegion as KGRegion;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.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);
|
||||
}
|
||||
@@ -621,9 +682,12 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
const lastSelectedRegionId = selectedRegions.length > 0
|
||||
? selectedRegions[selectedRegions.length - 1].getId()
|
||||
: null;
|
||||
const lastSelectedRegion = selectedRegions.length > 0
|
||||
? selectedRegions[selectedRegions.length - 1]
|
||||
: null;
|
||||
|
||||
setSelectedRegionId(lastSelectedRegionId);
|
||||
if (lastSelectedRegionId) {
|
||||
if (lastSelectedRegionId && lastSelectedRegion && !(lastSelectedRegion instanceof KGGlobalRegion)) {
|
||||
setActiveRegionId(lastSelectedRegionId);
|
||||
}
|
||||
|
||||
@@ -639,7 +703,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const lastSelectedRegion = selectedRegions[selectedRegions.length - 1];
|
||||
if (!lastSelectedRegion || lastSelectedRegion instanceof KGGlobalRegion) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastSelectedRegion instanceof KGAudioRegion) {
|
||||
openSpectrogramViewer(lastSelectedRegionId);
|
||||
} else if (lastSelectedRegion instanceof KGMidiRegion) {
|
||||
@@ -677,10 +744,22 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const regularSelectedRegionIds = selectedRegionIds.filter(selectedId => !isGlobalRegionId(selectedId));
|
||||
const orderedSelection = options.shiftKey
|
||||
? (selectedRegionIds.includes(regionId)
|
||||
? selectedRegionIds.filter(id => id !== regionId)
|
||||
: [...selectedRegionIds, regionId])
|
||||
? regularSelectedRegionIds.filter(id => id !== regionId)
|
||||
: [...regularSelectedRegionIds, regionId])
|
||||
: [regionId];
|
||||
|
||||
applyRegionSelection(orderedSelection);
|
||||
};
|
||||
|
||||
const selectGlobalRegion = (regionId: string, options: RegionClickOptions = { shiftKey: false }) => {
|
||||
const globalSelectedRegionIds = selectedRegionIds.filter(selectedId => isGlobalRegionId(selectedId));
|
||||
const orderedSelection = options.shiftKey
|
||||
? (globalSelectedRegionIds.includes(regionId)
|
||||
? globalSelectedRegionIds.filter(id => id !== regionId)
|
||||
: [...globalSelectedRegionIds, regionId])
|
||||
: [regionId];
|
||||
|
||||
applyRegionSelection(orderedSelection);
|
||||
@@ -688,13 +767,14 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
|
||||
const handleRegionLassoSelection = (regionIds: string[], options: RegionClickOptions = { shiftKey: false }) => {
|
||||
const orderedRegionIds = regionIds.filter(regionId => regions.some(region => region.id === regionId));
|
||||
const regularSelectedRegionIds = selectedRegionIds.filter(selectedId => !isGlobalRegionId(selectedId));
|
||||
const orderedSelection = options.shiftKey
|
||||
? orderedRegionIds.reduce<string[]>((nextSelection, regionId) => {
|
||||
if (nextSelection.includes(regionId)) {
|
||||
return nextSelection.filter(id => id !== regionId);
|
||||
}
|
||||
return [...nextSelection, regionId];
|
||||
}, [...selectedRegionIds])
|
||||
}, [...regularSelectedRegionIds])
|
||||
: orderedRegionIds;
|
||||
|
||||
applyRegionSelection(orderedSelection);
|
||||
@@ -787,6 +867,95 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
setShowCreateTrackModal(true);
|
||||
}, []);
|
||||
|
||||
const beginEditingGlobalRegion = useCallback((regionId: string) => {
|
||||
const region = findProjectRegionById(regionId);
|
||||
if (!(region instanceof KGMarkerRegion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingGlobalRegionId(regionId);
|
||||
setEditingGlobalRegionText(region.getName());
|
||||
}, [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(), { shiftKey: false });
|
||||
beginEditingGlobalRegion(occupiedRegion.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const command = new CreateGlobalMarkerRegionCommand(
|
||||
normalizedStartBeat,
|
||||
8 * timeSignature.numerator,
|
||||
DEFAULT_MARKER_REGION_NAME
|
||||
);
|
||||
KGCore.instance().executeCommand(command);
|
||||
refreshProjectState();
|
||||
|
||||
const createdRegion = command.getCreatedRegion();
|
||||
if (!createdRegion) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectGlobalRegion(createdRegion.getId(), { shiftKey: false });
|
||||
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]);
|
||||
|
||||
/**
|
||||
* Add keyboard event listener for region deletion
|
||||
* Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions
|
||||
@@ -811,9 +980,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
// Only handle if we're not in the piano roll (piano roll has its own delete handler)
|
||||
const isInPianoRoll = document.querySelector('.piano-roll')?.contains(event.target as Node);
|
||||
const isPianoRollOpen = showPianoRoll;
|
||||
const hasSelectedGlobalRegions = selectedRegionIds.some(regionId => isGlobalRegionId(regionId));
|
||||
|
||||
if (!isInPianoRoll && !isPianoRollOpen) {
|
||||
const deleted = deleteSelectedTrackAutomationPoints() || deleteSelectedRegions();
|
||||
if (!isInPianoRoll && (!isPianoRollOpen || hasSelectedGlobalRegions)) {
|
||||
const deleted = deleteSelectedGlobalRegions() || deleteSelectedTrackAutomationPoints() || deleteSelectedRegions();
|
||||
if (deleted) {
|
||||
// Prevent default behavior only if regions were actually deleted
|
||||
event.preventDefault();
|
||||
@@ -829,7 +999,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [deleteSelectedRegions, deleteSelectedTrackAutomationPoints, showPianoRoll]); // Dependencies for the effect
|
||||
}, [deleteSelectedGlobalRegions, deleteSelectedRegions, deleteSelectedTrackAutomationPoints, showPianoRoll]); // Dependencies for the effect
|
||||
|
||||
// Utility function to calculate playhead position from mouse coordinates (bar-level snapping)
|
||||
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
|
||||
@@ -1134,6 +1304,9 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (track.id === 'marker') {
|
||||
createMarkerAtPlayheadBar();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FaPlus />
|
||||
@@ -1145,10 +1318,33 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
<div className={`global-tracks-grid-shell${animateGlobalTracksMock ? ' expanded' : ' collapsed'}`}>
|
||||
<div className="global-tracks-grid" aria-hidden="true">
|
||||
{GLOBAL_TRACKS.map(track => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="global-track-grid-row"
|
||||
/>
|
||||
track.id === 'marker' ? (
|
||||
<GlobalMarkerLane
|
||||
key={track.id}
|
||||
markerRegions={markerRegions}
|
||||
maxBars={maxBars}
|
||||
timeSignature={timeSignature}
|
||||
selectedRegionIds={selectedRegionIds}
|
||||
editingRegionId={editingGlobalRegionId}
|
||||
editingText={editingGlobalRegionText}
|
||||
onEditingTextChange={setEditingGlobalRegionText}
|
||||
onCommitEdit={commitGlobalRegionEdit}
|
||||
onCancelEdit={() => {
|
||||
setEditingGlobalRegionId(null);
|
||||
setEditingGlobalRegionText('');
|
||||
}}
|
||||
onBeginEdit={beginEditingGlobalRegion}
|
||||
onSelectRegion={selectGlobalRegion}
|
||||
onCreateAtBeat={createMarkerAtBeat}
|
||||
onMoveRegion={moveGlobalMarkerRegion}
|
||||
onResizeRegion={resizeGlobalMarkerRegion}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={track.id}
|
||||
className="global-track-grid-row"
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
|
||||
import type { RegionClickOptions } from '../interfaces';
|
||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||
|
||||
interface GlobalMarkerLaneProps {
|
||||
markerRegions: KGMarkerRegion[];
|
||||
maxBars: number;
|
||||
timeSignature: { numerator: number; denominator: number };
|
||||
selectedRegionIds: string[];
|
||||
editingRegionId: string | null;
|
||||
editingText: string;
|
||||
onEditingTextChange: (value: string) => void;
|
||||
onCommitEdit: (regionId: string) => void;
|
||||
onCancelEdit: () => void;
|
||||
onBeginEdit: (regionId: string) => void;
|
||||
onSelectRegion: (regionId: string, options?: RegionClickOptions) => void;
|
||||
onCreateAtBeat: (startBeat: number) => void;
|
||||
onMoveRegion: (regionId: string, startBeat: number) => void;
|
||||
onResizeRegion: (regionId: string, edge: 'start' | 'end', beat: number) => void;
|
||||
}
|
||||
|
||||
type ResizeEdge = 'start' | 'end' | null;
|
||||
|
||||
const REGION_EDGE_HITBOX_PX = 8;
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
const GlobalMarkerLane: React.FC<GlobalMarkerLaneProps> = ({
|
||||
markerRegions,
|
||||
maxBars,
|
||||
timeSignature,
|
||||
selectedRegionIds,
|
||||
editingRegionId,
|
||||
editingText,
|
||||
onEditingTextChange,
|
||||
onCommitEdit,
|
||||
onCancelEdit,
|
||||
onBeginEdit,
|
||||
onSelectRegion,
|
||||
onCreateAtBeat,
|
||||
onMoveRegion,
|
||||
onResizeRegion,
|
||||
}) => {
|
||||
const laneRef = useRef<HTMLDivElement | null>(null);
|
||||
const [previewBeats, setPreviewBeats] = useState<Record<string, { startBeat: number; length: number }>>({});
|
||||
const [hoverEdges, setHoverEdges] = useState<Record<string, ResizeEdge>>({});
|
||||
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
||||
const interactionRef = useRef<{
|
||||
mode: 'drag' | 'resize' | null;
|
||||
regionId: string;
|
||||
initialMouseX: number;
|
||||
initialStartBeat: number;
|
||||
initialLength: number;
|
||||
resizeEdge: ResizeEdge;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const totalBeats = maxBars * timeSignature.numerator;
|
||||
const beatWidth = useMemo(() => {
|
||||
const barWidth = parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
|
||||
) || 40;
|
||||
return barWidth / timeSignature.numerator;
|
||||
}, [timeSignature.numerator]);
|
||||
|
||||
const clampStartBeat = (value: number) => Math.max(0, Math.min(totalBeats - 1, value));
|
||||
const clampEndBeat = (value: number) => Math.max(1, Math.min(totalBeats, value));
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
|
||||
const getBeatFromClientX = (clientX: number, mode: 'start' | 'end' = 'start') => {
|
||||
if (!laneRef.current) return 0;
|
||||
const rect = laneRef.current.getBoundingClientRect();
|
||||
const relativeX = clientX - rect.left;
|
||||
const rawBeat = relativeX / beatWidth;
|
||||
return mode === 'end'
|
||||
? clampEndBeat(Math.round(rawBeat))
|
||||
: clampStartBeat(Math.round(rawBeat));
|
||||
};
|
||||
|
||||
const getBarSnappedBeatFromClientX = (clientX: number) => {
|
||||
const beat = getBeatFromClientX(clientX);
|
||||
return clampStartBeat(Math.floor(beat / beatsPerBar) * beatsPerBar);
|
||||
};
|
||||
|
||||
const getRenderedBeatState = (region: KGMarkerRegion) => (
|
||||
previewBeats[region.getId()] ?? {
|
||||
startBeat: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}
|
||||
);
|
||||
|
||||
const getResizeEdgeFromMouseEvent = (
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>
|
||||
): ResizeEdge => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const offsetX = event.clientX - rect.left;
|
||||
|
||||
if (offsetX <= REGION_EDGE_HITBOX_PX) {
|
||||
return 'start';
|
||||
}
|
||||
|
||||
if (rect.width - offsetX <= REGION_EDGE_HITBOX_PX) {
|
||||
return 'end';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (isModifierKeyPressed(event)) {
|
||||
setIsModifierPressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (!isModifierKeyPressed(event)) {
|
||||
setIsModifierPressed(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!interactionRef.current) return;
|
||||
|
||||
const interaction = interactionRef.current;
|
||||
const deltaX = event.clientX - interaction.initialMouseX;
|
||||
if (Math.abs(deltaX) >= DRAG_THRESHOLD_PX) {
|
||||
interaction.moved = true;
|
||||
}
|
||||
|
||||
if (interaction.mode === 'drag') {
|
||||
const beatDelta = Math.round(deltaX / beatWidth);
|
||||
const nextStartBeat = clampStartBeat(interaction.initialStartBeat + beatDelta);
|
||||
setPreviewBeats({
|
||||
[interaction.regionId]: {
|
||||
startBeat: nextStartBeat,
|
||||
length: interaction.initialLength,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.mode === 'resize') {
|
||||
const desiredBeat = getBeatFromClientX(
|
||||
event.clientX,
|
||||
interaction.resizeEdge === 'end' ? 'end' : 'start'
|
||||
);
|
||||
|
||||
if (interaction.resizeEdge === 'start') {
|
||||
const nextStartBeat = Math.min(desiredBeat, interaction.initialStartBeat + interaction.initialLength - 1);
|
||||
const endBeat = interaction.initialStartBeat + interaction.initialLength;
|
||||
setPreviewBeats({
|
||||
[interaction.regionId]: {
|
||||
startBeat: nextStartBeat,
|
||||
length: Math.max(1, endBeat - nextStartBeat),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewBeats({
|
||||
[interaction.regionId]: {
|
||||
startBeat: interaction.initialStartBeat,
|
||||
length: Math.max(1, desiredBeat - interaction.initialStartBeat),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (event: MouseEvent) => {
|
||||
if (!interactionRef.current) return;
|
||||
|
||||
const interaction = interactionRef.current;
|
||||
interactionRef.current = null;
|
||||
|
||||
if (!interaction.moved) {
|
||||
setPreviewBeats({});
|
||||
onSelectRegion(interaction.regionId, { shiftKey: event.shiftKey });
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = previewBeats[interaction.regionId];
|
||||
setPreviewBeats({});
|
||||
|
||||
if (!preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.mode === 'drag') {
|
||||
onMoveRegion(interaction.regionId, preview.startBeat);
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.mode === 'resize' && interaction.resizeEdge) {
|
||||
const beat = interaction.resizeEdge === 'start'
|
||||
? preview.startBeat
|
||||
: preview.startBeat + preview.length;
|
||||
onResizeRegion(interaction.regionId, interaction.resizeEdge, beat);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [beatWidth, onMoveRegion, onResizeRegion, onSelectRegion, previewBeats, totalBeats]);
|
||||
|
||||
const handleLaneMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
if (!(event.target instanceof HTMLElement)) return;
|
||||
if (event.target.closest('.global-marker-region')) return;
|
||||
if (!isModifierKeyPressed(event)) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCreateAtBeat(getBarSnappedBeatFromClientX(event.clientX));
|
||||
};
|
||||
|
||||
const handleLaneDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!(event.target instanceof HTMLElement)) return;
|
||||
if (event.target.closest('.global-marker-region')) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCreateAtBeat(getBarSnappedBeatFromClientX(event.clientX));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={laneRef}
|
||||
className={`global-marker-lane${isModifierPressed ? ' pencil-cursor' : ''}`}
|
||||
onMouseDown={handleLaneMouseDown}
|
||||
onDoubleClick={handleLaneDoubleClick}
|
||||
>
|
||||
{markerRegions.map(region => {
|
||||
const { startBeat, length } = getRenderedBeatState(region);
|
||||
const isSelected = selectedRegionIds.includes(region.getId());
|
||||
const isEditing = editingRegionId === region.getId();
|
||||
const left = startBeat * beatWidth;
|
||||
const width = Math.max(beatWidth, length * beatWidth);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={region.getId()}
|
||||
className={`global-marker-region${isSelected ? ' selected' : ''}`}
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
cursor: isEditing ? 'text' : hoverEdges[region.getId()] ? 'ew-resize' : undefined,
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelectRegion(region.getId(), { shiftKey: false });
|
||||
onBeginEdit(region.getId());
|
||||
}}
|
||||
onMouseMove={(event) => {
|
||||
if (isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (interactionRef.current?.regionId === region.getId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeEdge = getResizeEdgeFromMouseEvent(event);
|
||||
setHoverEdges((current) => (
|
||||
current[region.getId()] === resizeEdge
|
||||
? current
|
||||
: {
|
||||
...current,
|
||||
[region.getId()]: resizeEdge,
|
||||
}
|
||||
));
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoverEdges((current) => {
|
||||
if (!current[region.getId()]) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[region.getId()]: null,
|
||||
};
|
||||
});
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
if (isEditing) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const resizeEdge = getResizeEdgeFromMouseEvent(event);
|
||||
|
||||
interactionRef.current = {
|
||||
mode: resizeEdge ? 'resize' : 'drag',
|
||||
regionId: region.getId(),
|
||||
initialMouseX: event.clientX,
|
||||
initialStartBeat: region.getStartFromBeat(),
|
||||
initialLength: region.getLength(),
|
||||
resizeEdge,
|
||||
moved: false,
|
||||
};
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
className="global-marker-input"
|
||||
value={editingText}
|
||||
onChange={(event) => onEditingTextChange(event.target.value.replace(/\r?\n/g, ' '))}
|
||||
onBlur={() => onCommitEdit(region.getId())}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onCommitEdit(region.getId());
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onCancelEdit();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span className="global-marker-label">{region.getName()}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalMarkerLane;
|
||||
Reference in New Issue
Block a user