From 4f11fe8e259f6115c75e617a6ace86e03c3e490e Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:31:35 -0700 Subject: [PATCH] feat: added region color customization feature --- src/components/common/ColorPalettePopup.css | 62 ++++++++++++++ src/components/common/ColorPalettePopup.tsx | 43 ++++++++++ src/components/common/index.ts | 1 + src/components/interfaces.ts | 4 + src/components/piano-roll/PianoRoll.css | 16 ++++ src/components/piano-roll/PianoRoll.tsx | 26 +++++- .../piano-roll/PianoRollToolbar.test.tsx | 29 +++++++ .../piano-roll/PianoRollToolbar.tsx | 34 +++++++- src/components/track/Region.css | 20 ++--- src/components/track/Track.css | 43 ++++++++++ src/components/track/TrackGridItem.tsx | 13 ++- src/components/track/TrackInfoItem.test.tsx | 39 ++++++++- src/components/track/TrackInfoItem.tsx | 61 +++++++++++--- src/constants/regionColorPalette.ts | 11 +++ src/core/KGCore.ts | 22 +++++ .../commands/region/PasteRegionsCommand.ts | 19 +++++ .../commands/region/SplitRegionCommand.ts | 4 + .../region/UpdateRegionCommand.test.ts | 54 ++++++++++++ .../commands/region/UpdateRegionCommand.ts | 19 ++++- .../commands/track/UpdateTrackCommand.test.ts | 23 +++++ src/core/commands/track/UpdateTrackCommand.ts | 20 +++++ src/core/region/KGRegion.ts | 11 +++ src/core/track/KGTrack.ts | 11 +++ src/hooks/useMainContentRegions.ts | 5 ++ src/i18n/messages/en_us.ts | 2 + src/i18n/messages/fr_fr.ts | 2 + src/i18n/messages/zh_cn.ts | 2 + src/i18n/messages/zh_hk.ts | 2 + src/stores/projectStore.ts | 21 ++++- src/util/regionColor.test.ts | 29 +++++++ src/util/regionColor.ts | 83 +++++++++++++++++++ 31 files changed, 700 insertions(+), 31 deletions(-) create mode 100644 src/components/common/ColorPalettePopup.css create mode 100644 src/components/common/ColorPalettePopup.tsx create mode 100644 src/constants/regionColorPalette.ts create mode 100644 src/core/commands/region/UpdateRegionCommand.test.ts create mode 100644 src/util/regionColor.test.ts create mode 100644 src/util/regionColor.ts diff --git a/src/components/common/ColorPalettePopup.css b/src/components/common/ColorPalettePopup.css new file mode 100644 index 0000000..349dec6 --- /dev/null +++ b/src/components/common/ColorPalettePopup.css @@ -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); +} diff --git a/src/components/common/ColorPalettePopup.tsx b/src/components/common/ColorPalettePopup.tsx new file mode 100644 index 0000000..69c9877 --- /dev/null +++ b/src/components/common/ColorPalettePopup.tsx @@ -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 = ({ + selectedColor, + onSelect, + className = '', +}) => ( +
+ +
+ {LOGIC_REGION_COLOR_SWATCHES.flat().map((color) => ( +
+
+); + +export default ColorPalettePopup; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index faae95c..d9ba3a7 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -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'; diff --git a/src/components/interfaces.ts b/src/components/interfaces.ts index e76a18b..eecb52d 100644 --- a/src/components/interfaces.ts +++ b/src/components/interfaces.ts @@ -16,6 +16,10 @@ export interface RegionUI { barNumber: number; length: number; name: string; + color?: string; + trackColor?: string; + effectiveColor?: string; + isAudioRegion?: boolean; } export interface RegionPreviewContentStyle { diff --git a/src/components/piano-roll/PianoRoll.css b/src/components/piano-roll/PianoRoll.css index f3899e4..c4c4e2f 100644 --- a/src/components/piano-roll/PianoRoll.css +++ b/src/components/piano-roll/PianoRoll.css @@ -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; diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index 312defd..16b9cae 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -101,7 +101,7 @@ const PianoRoll: React.FC = ({ 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 = ({ const activeInstrument = useMemo(() => ( 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 = ({ } }; + 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 = ({ detectingChords={isDetectingChords} onDetectTempo={audioRegion ? handleDetectTempo : undefined} detectingTempo={isDetectingTempo} + selectedRegionColor={selectedRegionColor} + onRegionColorSelect={activeEditableRegionId ? (color) => { void handleRegionColorSelect(color); } : undefined} /> diff --git a/src/components/piano-roll/PianoRollToolbar.test.tsx b/src/components/piano-roll/PianoRollToolbar.test.tsx index ba47647..2623a81 100644 --- a/src/components/piano-roll/PianoRollToolbar.test.tsx +++ b/src/components/piano-roll/PianoRollToolbar.test.tsx @@ -30,6 +30,16 @@ vi.mock('../common', () => ({ ); }, + ColorPalettePopup: ({ onSelect }: { onSelect: (value: string | null) => void }) => ( +
+ + +
+ ), })); 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( + + ); + + 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); + }); }); diff --git a/src/components/piano-roll/PianoRollToolbar.tsx b/src/components/piano-roll/PianoRollToolbar.tsx index d781c7a..ec0262f 100644 --- a/src/components/piano-roll/PianoRollToolbar.tsx +++ b/src/components/piano-roll/PianoRollToolbar.tsx @@ -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; detectingTempo?: boolean; + selectedRegionColor?: string; + onRegionColorSelect?: (color: string | null) => void; } const PianoRollToolbar: React.FC = ({ @@ -92,6 +94,8 @@ const PianoRollToolbar: React.FC = ({ 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 = ({ }, [showZoomSlider]); const [showMoreMenu, setShowMoreMenu] = React.useState(false); + const [showRegionColorPalette, setShowRegionColorPalette] = React.useState(false); const specMenuRef = React.useRef(null); React.useEffect(() => { @@ -158,6 +163,7 @@ const PianoRollToolbar: React.FC = ({ 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 = ({ ... {showMoreMenu && ( -
+
+ {onRegionColorSelect && ( +
+
setShowRegionColorPalette(open => !open)} + > + {t('pianoRoll.regionColor')} +
+ {showRegionColorPalette && ( +
+ { + onRegionColorSelect(color); + setShowRegionColorPalette(false); + setShowMoreMenu(false); + }} + /> +
+ )} +
+ )} {onDetectChords && (
= ({ if (detectingChords) { return; } + setShowRegionColorPalette(false); setShowMoreMenu(false); void onDetectChords(); }} @@ -409,6 +438,7 @@ const PianoRollToolbar: React.FC = ({ if (detectingTempo) { return; } + setShowRegionColorPalette(false); setShowMoreMenu(false); void onDetectTempo(); }} diff --git a/src/components/track/Region.css b/src/components/track/Region.css index 4c31836..b5b9c77 100644 --- a/src/components/track/Region.css +++ b/src/components/track/Region.css @@ -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 */ diff --git a/src/components/track/Track.css b/src/components/track/Track.css index 47a946c..942178c 100644 --- a/src/components/track/Track.css +++ b/src/components/track/Track.css @@ -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; } diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx index a4c04e6..f50b922 100644 --- a/src/components/track/TrackGridItem.tsx +++ b/src/components/track/TrackGridItem.tsx @@ -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 = ({ ); } + const effectiveColor = region.effectiveColor + ?? resolveRegionColor(coreRegion?.getColor(), track.getColor(), !!audioRegion || !!region.isAudioRegion); + const surfaceColors = buildRegionSurfaceColors(effectiveColor); + return ( | 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( + + ); + + 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 }); + }); }); diff --git a/src/components/track/TrackInfoItem.tsx b/src/components/track/TrackInfoItem.tsx index 579aa27..714a797 100644 --- a/src/components/track/TrackInfoItem.tsx +++ b/src/components/track/TrackInfoItem.tsx @@ -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 = ({ const [showSettingsDropdown, setShowSettingsDropdown] = useState(false); const [showAudioImportModal, setShowAudioImportModal] = useState(false); const [showAutomationDropdown, setShowAutomationDropdown] = useState(false); + const [showTrackColorPalette, setShowTrackColorPalette] = useState(false); const settingsDropdownRef = useRef(null); const automationDropdownRef = useRef(null); const suppressDragRef = useRef(false); @@ -115,6 +117,7 @@ const TrackInfoItem: React.FC = ({ !settingsDropdownRef.current.contains(event.target as Node) ) { setShowSettingsDropdown(false); + setShowTrackColorPalette(false); } if ( showAutomationDropdown && @@ -336,6 +339,9 @@ const TrackInfoItem: React.FC = ({ const handleSettingsButtonClick = (e: React.MouseEvent) => { e.stopPropagation(); setShowSettingsDropdown(!showSettingsDropdown); + if (showSettingsDropdown) { + setShowTrackColorPalette(false); + } }; const handleAutomationButtonClick = (e: React.MouseEvent) => { @@ -387,6 +393,17 @@ const TrackInfoItem: React.FC = ({ } }; + 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 (
= ({ > -
- -
+ {showSettingsDropdown && ( +
+ + + {showTrackColorPalette && ( +
+ +
+ )} +
+ )}
diff --git a/src/constants/regionColorPalette.ts b/src/constants/regionColorPalette.ts new file mode 100644 index 0000000..2e84859 --- /dev/null +++ b/src/constants/regionColorPalette.ts @@ -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(); diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts index b77695f..fe93e4a 100644 --- a/src/core/KGCore.ts +++ b/src/core/KGCore.ts @@ -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(); @@ -568,6 +570,25 @@ export class KGCore { clonedItems.push(clonedRegion); 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; @@ -579,6 +600,7 @@ export class KGCore { region.getStartFromBeat(), region.getLength() ); + clonedRegion.setColor(region.getColor()); clonedItems.push(clonedRegion); break; } diff --git a/src/core/commands/region/PasteRegionsCommand.ts b/src/core/commands/region/PasteRegionsCommand.ts index 0280002..fe97f65 100644 --- a/src/core/commands/region/PasteRegionsCommand.ts +++ b/src/core/commands/region/PasteRegionsCommand.ts @@ -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()}"`); } diff --git a/src/core/commands/region/SplitRegionCommand.ts b/src/core/commands/region/SplitRegionCommand.ts index 2a4775c..414a39f 100644 --- a/src/core/commands/region/SplitRegionCommand.ts +++ b/src/core/commands/region/SplitRegionCommand.ts @@ -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()}`); diff --git a/src/core/commands/region/UpdateRegionCommand.test.ts b/src/core/commands/region/UpdateRegionCommand.test.ts new file mode 100644 index 0000000..e92819b --- /dev/null +++ b/src/core/commands/region/UpdateRegionCommand.test.ts @@ -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'])); + }); +}); diff --git a/src/core/commands/region/UpdateRegionCommand.ts b/src/core/commands/region/UpdateRegionCommand.ts index 39e2b52..8077067 100644 --- a/src/core/commands/region/UpdateRegionCommand.ts +++ b/src/core/commands/region/UpdateRegionCommand.ts @@ -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]}`; @@ -152,4 +167,4 @@ export class UpdateRegionCommand extends KGCommand { public getChangedProperties(): Set { return new Set(this.changedProperties); } -} \ No newline at end of file +} diff --git a/src/core/commands/track/UpdateTrackCommand.test.ts b/src/core/commands/track/UpdateTrackCommand.test.ts index 5a97edc..6302d10 100644 --- a/src/core/commands/track/UpdateTrackCommand.test.ts +++ b/src/core/commands/track/UpdateTrackCommand.test.ts @@ -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'])); + }); }); diff --git a/src/core/commands/track/UpdateTrackCommand.ts b/src/core/commands/track/UpdateTrackCommand.ts index df61e67..921d635 100644 --- a/src/core/commands/track/UpdateTrackCommand.ts +++ b/src/core/commands/track/UpdateTrackCommand.ts @@ -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]}`; diff --git a/src/core/region/KGRegion.ts b/src/core/region/KGRegion.ts index f0181fe..6c9e513 100644 --- a/src/core/region/KGRegion.ts +++ b/src/core/region/KGRegion.ts @@ -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; diff --git a/src/core/track/KGTrack.ts b/src/core/track/KGTrack.ts index 4ae20c2..e7d3311 100644 --- a/src/core/track/KGTrack.ts +++ b/src/core/track/KGTrack.ts @@ -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; } diff --git a/src/hooks/useMainContentRegions.ts b/src/hooks/useMainContentRegions.ts index cb97b6d..6151db5 100644 --- a/src/hooks/useMainContentRegions.ts +++ b/src/hooks/useMainContentRegions.ts @@ -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, }); } }); diff --git a/src/i18n/messages/en_us.ts b/src/i18n/messages/en_us.ts index c631f81..a44e376 100644 --- a/src/i18n/messages/en_us.ts +++ b/src/i18n/messages/en_us.ts @@ -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.', diff --git a/src/i18n/messages/fr_fr.ts b/src/i18n/messages/fr_fr.ts index c40e5ba..60a4806 100644 --- a/src/i18n/messages/fr_fr.ts +++ b/src/i18n/messages/fr_fr.ts @@ -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.', diff --git a/src/i18n/messages/zh_cn.ts b/src/i18n/messages/zh_cn.ts index 18244b4..78e4aaa 100644 --- a/src/i18n/messages/zh_cn.ts +++ b/src/i18n/messages/zh_cn.ts @@ -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': '删除轨道失败。请重试。', diff --git a/src/i18n/messages/zh_hk.ts b/src/i18n/messages/zh_hk.ts index 4b10f37..b58922d 100644 --- a/src/i18n/messages/zh_hk.ts +++ b/src/i18n/messages/zh_hk.ts @@ -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': '刪除音軌失敗。請再試一次。', diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 8eaa5b2..f9757db 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -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; updateTrack: (track: KGTrack) => Promise; updateTrackProperties: (trackId: number, properties: TrackUpdateProperties) => Promise; + updateRegionProperties: (regionId: string, properties: RegionUpdateProperties) => Promise; setTrackInstrument: (trackId: number, instrument: InstrumentType) => Promise; reorderTracks: (sourceIndex: number, destinationIndex: number) => void; setStatus: (status: string) => void; @@ -856,6 +857,24 @@ export const useProjectStore = create((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 diff --git a/src/util/regionColor.test.ts b/src/util/regionColor.test.ts new file mode 100644 index 0000000..1cff1d2 --- /dev/null +++ b/src/util/regionColor.test.ts @@ -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'); + }); +}); diff --git a/src/util/regionColor.ts b/src/util/regionColor.ts new file mode 100644 index 0000000..f5bdf25 --- /dev/null +++ b/src/util/regionColor.ts @@ -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), + }; +}