feat: centered document and horizontal scrollbar for toolbar when editor has small window

This commit is contained in:
Steffen Becker committed 2026-06-27 00:37:43 +02:00
1 parent f6d1348a43
commit d20c376ea4
1 file changed
+158
+158
View File
@@ -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 @@
</script>
<main>
<div class="toolbar-region">
<div class="toolbar-clip" id="primary-toolbar" bind:this={toolbarClipEl} onwheel={onToolbarWheel}>
<div class="toolbar-stack" bind:this={toolbarStackEl} style="transform: translateX(-{tbScroll}px);">
<header class:expanded={toolbarExpanded}>
<button class="logo-btn" onclick={() => (aboutOpen = true)} aria-label="About PrimeText" title="About PrimeText">
<img src="/PrimeText.png" alt="PrimeText" class="app-logo" />
@@ -585,6 +668,27 @@
/>
</div>
{/if}
</div>
</div>
</div>
{#if tbOverflow > 0}
<div
class="toolbar-scrollbar"
role="scrollbar"
tabindex={0}
aria-orientation="horizontal"
aria-controls="primary-toolbar"
aria-valuemin={0}
aria-valuemax={Math.round(tbOverflow)}
aria-valuenow={Math.round(tbScroll)}
onpointerdown={onScrollbarPointerDown}
onpointermove={onScrollbarPointerMove}
onpointerup={onScrollbarPointerUp}
onkeydown={onScrollbarKeydown}
>
<div class="toolbar-scrollbar-thumb" style="width: {thumbWidth}px; transform: translateX({thumbLeft}px);"></div>
</div>
{/if}
</div>
<EditorComponent
bind:editor
@@ -671,6 +775,60 @@
height: 100%;
}
/* Toolbar scroll region. A stacking context (z-index) so the whole toolbar — incl.
its dropdowns, which live inside the translated stack — paints above the editor
and its overlays. */
.toolbar-region {
position: relative;
z-index: 200;
}
/* Clips the off-screen part of the toolbar horizontally only, so the page never
scrolls sideways (document stays centered). overflow-y stays visible so the
toolbar dropdowns can still open downward over the document. */
.toolbar-clip {
overflow-x: clip;
}
/* Both toolbar rows share one column sized to its widest row (buttons don't shrink);
translated left via inline transform to reveal hidden buttons. min-width keeps it
filling the viewport when the window is wide. */
.toolbar-stack {
width: max-content;
min-width: 100%;
}
/* Custom scrollbar strip under the toolbar (a native one auto-hides on macOS).
The thumb's width/position track the visible fraction of the stack; only rendered
when the toolbar overflows. */
.toolbar-scrollbar {
position: relative;
height: 9px;
background: var(--color-toolbar-bg);
border-bottom: 1px solid var(--color-border);
cursor: pointer;
touch-action: none;
}
.toolbar-scrollbar-thumb {
position: absolute;
top: 2px;
left: 0;
height: 5px;
border-radius: 3px;
background: var(--color-btn-hover);
cursor: grab;
touch-action: none;
}
.toolbar-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
.toolbar-scrollbar-thumb:active {
cursor: grabbing;
}
/* Basic toolbar: a complete toolbar with its own divider + elevation. When the
extended toolbar opens, those move to .toolbar-secondary so the two rows read
as one connected toolbar (border-color/shadow transition for a smooth merge). */