feat: add bar width multiplier (1x–8x horizontal zoom) for track grid
This commit is contained in:
@@ -153,3 +153,49 @@
|
|||||||
width: 100px;
|
width: 100px;
|
||||||
left: 0;
|
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,
|
isPlaying, startPlaying, stopPlaying, setPlayheadPosition,
|
||||||
currentTime, setBpm, setTimeSignature, setKeySignature,
|
currentTime, setBpm, setTimeSignature, setKeySignature,
|
||||||
maxBars, setMaxBars,
|
maxBars, setMaxBars,
|
||||||
|
barWidthMultiplier, setBarWidthMultiplier,
|
||||||
isLooping, toggleLoop,
|
isLooping, toggleLoop,
|
||||||
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
|
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
|
||||||
toggleChatBox, toggleSettings, cleanupProjectState,
|
toggleChatBox, toggleSettings, cleanupProjectState,
|
||||||
@@ -57,9 +58,25 @@ const Toolbar: React.FC = () => {
|
|||||||
// State for import modal
|
// State for import modal
|
||||||
const [showImportModal, setShowImportModal] = React.useState(false);
|
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
|
// State for open project modal
|
||||||
const [showOpenProject, setShowOpenProject] = React.useState(false);
|
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
|
// Key signature options
|
||||||
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
|
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
|
||||||
|
|
||||||
@@ -790,6 +807,28 @@ const Toolbar: React.FC = () => {
|
|||||||
|
|
||||||
<div className="toolbar-right">
|
<div className="toolbar-right">
|
||||||
<div className="transport-control">
|
<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">
|
<div className="transport-item">
|
||||||
<span className='current-time' onClick={handleCurrentTimeClick} style={{ cursor: 'pointer' }}>{currentTime}</span>
|
<span className='current-time' onClick={handleCurrentTimeClick} style={{ cursor: 'pointer' }}>{currentTime}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const DEBUG_MODE = {
|
|||||||
|
|
||||||
// Toolbar related constants
|
// Toolbar related constants
|
||||||
export const TOOLBAR_CONSTANTS = {
|
export const TOOLBAR_CONSTANTS = {
|
||||||
|
BASE_BAR_WIDTH: 40, // matches --track-grid-bar-width default in variables.css
|
||||||
};
|
};
|
||||||
|
|
||||||
// Region related constants
|
// Region related constants
|
||||||
|
|||||||
+15
-2
@@ -44,11 +44,15 @@ export class KGProject {
|
|||||||
@WithDefault([0, 0])
|
@WithDefault([0, 0])
|
||||||
private loopingRange: [number, number] = [0, 0]; // [startBar, endBar] - bar indices (0-based)
|
private loopingRange: [number, number] = [0, 0]; // [startBar, endBar] - bar indices (0-based)
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@WithDefault(1)
|
||||||
|
private barWidthMultiplier: number = 1;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@WithDefault(0)
|
@WithDefault(0)
|
||||||
private projectStructureVersion: number = 0;
|
private projectStructureVersion: number = 0;
|
||||||
|
|
||||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 4;
|
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 5;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Type(() => KGTrack, {
|
@Type(() => KGTrack, {
|
||||||
@@ -64,7 +68,7 @@ export class KGProject {
|
|||||||
private tracks: KGTrack[] = [];
|
private tracks: KGTrack[] = [];
|
||||||
|
|
||||||
// Constructor
|
// 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.name = name;
|
||||||
this.maxBars = maxBars;
|
this.maxBars = maxBars;
|
||||||
this.currentBars = currentBars;
|
this.currentBars = currentBars;
|
||||||
@@ -74,6 +78,7 @@ export class KGProject {
|
|||||||
this.selectedMode = selectedMode;
|
this.selectedMode = selectedMode;
|
||||||
this.isLooping = isLooping;
|
this.isLooping = isLooping;
|
||||||
this.loopingRange = loopingRange;
|
this.loopingRange = loopingRange;
|
||||||
|
this.barWidthMultiplier = barWidthMultiplier;
|
||||||
this.tracks = tracks;
|
this.tracks = tracks;
|
||||||
this.projectStructureVersion = projectStructureVersion;
|
this.projectStructureVersion = projectStructureVersion;
|
||||||
}
|
}
|
||||||
@@ -167,5 +172,13 @@ export class KGProject {
|
|||||||
public setLoopingRange(loopingRange: [number, number]): void {
|
public setLoopingRange(loopingRange: [number, number]): void {
|
||||||
this.loopingRange = loopingRange;
|
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 { upgradeToV2 } from './upgradeToV2';
|
||||||
import { upgradeToV3 } from './upgradeToV3';
|
import { upgradeToV3 } from './upgradeToV3';
|
||||||
import { upgradeToV4 } from './upgradeToV4';
|
import { upgradeToV4 } from './upgradeToV4';
|
||||||
|
import { upgradeToV5 } from './upgradeToV5';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upgrade the given project to the latest structure version, one version at a time.
|
* 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);
|
workingProject = upgradeToV4(workingProject);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 5: {
|
||||||
|
workingProject = upgradeToV5(workingProject);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
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 { ConfigManager } from '../core/config/ConfigManager';
|
||||||
import { upgradeProjectToLatest } from '../core/project-upgrader/KGProjectUpgrader';
|
import { upgradeProjectToLatest } from '../core/project-upgrader/KGProjectUpgrader';
|
||||||
import { toggleLoop } from '../util/loopUtil';
|
import { toggleLoop } from '../util/loopUtil';
|
||||||
|
import { TOOLBAR_CONSTANTS } from '../constants/uiConstants';
|
||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +35,16 @@ function updateMaxBarsCSS(maxBars: number): void {
|
|||||||
document.documentElement.style.setProperty('--max-number-of-bars', maxBars.toString());
|
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
|
// Define the store state interface
|
||||||
interface ProjectState {
|
interface ProjectState {
|
||||||
// State
|
// State
|
||||||
@@ -42,6 +53,7 @@ interface ProjectState {
|
|||||||
tracks: KGTrack[];
|
tracks: KGTrack[];
|
||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
maxBars: number;
|
maxBars: number;
|
||||||
|
barWidthMultiplier: number;
|
||||||
timeSignature: TimeSignature;
|
timeSignature: TimeSignature;
|
||||||
bpm: number;
|
bpm: number;
|
||||||
keySignature: KeySignature;
|
keySignature: KeySignature;
|
||||||
@@ -104,6 +116,7 @@ interface ProjectState {
|
|||||||
toggleLoop: () => void;
|
toggleLoop: () => void;
|
||||||
setBpm: (bpm: number) => void;
|
setBpm: (bpm: number) => void;
|
||||||
setMaxBars: (maxBars: number) => void;
|
setMaxBars: (maxBars: number) => void;
|
||||||
|
setBarWidthMultiplier: (multiplier: number) => void;
|
||||||
setTimeSignature: (timeSignature: TimeSignature) => void;
|
setTimeSignature: (timeSignature: TimeSignature) => void;
|
||||||
setKeySignature: (keySignature: KeySignature) => void;
|
setKeySignature: (keySignature: KeySignature) => void;
|
||||||
setSelectedMode: (selectedMode: string) => void;
|
setSelectedMode: (selectedMode: string) => void;
|
||||||
@@ -157,6 +170,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
updateTimeSignatureCSS(currentProject.getTimeSignature());
|
updateTimeSignatureCSS(currentProject.getTimeSignature());
|
||||||
// Initialize CSS variable for max bars on store creation
|
// Initialize CSS variable for max bars on store creation
|
||||||
updateMaxBarsCSS(currentProject.getMaxBars());
|
updateMaxBarsCSS(currentProject.getMaxBars());
|
||||||
|
// Initialize CSS variable for bar width multiplier on store creation
|
||||||
|
updateBarWidthMultiplierCSS(currentProject.getBarWidthMultiplier());
|
||||||
|
|
||||||
// Get initial ChatBox state from config
|
// Get initial ChatBox state from config
|
||||||
const configManager = ConfigManager.instance();
|
const configManager = ConfigManager.instance();
|
||||||
@@ -240,6 +255,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
tracks: currentProject.getTracks() as KGTrack[],
|
tracks: currentProject.getTracks() as KGTrack[],
|
||||||
currentStatus: KGCore.instance().getStatus() || 'Unknown',
|
currentStatus: KGCore.instance().getStatus() || 'Unknown',
|
||||||
maxBars: currentProject.getMaxBars(),
|
maxBars: currentProject.getMaxBars(),
|
||||||
|
barWidthMultiplier: currentProject.getBarWidthMultiplier(),
|
||||||
timeSignature: currentProject.getTimeSignature(),
|
timeSignature: currentProject.getTimeSignature(),
|
||||||
bpm: currentProject.getBpm(),
|
bpm: currentProject.getBpm(),
|
||||||
keySignature: currentProject.getKeySignature(),
|
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);
|
updateTimeSignatureCSS(timeSignature);
|
||||||
// Update CSS variable for max bars
|
|
||||||
updateMaxBarsCSS(maxBars);
|
updateMaxBarsCSS(maxBars);
|
||||||
|
updateBarWidthMultiplierCSS(projectToLoad.getBarWidthMultiplier());
|
||||||
|
|
||||||
// Log project loading info
|
// Log project loading info
|
||||||
console.log(`Project max bars: ${maxBars}`);
|
console.log(`Project max bars: ${maxBars}`);
|
||||||
console.log(`Setup audio synths for ${tracks.length} tracks`);
|
console.log(`Setup audio synths for ${tracks.length} tracks`);
|
||||||
@@ -685,6 +701,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
savedProjectName: savedName ?? projectToLoad.getName(),
|
savedProjectName: savedName ?? projectToLoad.getName(),
|
||||||
tracks: [...tracks],
|
tracks: [...tracks],
|
||||||
maxBars,
|
maxBars,
|
||||||
|
barWidthMultiplier: projectToLoad.getBarWidthMultiplier(),
|
||||||
timeSignature,
|
timeSignature,
|
||||||
bpm,
|
bpm,
|
||||||
keySignature,
|
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) => {
|
setTimeSignature: (timeSignature: TimeSignature) => {
|
||||||
try {
|
try {
|
||||||
// Create and execute the change project property command
|
// Create and execute the change project property command
|
||||||
const command = new ChangeProjectPropertyCommand({ timeSignature });
|
const command = new ChangeProjectPropertyCommand({ timeSignature });
|
||||||
KGCore.instance().executeCommand(command);
|
KGCore.instance().executeCommand(command);
|
||||||
|
|
||||||
// Update the store state
|
// Update the store state
|
||||||
set({ timeSignature });
|
set({ timeSignature });
|
||||||
updateTimeSignatureCSS(timeSignature);
|
updateTimeSignatureCSS(timeSignature);
|
||||||
|
|
||||||
console.log(`Set time signature to ${timeSignature.numerator}/${timeSignature.denominator}`);
|
console.log(`Set time signature to ${timeSignature.numerator}/${timeSignature.denominator}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error setting time signature:', error);
|
console.error('Error setting time signature:', error);
|
||||||
@@ -985,15 +1009,17 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
projectName: project.getName(),
|
projectName: project.getName(),
|
||||||
tracks: [...project.getTracks()] as KGTrack[], // Force new array reference - key for re-rendering!
|
tracks: [...project.getTracks()] as KGTrack[], // Force new array reference - key for re-rendering!
|
||||||
maxBars: project.getMaxBars(),
|
maxBars: project.getMaxBars(),
|
||||||
|
barWidthMultiplier: project.getBarWidthMultiplier(),
|
||||||
timeSignature: project.getTimeSignature(),
|
timeSignature: project.getTimeSignature(),
|
||||||
bpm: project.getBpm(),
|
bpm: project.getBpm(),
|
||||||
keySignature: project.getKeySignature(),
|
keySignature: project.getKeySignature(),
|
||||||
selectedMode: project.getSelectedMode()
|
selectedMode: project.getSelectedMode()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync CSS variables that affect layout
|
// Sync CSS variables that affect layout
|
||||||
updateTimeSignatureCSS(project.getTimeSignature());
|
updateTimeSignatureCSS(project.getTimeSignature());
|
||||||
updateMaxBarsCSS(project.getMaxBars());
|
updateMaxBarsCSS(project.getMaxBars());
|
||||||
|
updateBarWidthMultiplierCSS(project.getBarWidthMultiplier());
|
||||||
|
|
||||||
// Sync all related state
|
// Sync all related state
|
||||||
const actions = get();
|
const actions = get();
|
||||||
|
|||||||
@@ -123,8 +123,9 @@ export const createMockProject = (overrides: Partial<{
|
|||||||
'ionian', // selectedMode
|
'ionian', // selectedMode
|
||||||
false, // isLooping
|
false, // isLooping
|
||||||
[0, 0], // loopingRange
|
[0, 0], // loopingRange
|
||||||
|
1, // barWidthMultiplier
|
||||||
defaults.tracks, // tracks
|
defaults.tracks, // tracks
|
||||||
3 // projectStructureVersion
|
5 // projectStructureVersion
|
||||||
)
|
)
|
||||||
|
|
||||||
return project
|
return project
|
||||||
|
|||||||
Reference in New Issue
Block a user