feat: add bar width multiplier (1x–8x horizontal zoom) for track grid
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user