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
+29
View File
@@ -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');
});
});
+83
View File
@@ -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),
};
}