Skip to content

Commit c1b4a0c

Browse files
committed
Improve MIDI Studio V2 fast note editing and keyboard workflow - PR_26146_032-midi-studio-v2-fast-note-editing-and-keyboard-flow
1 parent 52bdf18 commit c1b4a0c

6 files changed

Lines changed: 465 additions & 15 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
const base = require("./playwright.config.cjs");
2+
3+
module.exports = {
4+
...base,
5+
use: {
6+
...base.use,
7+
launchOptions: {
8+
...(base.use?.launchOptions || {}),
9+
executablePath: "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
10+
}
11+
}
12+
};

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,20 @@ async function expectInstrumentClickKeepsScroll(page, locator) {
417417
await expect.poll(() => instrumentScrollSnapshot(page)).toEqual(before);
418418
}
419419

420+
async function timelineScrollSnapshot(page) {
421+
return page.locator("#instrumentGridOutput").evaluate((output) => {
422+
const firstHeader = output.querySelector(".midi-studio-v2__grid-cell--timing-header");
423+
const firstNoteCell = output.querySelector(".midi-studio-v2__octave-note-cell");
424+
return {
425+
headerLeft: Math.round(firstHeader?.getBoundingClientRect().left || 0),
426+
noteLeft: Math.round(firstNoteCell?.getBoundingClientRect().left || 0),
427+
scrollDataset: output.dataset.timelineScrollLeft || "",
428+
scrollLeft: Math.round(output.scrollLeft),
429+
scrollTop: Math.round(output.scrollTop)
430+
};
431+
});
432+
}
433+
420434
function laneHeader(page, lane) {
421435
return page.locator(`.midi-studio-v2__lane-header-cell[data-lane="${lane}"]`);
422436
}
@@ -801,6 +815,82 @@ test.describe("MIDI Studio V2", () => {
801815
}
802816
});
803817

818+
test("fast octave note editing supports drag painting keyboard shortcuts selection and timeline scroll sync", async ({ page }) => {
819+
const server = await openMidiStudioForImport(page);
820+
try {
821+
await page.locator("#toolImportManifestInput").setInputFiles(uatManifestPath);
822+
await selectInstrumentRow(page, "lead");
823+
await expect(octaveCell(page, "C6", 2)).not.toHaveAttribute("data-note-lanes", /lead/);
824+
825+
await octaveCell(page, "C6", 2).click();
826+
await expect(octaveCell(page, "C6", 2)).toHaveText("C6");
827+
await expect(octaveCell(page, "C6", 2)).toHaveAttribute("data-note-lanes", /lead/);
828+
await expect(octaveCell(page, "C6", 2)).toHaveClass(/midi-studio-v2__grid-cell--note-selected/);
829+
await octaveCell(page, "C6", 2).click();
830+
await expect(octaveCell(page, "C6", 2)).not.toHaveAttribute("data-note-lanes", /lead/);
831+
await octaveCell(page, "C6", 2).click();
832+
833+
await octaveCell(page, "C6", 3).dragTo(octaveCell(page, "C6", 5));
834+
for (const stepIndex of [3, 4, 5]) {
835+
await expect(octaveCell(page, "C6", stepIndex)).toHaveText("C6");
836+
await expect(octaveCell(page, "C6", stepIndex)).toHaveAttribute("data-note-lanes", /lead/);
837+
}
838+
await octaveCell(page, "C6", 5).dragTo(octaveCell(page, "C6", 7));
839+
for (const stepIndex of [5, 6, 7]) {
840+
await expect(octaveCell(page, "C6", stepIndex)).toHaveAttribute("data-note-lanes", /lead/);
841+
}
842+
843+
await octaveCell(page, "C5", 10).click();
844+
await octaveCell(page, "E5", 10).click();
845+
const chordValues = await page.evaluate(() => window.__midiStudioV2App.lastInstrumentGridResult.timeline
846+
.filter((event) => event.lane === "lead" && event.stepIndex === 10)
847+
.map((event) => event.value)
848+
.sort());
849+
expect(chordValues).toEqual(expect.arrayContaining(["C5", "E5"]));
850+
expect(chordValues.length).toBeGreaterThanOrEqual(2);
851+
852+
await octaveCell(page, "C6", 7).click();
853+
await page.keyboard.press("ArrowRight");
854+
await expect(octaveCell(page, "C6", 8)).toHaveClass(/midi-studio-v2__grid-cell--note-selected/);
855+
await page.keyboard.press("Control+D");
856+
await expect(octaveCell(page, "C6", 9)).toHaveAttribute("data-note-lanes", /lead/);
857+
await expect(octaveCell(page, "C6", 9)).toHaveClass(/midi-studio-v2__grid-cell--note-selected/);
858+
await page.keyboard.press("Delete");
859+
await expect(octaveCell(page, "C6", 9)).not.toHaveAttribute("data-note-lanes", /lead/);
860+
await expect(octaveCell(page, "C6", 9)).toHaveClass(/midi-studio-v2__grid-cell--note-selected/);
861+
await page.keyboard.press("ArrowLeft");
862+
await page.keyboard.press("Backspace");
863+
await expect(octaveCell(page, "C6", 8)).not.toHaveAttribute("data-note-lanes", /lead/);
864+
865+
const output = page.locator("#instrumentGridOutput");
866+
await output.evaluate((element) => {
867+
element.scrollLeft = 180;
868+
element.scrollTop = 90;
869+
element.dispatchEvent(new Event("scroll"));
870+
});
871+
const beforeScroll = await timelineScrollSnapshot(page);
872+
expect(beforeScroll.scrollDataset).toBe(String(beforeScroll.scrollLeft));
873+
await octaveCell(page, "D6", 11).click();
874+
await expect.poll(() => timelineScrollSnapshot(page)).toEqual(expect.objectContaining({
875+
scrollLeft: beforeScroll.scrollLeft,
876+
scrollTop: beforeScroll.scrollTop
877+
}));
878+
const afterScroll = await timelineScrollSnapshot(page);
879+
expect(afterScroll.headerLeft).toBe(afterScroll.noteLeft);
880+
expect(afterScroll.scrollDataset).toBe(String(afterScroll.scrollLeft));
881+
882+
await page.keyboard.press("Space");
883+
await expect(page.locator("#stopButton")).toBeEnabled();
884+
await expect(page.locator("#playButton")).toBeDisabled();
885+
await page.keyboard.press("Space");
886+
await expect(page.locator("#stopButton")).toBeDisabled();
887+
await expect(page.locator("#playButton")).toBeEnabled();
888+
} finally {
889+
await workspaceV2CoverageReporter.stop(page);
890+
await server.close();
891+
}
892+
});
893+
804894
test("imports UAT manifest and plays the upbeat multi-instrument studio arrangement", async ({ page }) => {
805895
const server = await openMidiStudioForImport(page);
806896
try {

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export class MidiStudioV2App {
8989
onWorkspaceExportManifest: () => this.statusLog.info("Workspace export is owned by Workspace Manager V2."),
9090
onWorkspaceImportManifest: () => this.statusLog.info("Workspace import is owned by Workspace Manager V2.")
9191
});
92+
this.mountKeyboardShortcuts();
9293
this.renderEmpty();
9394
const loadResult = await this.manifestLoader.loadInitialManifest();
9495
if (loadResult.ok) {
@@ -101,6 +102,43 @@ export class MidiStudioV2App {
101102
}
102103
}
103104

105+
mountKeyboardShortcuts() {
106+
this.window.document.addEventListener("keydown", (event) => this.handleKeyboardShortcut(event));
107+
}
108+
109+
handleKeyboardShortcut(event) {
110+
if (event.defaultPrevented) {
111+
return;
112+
}
113+
if (this.isEditableKeyboardTarget(event.target)) {
114+
return;
115+
}
116+
if (event.key === " " && !event.ctrlKey && !event.metaKey && !event.altKey) {
117+
event.preventDefault();
118+
if (this.playbackControl.isPlaying()) {
119+
this.stopPlayback();
120+
return;
121+
}
122+
void this.playSelectedSong();
123+
return;
124+
}
125+
if ((event.key === "Delete" || event.key === "Backspace") && this.instrumentGrid.deleteSelectedNotes()) {
126+
event.preventDefault();
127+
return;
128+
}
129+
if (event.key.startsWith("Arrow") && this.instrumentGrid.moveSelectionByKey(event.key)) {
130+
event.preventDefault();
131+
return;
132+
}
133+
if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "d" && this.instrumentGrid.duplicateSelectedNotes()) {
134+
event.preventDefault();
135+
}
136+
}
137+
138+
isEditableKeyboardTarget(target) {
139+
return Boolean(target?.closest?.("input, textarea, select, option, button, [contenteditable='true']"));
140+
}
141+
104142
renderEmpty() {
105143
this.payload = null;
106144
this.selectedSongId = "";
@@ -554,7 +592,7 @@ export class MidiStudioV2App {
554592
return;
555593
}
556594
this.lastInstrumentGridResult = result;
557-
if (detail.action === "add-lane" || detail.action === "delete-lane" || detail.action === "toggle-note") {
595+
if (["add-lane", "delete-lane", "delete-selected-note", "duplicate-selected-note", "paint-notes", "toggle-note"].includes(detail.action)) {
558596
this.instrumentGrid.render(result);
559597
} else {
560598
this.instrumentGrid.syncEditedGridResult(result);
@@ -564,6 +602,12 @@ export class MidiStudioV2App {
564602
this.statusLog.ok(`Added instrument row ${detail.laneLabel || detail.lane}; playback data updated.`);
565603
} else if (detail.action === "delete-lane") {
566604
this.statusLog.ok(`Deleted instrument row ${detail.laneLabel || detail.lane}; playback data updated.`);
605+
} else if (detail.action === "delete-selected-note") {
606+
this.statusLog.ok(`Deleted selected ${detail.rowToken || "note"} for ${detail.laneLabel || "instrument"}; playback data updated.`);
607+
} else if (detail.action === "duplicate-selected-note") {
608+
this.statusLog.ok(`Duplicated selected ${detail.rowToken || "note"} for ${detail.laneLabel || "instrument"}; playback data updated.`);
609+
} else if (detail.action === "paint-notes") {
610+
this.statusLog.ok(`Painted ${detail.rowToken || "notes"} for ${detail.laneLabel || "instrument"} across the timeline; playback data updated.`);
567611
} else if (detail.action === "toggle-note") {
568612
this.statusLog.ok(`Toggled ${detail.rowToken || detail.note || "note"} for ${detail.laneLabel || "instrument"}; visible timeline playback data updated.`);
569613
} else {

0 commit comments

Comments
 (0)