From 91b6e102eb28ccebadf943645f93b6439a0501ab Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Fri, 12 Jun 2026 19:42:24 +0300 Subject: [PATCH] Pane handles: extract shared useColResizeDrag pointer gesture --- src/app/components/PaneSplitHandle.tsx | 24 ++++------------ src/app/components/ResizeHandle.tsx | 35 ++++++----------------- src/app/hooks/useColResizeDrag.ts | 39 ++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 46 deletions(-) create mode 100644 src/app/hooks/useColResizeDrag.ts diff --git a/src/app/components/PaneSplitHandle.tsx b/src/app/components/PaneSplitHandle.tsx index 9b3fa32..7e5e04a 100644 --- a/src/app/components/PaneSplitHandle.tsx +++ b/src/app/components/PaneSplitHandle.tsx @@ -17,6 +17,7 @@ */ import { useRef } from 'react'; +import { useColResizeDrag } from '../hooks/useColResizeDrag'; import { splitLibraryWidth, splitValue, usePanesStore } from '../stores/PanesStore'; import styles from './ResizeHandle.module.css'; @@ -56,25 +57,10 @@ export function PaneSplitHandle({ label }: PaneSplitHandleProps) { usePanesStore.getState().setSplit(library, avail - library); }; - const onPointerDown = (e: React.PointerEvent) => { - if (e.button !== 0) return; // primary button only - e.preventDefault(); - const startX = e.clientX; - const startLib = flanks().lib?.clientWidth ?? 0; - - const onMove = (ev: PointerEvent) => applySplit(startLib + (ev.clientX - startX)); - const onUp = () => { - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - - window.addEventListener('pointermove', onMove); - window.addEventListener('pointerup', onUp); - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - }; + const onPointerDown = useColResizeDrag( + () => flanks().lib?.clientWidth ?? 0, + (startLib, delta) => applySplit(startLib + delta), + ); // Keyboard per WAI-ARIA APG → Window Splitter: arrows nudge the library side; // Home/End jump to the library's smallest/largest allowed share of the span. diff --git a/src/app/components/ResizeHandle.tsx b/src/app/components/ResizeHandle.tsx index b79af50..42b82b4 100644 --- a/src/app/components/ResizeHandle.tsx +++ b/src/app/components/ResizeHandle.tsx @@ -19,6 +19,7 @@ */ import { useLayoutEffect, useRef, useState } from 'react'; +import { useColResizeDrag } from '../hooks/useColResizeDrag'; import { clampSideWidth, sideWidthValue, usePanesStore, type PaneSide } from '../stores/PanesStore'; import styles from './ResizeHandle.module.css'; @@ -63,35 +64,15 @@ export function ResizeHandle({ side, label }: ResizeHandleProps) { setWidth(side, clampSideWidth(side, desired, readContainerWidth(), other)); }; - const onPointerDown = (e: React.PointerEvent) => { - if (e.button !== 0) return; // primary button only - e.preventDefault(); - const startX = e.clientX; - const startWidth = + // The left handle grows its pane as it moves right; the right handle (left + // of the preview) shrinks the preview as it moves right. + const onPointerDown = useColResizeDrag( + () => side === 'library' ? usePanesStore.getState().libraryWidth - : usePanesStore.getState().previewWidth; - - const onMove = (ev: PointerEvent) => { - const delta = ev.clientX - startX; - // The left handle grows its pane as it moves right; the right handle (left - // of the preview) shrinks the preview as it moves right. - const desired = side === 'library' ? startWidth + delta : startWidth - delta; - applyWidth(desired); - }; - const onUp = () => { - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - - window.addEventListener('pointermove', onMove); - window.addEventListener('pointerup', onUp); - // While dragging, force the resize cursor and suppress text selection. - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - }; + : usePanesStore.getState().previewWidth, + (startWidth, delta) => applyWidth(side === 'library' ? startWidth + delta : startWidth - delta), + ); // Keyboard model per WAI-ARIA APG → Window Splitter: arrows nudge; Home/End jump // to the pane's smallest/largest allowed size (clampSideWidth caps the extremes). diff --git a/src/app/hooks/useColResizeDrag.ts b/src/app/hooks/useColResizeDrag.ts new file mode 100644 index 0000000..10d730b --- /dev/null +++ b/src/app/hooks/useColResizeDrag.ts @@ -0,0 +1,39 @@ +/** + * useColResizeDrag — the shared pointer gesture behind the pane drag handles + * (ResizeHandle, PaneSplitHandle). + * + * Returns a `pointerdown` handler: on a primary-button press it measures the + * gesture's start value, then tracks the pointer on `window` (the pointer leaves + * the thin handle immediately) and feeds each horizontal delta to `apply`. While + * dragging, the body is forced to the `col-resize` cursor with text selection + * suppressed; release restores both and detaches the listeners. + * + * Only the gesture lives here — what "the start value" means (a pane width, a + * split position) and how a delta becomes layout is the caller's, as is the + * keyboard/ARIA half of the splitter contract (it differs per handle). + */ + +export function useColResizeDrag( + measureStart: () => number, + apply: (start: number, deltaX: number) => void, +): (e: React.PointerEvent) => void { + return (e) => { + if (e.button !== 0) return; // primary button only + e.preventDefault(); + const startX = e.clientX; + const start = measureStart(); + + const onMove = (ev: PointerEvent) => apply(start, ev.clientX - startX); + const onUp = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }; +}