From 853a1c101bf04b9339c306fa2efae8b6f8e27a34 Mon Sep 17 00:00:00 2001 From: Ethan Graf Date: Sun, 19 Jul 2026 00:39:05 -0400 Subject: [PATCH] Prevent editor scrollbar shift and enable wheel-scroll on tab strip Reserve the CodeMirror scroller's scrollbar gutter with `scrollbar-gutter: stable` so content no longer shifts left when a document first grows tall enough to need a vertical scrollbar. Add a non-passive wheel listener to the tile tab strip so a vertical mouse wheel scrolls it horizontally while the pointer is over it (trackpad horizontal gestures still work via the dominant axis). Co-Authored-By: Claude Opus 4.8 --- src/editors/automerge/automergeEditor.css | 7 +++++ src/workspaces/tiling/TileTabStrip.tsx | 34 ++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/editors/automerge/automergeEditor.css b/src/editors/automerge/automergeEditor.css index f6cb13a..e63addc 100644 --- a/src/editors/automerge/automergeEditor.css +++ b/src/editors/automerge/automergeEditor.css @@ -12,6 +12,13 @@ .automerge-editor-container .cm-scroller { overflow: auto; + /* + * Reserve the vertical scrollbar's gutter at all times so the content does + * not shift left the moment a document grows tall enough to scroll. The + * gutter is empty (invisible) until the scrollbar appears, then the scrollbar + * fills the space it already occupied — the content box width never changes. + */ + scrollbar-gutter: stable; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; diff --git a/src/workspaces/tiling/TileTabStrip.tsx b/src/workspaces/tiling/TileTabStrip.tsx index 3f4c930..bd38e66 100644 --- a/src/workspaces/tiling/TileTabStrip.tsx +++ b/src/workspaces/tiling/TileTabStrip.tsx @@ -1,4 +1,10 @@ -import { useState, type DragEvent, type MouseEvent } from 'react'; +import { + useEffect, + useRef, + useState, + type DragEvent, + type MouseEvent, +} from 'react'; import { PlusIcon, XIcon } from '../../icons'; import type { DocumentSlotState } from '../../documents/useDocumentSlot'; @@ -54,6 +60,31 @@ export function TileTabStrip({ }: TileTabStripProps) { const showContextMenu = useContextMenu(); + // The horizontal scroll container. A ref (not just the class) so we can attach + // a non-passive wheel listener — React registers `onWheel` as passive, which + // silently drops preventDefault(). + const stripRef = useRef(null); + + // Let a plain vertical mouse wheel scroll the tab strip sideways while the + // pointer is over it. Trackpads already emit deltaX for horizontal gestures, + // so we drive scrollLeft from whichever axis the user pushed hardest. + useEffect(() => { + const el = stripRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + // Nothing overflows horizontally — leave the event alone so it can scroll + // an ancestor as usual. + if (el.scrollWidth <= el.clientWidth) return; + const delta = + Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX; + if (delta === 0) return; + el.scrollLeft += delta; + e.preventDefault(); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => el.removeEventListener('wheel', onWheel); + }, []); + // Insertion point shown while dragging: undefined = no drag in progress, // string = before that tab, null = at the end. const [dropBefore, setDropBefore] = useState( @@ -146,6 +177,7 @@ export function TileTabStrip({ return (