Files
feuerwerk/src/editor/song/songEdit.ts
T
adminandClaude Opus 5.5 c33d6d98e5 Keep a song item's left edge from snapping on to its own end
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>
2026-10-07 14:00:58 +02:00

270 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}