Dragging the left edge of a song item to the right snapped to the grid and to the points where the item's content starts again. One of those points lies exactly on the item's end. When the edge caught it, the item shrank to a single tick and showed as a thin white line. The right edge already refused to snap back on to its own start, the left edge had no such rule. - trimDelta: a snapped start on or past the item's end now takes the last grid line before the end instead. - If that grid line lies before the current start, the start stays where it is. - Free trimming with Alt or Shift is unchanged and may still shrink the item to one tick. - Dragging the left edge far past the end now leaves one grid step, as the right edge already did, instead of one tick. Tests: - song-edit unit tests: 14 green (2 new expectations, 1 changed) - Counter-check with the rule removed: 2 red (1399 instead of 960, 1919 instead of 1800) - Prettier, ESLint, tsc clean Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
270 lines
9.9 KiB
TypeScript
270 lines
9.9 KiB
TypeScript
import { newId } from '../../core/ids';
|
||
import { itemEnd, itemFrom, type SongItem } from '../../core/music/song';
|
||
import { secondsOfTicks, ticksOfSeconds } from '../../core/music/time';
|
||
import { snapDown, snapNear } from '../pattern/rollEdit';
|
||
|
||
/**
|
||
* What the Song tab does to items (#334), as plain functions on lists so a test can check them.
|
||
* Everything snaps to the grid unless `free` – Alt or Shift held –, which puts it on the tick; an
|
||
* end of an item snaps to where what it plays starts over, too.
|
||
*/
|
||
|
||
/** Where what an item plays starts over: every `period` ticks on from `base`, both ways. */
|
||
export interface Rounds {
|
||
base: number;
|
||
period: number;
|
||
}
|
||
|
||
/** An item's rounds, given how long one is in ticks; null while that is not known (a sound not loaded). */
|
||
export function roundsOf(item: SongItem, period: number | null, tempo: number): Rounds | null {
|
||
if (period === null || !(period > 0)) return null;
|
||
const into = offsetTicks(item, tempo);
|
||
return { base: item.tick - (((into % period) + period) % period), period };
|
||
}
|
||
|
||
/** The grid line or the start of a round nearest a tick, whichever is nearer; may be between ticks. */
|
||
export function snapEdge(tick: number, grid: number, rounds: Rounds | null): number {
|
||
const line = snapNear(tick, grid);
|
||
if (!rounds) return line;
|
||
const seam = rounds.base + Math.round((tick - rounds.base) / rounds.period) * rounds.period;
|
||
return Math.abs(seam - tick) < Math.abs(line - tick) ? seam : line;
|
||
}
|
||
|
||
/**
|
||
* How far a drag moves the picked items: the grabbed one's start lands on the grid (or the tick),
|
||
* across as many tracks as the pointer went, and none goes before tick 0 or off the tracks.
|
||
*/
|
||
export function moveDelta(
|
||
items: readonly SongItem[],
|
||
picked: ReadonlySet<string>,
|
||
rows: ReadonlyMap<string, number>,
|
||
count: number,
|
||
anchor: SongItem,
|
||
wantedStart: number,
|
||
wantedRow: number,
|
||
grid: number,
|
||
free: boolean,
|
||
): { ticks: number; tracks: number } {
|
||
const start = free ? Math.round(wantedStart) : snapNear(wantedStart, grid);
|
||
return clampMove(
|
||
items,
|
||
picked,
|
||
rows,
|
||
count,
|
||
start - anchor.tick,
|
||
wantedRow - (rows.get(anchor.track) ?? 0),
|
||
);
|
||
}
|
||
|
||
export function clampMove(
|
||
items: readonly SongItem[],
|
||
picked: ReadonlySet<string>,
|
||
rows: ReadonlyMap<string, number>,
|
||
count: number,
|
||
ticks: number,
|
||
tracks: number,
|
||
): { ticks: number; tracks: number } {
|
||
const chosen = items.filter((i) => picked.has(i.id));
|
||
if (chosen.length === 0) return { ticks: 0, tracks: 0 };
|
||
const first = Math.min(...chosen.map((i) => i.tick));
|
||
const at = chosen.map((i) => rows.get(i.track) ?? 0);
|
||
const top = Math.min(...at);
|
||
const bottom = Math.max(...at);
|
||
return {
|
||
// `|| 0`: no -0 for an item at tick 0.
|
||
ticks: Math.max(ticks, -first) || 0,
|
||
tracks: Math.max(-top, Math.min(count - 1 - bottom, tracks)) || 0,
|
||
};
|
||
}
|
||
|
||
export function moved(
|
||
items: readonly SongItem[],
|
||
picked: ReadonlySet<string>,
|
||
trackIds: readonly string[],
|
||
ticks: number,
|
||
tracks: number,
|
||
): SongItem[] {
|
||
const rows = new Map(trackIds.map((id, i) => [id, i]));
|
||
return items.map((i) => {
|
||
if (!picked.has(i.id)) return i;
|
||
const row = (rows.get(i.track) ?? 0) + tracks;
|
||
return { ...i, tick: i.tick + ticks, track: trackIds[row] ?? i.track };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The length the grabbed item takes when its end is dragged to a tick: to the grid line or the end of
|
||
* a round nearest the pointer, but never back to its own start – then to the first grid line after
|
||
* it. The end of a round between two ticks is cut to the tick before, so nothing of the next sounds.
|
||
*/
|
||
export function draggedLength(
|
||
anchor: SongItem,
|
||
pointerTick: number,
|
||
grid: number,
|
||
free: boolean,
|
||
rounds: Rounds | null = null,
|
||
): number {
|
||
if (free) return Math.max(1, Math.round(pointerTick) - anchor.tick);
|
||
const end = Math.floor(snapEdge(pointerTick, grid, rounds) + 1e-6);
|
||
return end > anchor.tick ? end - anchor.tick : snapDown(anchor.tick, grid) + grid - anchor.tick;
|
||
}
|
||
|
||
/** Every picked item lengthened by what the grabbed one was, none shorter than a tick. */
|
||
export function resized(items: readonly SongItem[], picked: ReadonlySet<string>, by: number): SongItem[] {
|
||
return items.map((i) => (picked.has(i.id) ? { ...i, length: Math.max(1, i.length + by) } : i));
|
||
}
|
||
|
||
/** How far into what it plays an item can be pulled back: its offset, in ticks. */
|
||
function offsetTicks(item: SongItem, tempo: number): number {
|
||
return item.kind === 'sound' ? ticksOfSeconds(item.offset, tempo) : item.offset;
|
||
}
|
||
|
||
/** How long one round of an item is in ticks, null where that is not known. */
|
||
export type PeriodOf = (item: SongItem) => number | null;
|
||
|
||
/**
|
||
* How far the start edge of the grabbed item moves when it is dragged to a tick: to the grid line or
|
||
* the start of a round nearest the pointer, but never on to its own end – then to the last grid line
|
||
* before it, if that is not before its start. Held so that no picked item gets shorter than a tick or
|
||
* starts before 0. One whose round is known goes on left as far as that, repeating what it plays;
|
||
* one whose round is not stops at the beginning of what it plays.
|
||
*/
|
||
export function trimDelta(
|
||
items: readonly SongItem[],
|
||
picked: ReadonlySet<string>,
|
||
anchor: SongItem,
|
||
pointerTick: number,
|
||
grid: number,
|
||
free: boolean,
|
||
tempo: number,
|
||
periodOf: PeriodOf = () => null,
|
||
): number {
|
||
const rounds = roundsOf(anchor, periodOf(anchor), tempo);
|
||
const end = itemEnd(anchor);
|
||
let start = Math.round(free ? pointerTick : snapEdge(pointerTick, grid, rounds));
|
||
if (!free && start >= end) start = Math.max(anchor.tick, Math.ceil(end / grid) * grid - grid);
|
||
let by = start - anchor.tick;
|
||
for (const item of items) {
|
||
if (!picked.has(item.id)) continue;
|
||
by = Math.min(by, item.length - 1);
|
||
by = Math.max(by, -item.tick);
|
||
if (roundsOf(item, periodOf(item), tempo) === null)
|
||
by = Math.max(by, -Math.floor(offsetTicks(item, tempo) + 1e-6));
|
||
}
|
||
return by || 0;
|
||
}
|
||
|
||
/**
|
||
* An offset pulled out before what it plays taken round to the end of a round, and one within
|
||
* `slack` of a round's start put right on it, so a start put back on a round between two ticks plays
|
||
* from the very beginning. An offset past a round is otherwise kept: the pattern may grow.
|
||
*/
|
||
function withinRound(offset: number, period: number, slack: number): number {
|
||
let into = offset < 0 ? offset + Math.ceil(-offset / period - 1e-9) * period : offset;
|
||
const round = Math.round(into / period) * period;
|
||
if (Math.abs(into - round) <= slack) into = round;
|
||
return Math.max(0, into);
|
||
}
|
||
|
||
/**
|
||
* The picked items with their start edge moved by `by` ticks, what they play staying where it was.
|
||
* Where a round is known the offset goes round it, so an item pulled out left repeats from its end.
|
||
*/
|
||
export function trimmed(
|
||
items: readonly SongItem[],
|
||
picked: ReadonlySet<string>,
|
||
by: number,
|
||
tempo: number,
|
||
periodOf: PeriodOf = () => null,
|
||
): SongItem[] {
|
||
if (by === 0) return [...items];
|
||
return items.map((i) => {
|
||
if (!picked.has(i.id)) return i;
|
||
const sound = i.kind === 'sound';
|
||
const period = periodOf(i);
|
||
let offset = sound ? i.offset + secondsOfTicks(by, tempo) : i.offset + by;
|
||
if (period !== null && period > 0)
|
||
offset = sound
|
||
? withinRound(offset, secondsOfTicks(period, tempo), secondsOfTicks(0.5, tempo) + 1e-9)
|
||
: withinRound(offset, period, 0);
|
||
else offset = Math.max(0, offset);
|
||
return { ...i, tick: i.tick + by, length: Math.max(1, i.length - by), offset };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The picked items that `at` falls inside cut in two there. Gives the new list and the ids of the
|
||
* right-hand parts, which are what is picked afterwards.
|
||
*/
|
||
export function split(
|
||
items: readonly SongItem[],
|
||
picked: ReadonlySet<string>,
|
||
at: number,
|
||
tempo: number,
|
||
): { items: SongItem[]; right: string[] } {
|
||
const out: SongItem[] = [];
|
||
const right: string[] = [];
|
||
for (const item of items) {
|
||
const rest = picked.has(item.id) ? itemFrom(item, at, tempo) : null;
|
||
if (!rest) {
|
||
out.push(item);
|
||
continue;
|
||
}
|
||
const part = { ...rest, id: newId() };
|
||
out.push({ ...item, length: part.tick - item.tick }, part);
|
||
right.push(part.id);
|
||
}
|
||
return { items: out, right };
|
||
}
|
||
|
||
/** How far right a copy of the picked items goes so it stands right behind them: whole grid steps. */
|
||
export function duplicateOffset(
|
||
items: readonly SongItem[],
|
||
picked: ReadonlySet<string>,
|
||
grid: number,
|
||
): number {
|
||
const chosen = items.filter((i) => picked.has(i.id));
|
||
if (chosen.length === 0) return 0;
|
||
const first = Math.min(...chosen.map((i) => i.tick));
|
||
const end = Math.max(...chosen.map(itemEnd));
|
||
return Math.max(grid, Math.ceil((end - first) / grid) * grid);
|
||
}
|
||
|
||
/**
|
||
* Copies of items with ids of their own, moved so the earliest starts at `at`: on their tracks, or,
|
||
* given `row`, the topmost on that track and the others as far below as they were, as far as there
|
||
* are tracks. An item whose track is gone counts as on the first.
|
||
*/
|
||
export function placedCopies(
|
||
items: readonly SongItem[],
|
||
at: number,
|
||
trackIds: readonly string[],
|
||
row?: number,
|
||
): SongItem[] {
|
||
if (items.length === 0) return [];
|
||
const first = Math.min(...items.map((i) => i.tick));
|
||
const index = new Map(trackIds.map((id, i) => [id, i]));
|
||
const rows = items.map((i) => index.get(i.track) ?? 0);
|
||
const top = Math.min(...rows);
|
||
const bottom = Math.max(...rows);
|
||
const shift = row === undefined ? 0 : Math.max(-top, Math.min(trackIds.length - 1 - bottom, row - top));
|
||
return items.map((i, n) => ({
|
||
...i,
|
||
id: newId(),
|
||
tick: Math.max(0, at) + (i.tick - first),
|
||
track: trackIds[rows[n]! + shift] ?? i.track,
|
||
}));
|
||
}
|
||
|
||
/** How long an item dropped in is: its pattern, or its sound at the song's tempo, else a bar. */
|
||
export function droppedLength(
|
||
seconds: number | null,
|
||
patternTicks: number | null,
|
||
tempo: number,
|
||
bar: number,
|
||
): number {
|
||
if (patternTicks !== null) return Math.max(1, patternTicks);
|
||
if (seconds !== null && seconds > 0) return Math.max(1, Math.round(ticksOfSeconds(seconds, tempo)));
|
||
return bar;
|
||
}
|