Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions ui/src/components/ChainView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
GalleryLane,
LANE_GAP,
STEREO_TILE_SIZE,
fitTileSize,
StereoPanRail,
TILE_GAP,
TILE_SIZE,
Expand Down Expand Up @@ -127,6 +128,11 @@ export const ChainView: React.FC<ChainViewProps> = ({
}) => {
const actions = useChainActions();
const wheelScrollRef = useHorizontalWheelScroll<HTMLDivElement>();
// 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
Expand All @@ -143,9 +149,12 @@ export const ChainView: React.FC<ChainViewProps> = ({
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();
};
},
Expand Down Expand Up @@ -416,7 +425,21 @@ export const ChainView: React.FC<ChainViewProps> = ({
}

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:
Expand Down Expand Up @@ -471,7 +494,7 @@ export const ChainView: React.FC<ChainViewProps> = ({
padding: '0 24rem',
}}
>
{stereo && <StereoPanRail monoSum={monoSum} />}
{stereo && <StereoPanRail monoSum={monoSum} tileSize={tileSize} />}
<DragDropProvider
sensors={sensors}
onDragStart={handleDragStart}
Expand Down
29 changes: 27 additions & 2 deletions ui/src/components/GalleryLane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export const TILE_SIZE = 224;
export const STEREO_TILE_SIZE = 160;
/** Gap between tiles: the visible run of each connector line. */
export const TILE_GAP = 24;
/** Floor for fit-to-width shrinking (see fitTileSize): below this the
artwork stops reading and the hover chrome (power, swap, trash) no
longer fits a tile's top strip. */
export const MIN_TILE_SIZE = 128;
/** Vertical gap between the two stereo lanes. */
export const LANE_GAP = 24;
/** Gutter inside the scroll area; tiles fade out under it while scrolling. */
Expand Down Expand Up @@ -66,6 +70,22 @@ export const BRANCH_CIRCLE_SIZE = ICON_BOX_SIZE / 2;

/** X center of the connector gap *before* the tile at `index` (i.e. gap g
sits between tiles g-1 and g), in lane-content coordinates. */
/**
* Tile edge that lets `slots` tiles sit side by side in a scroller
* `scrollerWidth` design px wide (edge insets and gaps included), capped at
* `baseSize` and floored at MIN_TILE_SIZE. 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 (issue #84); shrinking the row until it
* fits keeps the whole chain in view, and the ordinary scroll takes over
* only past the floor. 0 width (not measured yet) keeps the base size, so
* the first paint never flashes a wrong size.
*/
export const fitTileSize = (baseSize: number, slots: number, scrollerWidth: number): number => {
if (scrollerWidth <= 0 || slots <= 0) return baseSize;
const usable = scrollerWidth - 2 * EDGE_FADE_WIDTH - (slots - 1) * TILE_GAP;
return Math.max(MIN_TILE_SIZE, Math.min(baseSize, Math.floor(usable / slots)));
};

export const gapCenterX = (gapIndex: number, tileSize: number) =>
gapIndex * (tileSize + TILE_GAP) - TILE_GAP / 2;

Expand Down Expand Up @@ -346,7 +366,10 @@ const PanRailChips: React.FC<{
* buttons for a MONO chip that says why. Solo and polarity stay live: they
* act on the chains inside the sum.
*/
export const StereoPanRail: React.FC<{ monoSum: boolean }> = ({ monoSum }) => {
export const StereoPanRail: React.FC<{ monoSum: boolean; tileSize: number }> = ({
monoSum,
tileSize,
}) => {
const { swapChains } = useChainActions();
const [panLeft, setPanLeft, onPanLeftDrag] = useParameter('chainPanLeft', 'slider');
const [panRight, setPanRight, onPanRightDrag] = useParameter('chainPanRight', 'slider');
Expand Down Expand Up @@ -417,7 +440,9 @@ export const StereoPanRail: React.FC<{ monoSum: boolean }> = ({ monoSum }) => {
flexDirection: 'column',
alignItems: 'center',
alignSelf: 'center',
height: `${STEREO_TILE_SIZE * 2 + LANE_GAP}rem`,
// Tracks the lanes' actual (fit-to-width) tile size so the pans stay
// level with their lanes.
height: `${tileSize * 2 + LANE_GAP}rem`,
flexShrink: 0,
// Room for the left edge-fade's 1rem outer overhang (see EdgeFade)
// so it doesn't sit on the link/swap pill.
Expand Down