feat: added region color customization feature

This commit is contained in:
Xiaohan-Tian
2026-07-01 22:31:35 -07:00
parent e626e1fc76
commit 4f11fe8e25
31 changed files with 700 additions and 31 deletions
@@ -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
View File
@@ -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';
+4
View File
@@ -16,6 +16,10 @@ export interface RegionUI {
barNumber: number;
length: number;
name: string;
color?: string;
trackColor?: string;
effectiveColor?: string;
isAudioRegion?: boolean;
}
export interface RegionPreviewContentStyle {
+16
View File
@@ -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;
+25 -1
View File
@@ -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);
});
});
+32 -2
View File
@@ -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();
}}
+10 -10
View File
@@ -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 */
+43
View File
@@ -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 -1
View File
@@ -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}
+37 -2
View File
@@ -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 });
});
});
+49 -12
View File
@@ -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>