Skip to content

Commit 3b6bc3d

Browse files
committed
Add octave grid zoom controls and restore visible square spreadsheet cells - PR_26146_039-midi-studio-v2-octave-grid-zoom-and-square-cells
1 parent db71c30 commit 3b6bc3d

6 files changed

Lines changed: 214 additions & 16 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# PR_26146_039 MIDI Studio V2 Octave Grid Zoom And Square Cells Validation
2+
3+
## Scope
4+
5+
- Continued from PR_26146_038.
6+
- Replaced microscopic 1px octave note cells with visible square cells.
7+
- Preserved 1px grid/cell borders.
8+
- Added Zoom Out and Zoom In controls to the Octave Timeline header after the Snap indicator.
9+
- Made zoom controls adjust the shared square cell size with bounded min/max values.
10+
- Preserved header/body alignment, frozen note labels, top/bottom horizontal scroll sync, active-note textless highlights, chord editing, Play/Stop, JSON Copy header placement, export Type + Save, and roadmap status rules.
11+
12+
## Files Changed
13+
14+
- `tools/midi-studio-v2/index.html`
15+
- `tools/midi-studio-v2/js/bootstrap.js`
16+
- `tools/midi-studio-v2/js/controls/InstrumentGridControl.js`
17+
- `tools/midi-studio-v2/styles/midiStudioV2.css`
18+
- `tests/playwright/tools/MidiStudioV2.spec.mjs`
19+
20+
## Validation Commands
21+
22+
```powershell
23+
node --check tools/midi-studio-v2/js/bootstrap.js
24+
node --check tools/midi-studio-v2/js/controls/InstrumentGridControl.js
25+
node --check tests/playwright/tools/MidiStudioV2.spec.mjs
26+
node -e "const fs=require('fs'); const p='tools/midi-studio-v2/index.html'; const html=fs.readFileSync(p,'utf8'); if(/<script(?![^>]*\ssrc=)[^>]*>/i.test(html)) throw new Error('Inline script block found'); if(/<style\b/i.test(html)) throw new Error('Inline style block found'); if(/\son[a-z]+\s*=/i.test(html)) throw new Error('Inline event handler found'); console.log('HTML external-only guard passed:', p);"
27+
node -e "const fs=require('fs'); const p='tools/midi-studio-v2/styles/midiStudioV2.css'; const css=fs.readFileSync(p,'utf8'); if(/\/\*[^]*$/.test(css.replace(/\/\*[^]*?\*\//g,''))) throw new Error('Unclosed CSS comment'); let depth=0; for (const ch of css.replace(/\/\*[^]*?\*\//g,'')) { if (ch==='{') depth++; if (ch==='}') depth--; if (depth<0) throw new Error('Unexpected }'); } if (depth!==0) throw new Error('Unbalanced CSS braces'); console.log('CSS syntax guard passed:', p);"
28+
npx.cmd playwright test tests/playwright/tools/MidiStudioV2.spec.mjs -g "octave timeline freezes compact headers and note labels while active cells stay textless|fast octave note editing supports drag painting keyboard shortcuts selection and timeline scroll sync|octave grid density supports icon controls and simultaneous chord editing|octave timeline editor is the default editable and playable Studio workflow|places Copy JSON in the JSON Details header and preserves copy behavior|exports through Type dropdown and Save without claiming files were written" --config=codex_playwright_system_chrome.config.cjs --reporter=list --workers=1 --timeout=60000
29+
git diff --check
30+
```
31+
32+
## Validation Result
33+
34+
PASS
35+
36+
## Syntax And Static Checks
37+
38+
- PASS: `tools/midi-studio-v2/js/bootstrap.js`
39+
- PASS: `tools/midi-studio-v2/js/controls/InstrumentGridControl.js`
40+
- PASS: `tests/playwright/tools/MidiStudioV2.spec.mjs`
41+
- PASS: `tools/midi-studio-v2/index.html` external-only guard
42+
- PASS: `tools/midi-studio-v2/styles/midiStudioV2.css` brace/comment guard
43+
44+
## Playwright Coverage
45+
46+
Targeted MIDI Studio V2 Playwright tests passed:
47+
48+
- PASS: grid cell width equals height.
49+
- PASS: grid cells are visibly larger than 1px.
50+
- PASS: grid cell border remains 1px.
51+
- PASS: zoom in increases square cell size.
52+
- PASS: zoom out decreases square cell size.
53+
- PASS: zoom controls are in the Octave Timeline header after the Snap text.
54+
- PASS: header/body alignment remains correct after zoom.
55+
- PASS: cells remain editable after zoom changes.
56+
- PASS: Play and Stop still work.
57+
- PASS: active notes remain highlighted without printed note text.
58+
- PASS: frozen note labels and horizontal scrolling continue to work.
59+
- PASS: multi-note/chord support, JSON Copy header placement, and export Type + Save remain covered.
60+
61+
Command result:
62+
63+
```text
64+
6 passed (1.4m)
65+
```
66+
67+
## Diff Check
68+
69+
- PASS: `git diff --check`
70+
- Note: Git reported LF-to-CRLF working-copy notices for changed files only; no whitespace errors were reported.
71+
72+
## Full Samples Smoke Test
73+
74+
Not run, per request.
75+
76+
## PR Status
77+
78+
PASS. Cells are visible square cells, not collapsed 1px cells, and width equals height under targeted Playwright validation.

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,27 @@ test.describe("MIDI Studio V2", () => {
974974
await expect(page.locator('.midi-studio-v2__timing-header-row-2.midi-studio-v2__note-table-column-header[data-step-index="1"]')).toHaveText("2");
975975
await expect(page.locator('.midi-studio-v2__timing-header-row-2.midi-studio-v2__note-table-column-header[data-step-index="2"]')).toHaveText("3");
976976
await expect(page.locator('.midi-studio-v2__timing-header-row-2.midi-studio-v2__note-table-column-header[data-step-index="3"]')).toHaveText("4");
977+
await expect(page.locator("#instrumentGridSnapIndicator")).toContainText("Snap:");
978+
await expect(page.locator("#instrumentGridZoomOutButton")).toHaveAttribute("aria-label", "Zoom out octave grid");
979+
await expect(page.locator("#instrumentGridZoomInButton")).toHaveAttribute("aria-label", "Zoom in octave grid");
980+
const zoomControlsLayout = await page.locator(".midi-studio-v2__timeline-title").evaluate((header) => {
981+
const snap = header.querySelector("#instrumentGridSnapIndicator").getBoundingClientRect();
982+
const zoomOut = header.querySelector("#instrumentGridZoomOutButton").getBoundingClientRect();
983+
const zoomIn = header.querySelector("#instrumentGridZoomInButton").getBoundingClientRect();
984+
return {
985+
zoomInAfterZoomOut: zoomIn.left > zoomOut.right,
986+
zoomOutAfterSnap: zoomOut.left > snap.right,
987+
zoomText: [
988+
header.querySelector("#instrumentGridZoomOutButton").textContent,
989+
header.querySelector("#instrumentGridZoomInButton").textContent
990+
]
991+
};
992+
});
993+
expect(zoomControlsLayout).toEqual({
994+
zoomInAfterZoomOut: true,
995+
zoomOutAfterSnap: true,
996+
zoomText: ["-", "+"]
997+
});
977998

978999
const output = page.locator("#instrumentGridOutput");
9791000
const gridLayout = await output.evaluate((element) => {
@@ -987,6 +1008,7 @@ test.describe("MIDI Studio V2", () => {
9871008
alternatingRowsDiffer: getComputedStyle(firstRow).backgroundColor !== getComputedStyle(secondRow).backgroundColor,
9881009
borderRightWidth: getComputedStyle(firstNote).borderRightWidth,
9891010
cellCssWidth: getComputedStyle(firstNote).width,
1011+
cellHeight: firstNote.getBoundingClientRect().height,
9901012
columnWidth: firstNote.getBoundingClientRect().width,
9911013
columnTemplate: getComputedStyle(grid).gridTemplateColumns,
9921014
containsGrid: grid?.parentElement === element,
@@ -998,13 +1020,45 @@ test.describe("MIDI Studio V2", () => {
9981020
expect(gridLayout.alternatingRowsDiffer).toBe(true);
9991021
expect(Number.parseFloat(gridLayout.borderRightWidth)).toBeGreaterThanOrEqual(0.8);
10001022
expect(Number.parseFloat(gridLayout.borderRightWidth)).toBeLessThanOrEqual(1);
1001-
expect(Number.parseFloat(gridLayout.cellCssWidth)).toBeGreaterThanOrEqual(0.8);
1002-
expect(Number.parseFloat(gridLayout.cellCssWidth)).toBeLessThanOrEqual(1);
1003-
expect(gridLayout.columnWidth).toBeLessThanOrEqual(1);
1004-
expect(gridLayout.columnTemplate).toContain("1px");
1023+
expect(gridLayout.columnWidth).toBeGreaterThan(1);
1024+
expect(Math.abs(gridLayout.columnWidth - gridLayout.cellHeight)).toBeLessThanOrEqual(1);
1025+
expect(Number.parseFloat(gridLayout.cellCssWidth)).toBeGreaterThan(1);
1026+
expect(gridLayout.columnTemplate).toContain("22px");
10051027
expect(gridLayout.labelWidth).toBeGreaterThan(40);
10061028
expect(gridLayout.topScrollbarHeight).toBeGreaterThanOrEqual(12);
10071029

1030+
await page.locator("#instrumentGridZoomInButton").click();
1031+
const zoomedInLayout = await output.evaluate((element) => {
1032+
const firstNote = element.querySelector('.midi-studio-v2__octave-note-cell[data-step-index="0"]');
1033+
const header = element.querySelector('.midi-studio-v2__grid-cell--beat-header[data-step-index="6"]').getBoundingClientRect();
1034+
const bodyCell = element.querySelector('.midi-studio-v2__octave-note-cell[data-row-token="C5"][data-step-index="6"]').getBoundingClientRect();
1035+
return {
1036+
bodyHeaderDelta: Math.abs(header.left - bodyCell.left),
1037+
height: firstNote.getBoundingClientRect().height,
1038+
width: firstNote.getBoundingClientRect().width
1039+
};
1040+
});
1041+
expect(zoomedInLayout.width).toBeGreaterThan(gridLayout.columnWidth);
1042+
expect(Math.abs(zoomedInLayout.width - zoomedInLayout.height)).toBeLessThanOrEqual(1);
1043+
expect(zoomedInLayout.bodyHeaderDelta).toBeLessThanOrEqual(1);
1044+
1045+
await page.locator("#instrumentGridZoomOutButton").click();
1046+
await page.locator("#instrumentGridZoomOutButton").click();
1047+
const zoomedOutLayout = await output.evaluate((element) => {
1048+
const firstNote = element.querySelector('.midi-studio-v2__octave-note-cell[data-step-index="0"]');
1049+
const header = element.querySelector('.midi-studio-v2__grid-cell--beat-header[data-step-index="6"]').getBoundingClientRect();
1050+
const bodyCell = element.querySelector('.midi-studio-v2__octave-note-cell[data-row-token="C5"][data-step-index="6"]').getBoundingClientRect();
1051+
return {
1052+
bodyHeaderDelta: Math.abs(header.left - bodyCell.left),
1053+
height: firstNote.getBoundingClientRect().height,
1054+
width: firstNote.getBoundingClientRect().width
1055+
};
1056+
});
1057+
expect(zoomedOutLayout.width).toBeLessThan(zoomedInLayout.width);
1058+
expect(zoomedOutLayout.width).toBeGreaterThan(1);
1059+
expect(Math.abs(zoomedOutLayout.width - zoomedOutLayout.height)).toBeLessThanOrEqual(1);
1060+
expect(zoomedOutLayout.bodyHeaderDelta).toBeLessThanOrEqual(1);
1061+
10081062
await output.evaluate((element) => {
10091063
element.style.maxWidth = "80px";
10101064
element.dispatchEvent(new Event("scroll"));
@@ -1106,8 +1160,7 @@ test.describe("MIDI Studio V2", () => {
11061160
};
11071161
});
11081162
expect(activeHighlight.content).not.toBe("none");
1109-
expect(Number.parseFloat(activeHighlight.width)).toBeGreaterThanOrEqual(0.8);
1110-
expect(Number.parseFloat(activeHighlight.width)).toBeLessThanOrEqual(1);
1163+
expect(Number.parseFloat(activeHighlight.width)).toBeGreaterThan(1);
11111164
} finally {
11121165
await workspaceV2CoverageReporter.stop(page);
11131166
await server.close();

tools/midi-studio-v2/index.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,11 @@ <h3>Song Sheet</h3>
197197
<div class="midi-studio-v2__timeline-title">
198198
<h2 id="instrumentGridHeading">Octave Timeline</h2>
199199
<p>Edit Notes Here</p>
200-
<span id="instrumentGridSnapIndicator" class="midi-studio-v2__snap-indicator">Snap: not set</span>
200+
<div class="midi-studio-v2__timeline-title-actions">
201+
<span id="instrumentGridSnapIndicator" class="midi-studio-v2__snap-indicator">Snap: not set</span>
202+
<button id="instrumentGridZoomOutButton" class="midi-studio-v2__zoom-button" type="button" aria-label="Zoom out octave grid" title="Zoom out octave grid">-</button>
203+
<button id="instrumentGridZoomInButton" class="midi-studio-v2__zoom-button" type="button" aria-label="Zoom in octave grid" title="Zoom in octave grid">+</button>
204+
</div>
201205
</div>
202206
<div class="midi-studio-v2__grid-settings" data-midi-studio-tab-panel="auto-create-parts">
203207
<label class="tool-starter__field" for="instrumentGridSectionsInput">

tools/midi-studio-v2/js/bootstrap.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ window.addEventListener("DOMContentLoaded", () => {
7272
generatePadButton: requireElement("#generatePadFromChordsButton"),
7373
gridOutput: requireElement("#instrumentGridOutput"),
7474
instrumentList: requireElement("#instrumentList"),
75+
instrumentGridZoomInButton: requireElement("#instrumentGridZoomInButton"),
76+
instrumentGridZoomOutButton: requireElement("#instrumentGridZoomOutButton"),
7577
jumpToSectionButton: requireElement("#jumpToSectionButton"),
7678
laneTypeSelect: requireElement("#instrumentGridLaneTypeSelect"),
7779
leadInput: requireElement("#instrumentGridLeadInput"),

tools/midi-studio-v2/js/controls/InstrumentGridControl.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ const INSTRUMENT_TYPE_GROUPS = [
6868
const REST_TOKENS = new Set(["", "-", ".", "rest"]);
6969
const PERCUSSION_ROWS = ["crash", "ride", "hat", "clap", "snare", "tom", "perc", "kick"];
7070
const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
71+
const OCTAVE_GRID_ZOOM = {
72+
default: 22,
73+
max: 38,
74+
min: 12,
75+
step: 4
76+
};
7177
const FLAT_TO_SHARP = {
7278
Ab: "G#",
7379
Bb: "A#",
@@ -223,6 +229,8 @@ export class InstrumentGridControl {
223229
generatePadButton,
224230
gridOutput,
225231
instrumentList,
232+
instrumentGridZoomInButton,
233+
instrumentGridZoomOutButton,
226234
jumpToSectionButton,
227235
laneTypeSelect,
228236
leadInput,
@@ -257,6 +265,8 @@ export class InstrumentGridControl {
257265
this.gridOutput = gridOutput;
258266
this.extraLaneSources = {};
259267
this.instrumentList = instrumentList;
268+
this.instrumentGridZoomInButton = instrumentGridZoomInButton;
269+
this.instrumentGridZoomOutButton = instrumentGridZoomOutButton;
260270
this.jumpToSectionButton = jumpToSectionButton;
261271
this.laneTypeSelect = laneTypeSelect;
262272
this.leadInput = leadInput;
@@ -285,6 +295,7 @@ export class InstrumentGridControl {
285295
this.playTimer = null;
286296
this.playheadStep = 0;
287297
this.lastPlayheadHighlightStep = null;
298+
this.octaveCellSize = OCTAVE_GRID_ZOOM.default;
288299
this.syncingTimelineScroll = false;
289300
this.timelineScrollProxy = null;
290301
this.timelineScrollProxySpacer = null;
@@ -322,11 +333,14 @@ export class InstrumentGridControl {
322333
input.addEventListener("change", () => this.updateSnapIndicator());
323334
});
324335
this.gridOutput.addEventListener("scroll", () => this.syncTimelineScrollState());
336+
this.instrumentGridZoomInButton?.addEventListener("click", () => this.adjustOctaveGridZoom(OCTAVE_GRID_ZOOM.step));
337+
this.instrumentGridZoomOutButton?.addEventListener("click", () => this.adjustOctaveGridZoom(-OCTAVE_GRID_ZOOM.step));
325338
this.addInstrumentButton?.addEventListener("click", () => this.addInstrumentRow());
326339
this.closeInstrumentPanelButton?.addEventListener("click", () => this.collapseInstrumentPanel());
327340
this.setTransportEnabled(false);
328341
this.populateSectionControls([]);
329342
this.updateSnapIndicator();
343+
this.applyOctaveGridZoom();
330344
this.selectLane(this.selectedLane);
331345
this.renderSelectionDetails();
332346
}
@@ -561,7 +575,7 @@ export class InstrumentGridControl {
561575
grid.className = "midi-studio-v2__instrument-grid midi-studio-v2__note-table midi-studio-v2__octave-timeline";
562576
grid.setAttribute("role", "table");
563577
grid.setAttribute("aria-label", "Octave timeline editor");
564-
grid.style.gridTemplateColumns = `4.25rem repeat(${result.totalSteps}, 1px)`;
578+
grid.style.gridTemplateColumns = `4.25rem repeat(${result.totalSteps}, var(--midi-studio-v2-octave-cell-size))`;
565579
this.renderNoteTableHeader(grid, result);
566580
octaveRows.forEach((row, rowIndex) => {
567581
grid.append(this.createOctaveRowHeader(row, rowIndex));
@@ -575,9 +589,34 @@ export class InstrumentGridControl {
575589
this.gridOutput.append(grid);
576590
this.updateTimelineScrollProxyWidth();
577591
this.window.requestAnimationFrame?.(() => this.updateTimelineScrollProxyWidth());
592+
this.applyOctaveGridZoom();
578593
this.applySelectedLaneHighlight();
579594
}
580595

596+
adjustOctaveGridZoom(delta) {
597+
const nextSize = Math.max(OCTAVE_GRID_ZOOM.min, Math.min(OCTAVE_GRID_ZOOM.max, this.octaveCellSize + delta));
598+
if (nextSize === this.octaveCellSize) {
599+
this.applyOctaveGridZoom();
600+
return;
601+
}
602+
const scrollState = this.captureTimelineScrollState();
603+
this.octaveCellSize = nextSize;
604+
this.applyOctaveGridZoom();
605+
this.updateTimelineScrollProxyWidth();
606+
this.restoreTimelineScrollState(scrollState);
607+
}
608+
609+
applyOctaveGridZoom() {
610+
const size = `${this.octaveCellSize}px`;
611+
this.gridOutput?.style.setProperty("--midi-studio-v2-octave-cell-size", size);
612+
if (this.instrumentGridZoomOutButton) {
613+
this.instrumentGridZoomOutButton.disabled = this.octaveCellSize <= OCTAVE_GRID_ZOOM.min;
614+
}
615+
if (this.instrumentGridZoomInButton) {
616+
this.instrumentGridZoomInButton.disabled = this.octaveCellSize >= OCTAVE_GRID_ZOOM.max;
617+
}
618+
}
619+
581620
createTimelineScrollProxy() {
582621
const proxy = document.createElement("div");
583622
proxy.className = "midi-studio-v2__timeline-scroll-proxy";

tools/midi-studio-v2/styles/midiStudioV2.css

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@
108108
justify-content: space-between;
109109
}
110110

111+
.midi-studio-v2__timeline-title-actions {
112+
align-items: center;
113+
display: inline-flex;
114+
gap: 0.35rem;
115+
margin-left: auto;
116+
min-width: 0;
117+
}
118+
111119
.midi-studio-v2__timeline-title p {
112120
color: var(--tool-starter-muted);
113121
font-size: 0.85rem;
@@ -320,6 +328,7 @@
320328
.midi-studio-v2__instrument-grid-output {
321329
max-width: 100%;
322330
overflow: auto;
331+
--midi-studio-v2-octave-cell-size: 22px;
323332
--midi-studio-v2-top-scrollbar-height: 16px;
324333
}
325334

@@ -646,13 +655,14 @@ body[data-tool-id="midi-studio-v2"] #instrumentGridContent {
646655
font-weight: 800;
647656
gap: 0;
648657
line-height: 1;
649-
min-height: 1.55rem;
650-
min-width: 1px;
658+
height: var(--midi-studio-v2-octave-cell-size);
659+
min-height: var(--midi-studio-v2-octave-cell-size);
660+
min-width: var(--midi-studio-v2-octave-cell-size);
651661
overflow: hidden;
652662
padding: 0;
653663
position: relative;
654664
white-space: normal;
655-
width: 1px;
665+
width: var(--midi-studio-v2-octave-cell-size);
656666
word-break: break-word;
657667
}
658668

@@ -678,14 +688,14 @@ body[data-tool-id="midi-studio-v2"] #instrumentGridContent {
678688

679689
.midi-studio-v2__octave-note-cell.midi-studio-v2__grid-cell--event::before {
680690
background: rgba(221, 214, 254, 0.78);
681-
border-radius: 0;
691+
border-radius: 0.18rem;
682692
content: "";
683693
display: block;
684694
height: 0.72rem;
685695
justify-self: stretch;
686-
margin: 0;
687-
min-width: 1px;
688-
width: 1px;
696+
margin: 0 0.12rem;
697+
min-width: 0;
698+
width: auto;
689699
}
690700

691701
.midi-studio-v2__note-block {
@@ -814,10 +824,22 @@ body[data-tool-id="midi-studio-v2"] #instrumentGridContent {
814824
color: var(--tool-starter-muted);
815825
font-size: 0.78rem;
816826
font-weight: 600;
817-
margin-left: auto;
818827
text-transform: none;
819828
}
820829

830+
.midi-studio-v2__zoom-button {
831+
align-items: center;
832+
border-radius: 6px;
833+
display: inline-flex;
834+
flex: 0 0 auto;
835+
font-size: 0.82rem;
836+
font-weight: 900;
837+
justify-content: center;
838+
min-height: 1.45rem;
839+
min-width: 1.55rem;
840+
padding: 0;
841+
}
842+
821843
.midi-studio-v2__section-tone-0 {
822844
background: color-mix(in srgb, var(--tool-starter-surface) 76%, #0ea5e9 24%);
823845
}

0 commit comments

Comments
 (0)