Paste under the pointer and pull song items out left in music tabs
A round of fixes to the Pattern and Song tabs: where a paste lands, what Shift does on a drag, how far the rows zoom, and how an item of a song is sized at its ends. - Shift does what Alt does on a drag: off the grid, on the tick. A Shift click on a note or an item still adds or unpicks it. A Shift press only decides on letting go: no drag toggles, a drag moves. - Ctrl+V lands under the pointer. The earliest copy goes on the grid line before it, the highest note on its row or the topmost item on its track, held to the keyboard or the tracks there are. Off the grid or left of bar 1 it lands at the start of playing as before. - The rows of the piano roll zoom to 64 px instead of 28, the tracks of a song to 320 px instead of 160. - Fit in the Pattern tab zooms the rows as well, so the notes fill the height, no longer held to the default of 14 px. - An item of a song can be pulled out by its left end past the start of what it plays, which then repeats. The offset goes round to the end of a round. A sound not loaded yet still stops at its start. - Both ends of an item snap to the start of a round of what it plays as well as to the grid, whichever is nearer. The end of a round between two ticks is cut to the tick before. A start put within half a tick of a round goes right on it, so it plays from the very beginning. - An offset past a whole round is not folded back to 0, so a pattern that grows later still plays from where the item said. - A middle-drag pans past the first and the last row, as it pans left of bar 1: the highest note or the first track up to half the height down, the other end up to half the height up. The room past them is dark in the piano roll and empty in the song. Tests: - Unit: song-edit 14, piano-roll 19, song 14 tests. New cases for the paste spot, pulling out left, the round snap and Fit. - e2e 64+65 on melektaus-atx: ALL PASSED - 54 checks (+4). New: Shift drag and Shift click, Ctrl+V under the pointer in both tabs, Fit zooming the rows, pulling a pattern out left and snapping a sound to the end of its second round. e2e 64 zooms the rows out again after Fit, so the keys it plays later are on screen. - Gate on melektaus-atx: 251 files, 3912 tests, CHECK EXIT 0. - Then the pan down the rows: e2e 64 on melektaus-atx ALL PASSED - 30 checks. Its pan check now also pans the top row down, half the grid's height, and a click above it draws nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a3101c590c
commit
5b96d0b329
11 files changed
+462
-101
No files matched your search
@@ -87,6 +87,12 @@ DARK_LABEL.fill = 0x30343c;
|
||||
|
||||
type Area = 'corner' | 'ruler' | 'keys' | 'grid' | 'lane' | 'laneLabel';
|
||||
|
||||
/**
|
||||
* What a press with Shift did to the picked notes: `added` the note, or found it picked – then a
|
||||
* click that changes nothing `unpick`s it. A drag with Shift moves or sizes off the grid instead.
|
||||
*/
|
||||
type ShiftPress = 'added' | 'unpick' | null;
|
||||
|
||||
type Gesture =
|
||||
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
|
||||
| { kind: 'key'; note: number; voice: number }
|
||||
@@ -109,9 +115,10 @@ type Gesture =
|
||||
notes: number;
|
||||
moved: boolean;
|
||||
pointer: Vec2;
|
||||
shift: ShiftPress;
|
||||
}
|
||||
| { kind: 'resize'; anchor: PatternNote; by: number; moved: boolean }
|
||||
| { kind: 'trim'; anchor: PatternNote; by: number }
|
||||
| { kind: 'resize'; anchor: PatternNote; by: number; moved: boolean; shift: ShiftPress }
|
||||
| { kind: 'trim'; anchor: PatternNote; by: number; shift: ShiftPress }
|
||||
| { kind: 'erase'; erased: Set<string> }
|
||||
| { kind: 'velocity'; anchor: PatternNote; value: number };
|
||||
|
||||
@@ -157,6 +164,8 @@ export class PianoRoll extends Widget {
|
||||
private gesture: Gesture | null = null;
|
||||
/** The part of a note under the pointer while nothing is held. */
|
||||
private hover: NoteHit | null = null;
|
||||
/** Where the pointer is over this widget, in its own pixels; null while it is elsewhere. */
|
||||
private pointer: Vec2 | null = null;
|
||||
/** The notes picked, by id; only this tab knows them. */
|
||||
private picked = new Set<string>();
|
||||
/** Where playing starts and a paste lands, in ticks. */
|
||||
@@ -202,6 +211,11 @@ export class PianoRoll extends Widget {
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||
this.on('pointermove', (e: FederatedPointerEvent) => {
|
||||
const p = this.toLocal(e.global);
|
||||
this.pointer = { x: p.x, y: p.y };
|
||||
});
|
||||
this.on('pointerleave', () => (this.pointer = null));
|
||||
this.onPointerRelease(() => this.onPointerUp());
|
||||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||||
state.on('project', () => {
|
||||
@@ -311,11 +325,18 @@ export class PianoRoll extends Widget {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Lands at the start of playing; false with nothing copied, so Ctrl+V can fall through. */
|
||||
/**
|
||||
* Lands under the pointer – the earliest note on the grid line before it, the highest on its row –
|
||||
* or, with the pointer off the grid, at the start of playing. False with nothing copied, so Ctrl+V
|
||||
* can fall through.
|
||||
*/
|
||||
paste(): boolean {
|
||||
const asset = this.asset();
|
||||
if (!asset || this.clipboard.length === 0) return false;
|
||||
const copies = placedCopies(this.clipboard, this.startTick);
|
||||
const spot = this.pasteSpot(asset);
|
||||
const copies = spot
|
||||
? placedCopies(this.clipboard, spot.tick, spot.note)
|
||||
: placedCopies(this.clipboard, this.startTick);
|
||||
this.commit(asset, [...asset.notes, ...copies], copies.length === 1 ? 'Paste note' : 'Paste notes');
|
||||
this.pick(copies.map((n) => n.id));
|
||||
return true;
|
||||
@@ -516,7 +537,10 @@ export class PianoRoll extends Widget {
|
||||
edge,
|
||||
};
|
||||
} else if (area === 'ruler') {
|
||||
this.gesture = { kind: 'cursor', tick: this.cursorTick(tick, patternGrid(asset.props), e.altKey) };
|
||||
this.gesture = {
|
||||
kind: 'cursor',
|
||||
tick: this.cursorTick(tick, patternGrid(asset.props), e.altKey || e.shiftKey),
|
||||
};
|
||||
} else if (area === 'grid') {
|
||||
this.pressGrid(asset, e, g);
|
||||
} else if (area === 'lane') {
|
||||
@@ -531,25 +555,31 @@ export class PianoRoll extends Widget {
|
||||
const hit = noteAt(this.view, asset.notes, g);
|
||||
const anchor = hit ? asset.notes.find((n) => n.id === hit.id) : undefined;
|
||||
if (!hit || !anchor) {
|
||||
this.gesture = { kind: 'empty', start: g, current: g, active: false, add: e.shiftKey, free: e.altKey };
|
||||
this.gesture = {
|
||||
kind: 'empty',
|
||||
start: g,
|
||||
current: g,
|
||||
active: false,
|
||||
add: e.shiftKey,
|
||||
free: e.altKey || e.shiftKey,
|
||||
};
|
||||
return;
|
||||
}
|
||||
let shift: ShiftPress = null;
|
||||
if (e.shiftKey) {
|
||||
if (this.picked.has(hit.id)) this.picked.delete(hit.id);
|
||||
else this.picked.add(hit.id);
|
||||
return;
|
||||
}
|
||||
if (!this.picked.has(hit.id)) this.picked = new Set([hit.id]);
|
||||
shift = this.picked.has(hit.id) ? 'unpick' : 'added';
|
||||
this.picked.add(hit.id);
|
||||
} else if (!this.picked.has(hit.id)) this.picked = new Set([hit.id]);
|
||||
if (hit.part === 'end') {
|
||||
this.gesture = { kind: 'resize', anchor, by: 0, moved: false };
|
||||
this.gesture = { kind: 'resize', anchor, by: 0, moved: false, shift };
|
||||
return;
|
||||
}
|
||||
if (hit.part === 'start') {
|
||||
this.gesture = { kind: 'trim', anchor, by: 0 };
|
||||
this.gesture = { kind: 'trim', anchor, by: 0, shift };
|
||||
return;
|
||||
}
|
||||
const grab = xToTick(this.view, g.x) - anchor.tick;
|
||||
this.gesture = { kind: 'move', anchor, grab, ticks: 0, notes: 0, moved: false, pointer: g };
|
||||
this.gesture = { kind: 'move', anchor, grab, ticks: 0, notes: 0, moved: false, pointer: g, shift };
|
||||
this.onPreview(anchor.note, false);
|
||||
}
|
||||
|
||||
@@ -565,7 +595,7 @@ export class PianoRoll extends Widget {
|
||||
}
|
||||
const tick = xToTick(this.view, g.x);
|
||||
const grid = patternGrid(asset.props);
|
||||
const free = e.altKey;
|
||||
const free = e.altKey || e.shiftKey;
|
||||
switch (gesture.kind) {
|
||||
case 'pan':
|
||||
this.view = clampRows(
|
||||
@@ -665,7 +695,10 @@ export class PianoRoll extends Widget {
|
||||
if (gesture.moved && (gesture.ticks !== 0 || gesture.notes !== 0)) {
|
||||
const label = this.picked.size === 1 ? 'Move note' : 'Move notes';
|
||||
this.commit(asset, shifted(asset.notes, this.picked, gesture.ticks, gesture.notes), label);
|
||||
} else if (!gesture.moved) this.picked = new Set([gesture.anchor.id]);
|
||||
} else if (!gesture.moved) {
|
||||
if (gesture.shift === null) this.picked = new Set([gesture.anchor.id]);
|
||||
else if (gesture.shift === 'unpick') this.picked.delete(gesture.anchor.id);
|
||||
}
|
||||
break;
|
||||
case 'resize':
|
||||
if (gesture.by !== 0) {
|
||||
@@ -675,7 +708,7 @@ export class PianoRoll extends Widget {
|
||||
this.picked.size === 1 ? 'Resize note' : 'Resize notes',
|
||||
);
|
||||
this.lastLength = gesture.anchor.length + gesture.by;
|
||||
}
|
||||
} else if (gesture.shift === 'unpick') this.picked.delete(gesture.anchor.id);
|
||||
break;
|
||||
case 'trim':
|
||||
if (gesture.by !== 0) {
|
||||
@@ -685,7 +718,7 @@ export class PianoRoll extends Widget {
|
||||
this.picked.size === 1 ? 'Resize note' : 'Resize notes',
|
||||
);
|
||||
this.lastLength = gesture.anchor.length - gesture.by;
|
||||
}
|
||||
} else if (gesture.shift === 'unpick') this.picked.delete(gesture.anchor.id);
|
||||
break;
|
||||
case 'erase':
|
||||
if (gesture.erased.size > 0) {
|
||||
@@ -750,7 +783,19 @@ export class PianoRoll extends Widget {
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** Where a press or a drag on the ruler puts the start: on the nearest grid line, with Alt on the tick. */
|
||||
/** Where a paste goes with the pointer over the grid: the grid line before it and its row; else null. */
|
||||
private pasteSpot(asset: PatternAsset): { tick: number; note: number } | null {
|
||||
const p = this.pointer;
|
||||
if (!p || p.x > this.w || p.y < 0 || this.areaAt(p) !== 'grid') return null;
|
||||
const g = this.inGrid(p);
|
||||
const tick = xToTick(this.view, g.x);
|
||||
const note = noteAtY(this.view, g.y);
|
||||
// Left of tick 0 nothing lands, as nothing is drawn there.
|
||||
if (note === null || tick < 0) return null;
|
||||
return { tick: snapDown(tick, patternGrid(asset.props)), note };
|
||||
}
|
||||
|
||||
/** Where a press or a drag on the ruler puts the start: on the nearest grid line, with Alt or Shift on the tick. */
|
||||
private cursorTick(tick: number, grid: number, free: boolean): number {
|
||||
return Math.max(0, free ? Math.round(tick) : snapNear(tick, grid));
|
||||
}
|
||||
@@ -952,6 +997,11 @@ export class PianoRoll extends Widget {
|
||||
this.gridG.rect(0, 0, zero, gh).fill({ color: 0x000000, alpha: 0.5 });
|
||||
this.laneG.rect(0, 0, zero, VELOCITY_HEIGHT).fill({ color: 0x000000, alpha: 0.35 });
|
||||
}
|
||||
// Above the highest note and below the lowest is there to look at too.
|
||||
const top = Math.min(gh, noteTop(this.view, HIGHEST_NOTE));
|
||||
const bottom = Math.max(0, noteTop(this.view, LOWEST_NOTE) + this.view.rowHeight);
|
||||
if (top > 0) this.gridG.rect(0, 0, gw, top).fill({ color: 0x000000, alpha: 0.5 });
|
||||
if (bottom < gh) this.gridG.rect(0, bottom, gw, gh - bottom).fill({ color: 0x000000, alpha: 0.5 });
|
||||
}
|
||||
|
||||
private drawNotes(notes: readonly PatternNote[], length: number, gw: number, gh: number): void {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { clamp } from '../../ui';
|
||||
|
||||
/**
|
||||
* What the piano roll does to notes (#333), as plain functions on lists so a test can check them.
|
||||
* Everything snaps to the grid unless `free` – Alt held –, which puts it on the tick.
|
||||
* Everything snaps to the grid unless `free` – Alt or Shift held –, which puts it on the tick.
|
||||
*/
|
||||
|
||||
/** The grid line at or before a tick. */
|
||||
@@ -134,11 +134,22 @@ export function duplicateOffset(
|
||||
return Math.max(grid, Math.ceil((end - first) / grid) * grid);
|
||||
}
|
||||
|
||||
/** Copies of notes with ids of their own, moved so the earliest starts at `at`. */
|
||||
export function placedCopies(notes: readonly PatternNote[], at: number): PatternNote[] {
|
||||
/**
|
||||
* Copies of notes with ids of their own, moved so the earliest starts at `at` – and, given `top`,
|
||||
* the highest lands on that note, as far as the keyboard lets them all go.
|
||||
*/
|
||||
export function placedCopies(notes: readonly PatternNote[], at: number, top?: number): PatternNote[] {
|
||||
if (notes.length === 0) return [];
|
||||
const first = Math.min(...notes.map((n) => n.tick));
|
||||
return notes.map((n) => ({ ...n, id: newId(), tick: Math.max(0, at) + (n.tick - first) }));
|
||||
const low = Math.min(...notes.map((n) => n.note));
|
||||
const high = Math.max(...notes.map((n) => n.note));
|
||||
const pitch = top === undefined ? 0 : clamp(top - high, LOWEST_NOTE - low, HIGHEST_NOTE - high);
|
||||
return notes.map((n) => ({
|
||||
...n,
|
||||
id: newId(),
|
||||
tick: Math.max(0, at) + (n.tick - first),
|
||||
note: n.note + pitch,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@ import { HIGHEST_NOTE, LOWEST_NOTE } from '../../core/instrument/notes';
|
||||
import type { PatternNote } from '../../core/music/pattern';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { clamp } from '../../ui';
|
||||
import { type TimeView, tickToX } from '../music/timeView';
|
||||
import { type TimeView, clampRowPan, tickToX } from '../music/timeView';
|
||||
|
||||
/**
|
||||
* The piano roll's geometry (#333), apart from Pixi so a test can drive it. Across it is a time
|
||||
@@ -17,7 +17,7 @@ export interface RollView extends TimeView {
|
||||
|
||||
export const ROWS = HIGHEST_NOTE - LOWEST_NOTE + 1;
|
||||
export const MIN_ROW_HEIGHT = 6;
|
||||
export const MAX_ROW_HEIGHT = 28;
|
||||
export const MAX_ROW_HEIGHT = 64;
|
||||
export const DEFAULT_ROW_HEIGHT = 14;
|
||||
/** How far in from an end of a note a press takes that end rather than the note. */
|
||||
export const END_GRIP = 6;
|
||||
@@ -36,10 +36,9 @@ export function noteAtY(view: RollView, y: number): number | null {
|
||||
return note >= LOWEST_NOTE && note <= HIGHEST_NOTE ? note : null;
|
||||
}
|
||||
|
||||
/** Keeps the rows on screen: no gap above the highest note or below the lowest. */
|
||||
/** Keeps the rows in reach: half the height past the highest note or the lowest at most. */
|
||||
export function clampRows(view: RollView, height: number): RollView {
|
||||
const min = Math.min(0, height - ROWS * view.rowHeight);
|
||||
return { ...view, panY: clamp(view.panY, min, 0) };
|
||||
return { ...view, panY: clampRowPan(view.panY, ROWS * view.rowHeight, height) };
|
||||
}
|
||||
|
||||
/** Zooms the rows about `y`, so the note under the pointer stays under it. */
|
||||
@@ -51,10 +50,10 @@ export function zoomRows(view: RollView, factor: number, y: number): RollView {
|
||||
|
||||
/**
|
||||
* The rows from `low` to `high` in the middle of `height` pixels, a row of room above and below:
|
||||
* at the default height where they fit, else lower.
|
||||
* as high as that fills the height, within what the rows can be zoomed to.
|
||||
*/
|
||||
export function centreRows(low: number, high: number, height: number): Pick<RollView, 'panY' | 'rowHeight'> {
|
||||
const rowHeight = clamp(height / (high - low + 3), MIN_ROW_HEIGHT, DEFAULT_ROW_HEIGHT);
|
||||
const rowHeight = clamp(height / (high - low + 3), MIN_ROW_HEIGHT, MAX_ROW_HEIGHT);
|
||||
const middle = (HIGHEST_NOTE - (low + high) / 2 + 0.5) * rowHeight;
|
||||
return { rowHeight, panY: height / 2 - middle };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user