feat: added i18n support; added Simplified Chinese support
This commit is contained in:
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import PianoKeys from './PianoKeys';
|
||||
import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data';
|
||||
import { I18nContext } from '../../i18n/I18nProvider';
|
||||
import { translate } from '../../i18n/translate';
|
||||
|
||||
type TestLiveNoteActivityListener = (...args: [{ pitch: number; isNoteOn: boolean }]) => void;
|
||||
|
||||
@@ -48,6 +50,24 @@ vi.mock('../../core/midi-input/KGMidiInput', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function renderWithLocale(
|
||||
ui: React.ReactElement,
|
||||
resolvedLocale: 'en_us' | 'zh_cn' = 'en_us',
|
||||
) {
|
||||
return render(
|
||||
<I18nContext.Provider
|
||||
value={{
|
||||
languageSetting: resolvedLocale,
|
||||
resolvedLocale,
|
||||
setLanguageSetting: async () => undefined,
|
||||
t: (key, params) => translate(key, params, resolvedLocale),
|
||||
}}
|
||||
>
|
||||
{ui}
|
||||
</I18nContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('PianoKeys', () => {
|
||||
const activeRegion = createMockMidiRegion({
|
||||
trackId: '1',
|
||||
@@ -66,7 +86,7 @@ describe('PianoKeys', () => {
|
||||
});
|
||||
|
||||
it('shows dot and background feedback for mouse preview while held', () => {
|
||||
const { container } = render(<PianoKeys activeRegion={activeRegion} />);
|
||||
const { container } = renderWithLocale(<PianoKeys activeRegion={activeRegion} />);
|
||||
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
|
||||
|
||||
fireEvent.mouseDown(key);
|
||||
@@ -81,7 +101,7 @@ describe('PianoKeys', () => {
|
||||
});
|
||||
|
||||
it('shows MIDI activity dot without background feedback', () => {
|
||||
const { container } = render(<PianoKeys activeRegion={activeRegion} />);
|
||||
const { container } = renderWithLocale(<PianoKeys activeRegion={activeRegion} />);
|
||||
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
|
||||
expect(midiInputMock.addLiveNoteActivityListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -103,7 +123,7 @@ describe('PianoKeys', () => {
|
||||
storeState.isPlaying = true;
|
||||
storeState.playheadPosition = 1;
|
||||
|
||||
const { container } = render(<PianoKeys activeRegion={activeRegion} />);
|
||||
const { container } = renderWithLocale(<PianoKeys activeRegion={activeRegion} />);
|
||||
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
|
||||
|
||||
expect(key.className).toContain('playback-active');
|
||||
@@ -115,7 +135,7 @@ describe('PianoKeys', () => {
|
||||
storeState.isPlaying = true;
|
||||
storeState.playheadPosition = 1;
|
||||
|
||||
const { container, rerender } = render(<PianoKeys activeRegion={activeRegion} />);
|
||||
const { container, rerender } = renderWithLocale(<PianoKeys activeRegion={activeRegion} />);
|
||||
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
|
||||
|
||||
fireEvent.mouseDown(key);
|
||||
@@ -127,7 +147,18 @@ describe('PianoKeys', () => {
|
||||
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
|
||||
|
||||
storeState.isPlaying = false;
|
||||
rerender(<PianoKeys activeRegion={activeRegion} />);
|
||||
rerender(
|
||||
<I18nContext.Provider
|
||||
value={{
|
||||
languageSetting: 'en_us',
|
||||
resolvedLocale: 'en_us',
|
||||
setLanguageSetting: async () => undefined,
|
||||
t: (key, params) => translate(key, params, 'en_us'),
|
||||
}}
|
||||
>
|
||||
<PianoKeys activeRegion={activeRegion} />
|
||||
</I18nContext.Provider>,
|
||||
);
|
||||
|
||||
expect(key.className).toContain('visual-active');
|
||||
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
|
||||
@@ -143,4 +174,55 @@ describe('PianoKeys', () => {
|
||||
|
||||
expect(screen.queryByTestId('piano-key-dot-C4')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders translated drum key labels under zh-CN for GM drum-kit tracks', () => {
|
||||
storeState.tracks = [createMockMidiTrack({ id: 1, instrument: 'standard' })];
|
||||
|
||||
const drumRegion = createMockMidiRegion({
|
||||
trackId: '1',
|
||||
notes: [createMockMidiNote({ id: 'kick', pitch: 35, startBeat: 0, endBeat: 1 })],
|
||||
});
|
||||
|
||||
const { container } = renderWithLocale(<PianoKeys activeRegion={drumRegion} />, 'zh_cn');
|
||||
const key = container.querySelector('[data-note="B1"]');
|
||||
|
||||
expect(key?.querySelector('.key-label')?.textContent).toBe('原底鼓');
|
||||
});
|
||||
|
||||
it('updates visible drum labels when locale changes', () => {
|
||||
storeState.tracks = [createMockMidiTrack({ id: 1, instrument: 'standard' })];
|
||||
|
||||
const drumRegion = createMockMidiRegion({
|
||||
trackId: '1',
|
||||
notes: [createMockMidiNote({ id: 'hihat', pitch: 42, startBeat: 0, endBeat: 1 })],
|
||||
});
|
||||
|
||||
const view = renderWithLocale(<PianoKeys activeRegion={drumRegion} />, 'en_us');
|
||||
expect(screen.getByText('ClosedHH')).toBeInTheDocument();
|
||||
|
||||
view.rerender(
|
||||
<I18nContext.Provider
|
||||
value={{
|
||||
languageSetting: 'zh_cn',
|
||||
resolvedLocale: 'zh_cn',
|
||||
setLanguageSetting: async () => undefined,
|
||||
t: (key, params) => translate(key, params, 'zh_cn'),
|
||||
}}
|
||||
>
|
||||
<PianoKeys activeRegion={drumRegion} />
|
||||
</I18nContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('闭镲')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps tuned percussion tracks on standard note labels', () => {
|
||||
storeState.tracks = [createMockMidiTrack({ id: 1, instrument: 'taiko_drum' })];
|
||||
|
||||
const { container } = renderWithLocale(<PianoKeys activeRegion={activeRegion} />, 'zh_cn');
|
||||
const key = container.querySelector('[data-note="C4"]');
|
||||
|
||||
expect(key?.querySelector('.key-label')?.textContent).toBe('C4');
|
||||
expect(screen.queryByText('原底鼓')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import { noteNameToPitch, midiPercussionKeyMap } from '../../util/midiUtil';
|
||||
import { noteNameToPitch } from '../../util/midiUtil';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGMidiInput, type LiveMidiNoteActivityEvent } from '../../core/midi-input/KGMidiInput';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
import { getPercussionKeyShortLabel, isGmDrumKitInstrument } from '../../i18n/percussion';
|
||||
|
||||
interface PianoKeysProps {
|
||||
activeRegion: KGMidiRegion | null;
|
||||
@@ -30,6 +32,7 @@ function decrementPitchCount(source: Map<number, number>, pitch: number): Map<nu
|
||||
}
|
||||
|
||||
const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
||||
const { t } = useI18n();
|
||||
const [mouseActivePitches, setMouseActivePitches] = useState<Map<number, number>>(new Map());
|
||||
const [midiActivePitches, setMidiActivePitches] = useState<Map<number, number>>(new Map());
|
||||
const mouseActivePitchesRef = useRef<Map<number, number>>(new Map());
|
||||
@@ -41,7 +44,7 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
||||
const isDrumTrack = useMemo(() => {
|
||||
if (!activeRegion) return false;
|
||||
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
|
||||
return track instanceof KGMidiTrack && track.getInstrument() === 'standard';
|
||||
return track instanceof KGMidiTrack && isGmDrumKitInstrument(track.getInstrument());
|
||||
}, [activeRegion, tracks]);
|
||||
|
||||
const playbackActivePitches = useMemo(() => {
|
||||
@@ -198,9 +201,9 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
||||
// For drum tracks, show drum labels when available
|
||||
let labelContent = null;
|
||||
if (isDrumTrack) {
|
||||
const drumInfo = midiPercussionKeyMap[pitch];
|
||||
if (drumInfo) {
|
||||
labelContent = <span className="key-label">{drumInfo.shortName}</span>;
|
||||
const drumLabel = getPercussionKeyShortLabel(pitch, t);
|
||||
if (drumLabel) {
|
||||
labelContent = <span className="key-label">{drumLabel}</span>;
|
||||
}
|
||||
} else if (isC) {
|
||||
labelContent = <span className="key-label">C{octave}</span>;
|
||||
|
||||
@@ -296,6 +296,16 @@
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.piano-roll-toolbar .mode-dropdown {
|
||||
min-width: 112px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.piano-roll-toolbar .toolbar-left .quant-dropdown {
|
||||
white-space: nowrap;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.piano-roll-toolbar .chord-guide-toggle-button {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
@@ -715,10 +725,17 @@
|
||||
}
|
||||
|
||||
.key-label {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding-left: 5px;
|
||||
padding-right: 18px;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,13 @@ import PianoRollContent from './PianoRollContent';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||
import { KGMidiTrack, type InstrumentType } from '../../core/track/KGMidiTrack';
|
||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||
import {
|
||||
KGPianoRollState,
|
||||
PIANO_ROLL_NO_SNAP,
|
||||
type PianoRollQuantizeLengthValue,
|
||||
type PianoRollQuantizePositionValue,
|
||||
type PianoRollSnapValue,
|
||||
} from '../../core/state/KGPianoRollState';
|
||||
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
import { beatsToBar } from '../../util/midiUtil';
|
||||
import { ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../core/commands';
|
||||
@@ -121,11 +127,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Quantization state
|
||||
const [quantPosition, setQuantPosition] = useState<string>('1/8');
|
||||
const [quantLength, setQuantLength] = useState<string>('1/8');
|
||||
const [quantPosition, setQuantPosition] = useState<PianoRollQuantizePositionValue>('1/8');
|
||||
const [quantLength, setQuantLength] = useState<PianoRollQuantizeLengthValue>('1/8');
|
||||
|
||||
// Snapping state
|
||||
const [snapping, setSnapping] = useState<string>('NO SNAP');
|
||||
const [snapping, setSnapping] = useState<PianoRollSnapValue>(PIANO_ROLL_NO_SNAP);
|
||||
|
||||
// Chord guide state
|
||||
const [chordGuide, setChordGuide] = useState<ChordGuideFunction>('N');
|
||||
@@ -713,8 +719,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
|
||||
// Handle snapping selection
|
||||
const handleSnappingSelect = useCallback((value: string) => {
|
||||
setSnapping(value);
|
||||
KGPianoRollState.instance().setCurrentSnap(value);
|
||||
const nextValue = value as PianoRollSnapValue;
|
||||
setSnapping(nextValue);
|
||||
KGPianoRollState.instance().setCurrentSnap(nextValue);
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`Selected snapping: ${value}`);
|
||||
}
|
||||
@@ -974,19 +981,21 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
// Handle quantization selection
|
||||
const handleQuantSelect = useCallback((type: 'position' | 'length', value: string) => {
|
||||
if (type === 'position') {
|
||||
setQuantPosition(value);
|
||||
const nextValue = value as PianoRollQuantizePositionValue;
|
||||
setQuantPosition(nextValue);
|
||||
|
||||
// Apply quantization immediately when position quantization is changed
|
||||
quantizeSelectedNotes(value);
|
||||
quantizeSelectedNotes(nextValue);
|
||||
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`quant-position selected: ${value}`);
|
||||
}
|
||||
} else {
|
||||
setQuantLength(value);
|
||||
const nextValue = value as PianoRollQuantizeLengthValue;
|
||||
setQuantLength(nextValue);
|
||||
|
||||
// Apply length quantization immediately when length quantization is changed
|
||||
quantizeNoteLength(value);
|
||||
quantizeNoteLength(nextValue);
|
||||
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`quant-length selected: ${value}`);
|
||||
@@ -1407,7 +1416,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
// Check snapping hotkeys
|
||||
if (event.key === snap_none_key) {
|
||||
actionType = 'snap';
|
||||
actionValue = 'NO SNAP';
|
||||
actionValue = PIANO_ROLL_NO_SNAP;
|
||||
} else if (event.key === snap_1_4_key) {
|
||||
actionType = 'snap';
|
||||
actionValue = '1/4';
|
||||
@@ -1453,7 +1462,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
|
||||
if (actionType === 'snap') {
|
||||
// Validate the snap value exists in snap options
|
||||
if (KGPianoRollState.SNAP_OPTIONS.includes(actionValue)) {
|
||||
if (KGPianoRollState.SNAP_OPTIONS.some(option => option.value === actionValue)) {
|
||||
// Change snapping value
|
||||
handleSnappingSelect(actionValue);
|
||||
|
||||
@@ -1467,9 +1476,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
}
|
||||
} else if (actionType === 'quantize' && quantType) {
|
||||
// Validate the quantValue exists in the appropriate options
|
||||
const validOptions = quantType === 'length' ? KGPianoRollState.QUANT_LEN_OPTIONS : KGPianoRollState.QUANT_POS_OPTIONS;
|
||||
const validOptions = quantType === 'length'
|
||||
? KGPianoRollState.QUANT_LEN_OPTIONS
|
||||
: KGPianoRollState.QUANT_POS_OPTIONS;
|
||||
|
||||
if (validOptions.includes(actionValue)) {
|
||||
if (validOptions.some(option => option.value === actionValue)) {
|
||||
// Apply quantization
|
||||
handleQuantSelect(quantType, actionValue);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||
import { KGPianoRollState, PIANO_ROLL_NO_SNAP } from '../../core/state/KGPianoRollState';
|
||||
import { createMockMidiControllerEvent, createMockMidiPitchBend, createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data';
|
||||
import { I18nContext } from '../../i18n/I18nProvider';
|
||||
import { translate } from '../../i18n/translate';
|
||||
|
||||
const coreMock = {
|
||||
selectedItems: [] as Array<{ getId(): string }>,
|
||||
@@ -45,6 +47,19 @@ import PianoRollAutomationLane from './PianoRollAutomationLane';
|
||||
import { getControllerNumberForAutomationType } from './pianoRollAutomation';
|
||||
|
||||
describe('PianoRollAutomationLane', () => {
|
||||
const renderWithLocale = (ui: React.ReactElement, locale: 'en_us' | 'zh_cn' = 'en_us') => render(
|
||||
<I18nContext.Provider
|
||||
value={{
|
||||
languageSetting: locale,
|
||||
resolvedLocale: locale,
|
||||
setLanguageSetting: async () => undefined,
|
||||
t: (key, params) => translate(key, params, locale),
|
||||
}}
|
||||
>
|
||||
{ui}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
coreMock.selectedItems = [];
|
||||
coreMock.currentProjectTracks = [];
|
||||
@@ -76,7 +91,7 @@ describe('PianoRollAutomationLane', () => {
|
||||
coreMock.currentProjectTracks = storeState.tracks;
|
||||
storeState.selectedPitchBendIds = ['bend-1'];
|
||||
|
||||
const { container } = render(
|
||||
const { container } = renderWithLocale(
|
||||
<PianoRollAutomationLane
|
||||
activeRegion={region}
|
||||
automationType="pitch-bend"
|
||||
@@ -106,7 +121,7 @@ describe('PianoRollAutomationLane', () => {
|
||||
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
|
||||
coreMock.currentProjectTracks = storeState.tracks;
|
||||
|
||||
const { container } = render(
|
||||
const { container } = renderWithLocale(
|
||||
<PianoRollAutomationLane
|
||||
activeRegion={region}
|
||||
automationType="pitch-bend"
|
||||
@@ -144,9 +159,9 @@ describe('PianoRollAutomationLane', () => {
|
||||
});
|
||||
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
|
||||
coreMock.currentProjectTracks = storeState.tracks;
|
||||
KGPianoRollState.instance().setCurrentSnap('NO SNAP');
|
||||
KGPianoRollState.instance().setCurrentSnap(PIANO_ROLL_NO_SNAP);
|
||||
|
||||
const { container } = render(
|
||||
const { container } = renderWithLocale(
|
||||
<PianoRollAutomationLane
|
||||
activeRegion={region}
|
||||
automationType="pitch-bend"
|
||||
@@ -188,7 +203,7 @@ describe('PianoRollAutomationLane', () => {
|
||||
coreMock.currentProjectTracks = storeState.tracks;
|
||||
storeState.selectedControllerEventIds = ['cc7-1'];
|
||||
|
||||
const { container } = render(
|
||||
const { container } = renderWithLocale(
|
||||
<PianoRollAutomationLane
|
||||
activeRegion={region}
|
||||
automationType="cc-7"
|
||||
@@ -224,7 +239,7 @@ describe('PianoRollAutomationLane', () => {
|
||||
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
|
||||
coreMock.currentProjectTracks = storeState.tracks;
|
||||
|
||||
const { container } = render(
|
||||
const { container } = renderWithLocale(
|
||||
<PianoRollAutomationLane
|
||||
activeRegion={region}
|
||||
automationType="cc-64"
|
||||
@@ -253,7 +268,7 @@ describe('PianoRollAutomationLane', () => {
|
||||
coreMock.currentProjectTracks = storeState.tracks;
|
||||
storeState.selectedPitchBendIds = ['bend-2'];
|
||||
|
||||
const { container } = render(
|
||||
const { container } = renderWithLocale(
|
||||
<PianoRollAutomationLane
|
||||
activeRegion={region}
|
||||
automationType="pitch-bend"
|
||||
@@ -286,7 +301,7 @@ describe('PianoRollAutomationLane', () => {
|
||||
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
|
||||
coreMock.currentProjectTracks = storeState.tracks;
|
||||
|
||||
const { container } = render(
|
||||
const { container } = renderWithLocale(
|
||||
<PianoRollAutomationLane
|
||||
activeRegion={region}
|
||||
automationType="pitch-bend"
|
||||
@@ -308,4 +323,27 @@ describe('PianoRollAutomationLane', () => {
|
||||
expect(getScrollLayer()?.classList.contains('pencil-cursor')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders translated automation lane labels in zh-CN', () => {
|
||||
const region = createMockMidiRegion({
|
||||
trackId: '1',
|
||||
trackIndex: 0,
|
||||
startFromBeat: 4,
|
||||
pitchBends: [createMockMidiPitchBend({ id: 'bend-1', beat: 0.5, value: 8192 })],
|
||||
});
|
||||
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
|
||||
coreMock.currentProjectTracks = storeState.tracks;
|
||||
|
||||
renderWithLocale(
|
||||
<PianoRollAutomationLane
|
||||
activeRegion={region}
|
||||
automationType="pitch-bend"
|
||||
maxBars={8}
|
||||
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||
/>,
|
||||
'zh_cn',
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('弯音 自动化轨')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { UpdateControllerEventPropertiesCommand } from '../../core/commands/note
|
||||
import { UpdatePitchBendPropertiesCommand } from '../../core/commands/note/UpdatePitchBendPropertiesCommand';
|
||||
import { KGMidiControllerEvent } from '../../core/midi/KGMidiControllerEvent';
|
||||
import { KGMidiPitchBend } from '../../core/midi/KGMidiPitchBend';
|
||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||
import { KGPianoRollState, PIANO_ROLL_NO_SNAP } from '../../core/state/KGPianoRollState';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import {
|
||||
MIDI_PITCH_BEND_MAX,
|
||||
@@ -19,11 +19,12 @@ import { useProjectStore } from '../../stores/projectStore';
|
||||
import { PIANO_ROLL_CONSTANTS } from '../../constants';
|
||||
import { getSnappedBeatPosition } from './pianoRollSnap';
|
||||
import {
|
||||
getAutomationLabel,
|
||||
getAutomationInterpolationMode,
|
||||
getControllerNumberForAutomationType,
|
||||
PIANO_ROLL_AUTOMATION_OPTIONS,
|
||||
type PianoRollAutomationType,
|
||||
} from './pianoRollAutomation';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
interface AutomationPoint {
|
||||
id: string;
|
||||
@@ -73,6 +74,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
||||
horizontalScrollLeft = 0,
|
||||
onHorizontalWheel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const laneRef = useRef<HTMLDivElement | null>(null);
|
||||
const isLassoSelectingRef = useRef(false);
|
||||
const lassoShiftKeyRef = useRef(false);
|
||||
@@ -237,8 +239,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
||||
? tracks.find(track => track.getId().toString() === activeRegion.getTrackId()) ?? null
|
||||
: null;
|
||||
|
||||
const selectedOption = PIANO_ROLL_AUTOMATION_OPTIONS.find(option => option.value === automationType);
|
||||
const laneLabel = selectedOption?.label ?? automationType;
|
||||
const laneLabel = getAutomationLabel(automationType, t);
|
||||
const interpolationMode = getAutomationInterpolationMode(automationType);
|
||||
const totalBeats = maxBars * timeSignature.numerator;
|
||||
const totalWidth = 'calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width))';
|
||||
@@ -412,7 +413,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
||||
}
|
||||
|
||||
const rawAbsoluteBeat = (coordinates.x - keyWidth) / beatWidth;
|
||||
const snappedAbsoluteBeat = KGPianoRollState.instance().getCurrentSnap() === 'NO SNAP'
|
||||
const snappedAbsoluteBeat = KGPianoRollState.instance().getCurrentSnap() === PIANO_ROLL_NO_SNAP
|
||||
? rawAbsoluteBeat
|
||||
: getSnappedBeatPosition(rawAbsoluteBeat, KGPianoRollState.instance().getCurrentSnap());
|
||||
const beatDelta = snappedAbsoluteBeat - dragState.originAbsoluteBeat;
|
||||
@@ -673,7 +674,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
||||
|
||||
const rawAbsoluteBeat = (coordinates.x - keyWidth) / beatWidth;
|
||||
const currentSnap = KGPianoRollState.instance().getCurrentSnap();
|
||||
const absoluteBeat = currentSnap === 'NO SNAP'
|
||||
const absoluteBeat = currentSnap === PIANO_ROLL_NO_SNAP
|
||||
? rawAbsoluteBeat
|
||||
: getSnappedBeatPosition(rawAbsoluteBeat, currentSnap);
|
||||
const relativeBeat = absoluteBeat - activeRegion.getStartFromBeat();
|
||||
@@ -770,7 +771,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
||||
<div
|
||||
className="piano-roll-automation-lane"
|
||||
data-testid="piano-roll-automation-lane"
|
||||
aria-label={`${laneLabel} automation lane`}
|
||||
aria-label={t('pianoRoll.automationLane', { label: laneLabel })}
|
||||
ref={laneRef}
|
||||
>
|
||||
<div className="piano-roll-automation-track" style={{ width: totalWidth }}>
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import PianoRollToolbar from './PianoRollToolbar';
|
||||
import { I18nContext } from '../../i18n/I18nProvider';
|
||||
import { translate } from '../../i18n/translate';
|
||||
|
||||
vi.mock('../common', () => ({
|
||||
KGDropdown: ({
|
||||
@@ -34,11 +36,25 @@ vi.mock('../../core/KGCore', () => ({
|
||||
FUNCTIONAL_CHORDS_DATA: {
|
||||
ionian: { name: 'Ionian' },
|
||||
dorian: { name: 'Dorian' },
|
||||
harmonic_minor: { name: 'Harmonic Minor' },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('PianoRollToolbar', () => {
|
||||
const renderWithLocale = (ui: React.ReactElement, locale: 'en_us' | 'zh_cn' = 'en_us') => render(
|
||||
<I18nContext.Provider
|
||||
value={{
|
||||
languageSetting: locale,
|
||||
resolvedLocale: locale,
|
||||
setLanguageSetting: async () => undefined,
|
||||
t: (key, params) => translate(key, params, locale),
|
||||
}}
|
||||
>
|
||||
{ui}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
|
||||
const baseProps = {
|
||||
sheetMusicViewEnabled: false,
|
||||
onSheetMusicViewToggle: vi.fn(),
|
||||
@@ -52,7 +68,7 @@ describe('PianoRollToolbar', () => {
|
||||
quantPosition: '1/8',
|
||||
quantLength: '1/8',
|
||||
onQuantSelect: vi.fn(),
|
||||
snapping: 'NO SNAP',
|
||||
snapping: 'none',
|
||||
onSnappingSelect: vi.fn(),
|
||||
selectedMode: 'ionian',
|
||||
onModeChange: vi.fn(),
|
||||
@@ -66,7 +82,7 @@ describe('PianoRollToolbar', () => {
|
||||
it('shows automation controls in midi mode and toggles the lane', () => {
|
||||
const onAutomationToggle = vi.fn();
|
||||
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="midi-edit"
|
||||
@@ -89,7 +105,7 @@ describe('PianoRollToolbar', () => {
|
||||
it('changes the automation type from the dropdown', () => {
|
||||
const onAutomationTypeChange = vi.fn();
|
||||
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="hybrid"
|
||||
@@ -107,7 +123,7 @@ describe('PianoRollToolbar', () => {
|
||||
});
|
||||
|
||||
it('renders chord guide toggle buttons and marks off as active by default', () => {
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="midi-edit"
|
||||
@@ -126,7 +142,7 @@ describe('PianoRollToolbar', () => {
|
||||
it('emits the selected chord guide value when toggle buttons are clicked', () => {
|
||||
const onChordGuideChange = vi.fn();
|
||||
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="midi-edit"
|
||||
@@ -147,7 +163,7 @@ describe('PianoRollToolbar', () => {
|
||||
});
|
||||
|
||||
it('hides automation controls in spectrogram mode', () => {
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="spectrogram"
|
||||
@@ -162,7 +178,7 @@ describe('PianoRollToolbar', () => {
|
||||
it('shows the spectrogram toggle for pure audio waveform mode and toggles it', () => {
|
||||
const onAudioSpectrogramToggle = vi.fn();
|
||||
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="audio-waveform"
|
||||
@@ -179,7 +195,7 @@ describe('PianoRollToolbar', () => {
|
||||
});
|
||||
|
||||
it('hides the spectrogram toggle in hybrid mode', () => {
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="hybrid"
|
||||
@@ -193,7 +209,7 @@ describe('PianoRollToolbar', () => {
|
||||
it('shows the detect chords action in spectrogram mode and triggers it', () => {
|
||||
const onDetectChords = vi.fn();
|
||||
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="spectrogram"
|
||||
@@ -211,7 +227,7 @@ describe('PianoRollToolbar', () => {
|
||||
it('shows the detect tempo action when the audio callback is provided and triggers it', () => {
|
||||
const onDetectTempo = vi.fn();
|
||||
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="spectrogram"
|
||||
@@ -229,7 +245,7 @@ describe('PianoRollToolbar', () => {
|
||||
it('shows the detect chords action in midi mode and triggers it', () => {
|
||||
const onDetectChords = vi.fn();
|
||||
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="midi-edit"
|
||||
@@ -247,7 +263,7 @@ describe('PianoRollToolbar', () => {
|
||||
it('disables the detect chords action while detection is running', () => {
|
||||
const onDetectChords = vi.fn();
|
||||
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="spectrogram"
|
||||
@@ -267,7 +283,7 @@ describe('PianoRollToolbar', () => {
|
||||
it('disables the detect tempo action while detection is running', () => {
|
||||
const onDetectTempo = vi.fn();
|
||||
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="spectrogram"
|
||||
@@ -286,7 +302,7 @@ describe('PianoRollToolbar', () => {
|
||||
});
|
||||
|
||||
it('does not show the detect tempo action without the audio callback', () => {
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="midi-edit"
|
||||
@@ -299,7 +315,7 @@ describe('PianoRollToolbar', () => {
|
||||
});
|
||||
|
||||
it('shows only the sheet controls when sheet mode is enabled', () => {
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
sheetMusicViewEnabled={true}
|
||||
@@ -316,7 +332,7 @@ describe('PianoRollToolbar', () => {
|
||||
});
|
||||
|
||||
it('shows the zoom button outside sheet mode', () => {
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
sheetMusicViewEnabled={false}
|
||||
@@ -324,13 +340,13 @@ describe('PianoRollToolbar', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '1x' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Zoom' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles the full-track sheet scope button', () => {
|
||||
const onSheetMusicTrackScopeToggle = vi.fn();
|
||||
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
sheetMusicViewEnabled={true}
|
||||
@@ -344,24 +360,74 @@ describe('PianoRollToolbar', () => {
|
||||
});
|
||||
|
||||
it('hides the full-track sheet scope button outside sheet mode and spectrogram mode', () => {
|
||||
const localeValue = {
|
||||
languageSetting: 'en_us' as const,
|
||||
resolvedLocale: 'en_us' as const,
|
||||
setLanguageSetting: async () => undefined,
|
||||
t: (key: string, params?: Record<string, string | number>) => translate(key, params, 'en_us'),
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
sheetMusicViewEnabled={false}
|
||||
mode="midi-edit"
|
||||
/>
|
||||
<I18nContext.Provider value={localeValue}>
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
sheetMusicViewEnabled={false}
|
||||
mode="midi-edit"
|
||||
/>
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Show Entire Track' })).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
sheetMusicViewEnabled={true}
|
||||
mode="spectrogram"
|
||||
/>
|
||||
<I18nContext.Provider value={localeValue}>
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
sheetMusicViewEnabled={true}
|
||||
mode="spectrogram"
|
||||
/>
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Show Entire Track' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders translated compact/full labels in zh-CN while emitting stable values', () => {
|
||||
const onAutomationTypeChange = vi.fn();
|
||||
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="midi-edit"
|
||||
showAutomationControls={true}
|
||||
automationEnabled={true}
|
||||
automationType="pitch-bend"
|
||||
onAutomationToggle={vi.fn()}
|
||||
onAutomationTypeChange={onAutomationTypeChange}
|
||||
/>,
|
||||
'zh_cn',
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '弯音' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '伊奥尼亚' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '无吸附' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '位置量化' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '长度量化' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '弯音' }));
|
||||
expect(onAutomationTypeChange).toHaveBeenCalledWith('cc-11');
|
||||
});
|
||||
|
||||
it('translates extended mode labels like harmonic minor', () => {
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="midi-edit"
|
||||
selectedMode="harmonic_minor"
|
||||
/>,
|
||||
'zh_cn',
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '和声小调' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,23 +5,10 @@ import { KGDropdown } from '../common';
|
||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import {
|
||||
PIANO_ROLL_AUTOMATION_OPTIONS,
|
||||
getTranslatedAutomationOptions,
|
||||
type PianoRollAutomationType,
|
||||
} from './pianoRollAutomation';
|
||||
|
||||
const POWER_OPTIONS = [
|
||||
{ label: 'Linear', value: '1.0' },
|
||||
{ label: '√ (default)', value: '0.5' },
|
||||
{ label: 'Mild', value: '0.4' },
|
||||
{ label: 'Strong', value: '0.3' },
|
||||
];
|
||||
|
||||
const CHORD_GUIDE_BUTTONS: Array<{ label: string; value: 'N' | 'T' | 'S' | 'D'; ariaLabel: string }> = [
|
||||
{ label: '⊘', value: 'N', ariaLabel: 'Chord guide off' },
|
||||
{ label: 'T', value: 'T', ariaLabel: 'Chord guide tonic' },
|
||||
{ label: 'S', value: 'S', ariaLabel: 'Chord guide subdominant' },
|
||||
{ label: 'D', value: 'D', ariaLabel: 'Chord guide dominant' },
|
||||
];
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
interface PianoRollToolbarProps {
|
||||
showAudioSpectrogramToggle?: boolean;
|
||||
@@ -106,11 +93,48 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
onDetectTempo,
|
||||
detectingTempo = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const showMidiControls = mode !== 'spectrogram' && mode !== 'audio-waveform' && !sheetMusicViewEnabled;
|
||||
const showAudioOnlyControls = mode === 'audio-waveform' && !sheetMusicViewEnabled;
|
||||
const showSpectrogramOnlyControls = mode === 'spectrogram' && !sheetMusicViewEnabled;
|
||||
const showSpecControls = !sheetMusicViewEnabled && (mode === 'spectrogram' || mode === 'hybrid');
|
||||
const showSpecMenu = !sheetMusicViewEnabled && (!!onDetectChords || !!onDetectTempo);
|
||||
const automationOptions = React.useMemo(() => getTranslatedAutomationOptions(t), [t]);
|
||||
const snapOptions = React.useMemo(
|
||||
() => KGPianoRollState.SNAP_OPTIONS.map(option => ({ label: t(option.labelKey), value: option.value })),
|
||||
[t],
|
||||
);
|
||||
const quantPositionOptions = React.useMemo(
|
||||
() => KGPianoRollState.QUANT_POS_OPTIONS.map(option => ({ label: t(option.labelKey), value: option.value })),
|
||||
[t],
|
||||
);
|
||||
const quantLengthOptions = React.useMemo(
|
||||
() => KGPianoRollState.QUANT_LEN_OPTIONS.map(option => ({ label: t(option.labelKey), value: option.value })),
|
||||
[t],
|
||||
);
|
||||
const POWER_OPTIONS = [
|
||||
{ label: t('pianoRoll.power.linear'), value: '1.0' },
|
||||
{ label: t('pianoRoll.power.sqrtDefault'), value: '0.5' },
|
||||
{ label: t('pianoRoll.power.mild'), value: '0.4' },
|
||||
{ label: t('pianoRoll.power.strong'), value: '0.3' },
|
||||
];
|
||||
const modeOptions = React.useMemo(
|
||||
() => Object.entries(KGCore.FUNCTIONAL_CHORDS_DATA).map(([id, data]) => {
|
||||
const translationKey = `pianoRoll.modeOption.${id}`;
|
||||
const translatedLabel = t(translationKey);
|
||||
return {
|
||||
label: translatedLabel === translationKey ? data.name : translatedLabel,
|
||||
value: id,
|
||||
};
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const CHORD_GUIDE_BUTTONS: Array<{ label: string; value: 'N' | 'T' | 'S' | 'D'; ariaLabel: string }> = [
|
||||
{ label: '⊘', value: 'N', ariaLabel: t('pianoRoll.chordGuide.off') },
|
||||
{ label: 'T', value: 'T', ariaLabel: t('pianoRoll.chordGuide.tonic') },
|
||||
{ label: 'S', value: 'S', ariaLabel: t('pianoRoll.chordGuide.subdominant') },
|
||||
{ label: 'D', value: 'D', ariaLabel: t('pianoRoll.chordGuide.dominant') },
|
||||
];
|
||||
|
||||
const [showZoomSlider, setShowZoomSlider] = React.useState(false);
|
||||
const zoomSliderRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -144,8 +168,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
<button
|
||||
className={`tool-button icon-only sheet-mode-toggle ${audioSpectrogramEnabled ? 'active' : ''}`}
|
||||
onClick={() => onAudioSpectrogramToggle?.()}
|
||||
title="Spectrogram View"
|
||||
aria-label="Spectrogram View"
|
||||
title={t('pianoRoll.spectrogramView')}
|
||||
aria-label={t('pianoRoll.spectrogramView')}
|
||||
>
|
||||
<svg className="spectrogram-view-icon" width="12" height="12" viewBox="0 0 10 10" fill="currentColor">
|
||||
<rect x="3" y="0.5" width="6.5" height="2.5" rx="0.4" />
|
||||
@@ -159,8 +183,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
<button
|
||||
className={`tool-button sheet-mode-toggle ${sheetMusicViewEnabled ? 'active' : ''}`}
|
||||
onClick={() => onSheetMusicViewToggle?.()}
|
||||
title="Sheet Music View"
|
||||
aria-label="Sheet Music View"
|
||||
title={t('pianoRoll.sheetMusicView')}
|
||||
aria-label={t('pianoRoll.sheetMusicView')}
|
||||
disabled={sheetMusicToggleDisabled}
|
||||
>
|
||||
♬
|
||||
@@ -176,14 +200,14 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
<button
|
||||
className={`tool-button ${activeTool === 'pointer' ? 'active' : ''}`}
|
||||
onClick={() => onToolSelect('pointer')}
|
||||
title="Pointer Tool"
|
||||
title={t('pianoRoll.pointerTool')}
|
||||
>
|
||||
<FaMousePointer className="piano-roll-tool-icon" />
|
||||
</button>
|
||||
<button
|
||||
className={`tool-button ${activeTool === 'pencil' ? 'active' : ''}`}
|
||||
onClick={() => onToolSelect('pencil')}
|
||||
title="Pencil Tool"
|
||||
title={t('pianoRoll.pencilTool')}
|
||||
>
|
||||
<FaPencilAlt className="piano-roll-tool-icon" />
|
||||
</button>
|
||||
@@ -192,30 +216,30 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
<button
|
||||
className={`tool-button automation-toggle-button ${automationEnabled ? 'active' : ''}`}
|
||||
onClick={() => onAutomationToggle?.()}
|
||||
title="Toggle automation lane"
|
||||
aria-label="Toggle automation lane"
|
||||
title={t('pianoRoll.toggleAutomationLane')}
|
||||
aria-label={t('pianoRoll.toggleAutomationLane')}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
<KGDropdown
|
||||
options={PIANO_ROLL_AUTOMATION_OPTIONS}
|
||||
options={automationOptions}
|
||||
value={automationType}
|
||||
onChange={(value) => onAutomationTypeChange?.(value as PianoRollAutomationType)}
|
||||
label="Automation"
|
||||
label={t('pianoRoll.automation')}
|
||||
buttonClassName="automation-type-dropdown"
|
||||
showValueAsLabel={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<KGDropdown
|
||||
options={Object.entries(KGCore.FUNCTIONAL_CHORDS_DATA).map(([id, data]) => ({ label: data.name, value: id }))}
|
||||
options={modeOptions}
|
||||
value={selectedMode}
|
||||
onChange={(value) => onModeChange(value)}
|
||||
label="Mode"
|
||||
label={t('pianoRoll.mode')}
|
||||
buttonClassName="mode-dropdown"
|
||||
showValueAsLabel={true}
|
||||
/>
|
||||
<div className="piano-roll-chord-guide-toolbar-group" role="group" aria-label="Chord guide">
|
||||
<div className="piano-roll-chord-guide-toolbar-group" role="group" aria-label={t('pianoRoll.chordGuide')}>
|
||||
{CHORD_GUIDE_BUTTONS.map((button, index) => (
|
||||
<button
|
||||
key={button.value}
|
||||
@@ -253,8 +277,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
<button
|
||||
className={`tool-button icon-only sheet-track-scope-toggle ${sheetMusicTrackScopeEnabled ? 'active' : ''}`}
|
||||
onClick={() => onSheetMusicTrackScopeToggle?.()}
|
||||
title={sheetMusicTrackScopeEnabled ? 'Show Active Region Only' : 'Show Entire Track'}
|
||||
aria-label={sheetMusicTrackScopeEnabled ? 'Show Active Region Only' : 'Show Entire Track'}
|
||||
title={sheetMusicTrackScopeEnabled ? t('pianoRoll.showActiveRegionOnly') : t('pianoRoll.showEntireTrack')}
|
||||
aria-label={sheetMusicTrackScopeEnabled ? t('pianoRoll.showActiveRegionOnly') : t('pianoRoll.showEntireTrack')}
|
||||
>
|
||||
<TbArrowBarToUp className="sheet-track-scope-icon" strokeWidth={2.5} />
|
||||
</button>
|
||||
@@ -268,7 +292,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
options={sheetQuantizationOptions}
|
||||
value={sheetQuantization}
|
||||
onChange={(value) => onSheetQuantizationChange?.(value)}
|
||||
label="Sheet Quant."
|
||||
label={t('pianoRoll.sheetQuantization')}
|
||||
buttonClassName="sheet-quantization"
|
||||
showValueAsLabel={true}
|
||||
/>
|
||||
@@ -276,25 +300,25 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
{showMidiControls && (
|
||||
<>
|
||||
<KGDropdown
|
||||
options={KGPianoRollState.SNAP_OPTIONS}
|
||||
options={snapOptions}
|
||||
value={snapping}
|
||||
onChange={(value) => onSnappingSelect(value)}
|
||||
label="Snap"
|
||||
label={t('pianoRoll.snap')}
|
||||
buttonClassName="snapping"
|
||||
showValueAsLabel={true}
|
||||
/>
|
||||
<KGDropdown
|
||||
options={KGPianoRollState.QUANT_POS_OPTIONS}
|
||||
options={quantPositionOptions}
|
||||
value={quantPosition}
|
||||
onChange={(value) => onQuantSelect('position', value)}
|
||||
label="Qua. Pos."
|
||||
label={t('pianoRoll.quantizePositionCompact')}
|
||||
buttonClassName={`quant-position ${blinkButton === 'quant-position' ? 'button-blink' : ''}`}
|
||||
/>
|
||||
<KGDropdown
|
||||
options={KGPianoRollState.QUANT_LEN_OPTIONS}
|
||||
options={quantLengthOptions}
|
||||
value={quantLength}
|
||||
onChange={(value) => onQuantSelect('length', value)}
|
||||
label="Qua. Len."
|
||||
label={t('pianoRoll.quantizeLengthCompact')}
|
||||
buttonClassName={`quant-length ${blinkButton === 'quant-length' ? 'button-blink' : ''}`}
|
||||
/>
|
||||
</>
|
||||
@@ -302,7 +326,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
|
||||
{showSpecControls && (
|
||||
<div className="spectrogram-toolbar-controls">
|
||||
<span className="spectrogram-control-label">Floor</span>
|
||||
<span className="spectrogram-control-label">{t('pianoRoll.floor')}</span>
|
||||
<input
|
||||
type="range"
|
||||
className="spectrogram-threshold-slider"
|
||||
@@ -318,7 +342,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
options={POWER_OPTIONS}
|
||||
value={power.toString()}
|
||||
onChange={v => onPowerChange?.(parseFloat(v))}
|
||||
label="Curve"
|
||||
label={t('pianoRoll.curve')}
|
||||
buttonClassName="curve-dropdown"
|
||||
showValueAsLabel={true}
|
||||
/>
|
||||
@@ -330,7 +354,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
<button
|
||||
className="quant-button"
|
||||
onClick={() => setShowZoomSlider(!showZoomSlider)}
|
||||
title="Zoom"
|
||||
title={t('pianoRoll.zoom')}
|
||||
aria-label={t('pianoRoll.zoom')}
|
||||
>
|
||||
{zoom}x
|
||||
</button>
|
||||
@@ -355,7 +380,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
<button
|
||||
className="quant-button"
|
||||
onClick={() => setShowMoreMenu(!showMoreMenu)}
|
||||
title="More options"
|
||||
title={t('pianoRoll.moreOptions')}
|
||||
aria-label={t('pianoRoll.moreOptions')}
|
||||
>
|
||||
...
|
||||
</button>
|
||||
@@ -373,7 +399,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
}}
|
||||
aria-disabled={detectingChords}
|
||||
>
|
||||
{detectingChords ? 'Detecting chords...' : 'Detect chords...'}
|
||||
{detectingChords ? t('pianoRoll.detectingChords') : t('pianoRoll.detectChords')}
|
||||
</div>
|
||||
)}
|
||||
{onDetectTempo && (
|
||||
@@ -388,7 +414,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
}}
|
||||
aria-disabled={detectingTempo}
|
||||
>
|
||||
{detectingTempo ? 'Detecting tempo...' : 'Detect tempo...'}
|
||||
{detectingTempo ? t('pianoRoll.detectingTempo') : t('pianoRoll.detectTempo')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock
|
||||
|
||||
const pianoRollState = {
|
||||
zoom: 1,
|
||||
getCurrentSnap: vi.fn(() => 'NO SNAP'),
|
||||
getCurrentSnap: vi.fn(() => 'none'),
|
||||
getActiveTool: vi.fn(() => 'pointer'),
|
||||
getAutomationViewEnabled: vi.fn(() => false),
|
||||
getCurrentAutomationType: vi.fn(() => 'pitch-bend'),
|
||||
@@ -26,6 +26,7 @@ const pianoRollState = {
|
||||
setCurrentSnap: vi.fn(),
|
||||
setCurrentSuitableChords: vi.fn(),
|
||||
setCurrentSuitableChordsPitchClasses: vi.fn(),
|
||||
setCurrentHoveredChordGuideCandidate: vi.fn(),
|
||||
};
|
||||
|
||||
const mockProject = {
|
||||
@@ -71,10 +72,11 @@ vi.mock('../../stores/projectStore', () => ({
|
||||
vi.mock('../../core/state/KGPianoRollState', () => ({
|
||||
KGPianoRollState: {
|
||||
instance: () => pianoRollState,
|
||||
SNAP_OPTIONS: ['NO SNAP'],
|
||||
QUANT_POS_OPTIONS: ['1/8'],
|
||||
QUANT_LEN_OPTIONS: ['1/8'],
|
||||
SNAP_OPTIONS: [{ value: 'none', labelKey: 'pianoRoll.snap.none' }],
|
||||
QUANT_POS_OPTIONS: [{ value: '1/8', labelKey: 'pianoRoll.quantize.1/8' }],
|
||||
QUANT_LEN_OPTIONS: [{ value: '1/8', labelKey: 'pianoRoll.quantize.1/8' }],
|
||||
},
|
||||
PIANO_ROLL_NO_SNAP: 'none',
|
||||
}));
|
||||
|
||||
vi.mock('../../core/KGCore', () => ({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TranslationParams } from '../../i18n/types';
|
||||
|
||||
export type PianoRollAutomationType =
|
||||
| 'pitch-bend'
|
||||
| 'cc-1'
|
||||
@@ -7,20 +9,37 @@ export type PianoRollAutomationType =
|
||||
| 'cc-64';
|
||||
|
||||
export interface PianoRollAutomationOption {
|
||||
label: string;
|
||||
value: PianoRollAutomationType;
|
||||
labelKey: string;
|
||||
interpolationMode: 'linear' | 'step';
|
||||
}
|
||||
|
||||
export const PIANO_ROLL_AUTOMATION_OPTIONS: PianoRollAutomationOption[] = [
|
||||
{ label: 'Pitch Bend', value: 'pitch-bend', interpolationMode: 'linear' },
|
||||
{ label: 'CC1', value: 'cc-1', interpolationMode: 'linear' },
|
||||
{ label: 'CC2', value: 'cc-2', interpolationMode: 'linear' },
|
||||
{ label: 'CC7', value: 'cc-7', interpolationMode: 'linear' },
|
||||
{ label: 'CC11', value: 'cc-11', interpolationMode: 'linear' },
|
||||
{ label: 'CC64', value: 'cc-64', interpolationMode: 'step' },
|
||||
{ value: 'pitch-bend', labelKey: 'pianoRoll.automationType.pitchBend', interpolationMode: 'linear' },
|
||||
{ value: 'cc-1', labelKey: 'pianoRoll.automationType.cc1', interpolationMode: 'linear' },
|
||||
{ value: 'cc-2', labelKey: 'pianoRoll.automationType.cc2', interpolationMode: 'linear' },
|
||||
{ value: 'cc-7', labelKey: 'pianoRoll.automationType.cc7', interpolationMode: 'linear' },
|
||||
{ value: 'cc-11', labelKey: 'pianoRoll.automationType.cc11', interpolationMode: 'linear' },
|
||||
{ value: 'cc-64', labelKey: 'pianoRoll.automationType.cc64', interpolationMode: 'step' },
|
||||
];
|
||||
|
||||
export function getTranslatedAutomationOptions(
|
||||
t: (key: string, params?: TranslationParams) => string,
|
||||
): Array<{ label: string; value: PianoRollAutomationType }> {
|
||||
return PIANO_ROLL_AUTOMATION_OPTIONS.map(option => ({
|
||||
label: t(option.labelKey),
|
||||
value: option.value,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getAutomationLabel(
|
||||
type: PianoRollAutomationType,
|
||||
t: (key: string, params?: TranslationParams) => string,
|
||||
): string {
|
||||
const option = PIANO_ROLL_AUTOMATION_OPTIONS.find(candidate => candidate.value === type);
|
||||
return option ? t(option.labelKey) : type;
|
||||
}
|
||||
|
||||
export function getControllerNumberForAutomationType(type: PianoRollAutomationType): number | null {
|
||||
switch (type) {
|
||||
case 'cc-1':
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PIANO_ROLL_NO_SNAP } from '../../core/state/KGPianoRollState';
|
||||
import { getSnapStep } from './pianoRollSnap';
|
||||
|
||||
describe('pianoRollSnap', () => {
|
||||
it('returns null for the no-snap sentinel', () => {
|
||||
expect(getSnapStep(PIANO_ROLL_NO_SNAP)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a beat step for fractional snap values', () => {
|
||||
expect(getSnapStep('1/4')).toBe(1);
|
||||
expect(getSnapStep('1/8')).toBe(0.5);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { PIANO_ROLL_NO_SNAP, type PianoRollSnapValue } from '../../core/state/KGPianoRollState';
|
||||
import { DEBUG_MODE } from '../../constants';
|
||||
|
||||
export function getSnapStep(currentSnap: string): number | null {
|
||||
if (currentSnap === 'NO SNAP') {
|
||||
export function getSnapStep(currentSnap: PianoRollSnapValue): number | null {
|
||||
if (currentSnap === PIANO_ROLL_NO_SNAP) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -15,7 +16,7 @@ export function getSnapStep(currentSnap: string): number | null {
|
||||
|
||||
export function getSnappedBeatPosition(
|
||||
beatPosition: number,
|
||||
currentSnap: string,
|
||||
currentSnap: PianoRollSnapValue,
|
||||
useFloorSnapping: boolean = false,
|
||||
): number {
|
||||
const snapStep = getSnapStep(currentSnap);
|
||||
@@ -36,7 +37,7 @@ export function getSnappedBeatPosition(
|
||||
return snappedPosition;
|
||||
}
|
||||
|
||||
export function getSnappedLength(length: number, currentSnap: string, minimumLength: number): number {
|
||||
export function getSnappedLength(length: number, currentSnap: PianoRollSnapValue, minimumLength: number): number {
|
||||
const snapStep = getSnapStep(currentSnap);
|
||||
if (snapStep === null) {
|
||||
return length;
|
||||
|
||||
Reference in New Issue
Block a user