feat: added region color customization feature
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
.color-palette-popup {
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
|
||||
padding: 8px;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.color-palette-popup button {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.color-palette-none {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0 0 2px;
|
||||
padding: 0;
|
||||
border-radius: 5px;
|
||||
background: #383838;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.color-palette-none:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.color-palette-none.active {
|
||||
box-shadow: 0 0 0 2px #ffffff, 0 0 0 3px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.color-palette-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(24, 24px);
|
||||
grid-auto-rows: 24px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.color-palette-swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-palette-swatch:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.color-palette-swatch.active {
|
||||
box-shadow: 0 0 0 2px #ffffff, 0 0 0 3px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { FaUndoAlt } from 'react-icons/fa';
|
||||
import { LOGIC_REGION_COLOR_SWATCHES } from '../../constants/regionColorPalette';
|
||||
import './ColorPalettePopup.css';
|
||||
|
||||
interface ColorPalettePopupProps {
|
||||
selectedColor?: string;
|
||||
onSelect: (color: string | null) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ColorPalettePopup: React.FC<ColorPalettePopupProps> = ({
|
||||
selectedColor,
|
||||
onSelect,
|
||||
className = '',
|
||||
}) => (
|
||||
<div className={`color-palette-popup ${className}`.trim()} role="menu" aria-label="Color palette">
|
||||
<button
|
||||
type="button"
|
||||
className={`color-palette-none${selectedColor === undefined ? ' active' : ''}`}
|
||||
onClick={() => onSelect(null)}
|
||||
aria-label="Reset color"
|
||||
title="Reset color"
|
||||
>
|
||||
<FaUndoAlt aria-hidden="true" />
|
||||
</button>
|
||||
<div className="color-palette-grid">
|
||||
{LOGIC_REGION_COLOR_SWATCHES.flat().map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className={`color-palette-swatch${selectedColor === color ? ' active' : ''}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => onSelect(color)}
|
||||
title={color}
|
||||
aria-label={`Select color ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ColorPalettePopup;
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as KGDropdown } from './KGDropdown';
|
||||
export { default as ColorPalettePopup } from './ColorPalettePopup';
|
||||
export { default as Playhead } from './Playhead';
|
||||
export { default as FileImportModal } from './FileImportModal';
|
||||
export { default as LoadingOverlay } from './LoadingOverlay';
|
||||
|
||||
@@ -16,6 +16,10 @@ export interface RegionUI {
|
||||
barNumber: number;
|
||||
length: number;
|
||||
name: string;
|
||||
color?: string;
|
||||
trackColor?: string;
|
||||
effectiveColor?: string;
|
||||
isAudioRegion?: boolean;
|
||||
}
|
||||
|
||||
export interface RegionPreviewContentStyle {
|
||||
|
||||
@@ -247,6 +247,22 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.piano-roll-menu-item-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.piano-roll-more-menu {
|
||||
overflow: visible;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.piano-roll-region-color-popup {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 1600;
|
||||
}
|
||||
|
||||
.piano-roll-automation-toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -101,7 +101,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
const isAudioWaveform = currentMode === 'audio-waveform';
|
||||
const isAudioOnly = isAudioWaveform || isSpectrogram;
|
||||
const isHybrid = currentMode === 'hybrid';
|
||||
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showEventListPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, automationRedrawVersion, refreshProjectState, setBpm } = useProjectStore();
|
||||
const { maxBars, tracks, updateTrack, updateRegionProperties, timeSignature, showChatBox, showKGOnePanel, showEventListPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, selectedRegionIds, automationRedrawVersion, refreshProjectState, setBpm } = useProjectStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
// Tool state for piano roll
|
||||
@@ -167,6 +167,14 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
const activeInstrument = useMemo<InstrumentType>(() => (
|
||||
parentMidiTrack instanceof KGMidiTrack ? parentMidiTrack.getInstrument() : 'acoustic_grand_piano'
|
||||
), [parentMidiTrack]);
|
||||
const activeEditableRegionId = audioRegion?.getId() ?? activeRegion?.getId() ?? null;
|
||||
const selectedRegionColor = useMemo(() => {
|
||||
if (audioRegion) {
|
||||
return audioRegion.getColor();
|
||||
}
|
||||
|
||||
return activeRegion?.getColor();
|
||||
}, [activeRegion, audioRegion]);
|
||||
const parsedSheetQuantization = useMemo(
|
||||
() => parseSheetQuantization(sheetQuantization),
|
||||
[sheetQuantization]
|
||||
@@ -492,6 +500,20 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegionColorSelect = useCallback(async (color: string | null) => {
|
||||
const allTrackRegionIds = new Set(tracks.flatMap(track => track.getRegions().map(region => region.getId())));
|
||||
const selectedProjectRegionIds = selectedRegionIds.filter(regionId => allTrackRegionIds.has(regionId));
|
||||
const targetRegionIds = activeEditableRegionId && selectedProjectRegionIds.includes(activeEditableRegionId)
|
||||
? selectedProjectRegionIds
|
||||
: activeEditableRegionId
|
||||
? [activeEditableRegionId]
|
||||
: [];
|
||||
|
||||
for (const regionId of targetRegionIds) {
|
||||
await updateRegionProperties(regionId, { color });
|
||||
}
|
||||
}, [activeEditableRegionId, selectedRegionIds, tracks, updateRegionProperties]);
|
||||
|
||||
const handleDetectChords = useCallback(async () => {
|
||||
if (!audioRegion && !activeRegion) {
|
||||
await showAlert('Open a MIDI or audio region before detecting chords.');
|
||||
@@ -1597,6 +1619,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
detectingChords={isDetectingChords}
|
||||
onDetectTempo={audioRegion ? handleDetectTempo : undefined}
|
||||
detectingTempo={isDetectingTempo}
|
||||
selectedRegionColor={selectedRegionColor}
|
||||
onRegionColorSelect={activeEditableRegionId ? (color) => { void handleRegionColorSelect(color); } : undefined}
|
||||
/>
|
||||
|
||||
<NoteAttributeBar selectedNotes={selectedNotes} isSpectrogram={isAudioOnly} activeRegion={activeRegion} />
|
||||
|
||||
@@ -30,6 +30,16 @@ vi.mock('../common', () => ({
|
||||
</button>
|
||||
);
|
||||
},
|
||||
ColorPalettePopup: ({ onSelect }: { onSelect: (value: string | null) => void }) => (
|
||||
<div>
|
||||
<button type="button" aria-label="Reset color" onClick={() => onSelect(null)}>
|
||||
Reset
|
||||
</button>
|
||||
<button type="button" onClick={() => onSelect('#3C8AC4')}>
|
||||
Color Swatch
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../core/KGCore', () => ({
|
||||
@@ -431,4 +441,23 @@ describe('PianoRollToolbar', () => {
|
||||
|
||||
expect(screen.getByRole('button', { name: '和声小调' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the region color action in the more menu and emits the chosen color', () => {
|
||||
const onRegionColorSelect = vi.fn();
|
||||
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="midi-edit"
|
||||
onRegionColorSelect={onRegionColorSelect}
|
||||
selectedRegionColor="#3C8AC4"
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'More options' }));
|
||||
fireEvent.click(screen.getByText('Region Color...'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset color' }));
|
||||
|
||||
expect(onRegionColorSelect).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { FaMousePointer, FaPencilAlt } from 'react-icons/fa';
|
||||
import { TbArrowBarToUp } from 'react-icons/tb';
|
||||
import { KGDropdown } from '../common';
|
||||
import { ColorPalettePopup, KGDropdown } from '../common';
|
||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import {
|
||||
@@ -50,6 +50,8 @@ interface PianoRollToolbarProps {
|
||||
detectingChords?: boolean;
|
||||
onDetectTempo?: () => void | Promise<void>;
|
||||
detectingTempo?: boolean;
|
||||
selectedRegionColor?: string;
|
||||
onRegionColorSelect?: (color: string | null) => void;
|
||||
}
|
||||
|
||||
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
@@ -92,6 +94,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
detectingChords = false,
|
||||
onDetectTempo,
|
||||
detectingTempo = false,
|
||||
selectedRegionColor,
|
||||
onRegionColorSelect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const showMidiControls = mode !== 'spectrogram' && mode !== 'audio-waveform' && !sheetMusicViewEnabled;
|
||||
@@ -151,6 +155,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
}, [showZoomSlider]);
|
||||
|
||||
const [showMoreMenu, setShowMoreMenu] = React.useState(false);
|
||||
const [showRegionColorPalette, setShowRegionColorPalette] = React.useState(false);
|
||||
const specMenuRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -158,6 +163,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (specMenuRef.current && !specMenuRef.current.contains(e.target as Node)) {
|
||||
setShowMoreMenu(false);
|
||||
setShowRegionColorPalette(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
@@ -386,7 +392,29 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
...
|
||||
</button>
|
||||
{showMoreMenu && (
|
||||
<div className="quant-dropdown" style={{ right: 0, left: 'auto', width: 'auto', whiteSpace: 'nowrap' }}>
|
||||
<div className="quant-dropdown piano-roll-more-menu" style={{ right: 0, left: 'auto', width: 'auto', whiteSpace: 'nowrap' }}>
|
||||
{onRegionColorSelect && (
|
||||
<div className="piano-roll-menu-item-wrapper">
|
||||
<div
|
||||
className="quant-option"
|
||||
onClick={() => setShowRegionColorPalette(open => !open)}
|
||||
>
|
||||
{t('pianoRoll.regionColor')}
|
||||
</div>
|
||||
{showRegionColorPalette && (
|
||||
<div className="piano-roll-region-color-popup">
|
||||
<ColorPalettePopup
|
||||
selectedColor={selectedRegionColor}
|
||||
onSelect={(color) => {
|
||||
onRegionColorSelect(color);
|
||||
setShowRegionColorPalette(false);
|
||||
setShowMoreMenu(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{onDetectChords && (
|
||||
<div
|
||||
className={`quant-option${detectingChords ? ' disabled' : ''}`}
|
||||
@@ -394,6 +422,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
if (detectingChords) {
|
||||
return;
|
||||
}
|
||||
setShowRegionColorPalette(false);
|
||||
setShowMoreMenu(false);
|
||||
void onDetectChords();
|
||||
}}
|
||||
@@ -409,6 +438,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
if (detectingTempo) {
|
||||
return;
|
||||
}
|
||||
setShowRegionColorPalette(false);
|
||||
setShowMoreMenu(false);
|
||||
void onDetectTempo();
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* Track Region Styles */
|
||||
.track-region {
|
||||
position: absolute;
|
||||
background-color: #4a6b8a;
|
||||
border: 2px solid #5a7b9a;
|
||||
background-color: var(--region-header-bg, #4a6b8a);
|
||||
border: 2px solid var(--region-border-color, #5a7b9a);
|
||||
border-radius: 3px;
|
||||
height: calc(100%);
|
||||
margin: 0px;
|
||||
@@ -48,7 +48,7 @@
|
||||
}
|
||||
|
||||
.region-header {
|
||||
background-color: #5a7b9a;
|
||||
background-color: var(--region-header-bg, #5a7b9a);
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
@@ -61,19 +61,19 @@
|
||||
}
|
||||
|
||||
.track-region:hover .region-header {
|
||||
background-color: #6a8baa;
|
||||
background-color: var(--region-header-hover-bg, #6a8baa);
|
||||
}
|
||||
|
||||
.region-content {
|
||||
height: calc(100% - 18px);
|
||||
background-color: #87CEFA; /* Light blue */
|
||||
background-color: var(--region-content-bg, #4B9A41); /* Default MIDI region color */
|
||||
width: 100%;
|
||||
position: relative; /* Allow overlayed controls */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.region-content.audio-region-content {
|
||||
background-color: #90EE90; /* Light green for audio regions */
|
||||
background-color: var(--region-content-bg, #39649E); /* Default audio region color */
|
||||
}
|
||||
|
||||
.region-preview-content {
|
||||
@@ -91,8 +91,8 @@
|
||||
|
||||
/* Audio region overrides */
|
||||
.track-region.audio-region {
|
||||
background-color: #3a6b4a;
|
||||
border-color: #4a8b5a;
|
||||
background-color: var(--region-header-bg, #3a6b4a);
|
||||
border-color: var(--region-border-color, #4a8b5a);
|
||||
}
|
||||
|
||||
.track-region.audio-region.selected {
|
||||
@@ -109,11 +109,11 @@
|
||||
}
|
||||
|
||||
.track-region.audio-region .region-header {
|
||||
background-color: #4a8b5a;
|
||||
background-color: var(--region-header-bg, #4a8b5a);
|
||||
}
|
||||
|
||||
.track-region.audio-region:hover .region-header {
|
||||
background-color: #5a9b6a;
|
||||
background-color: var(--region-header-hover-bg, #5a9b6a);
|
||||
}
|
||||
|
||||
/* Left-side button cluster inside region-content */
|
||||
|
||||
@@ -259,6 +259,49 @@
|
||||
color: #101010;
|
||||
}
|
||||
|
||||
.track-settings-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 10000;
|
||||
margin-top: 2px;
|
||||
min-width: 120px;
|
||||
padding: 4px 0;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
background: #2d2d2d;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.track-settings-menu-item {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
width: 100%;
|
||||
min-width: 120px;
|
||||
height: auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 6px 10px !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
color: #e0e0e0 !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 1.2 !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-settings-menu-item:hover {
|
||||
background: #444 !important;
|
||||
}
|
||||
|
||||
.track-settings-color-popup {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(100% + 6px);
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.track-grid.automation-active {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useProjectStore } from '../../stores/projectStore';
|
||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||
import { CHORD_REGION_IMPORT_MIME_TYPE } from '../../util/chordRegionImportUtil';
|
||||
import { TrackType } from '../../core/track/KGTrack';
|
||||
import { buildRegionSurfaceColors, resolveRegionColor } from '../../util/regionColor';
|
||||
|
||||
interface RegionResizePreviewBaseline {
|
||||
regionId: string;
|
||||
@@ -795,12 +796,22 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveColor = region.effectiveColor
|
||||
?? resolveRegionColor(coreRegion?.getColor(), track.getColor(), !!audioRegion || !!region.isAudioRegion);
|
||||
const surfaceColors = buildRegionSurfaceColors(effectiveColor);
|
||||
|
||||
return (
|
||||
<RegionItem
|
||||
key={region.id}
|
||||
id={region.id}
|
||||
name={region.name}
|
||||
style={getRegionStyle(region)}
|
||||
style={{
|
||||
...getRegionStyle(region),
|
||||
['--region-border-color' as string]: surfaceColors.borderColor,
|
||||
['--region-header-bg' as string]: surfaceColors.headerColor,
|
||||
['--region-header-hover-bg' as string]: surfaceColors.headerHoverColor,
|
||||
['--region-content-bg' as string]: surfaceColors.contentColor,
|
||||
}}
|
||||
barNumber={region.barNumber}
|
||||
length={region.length}
|
||||
trackIndex={index}
|
||||
|
||||
@@ -12,6 +12,7 @@ const storeState = {
|
||||
removeTrack: vi.fn(),
|
||||
toggleInstrumentSelectionForTrack: vi.fn(),
|
||||
importAudioToTrack: vi.fn(),
|
||||
updateTrackProperties: vi.fn(),
|
||||
tracks: [] as KGAudioTrack[],
|
||||
activeTrackAutomationTrackId: null as string | null,
|
||||
activeTrackAutomationType: null as string | null,
|
||||
@@ -21,8 +22,13 @@ const storeState = {
|
||||
let fileImportModalProps: Record<string, unknown> | null = null;
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: (selector?: (state: typeof storeState) => unknown) => (
|
||||
selector ? selector(storeState) : storeState
|
||||
useProjectStore: Object.assign(
|
||||
(selector?: (state: typeof storeState) => unknown) => (
|
||||
selector ? selector(storeState) : storeState
|
||||
),
|
||||
{
|
||||
getState: () => storeState,
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -73,6 +79,7 @@ describe('TrackInfoItem audio import', () => {
|
||||
storeState.removeTrack.mockReset();
|
||||
storeState.toggleInstrumentSelectionForTrack.mockReset();
|
||||
storeState.importAudioToTrack.mockReset();
|
||||
storeState.updateTrackProperties.mockReset();
|
||||
storeState.setTrackAutomationView.mockReset();
|
||||
vi.mocked(showConfirm).mockReset();
|
||||
});
|
||||
@@ -127,4 +134,32 @@ describe('TrackInfoItem audio import', () => {
|
||||
translate('track.controls.settings.deleteTrackConfirm', { name: '钢琴' }, 'zh_cn')
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the track color entry and clears the color override', async () => {
|
||||
const audioTrack = new KGAudioTrack('Audio Track', 1);
|
||||
audioTrack.setTrackIndex(0);
|
||||
audioTrack.setColor('#3C8AC4');
|
||||
storeState.tracks = [audioTrack];
|
||||
storeState.updateTrackProperties.mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<TrackInfoItem
|
||||
track={audioTrack}
|
||||
index={0}
|
||||
isDragging={false}
|
||||
isDragOver={false}
|
||||
onTrackNameEdit={vi.fn()}
|
||||
onDragStart={vi.fn()}
|
||||
onDragOver={vi.fn()}
|
||||
onDrop={vi.fn()}
|
||||
onDragEnd={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '更多操作' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '轨道颜色...' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset color' }));
|
||||
|
||||
expect(storeState.updateTrackProperties).toHaveBeenCalledWith(1, { color: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TbPiano } from 'react-icons/tb';
|
||||
import { TbDots } from 'react-icons/tb';
|
||||
import { FaFileAudio } from 'react-icons/fa';
|
||||
import KGDropdown from '../common/KGDropdown';
|
||||
import ColorPalettePopup from '../common/ColorPalettePopup';
|
||||
import FileImportModal from '../common/FileImportModal';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import { DEBUG_MODE } from '../../constants/uiConstants';
|
||||
@@ -91,6 +92,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
const [showSettingsDropdown, setShowSettingsDropdown] = useState(false);
|
||||
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
||||
const [showAutomationDropdown, setShowAutomationDropdown] = useState(false);
|
||||
const [showTrackColorPalette, setShowTrackColorPalette] = useState(false);
|
||||
const settingsDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const automationDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const suppressDragRef = useRef(false);
|
||||
@@ -115,6 +117,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
!settingsDropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowSettingsDropdown(false);
|
||||
setShowTrackColorPalette(false);
|
||||
}
|
||||
if (
|
||||
showAutomationDropdown &&
|
||||
@@ -336,6 +339,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
const handleSettingsButtonClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowSettingsDropdown(!showSettingsDropdown);
|
||||
if (showSettingsDropdown) {
|
||||
setShowTrackColorPalette(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutomationButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -387,6 +393,17 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrackColorSelect = async (color: string | null) => {
|
||||
try {
|
||||
await useProjectStore.getState().updateTrackProperties(track.getId(), { color });
|
||||
} catch (error) {
|
||||
console.error('Failed to update track color:', error);
|
||||
} finally {
|
||||
setShowTrackColorPalette(false);
|
||||
setShowSettingsDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`track-info ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''}`}
|
||||
@@ -545,18 +562,38 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
>
|
||||
<TbDots />
|
||||
</button>
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}>
|
||||
<KGDropdown
|
||||
options={[{ label: t('track.controls.settings.deleteTrack'), value: 'Delete Track' }]}
|
||||
value={''}
|
||||
onChange={handleSettingsAction}
|
||||
label={t('track.controls.moreActions')}
|
||||
hideButton={true}
|
||||
isOpen={showSettingsDropdown}
|
||||
onToggle={setShowSettingsDropdown}
|
||||
className="settings-dropdown"
|
||||
/>
|
||||
</div>
|
||||
{showSettingsDropdown && (
|
||||
<div className="track-settings-menu">
|
||||
<button
|
||||
type="button"
|
||||
className="track-settings-menu-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowTrackColorPalette((open) => !open);
|
||||
}}
|
||||
>
|
||||
{t('track.controls.settings.trackColor')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="track-settings-menu-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleSettingsAction('Delete Track');
|
||||
}}
|
||||
>
|
||||
{t('track.controls.settings.deleteTrack')}
|
||||
</button>
|
||||
{showTrackColorPalette && (
|
||||
<div className="track-settings-color-popup">
|
||||
<ColorPalettePopup
|
||||
selectedColor={track.getColor()}
|
||||
onSelect={handleTrackColorSelect}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const DEFAULT_MIDI_REGION_COLOR = '#4B9A41';
|
||||
export const DEFAULT_AUDIO_REGION_COLOR = '#39649E';
|
||||
|
||||
export const LOGIC_REGION_COLOR_SWATCHES: readonly string[][] = [
|
||||
['#B43F1D', '#B65720', '#B97423', '#BE9B2A', '#C3C031', '#A2BF30', '#84BE2E', '#6BBD2D', '#4EBC3A', '#4EBD58', '#4DBD78', '#4DBE9B', '#4CBEC2', '#3C99C0', '#3C8AC4', '#3D77C9', '#3B5ECC', '#3A43CE', '#4E41CA', '#643EC8', '#7A38C6', '#8D2EBE', '#B233BD', '#B33097'],
|
||||
['#8E3A1F', '#904C21', '#926124', '#957C27', '#9A972C', '#82962B', '#6C952A', '#599529', '#409435', '#40954D', '#409564', '#3F957D', '#3F9698', '#347B97', '#367099', '#39649E', '#3852A0', '#383EA2', '#463CA0', '#55389D', '#67359D', '#722995', '#8C2D96', '#8C2B7A'],
|
||||
['#69331E', '#6B3E20', '#6D4C22', '#706025', '#717126', '#627026', '#546F25', '#476F26', '#336F2E', '#336F3F', '#336E4F', '#337060', '#327070', '#2C5E70', '#2F5672', '#324F75', '#314476', '#323576', '#3B3476', '#463375', '#4F3073', '#57256F', '#68266F', '#69275E'],
|
||||
['#48271B', '#492F1C', '#4A381D', '#4C421F', '#4D4C1F', '#444C1E', '#3C4C1E', '#344B1F', '#264B24', '#264B2E', '#264B38', '#274B42', '#264B4C', '#23414B', '#263E4D', '#293A50', '#27324F', '#28294F', '#2E294F', '#342850', '#38254D', '#3D1F4A', '#47204B', '#481F41'],
|
||||
] as const;
|
||||
|
||||
export const LOGIC_REGION_COLOR_OPTIONS: readonly string[] = LOGIC_REGION_COLOR_SWATCHES.flat();
|
||||
@@ -5,6 +5,7 @@ import { ConfigManager } from './config/ConfigManager';
|
||||
import { KGProjectStorage } from './io/KGProjectStorage';
|
||||
import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader';
|
||||
import { KGMidiRegion } from './region/KGMidiRegion';
|
||||
import { KGAudioRegion } from './region/KGAudioRegion';
|
||||
import { KGMidiNote } from './midi/KGMidiNote';
|
||||
import { KGMidiControllerEvent } from './midi/KGMidiControllerEvent';
|
||||
import { KGMidiPitchBend } from './midi/KGMidiPitchBend';
|
||||
@@ -535,6 +536,7 @@ export class KGCore {
|
||||
region.getStartFromBeat(),
|
||||
region.getLength()
|
||||
);
|
||||
clonedRegion.setColor(region.getColor());
|
||||
|
||||
// Copy all notes within the region
|
||||
const originalNotes = region.getNotes();
|
||||
@@ -569,6 +571,25 @@ export class KGCore {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'KGAudioRegion': {
|
||||
const region = item as KGAudioRegion;
|
||||
const clonedRegion = new KGAudioRegion(
|
||||
generateUniqueId('KGAudioRegion'),
|
||||
region.getTrackId(),
|
||||
region.getTrackIndex(),
|
||||
region.getName(),
|
||||
region.getStartFromBeat(),
|
||||
region.getLength(),
|
||||
region.getAudioFileId(),
|
||||
region.getAudioFileName(),
|
||||
region.getAudioDurationSeconds(),
|
||||
region.getClipStartOffsetSeconds()
|
||||
);
|
||||
clonedRegion.setColor(region.getColor());
|
||||
clonedItems.push(clonedRegion);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'KGRegion': {
|
||||
const region = item as KGRegion;
|
||||
const clonedRegion = new KGRegion(
|
||||
@@ -579,6 +600,7 @@ export class KGCore {
|
||||
region.getStartFromBeat(),
|
||||
region.getLength()
|
||||
);
|
||||
clonedRegion.setColor(region.getColor());
|
||||
clonedItems.push(clonedRegion);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import { useProjectStore } from '../../../stores/projectStore';
|
||||
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||
|
||||
/**
|
||||
* Command to paste regions with their notes to a target track at a specific position
|
||||
@@ -71,6 +72,7 @@ export class PasteRegionsCommand extends KGCommand {
|
||||
newPosition,
|
||||
originalRegion.getLength()
|
||||
);
|
||||
newRegion.setColor(originalRegion.getColor());
|
||||
|
||||
// Copy all notes from the original region
|
||||
const originalNotes = originalRegion.getNotes();
|
||||
@@ -102,6 +104,22 @@ export class PasteRegionsCommand extends KGCommand {
|
||||
});
|
||||
|
||||
console.log(`Created MIDI region "${newRegion.getName()}" with ${originalNotes.length} notes`);
|
||||
} else if (originalRegion instanceof KGAudioRegion) {
|
||||
newRegion = new KGAudioRegion(
|
||||
generateUniqueId('KGAudioRegion'),
|
||||
targetTrack.getId().toString(),
|
||||
targetTrack.getTrackIndex(),
|
||||
`${originalRegion.getName()} (Copy)`,
|
||||
newPosition,
|
||||
originalRegion.getLength(),
|
||||
originalRegion.getAudioFileId(),
|
||||
originalRegion.getAudioFileName(),
|
||||
originalRegion.getAudioDurationSeconds(),
|
||||
originalRegion.getClipStartOffsetSeconds()
|
||||
);
|
||||
newRegion.setColor(originalRegion.getColor());
|
||||
|
||||
console.log(`Created audio region "${newRegion.getName()}"`);
|
||||
} else {
|
||||
// Fallback for other region types
|
||||
newRegion = new KGRegion(
|
||||
@@ -112,6 +130,7 @@ export class PasteRegionsCommand extends KGCommand {
|
||||
newPosition,
|
||||
originalRegion.getLength()
|
||||
);
|
||||
newRegion.setColor(originalRegion.getColor());
|
||||
|
||||
console.log(`Created region "${newRegion.getName()}"`);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ export class SplitRegionCommand extends KGCommand {
|
||||
this.splitAtBeat,
|
||||
regionLength - splitOffsetBeats
|
||||
);
|
||||
region1.setColor(originalRegion.getColor());
|
||||
region2.setColor(originalRegion.getColor());
|
||||
|
||||
for (const note of originalRegion.getNotes()) {
|
||||
if (note.getStartBeat() < splitOffsetBeats) {
|
||||
@@ -167,6 +169,7 @@ export class SplitRegionCommand extends KGCommand {
|
||||
originalRegion.getAudioDurationSeconds(),
|
||||
originalRegion.getClipStartOffsetSeconds()
|
||||
);
|
||||
this.region1.setColor(originalRegion.getColor());
|
||||
|
||||
this.region2 = new KGAudioRegion(
|
||||
generateUniqueId('KGAudioRegion'),
|
||||
@@ -180,6 +183,7 @@ export class SplitRegionCommand extends KGCommand {
|
||||
originalRegion.getAudioDurationSeconds(),
|
||||
originalRegion.getClipStartOffsetSeconds() + splitOffsetSeconds
|
||||
);
|
||||
this.region2.setColor(originalRegion.getColor());
|
||||
|
||||
} else {
|
||||
throw new Error(`Unsupported region type for splitting: ${originalRegion.getCurrentType()}`);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGProject } from '../../KGProject';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { UpdateRegionCommand } from './UpdateRegionCommand';
|
||||
|
||||
vi.mock('../../KGCore', () => ({
|
||||
KGCore: {
|
||||
instance: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('UpdateRegionCommand', () => {
|
||||
let track: KGTrack;
|
||||
let region: KGMidiRegion;
|
||||
let project: KGProject;
|
||||
const mockCore = {
|
||||
getCurrentProject: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
track = new KGTrack('Track 1', 1);
|
||||
region = new KGMidiRegion('region-1', '1', 0, 'Verse', 0, 4);
|
||||
track.setRegions([region]);
|
||||
project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 11);
|
||||
mockCore.getCurrentProject.mockReturnValue(project);
|
||||
vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore);
|
||||
});
|
||||
|
||||
it('updates color and restores it on undo', () => {
|
||||
const command = new UpdateRegionCommand('region-1', { color: '#3D77C9' });
|
||||
|
||||
command.execute();
|
||||
|
||||
expect(region.getColor()).toBe('#3D77C9');
|
||||
expect(command.getChangedProperties()).toEqual(new Set(['color']));
|
||||
|
||||
command.undo();
|
||||
|
||||
expect(region.getColor()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears an existing region color override', () => {
|
||||
region.setColor('#B43F1D');
|
||||
const command = new UpdateRegionCommand('region-1', { color: null });
|
||||
|
||||
command.execute();
|
||||
|
||||
expect(region.getColor()).toBeUndefined();
|
||||
expect(command.getChangedProperties()).toEqual(new Set(['color']));
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { KGTrack } from '../../track/KGTrack';
|
||||
*/
|
||||
export interface RegionUpdateProperties {
|
||||
name?: string;
|
||||
// Future properties can be added here (e.g., color, instrument, etc.)
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +58,7 @@ export class UpdateRegionCommand extends KGCommand {
|
||||
// Store original properties for undo
|
||||
this.originalProperties = {
|
||||
name: this.targetRegion.getName(),
|
||||
color: this.targetRegion.getColor(),
|
||||
};
|
||||
|
||||
// Apply updates and track what actually changes
|
||||
@@ -70,6 +71,12 @@ export class UpdateRegionCommand extends KGCommand {
|
||||
updatedProperties.push(`name: "${this.originalProperties.name}" → "${this.newProperties.name}"`);
|
||||
}
|
||||
|
||||
if ('color' in this.newProperties && this.newProperties.color !== this.originalProperties.color) {
|
||||
this.targetRegion.setColor(this.newProperties.color ?? undefined);
|
||||
this.changedProperties.add('color');
|
||||
updatedProperties.push(`color: ${this.originalProperties.color ?? 'none'} → ${this.newProperties.color ?? 'none'}`);
|
||||
}
|
||||
|
||||
if (updatedProperties.length > 0) {
|
||||
console.log(`Updated region ${this.regionId}: ${updatedProperties.join(', ')}`);
|
||||
} else {
|
||||
@@ -91,6 +98,11 @@ export class UpdateRegionCommand extends KGCommand {
|
||||
restoredProperties.push(`name: "${this.originalProperties.name}"`);
|
||||
}
|
||||
|
||||
if (this.changedProperties.has('color')) {
|
||||
this.targetRegion.setColor(this.originalProperties.color ?? undefined);
|
||||
restoredProperties.push(`color: ${this.originalProperties.color ?? 'none'}`);
|
||||
}
|
||||
|
||||
console.log(`Restored region ${this.regionId}: ${restoredProperties.join(', ')}`);
|
||||
}
|
||||
|
||||
@@ -101,6 +113,9 @@ export class UpdateRegionCommand extends KGCommand {
|
||||
if (this.newProperties.name !== undefined) {
|
||||
updatedProps.push('name');
|
||||
}
|
||||
if ('color' in this.newProperties) {
|
||||
updatedProps.push('color');
|
||||
}
|
||||
|
||||
if (updatedProps.length === 1) {
|
||||
return `Update region "${regionName}" ${updatedProps[0]}`;
|
||||
|
||||
@@ -84,4 +84,27 @@ describe('UpdateTrackCommand', () => {
|
||||
expect(mockAudioInterface.setTrackSolo).not.toHaveBeenCalled();
|
||||
expect(command.getChangedProperties()).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('updates color and restores it on undo', () => {
|
||||
const command = new UpdateTrackCommand(1, { color: '#3C8AC4' });
|
||||
|
||||
command.execute();
|
||||
|
||||
expect(track.getColor()).toBe('#3C8AC4');
|
||||
expect(command.getChangedProperties()).toEqual(new Set(['color']));
|
||||
|
||||
command.undo();
|
||||
|
||||
expect(track.getColor()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears an existing color override', () => {
|
||||
track.setColor('#B43F1D');
|
||||
const command = new UpdateTrackCommand(1, { color: null });
|
||||
|
||||
command.execute();
|
||||
|
||||
expect(track.getColor()).toBeUndefined();
|
||||
expect(command.getChangedProperties()).toEqual(new Set(['color']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface TrackUpdateProperties {
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
solo?: boolean;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,6 +52,7 @@ export class UpdateTrackCommand extends KGCommand {
|
||||
volume: this.targetTrack.getVolume(),
|
||||
muted: this.targetTrack.getMuted(),
|
||||
solo: this.targetTrack.getSolo(),
|
||||
color: this.targetTrack.getColor(),
|
||||
};
|
||||
|
||||
// Store original instrument if it's a MIDI track
|
||||
@@ -133,6 +135,16 @@ export class UpdateTrackCommand extends KGCommand {
|
||||
updatedProperties.push(`solo: ${originalSolo} → ${newSolo}`);
|
||||
}
|
||||
|
||||
if ('color' in this.newProperties && this.newProperties.color !== this.originalProperties.color) {
|
||||
const newColor = this.newProperties.color ?? undefined;
|
||||
const originalColor = this.originalProperties.color;
|
||||
|
||||
this.targetTrack.setColor(newColor);
|
||||
|
||||
this.changedProperties.add('color');
|
||||
updatedProperties.push(`color: ${originalColor ?? 'none'} → ${newColor}`);
|
||||
}
|
||||
|
||||
if (updatedProperties.length > 0) {
|
||||
console.log(`Updated track ${this.trackId}: ${updatedProperties.join(', ')}`);
|
||||
} else {
|
||||
@@ -204,6 +216,11 @@ export class UpdateTrackCommand extends KGCommand {
|
||||
restoredProperties.push(`solo: ${this.originalProperties.solo}`);
|
||||
}
|
||||
|
||||
if (this.changedProperties.has('color')) {
|
||||
this.targetTrack.setColor(this.originalProperties.color ?? undefined);
|
||||
restoredProperties.push(`color: ${this.originalProperties.color ?? 'none'}`);
|
||||
}
|
||||
|
||||
console.log(`Restored track ${this.trackId}: ${restoredProperties.join(', ')}`);
|
||||
}
|
||||
|
||||
@@ -229,6 +246,9 @@ export class UpdateTrackCommand extends KGCommand {
|
||||
if (this.newProperties.solo !== undefined) {
|
||||
updatedProps.push('solo');
|
||||
}
|
||||
if ('color' in this.newProperties) {
|
||||
updatedProps.push('color');
|
||||
}
|
||||
|
||||
if (updatedProps.length === 1) {
|
||||
return `Update track "${trackName}" ${updatedProps[0]}`;
|
||||
|
||||
@@ -27,6 +27,9 @@ export class KGRegion implements Selectable {
|
||||
@Expose()
|
||||
protected length: number = 0;
|
||||
|
||||
@Expose()
|
||||
protected color?: string;
|
||||
|
||||
@Expose()
|
||||
protected selected: boolean = false;
|
||||
|
||||
@@ -66,6 +69,10 @@ export class KGRegion implements Selectable {
|
||||
return this.length;
|
||||
}
|
||||
|
||||
public getColor(): string | undefined {
|
||||
return this.color;
|
||||
}
|
||||
|
||||
// Setters
|
||||
public setId(id: string): void {
|
||||
this.id = id;
|
||||
@@ -91,6 +98,10 @@ export class KGRegion implements Selectable {
|
||||
this.length = length;
|
||||
}
|
||||
|
||||
public setColor(color: string | undefined): void {
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
// interface methods
|
||||
public select(): void {
|
||||
this.selected = true;
|
||||
|
||||
@@ -34,6 +34,9 @@ export class KGTrack {
|
||||
@Expose()
|
||||
protected type: TrackType;
|
||||
|
||||
@Expose()
|
||||
protected color?: string;
|
||||
|
||||
@Expose()
|
||||
@WithDefault(AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME)
|
||||
protected volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
|
||||
@@ -99,6 +102,10 @@ export class KGTrack {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
public getColor(): string | undefined {
|
||||
return this.color;
|
||||
}
|
||||
|
||||
public getMuted(): boolean {
|
||||
return this.muted;
|
||||
}
|
||||
@@ -136,6 +143,10 @@ export class KGTrack {
|
||||
);
|
||||
}
|
||||
|
||||
public setColor(color: string | undefined): void {
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
public setMuted(muted: boolean): void {
|
||||
this.muted = muted;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { DEBUG_MODE } from '../constants';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
import { useRegionOperations } from './useRegionOperations';
|
||||
import { getAudioRegionDisplayLengthBeats } from '../util/globalTrackUtil';
|
||||
import { resolveRegionColor } from '../util/regionColor';
|
||||
|
||||
const DEFAULT_REGION_CLICK_OPTIONS: RegionClickOptions = {
|
||||
shiftKey: false,
|
||||
@@ -179,6 +180,10 @@ export function useMainContentRegions({
|
||||
barNumber,
|
||||
length,
|
||||
name: region.getName(),
|
||||
color: region.getColor(),
|
||||
trackColor: track.getColor(),
|
||||
effectiveColor: resolveRegionColor(region.getColor(), track.getColor(), region instanceof KGAudioRegion),
|
||||
isAudioRegion: region instanceof KGAudioRegion,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -327,6 +327,7 @@ export const enUsMessages: TranslationMessages = {
|
||||
'pianoRoll.curve': 'Curve',
|
||||
'pianoRoll.zoom': 'Zoom',
|
||||
'pianoRoll.moreOptions': 'More options',
|
||||
'pianoRoll.regionColor': 'Region Color...',
|
||||
'pianoRoll.quantize.1/1': '1/1',
|
||||
'pianoRoll.quantize.1/2': '1/2',
|
||||
'pianoRoll.quantize.1/3': '1/3',
|
||||
@@ -538,6 +539,7 @@ export const enUsMessages: TranslationMessages = {
|
||||
'track.controls.automation.volume': 'Volume',
|
||||
'track.controls.automation.pan': 'Pan',
|
||||
'track.controls.moreActions': 'More actions',
|
||||
'track.controls.settings.trackColor': 'Track Color...',
|
||||
'track.controls.settings.deleteTrack': 'Delete Track',
|
||||
'track.controls.settings.deleteTrackConfirm': 'Are you sure you want to delete track "{name}"?',
|
||||
'track.controls.settings.deleteTrackError': 'Failed to delete track. Please try again.',
|
||||
|
||||
@@ -315,6 +315,7 @@ export const frFrMessages: TranslationMessages = {
|
||||
'pianoRoll.curve': 'Courbe',
|
||||
'pianoRoll.zoom': 'Zoom',
|
||||
'pianoRoll.moreOptions': 'Plus d\'options',
|
||||
'pianoRoll.regionColor': 'Couleur de la région...',
|
||||
'pianoRoll.showActiveRegionOnly': 'Afficher seulement la région active',
|
||||
'pianoRoll.showEntireTrack': 'Afficher toute la piste',
|
||||
'pianoRoll.detectChords': 'Détecter les accords...',
|
||||
@@ -422,6 +423,7 @@ export const frFrMessages: TranslationMessages = {
|
||||
'track.controls.automation.volume': 'Volume',
|
||||
'track.controls.automation.pan': 'Panoramique',
|
||||
'track.controls.moreActions': 'Autres actions',
|
||||
'track.controls.settings.trackColor': 'Couleur de la piste...',
|
||||
'track.controls.settings.deleteTrack': 'Supprimer la piste',
|
||||
'track.controls.settings.deleteTrackConfirm': 'Voulez-vous vraiment supprimer la piste « {name} » ?',
|
||||
'track.controls.settings.deleteTrackError': 'Impossible de supprimer la piste. Veuillez réessayer.',
|
||||
|
||||
@@ -325,6 +325,7 @@ export const zhCnMessages: TranslationMessages = {
|
||||
'pianoRoll.curve': '曲线',
|
||||
'pianoRoll.zoom': '缩放',
|
||||
'pianoRoll.moreOptions': '更多选项',
|
||||
'pianoRoll.regionColor': '区域颜色...',
|
||||
'pianoRoll.quantize.1/1': '1/1',
|
||||
'pianoRoll.quantize.1/2': '1/2',
|
||||
'pianoRoll.quantize.1/3': '1/3',
|
||||
@@ -536,6 +537,7 @@ export const zhCnMessages: TranslationMessages = {
|
||||
'track.controls.automation.volume': '音量',
|
||||
'track.controls.automation.pan': '声像',
|
||||
'track.controls.moreActions': '更多操作',
|
||||
'track.controls.settings.trackColor': '轨道颜色...',
|
||||
'track.controls.settings.deleteTrack': '删除轨道',
|
||||
'track.controls.settings.deleteTrackConfirm': '确定要删除轨道“{name}”吗?',
|
||||
'track.controls.settings.deleteTrackError': '删除轨道失败。请重试。',
|
||||
|
||||
@@ -325,6 +325,7 @@ export const zhHkMessages: TranslationMessages = {
|
||||
'pianoRoll.curve': '曲線',
|
||||
'pianoRoll.zoom': '縮放',
|
||||
'pianoRoll.moreOptions': '更多選項',
|
||||
'pianoRoll.regionColor': '區域顏色...',
|
||||
'pianoRoll.quantize.1/1': '1/1',
|
||||
'pianoRoll.quantize.1/2': '1/2',
|
||||
'pianoRoll.quantize.1/3': '1/3',
|
||||
@@ -536,6 +537,7 @@ export const zhHkMessages: TranslationMessages = {
|
||||
'track.controls.automation.volume': '音量',
|
||||
'track.controls.automation.pan': '聲像',
|
||||
'track.controls.moreActions': '更多操作',
|
||||
'track.controls.settings.trackColor': '音軌顏色...',
|
||||
'track.controls.settings.deleteTrack': '刪除音軌',
|
||||
'track.controls.settings.deleteTrackConfirm': '確定要刪除音軌「{name}」嗎?',
|
||||
'track.controls.settings.deleteTrackError': '刪除音軌失敗。請再試一次。',
|
||||
|
||||
@@ -11,7 +11,7 @@ import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||
import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent';
|
||||
import { KGRegion } from '../core/region/KGRegion';
|
||||
import { AddTrackCommand, AddAudioTrackCommand, RemoveTrackCommand, ReorderTracksCommand, UpdateTrackCommand, type TrackUpdateProperties, PasteRegionsCommand, PasteNotesCommand, ChangeProjectPropertyCommand, ImportAudioCommand } from '../core/commands';
|
||||
import { AddTrackCommand, AddAudioTrackCommand, RemoveTrackCommand, ReorderTracksCommand, UpdateTrackCommand, type TrackUpdateProperties, PasteRegionsCommand, PasteNotesCommand, ChangeProjectPropertyCommand, ImportAudioCommand, UpdateRegionCommand, type RegionUpdateProperties } from '../core/commands';
|
||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||
import { KGAudioFileStorage } from '../core/io/KGAudioFileStorage';
|
||||
@@ -183,6 +183,7 @@ interface ProjectState {
|
||||
removeTrack: (id: number) => Promise<void>;
|
||||
updateTrack: (track: KGTrack) => Promise<void>;
|
||||
updateTrackProperties: (trackId: number, properties: TrackUpdateProperties) => Promise<void>;
|
||||
updateRegionProperties: (regionId: string, properties: RegionUpdateProperties) => Promise<void>;
|
||||
setTrackInstrument: (trackId: number, instrument: InstrumentType) => Promise<void>;
|
||||
reorderTracks: (sourceIndex: number, destinationIndex: number) => void;
|
||||
setStatus: (status: string) => void;
|
||||
@@ -856,6 +857,24 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
updateRegionProperties: async (regionId: string, properties: RegionUpdateProperties) => {
|
||||
try {
|
||||
const command = new UpdateRegionCommand(regionId, properties);
|
||||
KGCore.instance().executeCommand(command);
|
||||
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
set({
|
||||
tracks: [...project.getTracks()] as KGTrack[],
|
||||
globalTracks: [...getProjectGlobalTracks(project)],
|
||||
});
|
||||
|
||||
console.log(`Updated region ${regionId} properties`);
|
||||
} catch (error) {
|
||||
console.error('Error updating region properties:', error);
|
||||
get().setStatus('Failed to update region properties');
|
||||
}
|
||||
},
|
||||
|
||||
setTrackInstrument: async (trackId: number, instrument: InstrumentType) => {
|
||||
try {
|
||||
// Use the new updateTrackProperties method with command pattern
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_AUDIO_REGION_COLOR,
|
||||
DEFAULT_MIDI_REGION_COLOR,
|
||||
} from '../constants/regionColorPalette';
|
||||
import { buildRegionSurfaceColors, resolveRegionColor } from './regionColor';
|
||||
|
||||
describe('regionColor helpers', () => {
|
||||
it('prefers the region color over the track color', () => {
|
||||
expect(resolveRegionColor('#123456', '#654321', false)).toBe('#123456');
|
||||
});
|
||||
|
||||
it('falls back to the track color when the region color is missing', () => {
|
||||
expect(resolveRegionColor(undefined, '#654321', false)).toBe('#654321');
|
||||
});
|
||||
|
||||
it('falls back to the default type color when no overrides are present', () => {
|
||||
expect(resolveRegionColor(undefined, undefined, false)).toBe(DEFAULT_MIDI_REGION_COLOR);
|
||||
expect(resolveRegionColor(undefined, undefined, true)).toBe(DEFAULT_AUDIO_REGION_COLOR);
|
||||
});
|
||||
|
||||
it('builds distinct surface colors for region chrome', () => {
|
||||
const colors = buildRegionSurfaceColors('#4CBEC2');
|
||||
|
||||
expect(colors.borderColor).not.toBe('#4CBEC2');
|
||||
expect(colors.headerColor).not.toBe('#4CBEC2');
|
||||
expect(colors.contentColor).not.toBe('#4CBEC2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
DEFAULT_AUDIO_REGION_COLOR,
|
||||
DEFAULT_MIDI_REGION_COLOR,
|
||||
} from '../constants/regionColorPalette';
|
||||
|
||||
function clampChannel(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeHex(color: string): string {
|
||||
const trimmed = color.trim();
|
||||
const hex = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed;
|
||||
if (hex.length === 3) {
|
||||
return `#${hex.split('').map(channel => channel + channel).join('').toUpperCase()}`;
|
||||
}
|
||||
return `#${hex.toUpperCase()}`;
|
||||
}
|
||||
|
||||
function parseHexColor(color: string): [number, number, number] | null {
|
||||
const normalized = normalizeHex(color);
|
||||
if (!/^#[0-9A-F]{6}$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
parseInt(normalized.slice(1, 3), 16),
|
||||
parseInt(normalized.slice(3, 5), 16),
|
||||
parseInt(normalized.slice(5, 7), 16),
|
||||
];
|
||||
}
|
||||
|
||||
function rgbToHex(red: number, green: number, blue: number): string {
|
||||
return `#${[red, green, blue].map(channel => clampChannel(channel).toString(16).padStart(2, '0')).join('').toUpperCase()}`;
|
||||
}
|
||||
|
||||
function mixColor(color: string, target: [number, number, number], amount: number): string {
|
||||
const rgb = parseHexColor(color);
|
||||
if (!rgb) {
|
||||
return color;
|
||||
}
|
||||
|
||||
return rgbToHex(
|
||||
rgb[0] + (target[0] - rgb[0]) * amount,
|
||||
rgb[1] + (target[1] - rgb[1]) * amount,
|
||||
rgb[2] + (target[2] - rgb[2]) * amount,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeRegionColor(color: string | undefined): string | undefined {
|
||||
if (!color) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = normalizeHex(color);
|
||||
return /^#[0-9A-F]{6}$/.test(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function resolveRegionColor(
|
||||
regionColor: string | undefined,
|
||||
trackColor: string | undefined,
|
||||
isAudioRegion: boolean,
|
||||
): string {
|
||||
return normalizeRegionColor(regionColor)
|
||||
?? normalizeRegionColor(trackColor)
|
||||
?? (isAudioRegion ? DEFAULT_AUDIO_REGION_COLOR : DEFAULT_MIDI_REGION_COLOR);
|
||||
}
|
||||
|
||||
export function darkenRegionColor(color: string, amount: number): string {
|
||||
return mixColor(color, [0, 0, 0], amount);
|
||||
}
|
||||
|
||||
export function lightenRegionColor(color: string, amount: number): string {
|
||||
return mixColor(color, [255, 255, 255], amount);
|
||||
}
|
||||
|
||||
export function buildRegionSurfaceColors(color: string) {
|
||||
return {
|
||||
borderColor: darkenRegionColor(color, 0.35),
|
||||
headerColor: darkenRegionColor(color, 0.2),
|
||||
headerHoverColor: darkenRegionColor(color, 0.1),
|
||||
contentColor: lightenRegionColor(color, 0.06),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user