feat: add bar width multiplier (1x–8x horizontal zoom) for track grid

This commit is contained in:
Xiaohan-Tian
2026-04-10 18:17:36 -07:00
parent 4917e54048
commit 139684b413
8 changed files with 153 additions and 9 deletions
+46
View File
@@ -153,3 +153,49 @@
width: 100px;
left: 0;
}
/* Clickable zoom styling */
.current-zoom {
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-weight: normal;
letter-spacing: 0.5px;
cursor: pointer;
transition: background-color 0.2s ease;
padding: 2px 4px;
border-radius: 2px;
}
.current-zoom:hover {
background-color: #4a4a4a;
color: #fff;
}
.zoom-slider-popup {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
background-color: #2d2d2d;
border: 1px solid #444;
border-radius: 3px;
padding: 8px 12px;
z-index: 10000;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
white-space: nowrap;
margin-top: 2px;
}
.zoom-slider-popup input[type="range"] {
width: 120px;
}
.zoom-slider-label {
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
color: #e0e0e0;
min-width: 20px;
text-align: center;
}
+39
View File
@@ -36,6 +36,7 @@ const Toolbar: React.FC = () => {
isPlaying, startPlaying, stopPlaying, setPlayheadPosition,
currentTime, setBpm, setTimeSignature, setKeySignature,
maxBars, setMaxBars,
barWidthMultiplier, setBarWidthMultiplier,
isLooping, toggleLoop,
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
toggleChatBox, toggleSettings, cleanupProjectState,
@@ -57,9 +58,25 @@ const Toolbar: React.FC = () => {
// State for import modal
const [showImportModal, setShowImportModal] = React.useState(false);
// State for zoom slider popup
const [showZoomSlider, setShowZoomSlider] = React.useState(false);
const zoomSliderRef = React.useRef<HTMLDivElement>(null);
// State for open project modal
const [showOpenProject, setShowOpenProject] = React.useState(false);
// Close zoom slider on click outside
React.useEffect(() => {
if (!showZoomSlider) return;
const handleClickOutside = (e: MouseEvent) => {
if (zoomSliderRef.current && !zoomSliderRef.current.contains(e.target as Node)) {
setShowZoomSlider(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showZoomSlider]);
// Key signature options
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
@@ -790,6 +807,28 @@ const Toolbar: React.FC = () => {
<div className="toolbar-right">
<div className="transport-control">
<div className="transport-item" style={{ position: 'relative' }} ref={zoomSliderRef}>
<span
className='current-zoom'
onClick={() => setShowZoomSlider(!showZoomSlider)}
style={{ cursor: 'pointer' }}
>
{barWidthMultiplier}x
</span>
{showZoomSlider && (
<div className="zoom-slider-popup">
<input
type="range"
min="1"
max="8"
step="1"
value={barWidthMultiplier}
onChange={(e) => setBarWidthMultiplier(parseInt(e.target.value))}
/>
<span className="zoom-slider-label">{barWidthMultiplier}x</span>
</div>
)}
</div>
<div className="transport-item">
<span className='current-time' onClick={handleCurrentTimeClick} style={{ cursor: 'pointer' }}>{currentTime}</span>
</div>
+1
View File
@@ -18,6 +18,7 @@ export const DEBUG_MODE = {
// Toolbar related constants
export const TOOLBAR_CONSTANTS = {
BASE_BAR_WIDTH: 40, // matches --track-grid-bar-width default in variables.css
};
// Region related constants
+15 -2
View File
@@ -44,11 +44,15 @@ export class KGProject {
@WithDefault([0, 0])
private loopingRange: [number, number] = [0, 0]; // [startBar, endBar] - bar indices (0-based)
@Expose()
@WithDefault(1)
private barWidthMultiplier: number = 1;
@Expose()
@WithDefault(0)
private projectStructureVersion: number = 0;
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 4;
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 5;
@Expose()
@Type(() => KGTrack, {
@@ -64,7 +68,7 @@ export class KGProject {
private tracks: KGTrack[] = [];
// Constructor
constructor(name: string = "Untitled Project", maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION) {
constructor(name: string = "Untitled Project", maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], barWidthMultiplier: number = 1, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION) {
this.name = name;
this.maxBars = maxBars;
this.currentBars = currentBars;
@@ -74,6 +78,7 @@ export class KGProject {
this.selectedMode = selectedMode;
this.isLooping = isLooping;
this.loopingRange = loopingRange;
this.barWidthMultiplier = barWidthMultiplier;
this.tracks = tracks;
this.projectStructureVersion = projectStructureVersion;
}
@@ -167,5 +172,13 @@ export class KGProject {
public setLoopingRange(loopingRange: [number, number]): void {
this.loopingRange = loopingRange;
}
public getBarWidthMultiplier(): number {
return this.barWidthMultiplier;
}
public setBarWidthMultiplier(barWidthMultiplier: number): void {
this.barWidthMultiplier = barWidthMultiplier;
}
}
@@ -3,6 +3,7 @@ import { upgradeToV1 } from './upgradeToV1';
import { upgradeToV2 } from './upgradeToV2';
import { upgradeToV3 } from './upgradeToV3';
import { upgradeToV4 } from './upgradeToV4';
import { upgradeToV5 } from './upgradeToV5';
/**
* Upgrade the given project to the latest structure version, one version at a time.
@@ -38,6 +39,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
workingProject = upgradeToV4(workingProject);
break;
}
case 5: {
workingProject = upgradeToV5(workingProject);
break;
}
default: {
// If an upgrader is missing, throw to prevent loading incompatible structures
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
+13
View File
@@ -0,0 +1,13 @@
import { KGProject } from '../KGProject';
export function upgradeToV5(project: KGProject): KGProject {
try {
const current = project.getBarWidthMultiplier?.();
if (current === undefined || current === null) {
project.setBarWidthMultiplier(1);
}
} finally {
project.setProjectStructureVersion(5);
}
return project;
}
+32 -6
View File
@@ -16,6 +16,7 @@ import { KGAudioFileStorage } from '../core/io/KGAudioFileStorage';
import { ConfigManager } from '../core/config/ConfigManager';
import { upgradeProjectToLatest } from '../core/project-upgrader/KGProjectUpgrader';
import { toggleLoop } from '../util/loopUtil';
import { TOOLBAR_CONSTANTS } from '../constants/uiConstants';
import * as Tone from 'tone';
/**
@@ -34,6 +35,16 @@ function updateMaxBarsCSS(maxBars: number): void {
document.documentElement.style.setProperty('--max-number-of-bars', maxBars.toString());
}
/**
* Update CSS custom property for track grid bar width based on multiplier
*/
function updateBarWidthMultiplierCSS(multiplier: number): void {
document.documentElement.style.setProperty(
'--track-grid-bar-width',
`${TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * multiplier}px`
);
}
// Define the store state interface
interface ProjectState {
// State
@@ -42,6 +53,7 @@ interface ProjectState {
tracks: KGTrack[];
currentStatus: string;
maxBars: number;
barWidthMultiplier: number;
timeSignature: TimeSignature;
bpm: number;
keySignature: KeySignature;
@@ -104,6 +116,7 @@ interface ProjectState {
toggleLoop: () => void;
setBpm: (bpm: number) => void;
setMaxBars: (maxBars: number) => void;
setBarWidthMultiplier: (multiplier: number) => void;
setTimeSignature: (timeSignature: TimeSignature) => void;
setKeySignature: (keySignature: KeySignature) => void;
setSelectedMode: (selectedMode: string) => void;
@@ -157,6 +170,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
updateTimeSignatureCSS(currentProject.getTimeSignature());
// Initialize CSS variable for max bars on store creation
updateMaxBarsCSS(currentProject.getMaxBars());
// Initialize CSS variable for bar width multiplier on store creation
updateBarWidthMultiplierCSS(currentProject.getBarWidthMultiplier());
// Get initial ChatBox state from config
const configManager = ConfigManager.instance();
@@ -240,6 +255,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
tracks: currentProject.getTracks() as KGTrack[],
currentStatus: KGCore.instance().getStatus() || 'Unknown',
maxBars: currentProject.getMaxBars(),
barWidthMultiplier: currentProject.getBarWidthMultiplier(),
timeSignature: currentProject.getTimeSignature(),
bpm: currentProject.getBpm(),
keySignature: currentProject.getKeySignature(),
@@ -669,11 +685,11 @@ export const useProjectStore = create<ProjectState>((set, get) => {
}
}
// Update CSS variable for time signature numerator
// Update CSS variables
updateTimeSignatureCSS(timeSignature);
// Update CSS variable for max bars
updateMaxBarsCSS(maxBars);
updateBarWidthMultiplierCSS(projectToLoad.getBarWidthMultiplier());
// Log project loading info
console.log(`Project max bars: ${maxBars}`);
console.log(`Setup audio synths for ${tracks.length} tracks`);
@@ -685,6 +701,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
savedProjectName: savedName ?? projectToLoad.getName(),
tracks: [...tracks],
maxBars,
barWidthMultiplier: projectToLoad.getBarWidthMultiplier(),
timeSignature,
bpm,
keySignature,
@@ -781,16 +798,23 @@ export const useProjectStore = create<ProjectState>((set, get) => {
}
},
setBarWidthMultiplier: (multiplier: number) => {
const project = KGCore.instance().getCurrentProject();
project.setBarWidthMultiplier(multiplier);
set({ barWidthMultiplier: multiplier });
updateBarWidthMultiplierCSS(multiplier);
},
setTimeSignature: (timeSignature: TimeSignature) => {
try {
// Create and execute the change project property command
const command = new ChangeProjectPropertyCommand({ timeSignature });
KGCore.instance().executeCommand(command);
// Update the store state
set({ timeSignature });
updateTimeSignatureCSS(timeSignature);
console.log(`Set time signature to ${timeSignature.numerator}/${timeSignature.denominator}`);
} catch (error) {
console.error('Error setting time signature:', error);
@@ -985,15 +1009,17 @@ export const useProjectStore = create<ProjectState>((set, get) => {
projectName: project.getName(),
tracks: [...project.getTracks()] as KGTrack[], // Force new array reference - key for re-rendering!
maxBars: project.getMaxBars(),
barWidthMultiplier: project.getBarWidthMultiplier(),
timeSignature: project.getTimeSignature(),
bpm: project.getBpm(),
keySignature: project.getKeySignature(),
selectedMode: project.getSelectedMode()
});
// Sync CSS variables that affect layout
updateTimeSignatureCSS(project.getTimeSignature());
updateMaxBarsCSS(project.getMaxBars());
updateBarWidthMultiplierCSS(project.getBarWidthMultiplier());
// Sync all related state
const actions = get();
+2 -1
View File
@@ -123,8 +123,9 @@ export const createMockProject = (overrides: Partial<{
'ionian', // selectedMode
false, // isLooping
[0, 0], // loopingRange
1, // barWidthMultiplier
defaults.tracks, // tracks
3 // projectStructureVersion
5 // projectStructureVersion
)
return project