diff --git a/src/App.svelte b/src/App.svelte index 3e7f213..1f10e97 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -135,6 +135,86 @@ saveToolbarExpanded(toolbarExpanded); } + // Horizontal toolbar scrolling: when too narrow for all buttons, the toolbar stack + // is translated left via a custom scrollbar (a native one auto-hides on macOS), so + // the document stays centered — the page itself never scrolls horizontally. + const MIN_THUMB = 24; + let toolbarClipEl: HTMLDivElement | null = $state(null); + let toolbarStackEl: HTMLDivElement | null = $state(null); + let toolbarStackWidth = $state(0); // full content width of the stack + let toolbarViewWidth = $state(0); // visible (clipped) width + let tbScroll = $state(0); // current left offset in px + let tbOverflow = $derived(Math.max(0, toolbarStackWidth - toolbarViewWidth)); + let thumbWidth = $derived( + toolbarStackWidth > 0 ? Math.max(MIN_THUMB, (toolbarViewWidth / toolbarStackWidth) * toolbarViewWidth) : 0, + ); + let thumbTravel = $derived(Math.max(0, toolbarViewWidth - thumbWidth)); + let thumbLeft = $derived(tbOverflow > 0 ? (tbScroll / tbOverflow) * thumbTravel : 0); + + // Track the stack's content width and the visible width; clamp the offset into + // range when either changes (e.g. window resize, toolbar expand/collapse). + $effect(() => { + const clip = toolbarClipEl, stack = toolbarStackEl; + if (!clip || !stack) return; + const measure = () => { + toolbarViewWidth = clip.clientWidth; + toolbarStackWidth = stack.scrollWidth; + tbScroll = Math.min(tbScroll, Math.max(0, toolbarStackWidth - toolbarViewWidth)); + }; + const ro = new ResizeObserver(measure); + ro.observe(clip); + ro.observe(stack); + measure(); + return () => ro.disconnect(); + }); + + // Wheel over the toolbar scrolls it horizontally (it has no vertical scroll), so a + // trackpad/mouse can pan to the hidden buttons without grabbing the scrollbar. + function onToolbarWheel(e: WheelEvent) { + if (tbOverflow <= 0) return; + const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + if (delta === 0) return; + e.preventDefault(); + tbScroll = Math.min(tbOverflow, Math.max(0, tbScroll + delta)); + } + + // Custom scrollbar drag. All handlers live on the track; a press outside the thumb + // first jumps it under the pointer, then both modes drag from there. + let tbDragX = 0, tbDragScroll = 0, tbDragging = false; + function onScrollbarPointerDown(e: PointerEvent) { + if (thumbTravel <= 0) return; + const track = e.currentTarget as HTMLElement; + const thumb = track.querySelector('.toolbar-scrollbar-thumb'); + if (!thumb?.contains(e.target as Node)) { + const rect = track.getBoundingClientRect(); + tbScroll = Math.min(tbOverflow, Math.max(0, ((e.clientX - rect.left - thumbWidth / 2) / thumbTravel) * tbOverflow)); + } + tbDragging = true; + tbDragX = e.clientX; + tbDragScroll = tbScroll; + track.setPointerCapture(e.pointerId); + } + function onScrollbarPointerMove(e: PointerEvent) { + if (!tbDragging || thumbTravel <= 0) return; + const delta = ((e.clientX - tbDragX) / thumbTravel) * tbOverflow; + tbScroll = Math.min(tbOverflow, Math.max(0, tbDragScroll + delta)); + } + function onScrollbarPointerUp(e: PointerEvent) { + tbDragging = false; + try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* already released */ } + } + function onScrollbarKeydown(e: KeyboardEvent) { + if (thumbTravel <= 0) return; + let next = tbScroll; + if (e.key === 'ArrowLeft') next -= 40; + else if (e.key === 'ArrowRight') next += 40; + else if (e.key === 'Home') next = 0; + else if (e.key === 'End') next = tbOverflow; + else return; + e.preventDefault(); + tbScroll = Math.min(tbOverflow, Math.max(0, next)); + } + // Combined height + opacity transition: the extended toolbar slides and fades // in/out as one motion when the toolbar is expanded/collapsed. function expand(node: HTMLElement, { duration = 180 } = {}) { @@ -391,6 +471,9 @@
+
+
+
{/if} +
+
+ + {#if tbOverflow > 0} +
+
+
+ {/if}