From c33d6d98e516b0518706dc47abb7d4c61de38038 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 7 Oct 2026 14:00:58 +0200 Subject: [PATCH] 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 --- src/editor/song/songEdit.ts | 7 +++++-- tests/editor/song-edit.test.ts | 8 +++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/editor/song/songEdit.ts b/src/editor/song/songEdit.ts index 93fd6b72..66a103d5 100644 --- a/src/editor/song/songEdit.ts +++ b/src/editor/song/songEdit.ts @@ -124,7 +124,8 @@ 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. Held so that no picked item gets shorter than a tick 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. */ @@ -139,7 +140,9 @@ export function trimDelta( periodOf: PeriodOf = () => null, ): number { const rounds = roundsOf(anchor, periodOf(anchor), tempo); - const start = Math.round(free ? pointerTick : snapEdge(pointerTick, grid, rounds)); + 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; diff --git a/tests/editor/song-edit.test.ts b/tests/editor/song-edit.test.ts index 85a1982d..3c7cd673 100644 --- a/tests/editor/song-edit.test.ts +++ b/tests/editor/song-edit.test.ts @@ -88,7 +88,9 @@ describe('moving and sizing items (#334)', () => { const picked = new Set(['s']); // 0.25 s at 120 BPM is 240 ticks: that is as far back as the left end goes. expect(trimDelta([sound], picked, sound, 0, 120, false, 120)).toBe(-240); - expect(trimDelta([sound], picked, sound, 5 * BAR, 120, false, 120)).toBe(BAR - 1); + // Past its own end the left end stops on the last grid line before it. + expect(trimDelta([sound], picked, sound, 5 * BAR, 120, false, 120)).toBe(BAR - 120); + expect(trimDelta([sound], picked, sound, 5 * BAR, 120, true, 120)).toBe(BAR - 1); const [cut] = trimmed([sound], picked, 480, 120); expect([cut!.tick, cut!.length, cut!.offset]).toEqual([BAR + 480, BAR - 480, 0.75]); const pattern = item('p', 't1', BAR, BAR, { offset: 120 }); @@ -125,6 +127,10 @@ describe('moving and sizing items (#334)', () => { const by = trimDelta([cut], picked, cut, BAR + 705, 480, false, 120, () => 700.4); expect(by).toBe(600); expect(trimmed([cut], picked, by, 120, () => 700.4)[0]!.offset).toBe(secondsOfTicks(700.4, 120)); + // A start dragged right up to a round that is its own end does not snap there, it would leave a + // tick: it takes the grid line before. + const two = item('t', 't1', BAR, 1400, { kind: 'sound', offset: 0 }); + expect(trimDelta([two], new Set(['t']), two, BAR + 1390, 480, false, 120, () => 700)).toBe(960); // An offset a whole pattern in stays so: the pattern may grow later. const far = item('f', 't1', BAR, 2 * BAR); expect(trimmed([far], new Set(['f']), BAR, 120, () => BAR)[0]!.offset).toBe(BAR);