Skip to content

feat(timeline): proportional stretch of multi-block selections - #175

Merged
boidushya merged 8 commits into
better-lyrics:masterfrom
Suoerix:feat/multi-block-proportional-stretch
Aug 26, 2026
Merged

feat(timeline): proportional stretch of multi-block selections#175
boidushya merged 8 commits into
better-lyrics:masterfrom
Suoerix:feat/multi-block-proportional-stretch

Conversation

@Suoerix

@Suoerix Suoerix commented Aug 25, 2026

Copy link
Copy Markdown

Dragging the boundary edge of a multi-block selection now performs a proportional stretch anchored at the opposite side of the selection, instead of resizing a single word:

  • Right edge of the latest selected block → anchored at the selection start
  • Left edge of the earliest selected block → anchored at the selection end
  • Internal edges and single-block selections keep the plain resize behaviour

Syllable groups are deliberately NOT expanded for stretch (unlike nudge).

Behaviour details

  • Live preview during the drag writes transiently to lines (no dirty flag, no history). On pointerup, the pre-drag snapshot is restored first, then committed via updateLinesWithHistory with propagateToSiblings: false, mirroring the nudge gesture — ensuring undo restores in a single step.
  • Esc or pointercancel cancels and restores the pre-drag snapshot; sub-threshold drags commit nothing.
  • Snap targets the dragged edge against unselected block boundaries; Mod/Ctrl bypass remains consistent.
  • The requested factor is clamped to a feasible interval derived from non-selected neighbours, minWordDuration and the audio bounds; growth lands flush against the blocking neighbour instead of crossing it. Cross-line selections are clamped independently per line (consistent with nudge semantics).
  • Background words ride along and are stamped with manual provenance; line-synced rows scale their begin/end with the same factor. Updates for main track + bg on the same line are merged into a single entry.
  • Selection members can never start overlapping each other: the affine map (k > 0) is strictly increasing, so only neighbours and global bounds constrain the factor.
  • Race condition protection: If external code modifies lines during drag (Ctrl+Z, import replacement, project clear), the gesture detects non-own writes via array reference validation on pointerup → abandons both restore and commit, letting external state win. This prevents swallowing undo results or creating history entries with stale snapshots as before-state.

Implementation

File Role
stretch-targets.ts (new) Selection → track partition (same routing as the nudge partitioner, minus group expansion) + feasible-factor bounds with generic anchor-affine constraint math newX = A + (x−A)·k (five clamp categories, NaN guard)
stretch-drag.ts (new) planStretchDrag qualification: ≥2 blocks, dragged block must be selected and at selection extreme edge
stretch-selection.ts (new) stretchSelections pure mapping (anchor: "start" | "end")
use-selection-stretch.ts (new) Pointer gesture hook: full snapshot on pointerdown, real-time preview, snap integration, Escape/unmount restore, re-entry/unload protection
word-track.tsx Hook-up point: handleResizeStart tries the stretch first (+18 net lines), falls back to plain resize
lines-slice.ts / types.ts New setTransientLines explicit entry: dedicated for gesture preview, JSDoc declares contract "no history/dirty mutation, preserves array reference, caller responsible for restore and external write validation"

@boidushya boidushya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your contribution!

@boidushya
boidushya merged commit b69ce62 into better-lyrics:master Aug 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants