From 1b9ff613c5f646862d00d0241d2d5b205ccd6651 Mon Sep 17 00:00:00 2001 From: Herb Date: Tue, 15 Sep 2026 21:24:46 +0200 Subject: [PATCH] fix(ui): fit chain tiles to the lane width (#84) At the full 224 px only three tiles fit the plugin's width, so a 5-6 block rig lived behind a hidden scrollbar and a wheel-only pan. The lane now measures its scroller (ResizeObserver, design px) and shrinks the tiles until the widest row fits, capped at the mono/stereo base size and floored at 128 px; past the floor the ordinary scroll takes over. Branched layouts count the branch lane's indent, the stereo pan rail follows the live tile size, and the slot count comes from native state so a drag never resizes the row mid-gesture. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 6d71c4017f5955216f5b438046c674ee5c078b17) --- ui/src/components/ChainView.tsx | 27 +++++++++++++++++++++++++-- ui/src/components/GalleryLane.tsx | 29 +++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/ui/src/components/ChainView.tsx b/ui/src/components/ChainView.tsx index a0754665..a243b61c 100644 --- a/ui/src/components/ChainView.tsx +++ b/ui/src/components/ChainView.tsx @@ -18,6 +18,7 @@ import { GalleryLane, LANE_GAP, STEREO_TILE_SIZE, + fitTileSize, StereoPanRail, TILE_GAP, TILE_SIZE, @@ -127,6 +128,11 @@ export const ChainView: React.FC = ({ }) => { const actions = useChainActions(); const wheelScrollRef = useHorizontalWheelScroll(); + // The scroller's width in design px, for fit-to-width tiles (see + // fitTileSize). Measured, not derived from the design box: DAW hosts and + // the standalone resize the editor, and the meters beside the lane take + // a fixed share. + const [scrollerWidth, setScrollerWidth] = useState(0); // One callback ref wires the scroller: it restores the saved offset before // first paint, persists it as the user scrolls, and attaches the wheel // hook's panning. The hook returns a cleanup (and once a ref callback @@ -143,9 +149,12 @@ export const ChainView: React.FC = ({ const save = () => sessionStorage.setItem(CHAIN_SCROLL_STORAGE_KEY, String(el.scrollLeft / getUiScale())); el.addEventListener('scroll', save, { passive: true }); + const resize = new ResizeObserver(() => setScrollerWidth(el.clientWidth / getUiScale())); + resize.observe(el); const wheelCleanup = wheelScrollRef(el); return () => { el.removeEventListener('scroll', save); + resize.disconnect(); if (typeof wheelCleanup === 'function') wheelCleanup(); }; }, @@ -416,7 +425,21 @@ export const ChainView: React.FC = ({ } const stereo = chainRight != null; - const tileSize = stereo ? STEREO_TILE_SIZE : TILE_SIZE; + // Slots the widest row needs: a branched lane is indented past the + // trunk's tap, so its tiles count from there. Native lengths, not the + // optimistic lanes: a drag's stand-in or cross-lane reflow must not + // resize every tile mid-gesture. + const branchIndent = + stereo && branch != null + ? (branch.side === 'left' ? chain : (chainRight ?? [])).findIndex( + (i) => i.blockId === branch.afterBlockId + ) + 1 + : 0; + const slotsAcross = Math.max( + chain.length + (branch?.side === 'right' ? branchIndent : 0), + (chainRight?.length ?? 0) + (branch?.side === 'left' ? branchIndent : 0) + ); + const tileSize = fitTileSize(stereo ? STEREO_TILE_SIZE : TILE_SIZE, slotsAcross, scrollerWidth); // Branched layout: the branch lane starts at the trunk's tap gap, so its // row is indented past the whole trunk prefix (matching the signal flow: @@ -471,7 +494,7 @@ export const ChainView: React.FC = ({ padding: '0 24rem', }} > - {stereo && } + {stereo && }