From ae4a43f406db682fcefef840db11e2c0c6342f25 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 25 Mar 2023 21:16:10 -0700 Subject: [PATCH] Refactor and improve layout resizing --- index.html | 1 - src-web/components/App.tsx | 13 +- src-web/components/RequestPane.tsx | 9 +- src-web/components/RequestResponse.tsx | 53 +++--- src-web/components/ResizeHandle.tsx | 57 +++++++ src-web/components/ResponsePane.tsx | 174 ++++++++++---------- src-web/components/SidebarDisplayToggle.tsx | 16 ++ src-web/components/Workspace.tsx | 164 ++++-------------- src-web/components/WorkspaceHeader.tsx | 15 +- src-web/components/core/PairEditor.tsx | 6 +- src-web/components/core/Tabs/Tabs.tsx | 2 +- src-web/hooks/useKeyValue.ts | 20 ++- src-web/hooks/useSidebarDisplay.ts | 42 +++-- src-web/lib/keyValueStore.ts | 11 +- 14 files changed, 294 insertions(+), 289 deletions(-) create mode 100644 src-web/components/ResizeHandle.tsx create mode 100644 src-web/components/SidebarDisplayToggle.tsx diff --git a/index.html b/index.html index 7ab83b24..a50c3894 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,6 @@ - Yaak App diff --git a/src-web/components/App.tsx b/src-web/components/App.tsx index a5e48010..81717ba2 100644 --- a/src-web/components/App.tsx +++ b/src-web/components/App.tsx @@ -12,7 +12,8 @@ import { keyValueQueryKey } from '../hooks/useKeyValue'; import { requestsQueryKey } from '../hooks/useRequests'; import { responsesQueryKey } from '../hooks/useResponses'; import { routePaths } from '../hooks/useRoutes'; -import { SidebarDisplayKeys } from '../hooks/useSidebarDisplay'; +import type { SidebarDisplay } from '../hooks/useSidebarDisplay'; +import { sidebarDisplayDefaultValue, sidebarDisplayKey } from '../hooks/useSidebarDisplay'; import { workspacesQueryKey } from '../hooks/useWorkspaces'; import { DEFAULT_FONT_SIZE } from '../lib/constants'; import { extractKeyValue, getKeyValue, setKeyValue } from '../lib/keyValueStore'; @@ -139,8 +140,14 @@ await listen('refresh', () => { }); await listen('toggle_sidebar', async () => { - const hidden = await getKeyValue({ key: SidebarDisplayKeys.hidden, fallback: false }); - await setKeyValue({ key: SidebarDisplayKeys.hidden, value: !hidden }); + const display = await getKeyValue({ + key: sidebarDisplayKey, + fallback: sidebarDisplayDefaultValue, + }); + await setKeyValue({ + key: sidebarDisplayKey, + value: { width: display.width, hidden: !display.hidden }, + }); }); await listen('zoom', ({ payload: zoomDelta }: { payload: number }) => { diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index 9d79c0db..e3f8f8bb 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -1,5 +1,6 @@ import classnames from 'classnames'; -import { useCallback, useMemo, useState } from 'react'; +import type { CSSProperties } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { useActiveRequest } from '../hooks/useActiveRequest'; import { useKeyValue } from '../hooks/useKeyValue'; import { useUpdateRequest } from '../hooks/useUpdateRequest'; @@ -15,11 +16,12 @@ import { ParametersEditor } from './ParameterEditor'; import { UrlBar } from './UrlBar'; interface Props { + style?: CSSProperties; fullHeight: boolean; className?: string; } -export function RequestPane({ fullHeight, className }: Props) { +export const RequestPane = memo(function RequestPane({ style, fullHeight, className }: Props) { const activeRequest = useActiveRequest(); const activeRequestId = activeRequest?.id ?? null; const updateRequest = useUpdateRequest(activeRequestId); @@ -78,6 +80,7 @@ export function RequestPane({ fullHeight, className }: Props) { return (
{activeRequest && ( @@ -146,4 +149,4 @@ export function RequestPane({ fullHeight, className }: Props) { )}
); -} +}); diff --git a/src-web/components/RequestResponse.tsx b/src-web/components/RequestResponse.tsx index f14d5ad6..b6bcdf1f 100644 --- a/src-web/components/RequestResponse.tsx +++ b/src-web/components/RequestResponse.tsx @@ -1,15 +1,16 @@ import classnames from 'classnames'; import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useWindowSize } from 'react-use'; import { useKeyValue } from '../hooks/useKeyValue'; +import { useSidebarDisplay } from '../hooks/useSidebarDisplay'; import { clamp } from '../lib/clamp'; import { RequestPane } from './RequestPane'; +import { ResizeHandle } from './ResizeHandle'; import { ResponsePane } from './ResponsePane'; -import { ResizeBar } from './Workspace'; interface Props { style: CSSProperties; - vertical?: boolean; } const rqst = { gridArea: 'rqst' }; @@ -19,9 +20,11 @@ const drag = { gridArea: 'drag' }; const DEFAULT = 0.5; const MIN_WIDTH_PX = 10; const MIN_HEIGHT_PX = 100; +const STACK_VERTICAL_WIDTH = 600; -export default function RequestResponse({ style, vertical }: Props) { +export const RequestResponse = memo(function RequestResponse({ style }: Props) { const containerRef = useRef(null); + const [vertical, setVertical] = useState(false); const widthKv = useKeyValue({ key: 'body_width', defaultValue: DEFAULT }); const heightKv = useKeyValue({ key: 'body_height', defaultValue: DEFAULT }); const width = widthKv.value ?? DEFAULT; @@ -31,19 +34,27 @@ export default function RequestResponse({ style, vertical }: Props) { null, ); + const windowSize = useWindowSize(); + const sidebar = useSidebarDisplay(); + useLayoutEffect(() => { + if (!containerRef.current) return; + const { width } = containerRef.current.getBoundingClientRect(); + setVertical(width < STACK_VERTICAL_WIDTH); + }, [containerRef.current, windowSize, sidebar.width, sidebar.hidden]); + const styles = useMemo( () => ({ ...style, gridTemplate: vertical ? ` ' ${rqst.gridArea}' minmax(0,${1 - height}fr) - ' ${drag.gridArea}' auto + ' ${drag.gridArea}' 0 ' ${resp.gridArea}' minmax(0,${height}fr) / 1fr ` : ` ' ${rqst.gridArea} ${drag.gridArea} ${resp.gridArea}' minmax(0,1fr) - / ${1 - width}fr auto ${width}fr + / ${1 - width}fr 0 ${width}fr `, }), [vertical, width, height, style], @@ -108,22 +119,18 @@ export default function RequestResponse({ style, vertical }: Props) { ); return ( -
-
- -
-
- -
-
- -
+
+ + +
); -} +}); diff --git a/src-web/components/ResizeHandle.tsx b/src-web/components/ResizeHandle.tsx new file mode 100644 index 00000000..8910aef2 --- /dev/null +++ b/src-web/components/ResizeHandle.tsx @@ -0,0 +1,57 @@ +import classnames from 'classnames'; +import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; +import React from 'react'; + +interface ResizeBarProps { + style?: CSSProperties; + className?: string; + barClassName?: string; + isResizing: boolean; + onResizeStart: (e: ReactMouseEvent) => void; + onReset?: () => void; + side: 'left' | 'right' | 'top'; + justify: 'center' | 'end' | 'start'; +} + +export function ResizeHandle({ + style, + justify, + className, + onResizeStart, + onReset, + isResizing, + side, +}: ResizeBarProps) { + const vertical = side === 'top'; + return ( +
+ {/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */} + {isResizing && ( +
+ )} +
+ ); +} diff --git a/src-web/components/ResponsePane.tsx b/src-web/components/ResponsePane.tsx index 18680952..f0059868 100644 --- a/src-web/components/ResponsePane.tsx +++ b/src-web/components/ResponsePane.tsx @@ -1,4 +1,5 @@ import classnames from 'classnames'; +import type { CSSProperties } from 'react'; import { memo, useEffect, useMemo, useState } from 'react'; import { useActiveRequestId } from '../hooks/useActiveRequestId'; import { useDeleteResponse } from '../hooks/useDeleteResponse'; @@ -17,10 +18,11 @@ import { StatusColor } from './core/StatusColor'; import { Webview } from './core/Webview'; interface Props { + style?: CSSProperties; className?: string; } -export const ResponsePane = memo(function ResponsePane({ className }: Props) { +export const ResponsePane = memo(function ResponsePane({ style, className }: Props) { const [pinnedResponseId, setPinnedResponseId] = useState(null); const activeRequestId = useActiveRequestId(); const responses = useResponses(activeRequestId); @@ -43,95 +45,93 @@ export const ResponsePane = memo(function ResponsePane({ className }: Props) { ); return ( -
-
+ - {/**/} - {/**/} - - {activeResponse && ( - <> -
- - {activeResponse.status} - {activeResponse.statusReason && ` ${activeResponse.statusReason}`} - -  •  - {activeResponse.elapsed}ms  •  - {Math.round(activeResponse.body.length / 1000)} KB -
+ {activeResponse && ( + <> +
+ + {activeResponse.status} + {activeResponse.statusReason && ` ${activeResponse.statusReason}`} + +  •  + {activeResponse.elapsed}ms  •  + {Math.round(activeResponse.body.length / 1000)} KB +
- - ({ - label: r.status + ' - ' + r.elapsed + ' ms', - leftSlot: activeResponse?.id === r.id ? : <>, - onSelect: () => setPinnedResponseId(r.id), - })), - ]} - > - - - - - )} -
+ + ({ + label: r.status + ' - ' + r.elapsed + ' ms', + leftSlot: activeResponse?.id === r.id ? : <>, + onSelect: () => setPinnedResponseId(r.id), + })), + ]} + > + + + + + )} +
- {!activeResponse ? null : activeResponse?.error ? ( -
-
{activeResponse.error}
-
- ) : viewMode === 'pretty' && contentType.includes('html') ? ( - - ) : viewMode === 'pretty' && contentType.includes('json') ? ( - - ) : activeResponse?.body ? ( - - ) : null} -
+ {!activeResponse ? null : activeResponse?.error ? ( +
+
{activeResponse.error}
+
+ ) : viewMode === 'pretty' && contentType.includes('html') ? ( + + ) : viewMode === 'pretty' && contentType.includes('json') ? ( + + ) : activeResponse?.body ? ( + + ) : null}
); }); diff --git a/src-web/components/SidebarDisplayToggle.tsx b/src-web/components/SidebarDisplayToggle.tsx new file mode 100644 index 00000000..b9a2f834 --- /dev/null +++ b/src-web/components/SidebarDisplayToggle.tsx @@ -0,0 +1,16 @@ +import { memo } from 'react'; +import { useSidebarDisplay } from '../hooks/useSidebarDisplay'; +import { IconButton } from './core/IconButton'; + +export const SidebarDisplayToggle = memo(function SidebarDisplayToggle() { + const sidebarDisplay = useSidebarDisplay(); + return ( + + ); +}); diff --git a/src-web/components/Workspace.tsx b/src-web/components/Workspace.tsx index ae8e1694..1e4180b4 100644 --- a/src-web/components/Workspace.tsx +++ b/src-web/components/Workspace.tsx @@ -1,68 +1,20 @@ import classnames from 'classnames'; -import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode } from 'react'; -import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; -import { useWindowSize } from 'react-use'; +import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useSidebarDisplay } from '../hooks/useSidebarDisplay'; +import { RequestResponse } from './RequestResponse'; +import { ResizeHandle } from './ResizeHandle'; import { Sidebar } from './Sidebar'; import { WorkspaceHeader } from './WorkspaceHeader'; -import RequestResponse from './RequestResponse'; const side = { gridArea: 'side' }; const head = { gridArea: 'head' }; const body = { gridArea: 'body' }; - -const FLOATING_SIDEBAR_BREAKPOINT = 960; +const drag = { gridArea: 'drag' }; export default function Workspace() { - const windowSize = useWindowSize(); - const styles = useMemo( - () => ({ - gridTemplate: ` - ' ${head.gridArea} ${head.gridArea}' auto - ' ${side.gridArea} ${body.gridArea}' minmax(0,1fr) - / auto 1fr - `, - }), - [], - ); - - return ( -
- - - - - - - -
- ); -} - -const HeaderContainer = memo(function HeaderContainer({ children }: { children: ReactNode }) { - return ( -
- {children} -
- ); -}); - -interface SidebarContainerProps { - children: ReactNode; - style: CSSProperties; - floating?: boolean; -} - -const SidebarContainer = memo(function SidebarContainer({ - children, - style, - floating, -}: SidebarContainerProps) { const sidebar = useSidebarDisplay(); + const [isResizing, setIsResizing] = useState(false); const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>( null, @@ -83,7 +35,7 @@ const SidebarContainer = memo(function SidebarContainer({ const mouseStartX = e.clientX; const startWidth = sidebar.width; moveState.current = { - move: (e: MouseEvent) => { + move: async (e: MouseEvent) => { e.preventDefault(); // Prevent text selection and things sidebar.set(startWidth + (e.clientX - mouseStartX)); }, @@ -97,96 +49,44 @@ const SidebarContainer = memo(function SidebarContainer({ document.documentElement.addEventListener('mouseup', moveState.current.up); setIsResizing(true); }, - [sidebar.width], + [sidebar.width, sidebar.hidden], ); - const sidebarStyles = useMemo( + const sideWidth = sidebar.hidden ? 0 : sidebar.width; + const styles = useMemo( () => ({ - width: sidebar.hidden ? 0 : sidebar.width, // No width when hidden - borderWidth: sidebar.hidden ? 0 : undefined, // No border when hidden + gridTemplate: ` + ' ${head.gridArea} ${head.gridArea} ${head.gridArea}' auto + ' ${side.gridArea} ${drag.gridArea} ${body.gridArea}' minmax(0,1fr) + / ${sideWidth}px 0 1fr`, }), - [sidebar.width, sidebar.hidden, style], + [sideWidth], ); - const commonClassname = classnames('overflow-hidden bg-gray-100 border-highlight'); - - if (floating) { - return ( -
-
- {children} -
-
- ); - } - return ( -
- +
+ +
+
+ +
+ - {children} -
- ); -}); - -interface ResizeBarProps { - className?: string; - barClassName?: string; - isResizing: boolean; - onResizeStart: (e: ReactMouseEvent) => void; - onReset?: () => void; - side: 'left' | 'right' | 'top'; - justify: 'center' | 'end' | 'start'; -} - -export function ResizeBar({ - justify, - className, - onResizeStart, - onReset, - isResizing, - side, -}: ResizeBarProps) { - const vertical = side === 'top'; - return ( -
- {/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */} - {isResizing && ( -
- )} +
); } diff --git a/src-web/components/WorkspaceHeader.tsx b/src-web/components/WorkspaceHeader.tsx index 1b872424..2d5a118a 100644 --- a/src-web/components/WorkspaceHeader.tsx +++ b/src-web/components/WorkspaceHeader.tsx @@ -1,28 +1,23 @@ import classnames from 'classnames'; +import { memo } from 'react'; import { useActiveRequest } from '../hooks/useActiveRequest'; import { useSidebarDisplay } from '../hooks/useSidebarDisplay'; import { IconButton } from './core/IconButton'; import { HStack } from './core/Stacks'; import { RequestSettingsDropdown } from './RequestSettingsDropdown'; +import { SidebarDisplayToggle } from './SidebarDisplayToggle'; import { WorkspaceDropdown } from './WorkspaceDropdown'; interface Props { className?: string; } -export function WorkspaceHeader({ className }: Props) { +export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) { const activeRequest = useActiveRequest(); - const sidebarDisplay = useSidebarDisplay(); return ( - +
@@ -43,4 +38,4 @@ export function WorkspaceHeader({ className }: Props) {
); -} +}); diff --git a/src-web/components/core/PairEditor.tsx b/src-web/components/core/PairEditor.tsx index 2483d2b2..e582b99f 100644 --- a/src-web/components/core/PairEditor.tsx +++ b/src-web/components/core/PairEditor.tsx @@ -125,9 +125,9 @@ export const PairEditor = memo(function PairEditor({ className={classnames( className, '@container', - 'pb-2 grid', - // NOTE: Add padding to top so overflow doesn't hide drop marker - 'pt-1 -my-1', + 'overflow-auto max-h-full pb-2 grid', + // Move over the width of the drag handle + '-ml-3', )} > {pairs.map((p, i) => { diff --git a/src-web/components/core/Tabs/Tabs.tsx b/src-web/components/core/Tabs/Tabs.tsx index 25492820..225b43a7 100644 --- a/src-web/components/core/Tabs/Tabs.tsx +++ b/src-web/components/core/Tabs/Tabs.tsx @@ -127,7 +127,7 @@ export const TabContent = memo(function TabContent({
{children}
diff --git a/src-web/hooks/useKeyValue.ts b/src-web/hooks/useKeyValue.ts index 51bf68d9..33aa665b 100644 --- a/src-web/hooks/useKeyValue.ts +++ b/src-web/hooks/useKeyValue.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery } from '@tanstack/react-query'; +import { useCallback } from 'react'; import { buildKeyValueKey, getKeyValue, setKeyValue } from '../lib/keyValueStore'; const DEFAULT_NAMESPACE = 'app'; @@ -13,7 +14,8 @@ export function keyValueQueryKey({ return ['key_value', { namespace, key: buildKeyValueKey(key) }]; } -export function useKeyValue({ +// eslint-disable-next-line @typescript-eslint/ban-types +export function useKeyValue({ namespace = DEFAULT_NAMESPACE, key, defaultValue, @@ -31,9 +33,23 @@ export function useKeyValue({ mutationFn: (value) => setKeyValue({ namespace, key, value }), }); + const set = useCallback( + (value: ((v: T) => T) | T) => { + if (typeof value === 'function') { + mutate.mutate(value(query.data ?? defaultValue)); + } else { + mutate.mutate(value); + } + }, + [query.data, defaultValue], + ); + + const reset = useCallback(() => mutate.mutate(defaultValue), [defaultValue]); + return { value: query.data, isLoading: query.isLoading, - set: (value: T) => mutate.mutate(value), + set, + reset, }; } diff --git a/src-web/hooks/useSidebarDisplay.ts b/src-web/hooks/useSidebarDisplay.ts index b65b6592..28314e1f 100644 --- a/src-web/hooks/useSidebarDisplay.ts +++ b/src-web/hooks/useSidebarDisplay.ts @@ -1,27 +1,37 @@ -import { useCallback } from 'react'; -import { clamp } from '../lib/clamp'; +import { useCallback, useMemo } from 'react'; import { useKeyValue } from './useKeyValue'; const START_WIDTH = 200; -const MIN_WIDTH = 110; -const MAX_WIDTH = 500; +const MIN_WIDTH = 150; +const COLLAPSE_WIDTH = MIN_WIDTH * 0.25; -export enum SidebarDisplayKeys { - width = 'sidebar_width', - hidden = 'sidebar_hidden', +export const sidebarDisplayKey = 'sidebar_display'; +export const sidebarDisplayDefaultValue: SidebarDisplay = { hidden: false, width: START_WIDTH }; + +export interface SidebarDisplay { + hidden: boolean; + width: number; } export function useSidebarDisplay() { - const hiddenKv = useKeyValue({ key: SidebarDisplayKeys.hidden, defaultValue: false }); - const widthKv = useKeyValue({ key: SidebarDisplayKeys.width, defaultValue: START_WIDTH }); - const hidden = hiddenKv.value; - const width = widthKv.value ?? START_WIDTH; + const display = useKeyValue({ + key: sidebarDisplayKey, + defaultValue: sidebarDisplayDefaultValue, + }); + const hidden = display.value?.hidden ?? false; + const width = display.value?.width ?? START_WIDTH; - const set = useCallback((v: number) => widthKv.set(clamp(v, MIN_WIDTH, MAX_WIDTH)), []); - const reset = useCallback(() => widthKv.set(START_WIDTH), []); - const hide = useCallback(() => hiddenKv.set(true), []); - const show = useCallback(() => hiddenKv.set(false), []); - const toggle = useCallback(() => hiddenKv.set(!hiddenKv.value), [hiddenKv.value]); + const set = useCallback( + (width: number) => { + const hidden = width < COLLAPSE_WIDTH; + display.set({ hidden, width: Math.max(MIN_WIDTH, width) }); + }, + [display.set], + ); + const hide = useCallback(() => display.set((v) => ({ ...v, hidden: true })), [display.set]); + const show = useCallback(() => display.set((v) => ({ ...v, hidden: false })), [display.set]); + const toggle = useMemo(() => (hidden ? show : hide), [hidden, show, hide]); + const reset = display.reset; return { width, hidden, set, reset, hide, show, toggle }; } diff --git a/src-web/lib/keyValueStore.ts b/src-web/lib/keyValueStore.ts index 329089b4..aa8f07fd 100644 --- a/src-web/lib/keyValueStore.ts +++ b/src-web/lib/keyValueStore.ts @@ -3,8 +3,6 @@ import type { KeyValue } from './models'; const DEFAULT_NAMESPACE = 'app'; -type KeyValueValue = string | number | boolean; - export async function setKeyValue({ namespace = DEFAULT_NAMESPACE, key, @@ -22,7 +20,7 @@ export async function setKeyValue({ return value; } -export async function getKeyValue({ +export async function getKeyValue({ namespace = DEFAULT_NAMESPACE, key, fallback, @@ -38,7 +36,7 @@ export async function getKeyValue({ return extractKeyValueOrFallback(kv, fallback); } -export function extractKeyValue(kv: KeyValue | null): T | undefined { +export function extractKeyValue(kv: KeyValue | null): T | undefined { if (kv === null) return undefined; try { return JSON.parse(kv.value) as T; @@ -47,10 +45,7 @@ export function extractKeyValue(kv: KeyValue | null): T } } -export function extractKeyValueOrFallback( - kv: KeyValue | null, - fallback: T, -): T { +export function extractKeyValueOrFallback(kv: KeyValue | null, fallback: T): T { const v = extractKeyValue(kv); if (v === undefined) return fallback; return v;