Size notes by either end and pan before bar one in music tabs
A round of small fixes to the Pattern and Song tabs: what the ruler and a pan do in both, and a few things the tabs carried that were in the way. - A middle-drag can take bar 1 up to half the grid or lanes in. The time before it is drawn dark on the ruler, the grid and the lanes. Nothing lands there: a click draws no note, a drop shows no frame. - A press or drag on the ruler puts the start on the nearest grid line, no longer on the one before the pointer. - A new song has one track instead of four. The "+ Track" row under the heads is gone, a head's menu adds tracks above or below. A song still keeps at least one, so that menu is always there. - A note can be sized by its left end too: the start goes to the nearest grid line and the end stays put, never before tick 0. - An end of a note under the pointer lights up and shows a resize cursor, as on an item of the Song tab. Tests: - Unit: piano-roll, song-edit, song, song-play 52 tests. Two new cases for the left end and the pan, the song fixtures add their tracks themselves now. - e2e 64+65 on melektaus-atx: ALL PASSED - 50 checks (+2). e2e 65 adds its tracks through the head's menu and clicks the ruler three quarters into a bar, which only the nearest line passes. - Gate on melektaus-atx: 251 files, 3909 tests, CHECK EXIT 0. - A probe shot of the piano roll panned in: the time before bar 1 dark, the left end of a note lit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa097bd076
commit
a3101c590c
15 files changed
+260
-89
No files matched your search
@@ -56,7 +56,7 @@ export const SONG_GRID_KEY = 'grid';
|
||||
export const DEFAULT_SONG_GRID = 'bar';
|
||||
export const SONG_GRID_OPTIONS = [{ value: 'bar', label: 'Bar' }, ...GRID_OPTIONS];
|
||||
/** A new song has this many tracks. */
|
||||
export const DEFAULT_TRACKS = 4;
|
||||
export const DEFAULT_TRACKS = 1;
|
||||
/** A song is never shorter than this many bars, on the ruler or when it plays. */
|
||||
export const MIN_BARS = 1;
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ export function songHooks({ state, editor }: HookContext) {
|
||||
laneAt: (tick: number, track: number) => timeline.lanePoint(tick, track),
|
||||
itemAt: (itemId: string, part?: 'start' | 'body' | 'end') => timeline.itemPoint(itemId, part),
|
||||
headAt: (track: number, part: HeadPart, share?: number) => timeline.headPoint(track, part, share),
|
||||
addTrackAt: () => timeline.addTrackPoint(),
|
||||
rulerAt: (tick: number) => timeline.rulerPoint(tick),
|
||||
loopAt: (tick: number) => timeline.loopPoint(tick),
|
||||
view: () => timeline.viewInfo(),
|
||||
|
||||
@@ -44,6 +44,9 @@ export class TimeRuler extends Container {
|
||||
const x1 = Math.min(width, tickToX(view, marks.loop.end));
|
||||
if (x1 > x0) g.rect(x0, 1, x1 - x0, LOOP_STRIP - 2).fill({ color: c.accent, alpha: 0.75 });
|
||||
}
|
||||
// Before tick 0 there is nothing to play: it reads darker.
|
||||
const zero = Math.min(width, tickToX(view, 0));
|
||||
if (zero > 0) g.rect(0, 0, zero, RULER_HEIGHT).fill({ color: 0x000000, alpha: 0.3 });
|
||||
g.rect(0, LOOP_STRIP, width, 1).fill(c.border);
|
||||
g.rect(0, RULER_HEIGHT - 1, width, 1).fill(c.border);
|
||||
|
||||
|
||||
@@ -37,9 +37,16 @@ export function zoomTime(view: TimeView, factor: number, x: number): TimeView {
|
||||
return { panX: x - tick * pxPerTick, pxPerTick };
|
||||
}
|
||||
|
||||
/** Time starts at 0: nothing to look at left of it. */
|
||||
export function clampTimePan<V extends TimeView>(view: V): V {
|
||||
return view.panX > 0 ? { ...view, panX: 0 } : view;
|
||||
/** How far right of the area's left edge tick 0 can be panned, as a share of the area's width. */
|
||||
export const TIME_LEAD = 0.5;
|
||||
|
||||
/**
|
||||
* Time starts at 0, but it need not stand at the left edge: a pan can take it up to half the area in,
|
||||
* to look at, with nothing to place left of it.
|
||||
*/
|
||||
export function clampTimePan<V extends TimeView>(view: V, width: number): V {
|
||||
const most = Math.max(0, width * TIME_LEAD);
|
||||
return view.panX > most ? { ...view, panX: most } : view;
|
||||
}
|
||||
|
||||
/** `length` ticks across `width` pixels, from tick 0. */
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { EditorState } from '../state';
|
||||
import {
|
||||
clampShift,
|
||||
draggedLength,
|
||||
draggedStart,
|
||||
drawnNote,
|
||||
duplicateOffset,
|
||||
moveDelta,
|
||||
@@ -46,12 +47,16 @@ import {
|
||||
shifted,
|
||||
snapDown,
|
||||
snapNear,
|
||||
trimmed,
|
||||
withVelocity,
|
||||
} from './rollEdit';
|
||||
import {
|
||||
centreRows,
|
||||
clampRows,
|
||||
DEFAULT_ROW_HEIGHT,
|
||||
END_GRIP,
|
||||
type NoteBox,
|
||||
type NoteHit,
|
||||
noteAt,
|
||||
noteAtY,
|
||||
noteBox,
|
||||
@@ -106,6 +111,7 @@ type Gesture =
|
||||
pointer: Vec2;
|
||||
}
|
||||
| { kind: 'resize'; anchor: PatternNote; by: number; moved: boolean }
|
||||
| { kind: 'trim'; anchor: PatternNote; by: number }
|
||||
| { kind: 'erase'; erased: Set<string> }
|
||||
| { kind: 'velocity'; anchor: PatternNote; value: number };
|
||||
|
||||
@@ -121,8 +127,8 @@ function mix(a: number, b: number, t: number): number {
|
||||
/**
|
||||
* The Pattern tab's canvas (#333): the keys down the left, a ruler along the top, the notes in a grid
|
||||
* between them and their velocities in a lane below. A click into the grid draws a note, a drag spans
|
||||
* a band that picks notes; a note is dragged by its body, lengthened by its right end, and goes with a
|
||||
* right-click or Delete. A press or a drag on the ruler moves where playing starts, one on the strip
|
||||
* a band that picks notes; a note is dragged by its body, sized by either end, which lights up under
|
||||
* the pointer, and goes with a right-click or Delete. A press or a drag on the ruler moves where playing starts, one on the strip
|
||||
* along its top sets the loop. The wheel zooms: the time over the grid, the rows over the keys.
|
||||
*/
|
||||
export class PianoRoll extends Widget {
|
||||
@@ -149,6 +155,8 @@ export class PianoRoll extends Widget {
|
||||
private needsFit = true;
|
||||
private assetId = '';
|
||||
private gesture: Gesture | null = null;
|
||||
/** The part of a note under the pointer while nothing is held. */
|
||||
private hover: NoteHit | 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. */
|
||||
@@ -217,6 +225,7 @@ export class PianoRoll extends Widget {
|
||||
if (this.assetId === assetId) return;
|
||||
this.endGesture();
|
||||
this.assetId = assetId;
|
||||
this.hover = null;
|
||||
this.picked.clear();
|
||||
this.startTick = 0;
|
||||
this.needsFit = true;
|
||||
@@ -419,6 +428,8 @@ export class PianoRoll extends Widget {
|
||||
playhead: number | null;
|
||||
loop: LoopRange | null;
|
||||
keys: number | null;
|
||||
hover: NoteHit | null;
|
||||
cursor: string;
|
||||
} {
|
||||
const g = this.gesture;
|
||||
return {
|
||||
@@ -427,6 +438,8 @@ export class PianoRoll extends Widget {
|
||||
playhead: this.playhead,
|
||||
loop: this.shownLoop(),
|
||||
keys: g?.kind === 'key' ? g.note : null,
|
||||
hover: this.hover,
|
||||
cursor: String(this.cursor ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -531,6 +544,10 @@ export class PianoRoll extends Widget {
|
||||
this.gesture = { kind: 'resize', anchor, by: 0, moved: false };
|
||||
return;
|
||||
}
|
||||
if (hit.part === 'start') {
|
||||
this.gesture = { kind: 'trim', anchor, by: 0 };
|
||||
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.onPreview(anchor.note, false);
|
||||
@@ -539,20 +556,27 @@ export class PianoRoll extends Widget {
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
const gesture = this.gesture;
|
||||
const asset = this.asset();
|
||||
if (!gesture || !asset) return;
|
||||
if (!asset) return;
|
||||
const local = this.toLocal(e.global);
|
||||
const g = this.inGrid({ x: local.x, y: local.y });
|
||||
if (!gesture) {
|
||||
this.followHover(asset, local, g);
|
||||
return;
|
||||
}
|
||||
const tick = xToTick(this.view, g.x);
|
||||
const grid = patternGrid(asset.props);
|
||||
const free = e.altKey;
|
||||
switch (gesture.kind) {
|
||||
case 'pan':
|
||||
this.view = clampRows(
|
||||
clampTimePan({
|
||||
...this.view,
|
||||
panX: gesture.panX + (local.x - gesture.pointer.x),
|
||||
panY: gesture.panY + (local.y - gesture.pointer.y),
|
||||
}),
|
||||
clampTimePan(
|
||||
{
|
||||
...this.view,
|
||||
panX: gesture.panX + (local.x - gesture.pointer.x),
|
||||
panY: gesture.panY + (local.y - gesture.pointer.y),
|
||||
},
|
||||
this.gridWidth,
|
||||
),
|
||||
this.gridHeight,
|
||||
);
|
||||
break;
|
||||
@@ -596,6 +620,9 @@ export class PianoRoll extends Widget {
|
||||
gesture.moved = true;
|
||||
gesture.by = draggedLength(gesture.anchor, tick, grid, free) - gesture.anchor.length;
|
||||
break;
|
||||
case 'trim':
|
||||
gesture.by = draggedStart(gesture.anchor, tick, grid, free) - gesture.anchor.tick;
|
||||
break;
|
||||
case 'erase':
|
||||
this.erase(asset, g);
|
||||
break;
|
||||
@@ -650,6 +677,16 @@ export class PianoRoll extends Widget {
|
||||
this.lastLength = gesture.anchor.length + gesture.by;
|
||||
}
|
||||
break;
|
||||
case 'trim':
|
||||
if (gesture.by !== 0) {
|
||||
this.commit(
|
||||
asset,
|
||||
trimmed(asset.notes, this.picked, gesture.by),
|
||||
this.picked.size === 1 ? 'Resize note' : 'Resize notes',
|
||||
);
|
||||
this.lastLength = gesture.anchor.length - gesture.by;
|
||||
}
|
||||
break;
|
||||
case 'erase':
|
||||
if (gesture.erased.size > 0) {
|
||||
this.commit(
|
||||
@@ -680,7 +717,8 @@ export class PianoRoll extends Widget {
|
||||
|
||||
private draw(asset: PatternAsset, at: Vec2, grid: number, free: boolean): void {
|
||||
const note = noteAtY(this.view, at.y);
|
||||
if (note === null) return;
|
||||
// Left of tick 0 is there to look at, not to draw on.
|
||||
if (note === null || xToTick(this.view, at.x) < 0) return;
|
||||
const drawn = drawnNote(
|
||||
xToTick(this.view, at.x),
|
||||
note,
|
||||
@@ -702,9 +740,19 @@ export class PianoRoll extends Widget {
|
||||
if (hit) this.gesture.erased.add(hit.id);
|
||||
}
|
||||
|
||||
/** Where a press or a drag on the ruler puts the start: on the grid line before it, with Alt on the tick. */
|
||||
/** The part of a note under the pointer: an end it can be dragged by shows a resize cursor and lights up. */
|
||||
private followHover(asset: PatternAsset, local: Vec2, g: Vec2): void {
|
||||
const hit = this.areaAt(local) === 'grid' && local.x <= this.w ? noteAt(this.view, asset.notes, g) : null;
|
||||
const cursor = hit && hit.part !== 'body' ? 'ew-resize' : 'default';
|
||||
if (this.cursor !== cursor) this.cursor = cursor;
|
||||
if (JSON.stringify(hit) === JSON.stringify(this.hover)) return;
|
||||
this.hover = hit;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** Where a press or a drag on the ruler puts the start: on the nearest grid line, with Alt on the tick. */
|
||||
private cursorTick(tick: number, grid: number, free: boolean): number {
|
||||
return Math.max(0, free ? Math.round(tick) : snapDown(tick, grid));
|
||||
return Math.max(0, free ? Math.round(tick) : snapNear(tick, grid));
|
||||
}
|
||||
|
||||
private setStart(tick: number): void {
|
||||
@@ -745,7 +793,7 @@ export class PianoRoll extends Widget {
|
||||
else if (area === 'ruler' || area === 'grid' || area === 'lane')
|
||||
view = { ...this.view, ...zoomTime(this.view, factor, g.x) };
|
||||
else return;
|
||||
this.view = clampRows(clampTimePan(view), this.gridHeight);
|
||||
this.view = clampRows(clampTimePan(view, this.gridWidth), this.gridHeight);
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
e.preventDefault();
|
||||
@@ -758,6 +806,7 @@ export class PianoRoll extends Widget {
|
||||
const g = this.gesture;
|
||||
if (g?.kind === 'move' && g.moved) return shifted(asset.notes, this.picked, g.ticks, g.notes);
|
||||
if (g?.kind === 'resize') return resized(asset.notes, this.picked, g.by);
|
||||
if (g?.kind === 'trim') return trimmed(asset.notes, this.picked, g.by);
|
||||
if (g?.kind === 'velocity') return withVelocity(asset.notes, this.picked, g.anchor, g.value);
|
||||
if (g?.kind === 'erase') return asset.notes.filter((n) => !g.erased.has(n.id));
|
||||
return asset.notes;
|
||||
@@ -898,6 +947,11 @@ export class PianoRoll extends Widget {
|
||||
.rect(Math.max(0, end), 0, gw - Math.max(0, end), VELOCITY_HEIGHT)
|
||||
.fill({ color: 0x000000, alpha: 0.25 });
|
||||
}
|
||||
const zero = Math.min(gw, tickToX(this.view, 0));
|
||||
if (zero > 0) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
private drawNotes(notes: readonly PatternNote[], length: number, gw: number, gh: number): void {
|
||||
@@ -912,9 +966,22 @@ export class PianoRoll extends Widget {
|
||||
.rect(box.x, box.y + 1, w, Math.max(1, box.h - 1))
|
||||
.fill({ color: picked ? mix(HARD, 0xffffff, 0.35) : mix(SOFT, HARD, note.velocity), alpha })
|
||||
.stroke({ width: 1, color: picked ? c.accentText : c.viewport, alpha, alignment: 1 });
|
||||
if (this.hover?.id === note.id && this.hover.part !== 'body')
|
||||
this.drawGrip({ ...box, w: Math.max(4, box.w) }, this.hover.part);
|
||||
}
|
||||
}
|
||||
|
||||
/** The end of a note the pointer would drag, lit, as on an item of the Song tab. */
|
||||
private drawGrip(box: NoteBox, part: 'start' | 'end'): void {
|
||||
const reach = Math.min(END_GRIP, box.w / 3);
|
||||
const y = box.y + 1;
|
||||
const h = Math.max(1, box.h - 1);
|
||||
this.gridG
|
||||
.rect(part === 'end' ? box.x + box.w - reach : box.x, y, reach, h)
|
||||
.fill({ color: 0xffffff, alpha: 0.35 });
|
||||
this.gridG.rect(part === 'end' ? box.x + box.w - 2 : box.x, y, 2, h).fill(Theme.colors.accentText);
|
||||
}
|
||||
|
||||
private drawLane(notes: readonly PatternNote[], length: number, gw: number): void {
|
||||
const c = Theme.colors;
|
||||
this.laneG.rect(0, VELOCITY_HEIGHT / 2, gw, 1).fill({ color: c.border, alpha: 0.5 });
|
||||
|
||||
@@ -95,6 +95,32 @@ export function resized(
|
||||
return notes.map((n) => (picked.has(n.id) ? { ...n, length: Math.max(1, n.length + by) } : n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the grabbed note starts when its start is dragged to a tick: on the grid line nearest the
|
||||
* pointer, its end staying put. Never up to its end – then on the last grid line before it – and
|
||||
* never before tick 0.
|
||||
*/
|
||||
export function draggedStart(anchor: PatternNote, pointerTick: number, grid: number, free: boolean): number {
|
||||
const end = anchor.tick + anchor.length;
|
||||
if (free) return clamp(Math.round(pointerTick), 0, end - 1);
|
||||
const start = snapNear(pointerTick, grid);
|
||||
return Math.max(0, start < end ? start : snapDown(end - 1, grid));
|
||||
}
|
||||
|
||||
/** Every picked note's start moved by what the grabbed one's was, its end kept, none shorter than a tick. */
|
||||
export function trimmed(
|
||||
notes: readonly PatternNote[],
|
||||
picked: ReadonlySet<string>,
|
||||
by: number,
|
||||
): PatternNote[] {
|
||||
return notes.map((n) => {
|
||||
if (!picked.has(n.id)) return n;
|
||||
const end = n.tick + n.length;
|
||||
const tick = clamp(n.tick + by, 0, end - 1);
|
||||
return { ...n, tick, length: end - tick };
|
||||
});
|
||||
}
|
||||
|
||||
/** How far right a copy of the picked notes goes so it stands right behind them: whole grid steps. */
|
||||
export function duplicateOffset(
|
||||
notes: readonly PatternNote[],
|
||||
|
||||
@@ -19,8 +19,8 @@ export const ROWS = HIGHEST_NOTE - LOWEST_NOTE + 1;
|
||||
export const MIN_ROW_HEIGHT = 6;
|
||||
export const MAX_ROW_HEIGHT = 28;
|
||||
export const DEFAULT_ROW_HEIGHT = 14;
|
||||
/** How far in from a note's right end a press takes its length rather than the note. */
|
||||
const END_GRIP = 6;
|
||||
/** How far in from an end of a note a press takes that end rather than the note. */
|
||||
export const END_GRIP = 6;
|
||||
/** A note narrower than this is still this wide to the pointer. */
|
||||
const MIN_HIT_WIDTH = 4;
|
||||
/** How close to a bar of the velocity lane a press must be to take it. */
|
||||
@@ -73,10 +73,12 @@ export function noteBox(view: RollView, note: Pick<PatternNote, 'tick' | 'length
|
||||
|
||||
export interface NoteHit {
|
||||
id: string;
|
||||
/** The body moves the note, the end changes its length. */
|
||||
part: 'body' | 'end';
|
||||
/** The body moves the note, an end moves that end and so changes its length. */
|
||||
part: NotePart;
|
||||
}
|
||||
|
||||
export type NotePart = 'start' | 'body' | 'end';
|
||||
|
||||
/** The note under a point; where notes lie over each other, the one drawn last. */
|
||||
export function noteAt(view: RollView, notes: readonly PatternNote[], point: Vec2): NoteHit | null {
|
||||
for (let i = notes.length - 1; i >= 0; i--) {
|
||||
@@ -85,7 +87,8 @@ export function noteAt(view: RollView, notes: readonly PatternNote[], point: Vec
|
||||
const w = Math.max(box.w, MIN_HIT_WIDTH);
|
||||
if (point.x < box.x || point.x >= box.x + w || point.y < box.y || point.y >= box.y + box.h) continue;
|
||||
const grip = Math.min(END_GRIP, w / 3);
|
||||
return { id: note.id, part: point.x >= box.x + w - grip ? 'end' : 'body' };
|
||||
const part = point.x >= box.x + w - grip ? 'end' : point.x < box.x + grip ? 'start' : 'body';
|
||||
return { id: note.id, part };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,6 @@ import {
|
||||
trimmed,
|
||||
} from './songEdit';
|
||||
import {
|
||||
ADD_ROW_HEIGHT,
|
||||
type Box,
|
||||
clampTracks,
|
||||
HEAD_BOXES,
|
||||
@@ -182,7 +181,6 @@ export class SongTimeline extends Widget {
|
||||
private readonly ruler = new TimeRuler();
|
||||
private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() };
|
||||
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
||||
private readonly addLabel = crispText({ text: '+ Track', style: textStyle('muted') });
|
||||
private view: SongView = { panX: 0, pxPerTick: 0.02, panY: 0, trackHeight: TRACK_HEIGHT };
|
||||
private needsFit = true;
|
||||
private assetId = '';
|
||||
@@ -210,7 +208,7 @@ export class SongTimeline extends Widget {
|
||||
super();
|
||||
this.focusable = true;
|
||||
this.lanesLayer.addChild(this.lanesG, this.itemsG, this.itemLabels, this.overlay);
|
||||
this.headsLayer.addChild(this.headsG, this.headLabels, this.addLabel);
|
||||
this.headsLayer.addChild(this.headsG, this.headLabels);
|
||||
this.addChild(this.bg, this.lanesLayer, this.headsLayer, this.ruler, this.hint);
|
||||
for (const clip of Object.values(this.clips)) this.addChild(clip);
|
||||
this.lanesLayer.mask = this.clips.lanes;
|
||||
@@ -219,7 +217,6 @@ export class SongTimeline extends Widget {
|
||||
this.hint.eventMode = 'none';
|
||||
this.itemLabels.eventMode = 'none';
|
||||
this.headLabels.eventMode = 'none';
|
||||
this.addLabel.eventMode = 'none';
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||
@@ -489,13 +486,6 @@ export class SongTimeline extends Widget {
|
||||
};
|
||||
}
|
||||
|
||||
addTrackPoint(): Vec2 {
|
||||
const asset = this.asset();
|
||||
const at = this.getGlobalPosition();
|
||||
const top = trackTop(this.view, asset?.tracks.length ?? 0);
|
||||
return { x: at.x + HEAD_WIDTH / 2, y: at.y + RULER_HEIGHT + top + ADD_ROW_HEIGHT / 2 };
|
||||
}
|
||||
|
||||
rulerPoint(tick: number): Vec2 {
|
||||
const at = this.getGlobalPosition();
|
||||
return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT - 6 };
|
||||
@@ -621,11 +611,7 @@ export class SongTimeline extends Widget {
|
||||
|
||||
private pressHead(asset: SongAsset, e: FederatedPointerEvent, y: number, x: number): void {
|
||||
const row = trackAtY(this.view, asset.tracks.length, y);
|
||||
if (row === null) {
|
||||
const below = y - trackTop(this.view, asset.tracks.length);
|
||||
if (e.button === 0 && below >= 0 && below < ADD_ROW_HEIGHT) this.addTrack();
|
||||
return;
|
||||
}
|
||||
if (row === null) return;
|
||||
const track = asset.tracks[row]!;
|
||||
if (e.button === 2) {
|
||||
this.openTrackMenu(asset, row, e.global);
|
||||
@@ -711,11 +697,14 @@ export class SongTimeline extends Widget {
|
||||
switch (gesture.kind) {
|
||||
case 'pan':
|
||||
this.view = clampTracks(
|
||||
clampTimePan({
|
||||
...this.view,
|
||||
panX: gesture.panX + (local.x - gesture.pointer.x),
|
||||
panY: gesture.panY + (local.y - gesture.pointer.y),
|
||||
}),
|
||||
clampTimePan(
|
||||
{
|
||||
...this.view,
|
||||
panX: gesture.panX + (local.x - gesture.pointer.x),
|
||||
panY: gesture.panY + (local.y - gesture.pointer.y),
|
||||
},
|
||||
this.lanesWidth,
|
||||
),
|
||||
asset.tracks.length,
|
||||
this.lanesHeight,
|
||||
);
|
||||
@@ -851,7 +840,13 @@ export class SongTimeline extends Widget {
|
||||
/** Follows an asset dragged in from the panel: a pattern or a sound lands on the lane under the pointer. */
|
||||
private trackDrop(asset: SongAsset, assetId: string, local: Vec2, g: Vec2): void {
|
||||
const dragged = findAsset(this.state.project, assetId);
|
||||
const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h;
|
||||
// Left of tick 0 is there to look at: nothing lands there.
|
||||
const inside =
|
||||
local.x >= HEAD_WIDTH &&
|
||||
local.x <= this.w &&
|
||||
local.y >= RULER_HEIGHT &&
|
||||
local.y <= this.h &&
|
||||
xToTick(this.view, g.x) >= 0;
|
||||
const row = trackAtY(this.view, asset.tracks.length, g.y);
|
||||
if (!inside || row === null || !dragged || (dragged.type !== 'pattern' && dragged.type !== 'sound')) {
|
||||
if (this.drop) {
|
||||
@@ -885,9 +880,9 @@ export class SongTimeline extends Widget {
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** Where a press or a drag on the ruler puts the start: on the grid line before it, with Alt on the tick. */
|
||||
/** Where a press or a drag on the ruler puts the start: on the nearest grid line, with Alt on the tick. */
|
||||
private cursorTick(tick: number, grid: number, free: boolean): number {
|
||||
return Math.max(0, free ? Math.round(tick) : snapDown(tick, grid));
|
||||
return Math.max(0, free ? Math.round(tick) : snapNear(tick, grid));
|
||||
}
|
||||
|
||||
private setStart(tick: number): void {
|
||||
@@ -969,7 +964,7 @@ export class SongTimeline extends Widget {
|
||||
else if (area === 'ruler' || area === 'lanes')
|
||||
view = { ...this.view, ...zoomTime(this.view, factor, g.x) };
|
||||
else return;
|
||||
this.view = clampTracks(clampTimePan(view), asset.tracks.length, this.lanesHeight);
|
||||
this.view = clampTracks(clampTimePan(view, this.lanesWidth), asset.tracks.length, this.lanesHeight);
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
e.preventDefault();
|
||||
@@ -1085,7 +1080,6 @@ export class SongTimeline extends Widget {
|
||||
if (!asset) {
|
||||
this.syncTexts(this.itemTexts, this.itemLabels, []);
|
||||
this.syncTexts(this.headTexts, this.headLabels, []);
|
||||
this.addLabel.visible = false;
|
||||
this.ruler.visible = false;
|
||||
this.hint.text = 'No song selected – pick one in the Assets panel or create one.';
|
||||
this.hint.position.set(HEAD_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
|
||||
@@ -1161,6 +1155,8 @@ export class SongTimeline extends Widget {
|
||||
this.lanesG
|
||||
.rect(Math.max(0, end), 0, lw - Math.max(0, end), bottom)
|
||||
.fill({ color: 0x000000, alpha: 0.2 });
|
||||
const zero = Math.min(lw, tickToX(this.view, 0));
|
||||
if (zero > 0) this.lanesG.rect(0, 0, zero, bottom).fill({ color: 0x000000, alpha: 0.4 });
|
||||
}
|
||||
|
||||
private drawItems(asset: SongAsset, lw: number, lh: number): void {
|
||||
@@ -1287,7 +1283,7 @@ export class SongTimeline extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/** The heads: name, mute and solo, the volume and pan sliders, and the row that adds a track. */
|
||||
/** The heads: name, mute and solo, the volume and pan sliders. A track is added from a head's menu. */
|
||||
private drawHeads(tracks: readonly SongTrack[]): void {
|
||||
const c = Theme.colors;
|
||||
const labels: Placed[] = [];
|
||||
@@ -1328,10 +1324,6 @@ export class SongTimeline extends Widget {
|
||||
this.headsG.rect(centre, y + pan.y - 2, 1, pan.h + 4).fill({ color: c.text, alpha: 0.5 });
|
||||
});
|
||||
this.syncTexts(this.headTexts, this.headLabels, labels);
|
||||
const addTop = trackTop(this.view, tracks.length);
|
||||
this.headsG.rect(0, addTop, HEAD_WIDTH - 1, ADD_ROW_HEIGHT).fill({ color: 0xffffff, alpha: 0.02 });
|
||||
this.addLabel.visible = true;
|
||||
this.addLabel.position.set(Theme.padding, addTop + (ADD_ROW_HEIGHT - this.addLabel.height) / 2);
|
||||
}
|
||||
|
||||
private toggle(box: Box, y: number, on: boolean, color: number): void {
|
||||
|
||||
@@ -19,8 +19,6 @@ export const MIN_TRACK_HEIGHT = 28;
|
||||
export const MAX_TRACK_HEIGHT = 160;
|
||||
/** A track lower than this has no room for the volume and pan sliders in its head. */
|
||||
export const SLIDERS_MIN_HEIGHT = 46;
|
||||
/** The row under the last track that adds one. */
|
||||
export const ADD_ROW_HEIGHT = 28;
|
||||
/** How close to an end of an item a press takes that end. */
|
||||
export const EDGE_REACH = 6;
|
||||
const INSET = 2;
|
||||
@@ -149,9 +147,9 @@ export function peakOver(peaks: ArrayLike<number>, from: number, to: number): nu
|
||||
return peak;
|
||||
}
|
||||
|
||||
/** Keeps the tracks and the row that adds one in reach: nothing to scroll to above or far below. */
|
||||
/** Keeps the tracks in reach: nothing to scroll to above the first or below the last. */
|
||||
export function clampTracks<V extends SongView>(view: V, count: number, height: number): V {
|
||||
const content = count * view.trackHeight + ADD_ROW_HEIGHT;
|
||||
const content = count * view.trackHeight;
|
||||
const panY = clamp(view.panY, Math.min(0, height - content), 0);
|
||||
return panY === view.panY ? view : { ...view, panY };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user