Skip to content

feat(editor): magnetic drag snapping — guidelines attract, never lock - #264

Merged
byrongamatos merged 2 commits into
mainfrom
feat/editor-magnetic-snap
Jul 14, 2026
Merged

feat(editor): magnetic drag snapping — guidelines attract, never lock#264
byrongamatos merged 2 commits into
mainfrom
feat/editor-magnetic-snap

Conversation

@ChrisBeWithYou

@ChrisBeWithYou ChrisBeWithYou commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

What

The design call from this session: "It's magnetic, not locking." With snapping on, dragging was hard-locked to the grid (Alt = the only escape). Now, per the DAW piano-roll feel:

  • While dragging, the grabbed note's target sticks to the nearest guideline of the current subdivision inside a small magnet, and pulling past the snap point releases it to follow the pointer exactly — minor off-grid adjustments need no modifier.
  • The magnet radius is screen-space (MAGNET_PX = 8), so it's zoom-aware like Logic's Smart snap: zoom in → the magnet spans less time → finer control for free.
  • It's additionally capped at 35% of the local guideline gap (MAGNET_GAP_FRAC), so however dense the grid is (fine subdivision, low zoom), the middle of every gap stays free — without the cap, guidelines closer than the magnet's diameter would cover the whole timeline and dragging would degrade to locked stepping. Caught live: at default zoom with 1/4-beat snap, gaps are ~15px and an uncapped 8px magnet could never be escaped.
  • The sustain end-edge resize drag — previously not snapped at all — now magnetically snaps the grabbed note's end edge to the same guidelines (the edges-snap model), with the whole resize group taking one effective delta so relative lengths hold.
  • Unchanged: Alt-drag = fully free from the first pixel; click-placement still snaps fully (a click has no "pull past" gesture); the explicit Resnap selection remains a full quantize (fix(editor): resnap works with Snap off and snaps BOTH edges to the subdivision guidelines #263).

Tests

tests/magnetic_snap.test.mjs (7, fail on main): stick-within / release-beyond, zoom awareness both directions, the gap-cap free band (mid-gap stays free even at 20 px/s), snap-off identity, the pinned constants, and the _groupTimeDeltaPure composition (a magnetic stick = zero group creep; a release = exact pointer delta). Full suite 147 green, lint 0 errors, routes.py untouched.

Live-verified

Real pak, real mouse drags on a real note (canvas hit-tested): a 4px pull sticks to the guideline (note lands exactly on the downbeat), a 40px pull releases and lands at precisely the pointer position, off-grid (2.5333 vs expected 2.533). Zero page errors. The resize-edge path was exercised too (the first probe accidentally grabbed an edge — it resized without page errors).

🤖 Generated with Claude Code

https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q

Summary by CodeRabbit

  • New Features

    • Piano-roll note dragging now magnetically snaps to nearby grid guidelines.
    • Sustained-note edges use the same snapping behavior while resizing.
    • Dragging beyond the snap range releases the note to follow the pointer freely.
    • Alt-drag remains fully unrestricted, while clicks and “Resnap selection” behavior are unchanged.
  • Bug Fixes

    • Improved snapping consistency across zoom levels and onset-based timing modes.
    • Prevented snapping issues when no grid guidelines are available.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5512c7d5-09a4-4e5e-bf02-9af26ab4b29b

📥 Commits

Reviewing files that changed from the base of the PR and between f56896a and 455d7cd.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • src/loop.js
  • src/mouse.js
  • tests/magnetic_snap.test.mjs

📝 Walkthrough

Walkthrough

Dragging notes and sustain edges now uses zoom-aware magnetic snapping that releases beyond a screen-space radius. Alt-drag remains unsnapped, onset snapping is preserved, and dedicated tests cover grid, zoom, disabled, degenerate-grid, and group-delta cases.

Changes

Magnetic drag snapping

Layer / File(s) Summary
Magnetic snap calculation
src/loop.js
Adds MAGNET_PX, MAGNET_GAP_FRAC, and magneticSnapTime(t) with zoom-aware stick/release behavior and onset-mode handling.
Note drag integration
src/mouse.js
Uses magnetic snapping for sustain-edge resizing and note movement while preserving free Alt-drag behavior.
Behavior validation and documentation
tests/magnetic_snap.test.mjs, CHANGELOG.md
Tests snapping thresholds, zoom behavior, disabled and degenerate grids, onset snapping, group deltas, and documents the updated drag behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Editor as Note editor
  participant Mouse as mouse drag handlers
  participant Snap as magneticSnapTime
  participant Group as _groupTimeDeltaPure
  Editor->>Mouse: drag note or sustain edge
  Mouse->>Snap: evaluate dragged time
  Snap-->>Mouse: snapped or free pointer time
  Mouse->>Group: compose group time delta
  Group-->>Editor: apply note movement
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main editor change: magnetic drag snapping that attracts to guidelines without locking.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/editor-magnetic-snap

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/loop.js`:
- Around line 422-440: Update magneticSnapTime so snaps produced by snapTime()
to an onset use ONSET_SNAP_TOL as the pull-radius limit instead of the beat-grid
gap cap. Preserve the existing grid-gap-based radius for regular subdivision
snaps, and keep the existing snap-off and unavailable-grid behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6e44dad1-6db1-4e6a-900d-8bec4302bb99

📥 Commits

Reviewing files that changed from the base of the PR and between a866705 and 15da069.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • src/loop.js
  • src/mouse.js
  • tests/magnetic_snap.test.mjs

Comment thread src/loop.js
@byrongamatos
byrongamatos force-pushed the feat/editor-magnetic-snap branch from 15da069 to f56896a Compare July 14, 2026 18:02
@byrongamatos

Copy link
Copy Markdown
Collaborator

Onset finding is correct and was already fixed in f56896a (pushed before this review landed): an onset hit now passes through magneticSnapTime ungated — ONSET_SNAP_TOL is its own radius, and onsets are sparse so the lock risk the grid-gap cap exists to prevent doesn't apply. The gap cap still governs the grid fallback. Pinned by a new test (fine 1/16 grid + an attack 40 ms off it) which fails without the guard.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
git log --oneline -5
git show f56896a --stat 2>/dev/null || echo "commit not found"
git log --all --oneline | grep -i f56896a || echo "not in history"

Length of output: 2095


🏁 Script executed:

#!/bin/bash
sed -n '1,80p' src/loop.js | grep -n "magneticSnapTime\|ONSET_SNAP_TOL\|MAGNET" 
git show f56896a -- src/loop.js

Length of output: 2916


@byrongamatos Confirmed — I checked commit f56896a and the fix matches your description exactly: magneticSnapTime now checks S.snapMode === 'onset' first and returns the snapTime result untouched when _nearestOnsetTimePure finds a hit within ONSET_SNAP_TOL, bypassing the grid-gap cap entirely for onset hits. The grid magnet (with the MAGNET_GAP_FRAC cap) still governs the fallback path, and the added guards for zero guidelines / single-beat degenerate grids look correct too. Good catch on the fine-subdivision regression (1/16 gap ~11ms vs. 70ms onset tolerance) — that's a real edge case the original gating would have silently broken.

Kicking off a full review now.

✅ Action performed

Full review finished.


Your plan includes PR reviews subject to rate limits. More reviews will be available in 30 minutes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@node_modules`:
- Line 1: Remove the tracked node_modules symbolic link, add node_modules to the
repository’s ignore rules, and ensure the package manifest and lockfile are
tracked so dependencies can be installed reproducibly without committing local
dependency contents.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b3a28d4b-dbee-4762-a687-455cfe0dc5aa

📥 Commits

Reviewing files that changed from the base of the PR and between 15da069 and f56896a.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • node_modules
  • src/loop.js
  • src/mouse.js
  • tests/magnetic_snap.test.mjs
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/mouse.js
  • src/loop.js
  • CHANGELOG.md

Comment thread node_modules Outdated
@byrongamatos
byrongamatos force-pushed the feat/editor-magnetic-snap branch from f56896a to 39f46b9 Compare July 14, 2026 18:08
@byrongamatos

Copy link
Copy Markdown
Collaborator

Correct catch on node_modules — that was a stray git add -A picking up the worktree's node_modules symlink. .gitignore has node_modules/ with a trailing slash, which matches a directory but not a symlink of the same name, so it slipped through. Dropped from the branch in 39f46b9; the diff is now CHANGELOG + src/loop.js + src/mouse.js + tests only. (Tightening .gitignore itself belongs on main, not in a snapping PR.)

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
git log --oneline -8
git show --stat 39f46b9 2>/dev/null || echo "commit not found"
git diff --stat main...HEAD 2>/dev/null || git diff --stat origin/main...HEAD 2>/dev/null

Length of output: 2560


@byrongamatos Confirmed — commit 39f46b9 shows the branch diff is now scoped to CHANGELOG.md, src/loop.js, src/mouse.js, and tests/magnetic_snap.test.mjs; no node_modules artifacts remain. Good explanation on the .gitignore trailing-slash nuance (directory match vs. symlink) — that's a subtle gotcha worth keeping in mind for future stray-add incidents, though as you note, fixing it belongs on main rather than in this PR.

Kicking off a full review now.

✅ Action performed

Full review finished.


Your plan includes PR reviews subject to rate limits. More reviews will be available in 25 minutes.

ChrisBeWithYou and others added 2 commits July 14, 2026 21:02
magneticSnapTime: a guideline attracts the dragged edge within a small
screen-space radius (MAGNET_PX, zoom-aware) capped at 35% of the local
guideline gap so a free band always survives between magnets; pulling
past the snap point releases the edge to follow the pointer exactly.
Wired into the note move drag (replacing the hard grid lock) and the
sustain end-edge resize drag (previously unsnapped); Alt = fully free
unchanged; clicks and the explicit resnap verb still snap fully.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q
magneticSnapTime re-gated EVERY snapTime result on the grid magnet's radius —
including the onset-snap hit. Onset mode is already a magnet (ONSET_SNAP_TOL =
70 ms is its radius) and onsets are sparse, so it can't degrade into the locked
stepping the grid cap exists to prevent. But the cap is a fraction of the local
guideline gap, and at a fine subdivision that gap is tiny: at 1/16 the magnet is
~11 ms, well under the 70 ms tolerance, so the attack the user aimed at got
released and drags in onset mode silently stopped snapping to onsets — a
regression against snapTime, which drags used before this branch.

An onset hit now passes through untouched; the grid magnet still governs the
grid fallback (and still releases mid-gap).

Also pins the guards that had no test: zero guidelines and a single-beat
degenerate grid return the input unchanged (no NaN out of the falloff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@byrongamatos
byrongamatos force-pushed the feat/editor-magnetic-snap branch from 39f46b9 to 455d7cd Compare July 14, 2026 19:02
@byrongamatos
byrongamatos merged commit 29ba9de into main Jul 14, 2026
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