diff --git a/tickets/010-resizable-sections.md b/tickets/010-resizable-sections.md deleted file mode 100644 index 6031766..0000000 --- a/tickets/010-resizable-sections.md +++ /dev/null @@ -1,8 +0,0 @@ -# Make layout sections resizable - -The sections should be resizable. Currently the log view is too small and can't be resized. - -## Acceptance criteria -- Main layout sections (including the log view) can be resized by the user, e.g. via a drag handle between panes. -- Resized proportions are reasonably persisted (e.g. in local storage) across reloads, consistent with other UI state persistence in the app. -- Log view in particular can be enlarged. diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index f055571..3f09b6a 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react' +import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react' import { NavMenu } from './components/NavMenu' import { FilterBar } from './components/FilterBar' import { LogTimeline, computeBucketMs, computePresetRange, type TimeRange, type TimeRangePresetId } from './components/LogTimeline' @@ -22,6 +22,85 @@ import { DocsNavProvider } from './lib/docsNav' const POLL_MS = 2000 +// Height (px) the log viewer had before it became resizable — kept as the default so existing +// layouts don't jump on first load after this change. +const DEFAULT_LOG_VIEWER_HEIGHT_PX = 352 +const MIN_LOG_VIEWER_HEIGHT_PX = 200 +// Leaves room below the log viewer for the drag handle and at least a sliver of the next +// section, so a drag can never grow it to swallow the whole viewport. +const LOG_VIEWER_HEIGHT_VIEWPORT_MARGIN_PX = 160 + +// Namespaced under `ottyl.` per the app's existing localStorage convention (see +// CollapsibleSection.tsx's COLLAPSED_SECTIONS_STORAGE_KEY, LogPreview.tsx's +// VISIBLE_PARAM_COLUMNS_STORAGE_KEY). +const LOG_VIEWER_HEIGHT_STORAGE_KEY = 'ottyl.logViewerHeightPx' + +function clampLogViewerHeight(px: number): number { + const max = Math.max(MIN_LOG_VIEWER_HEIGHT_PX, window.innerHeight - LOG_VIEWER_HEIGHT_VIEWPORT_MARGIN_PX) + return Math.min(max, Math.max(MIN_LOG_VIEWER_HEIGHT_PX, px)) +} + +function loadLogViewerHeight(): number { + try { + const raw = localStorage.getItem(LOG_VIEWER_HEIGHT_STORAGE_KEY) + if (!raw) return DEFAULT_LOG_VIEWER_HEIGHT_PX + const parsed = Number(raw) + return Number.isFinite(parsed) ? clampLogViewerHeight(parsed) : DEFAULT_LOG_VIEWER_HEIGHT_PX + } catch { + return DEFAULT_LOG_VIEWER_HEIGHT_PX + } +} + +function saveLogViewerHeight(px: number) { + try { + localStorage.setItem(LOG_VIEWER_HEIGHT_STORAGE_KEY, String(px)) + } catch { + // best-effort — private browsing or full storage just means the size won't persist across reloads + } +} + +/// Thin drag handle between the log viewer and the sections below it. Reports each pointer-move +/// delta via `onResize` (letting the caller clamp/apply it) and fires `onResizeEnd` on release so +/// the final size can be persisted once, not on every pixel of movement. +function ResizeHandle({ onResize, onResizeEnd }: { onResize: (deltaY: number) => void; onResizeEnd: () => void }) { + const draggingRef = useRef(false) + + function handlePointerDown(e: ReactPointerEvent) { + e.preventDefault() + draggingRef.current = true + let lastY = e.clientY + + function handlePointerMove(ev: PointerEvent) { + if (!draggingRef.current) return + onResize(ev.clientY - lastY) + lastY = ev.clientY + } + + function handlePointerUp() { + draggingRef.current = false + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + onResizeEnd() + } + + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + } + + return ( +
+
+
+ ) +} + /// Reads the routing params `PopOutButton` writes: `section` picks which primary section to render full-page, and `startMs`/`endMs` carry over the range for sections with no `LogTimeline` of their own to pick one. function parsePopOutQuery(): { section: PopOutSectionId | null; range: TimeRange | null } { const query = new URLSearchParams(window.location.search) @@ -190,6 +269,18 @@ function FullApp() { const [filter, setFilter] = useState('') const [filterError, setFilterError] = useState(null) const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer') + const [logViewerHeight, setLogViewerHeight] = useState(() => loadLogViewerHeight()) + + function handleLogViewerResize(deltaY: number) { + setLogViewerHeight((current) => clampLogViewerHeight(current + deltaY)) + } + + function handleLogViewerResizeEnd() { + setLogViewerHeight((current) => { + saveLogViewerHeight(current) + return current + }) + } // Read via ref inside the poll loop instead of depending on `range` directly, which would restart the interval every rolling-preset tick. const rangeRef = useRef(range) @@ -289,8 +380,14 @@ function FullApp() { } /> - {/* min-h-[...] keeps this from being squeezed to 0 by the sections below it in the shared scroll container — flex-1 alone lets a flex item shrink past its content, which let this section's absolutely-unclipped content spill over Params et al instead of the column just scrolling. */} -