Skip to content

Commit f54604c

Browse files
committed
improvement(workflow): shared useDragResize hook + review fixes
Extract the drag mechanism into a shared useDragResize hook (pointer capture, rAF-aligned apply, commit-on-release) consumed by the panel, terminal, and output-panel resize hooks. Fixes from adversarial review: commit the last computed value instead of reading the CSS var back (a fast single-frame flick could be lost to a cancelled rAF, and a pre-rehydration read returned '' -> NaN), floor the panel/terminal max clamp at the minimum so narrow viewports can't invert the clamp, guard pointerup/pointercancel by pointerId so a second touch pointer can't kill the drag, and capture the terminal rect once on drag start instead of per-frame getBoundingClientRect. Remove the now-dead isResizing store state and centralize CONTENT_WINDOW_GAP in stores/constants.
1 parent 287a951 commit f54604c

9 files changed

Lines changed: 236 additions & 285 deletions

File tree

Lines changed: 29 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,40 @@
1-
import { useCallback, useEffect, useRef } from 'react'
2-
import { PANEL_WIDTH } from '@/stores/constants'
1+
import { useDragResize } from '@/hooks/use-drag-resize'
2+
import { CONTENT_WINDOW_GAP, PANEL_WIDTH } from '@/stores/constants'
33
import { usePanelStore } from '@/stores/panel'
44

5-
/** Inset gap between the viewport edge and the content window */
6-
const CONTENT_WINDOW_GAP = 8
5+
/**
6+
* Computes the clamped panel width for a pointer position. The maximum is
7+
* floored at the minimum so a narrow viewport can never invert the clamp
8+
* and force the panel below {@link PANEL_WIDTH.MIN}.
9+
*/
10+
function computePanelWidth(ev: PointerEvent): number {
11+
const maxWidth = Math.max(PANEL_WIDTH.MIN, window.innerWidth * PANEL_WIDTH.MAX_PERCENTAGE)
12+
const newWidth = window.innerWidth - CONTENT_WINDOW_GAP - ev.clientX
13+
return Math.min(Math.max(newWidth, PANEL_WIDTH.MIN), maxWidth)
14+
}
15+
16+
/**
17+
* Applies the panel width per frame. The `--panel-width` CSS variable alone
18+
* sizes `.panel-container`, so no React work happens during the drag.
19+
*/
20+
function applyPanelWidth(width: number): void {
21+
document.documentElement.style.setProperty('--panel-width', `${width}px`)
22+
}
723

824
/**
925
* Handles panel drag-resize with zero React renders during the drag.
26+
* The final width is committed to the store (one re-render + one
27+
* localStorage write) when the drag ends.
1028
*
11-
* Mirrors the sidebar resize architecture (`use-sidebar-resize.ts`):
12-
*
13-
* pointerdown → capture the pointer on the handle (so move/up keep arriving
14-
* even when the cursor leaves the window)
15-
* pointermove → write to --panel-width inside a requestAnimationFrame
16-
* callback (the CSS variable alone sizes `.panel-container`,
17-
* so no React work happens per frame)
18-
* pointerup → cancel any pending RAF, tear down, persist the final width
19-
* to Zustand once (one re-render + one localStorage write)
20-
*
21-
* The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so
22-
* an interrupted gesture can never leave the drag listeners or body cursor
23-
* stuck. A single-flight guard prevents stacking listeners across rapid
24-
* presses, and an unmount cleanup tears down a drag still in flight.
29+
* @returns Pointer-down handler for the resize handle
2530
*/
2631
export function usePanelResize() {
2732
const setPanelWidth = usePanelStore((s) => s.setPanelWidth)
28-
const setIsResizing = usePanelStore((s) => s.setIsResizing)
29-
const cleanupRef = useRef<(() => void) | null>(null)
30-
31-
const handlePointerDown = useCallback(
32-
(e: React.PointerEvent<HTMLElement>) => {
33-
if (cleanupRef.current) return
34-
35-
const handle = e.currentTarget
36-
const pointerId = e.pointerId
37-
setIsResizing(true)
38-
document.body.style.cursor = 'ew-resize'
39-
document.body.style.userSelect = 'none'
40-
handle.setPointerCapture?.(pointerId)
41-
42-
let rafId: number | null = null
43-
44-
const onPointerMove = (ev: PointerEvent) => {
45-
if (rafId !== null) cancelAnimationFrame(rafId)
46-
rafId = requestAnimationFrame(() => {
47-
const maxWidth = window.innerWidth * PANEL_WIDTH.MAX_PERCENTAGE
48-
const newWidth = window.innerWidth - CONTENT_WINDOW_GAP - ev.clientX
49-
const clamped = Math.min(Math.max(newWidth, PANEL_WIDTH.MIN), maxWidth)
50-
document.documentElement.style.setProperty('--panel-width', `${clamped}px`)
51-
rafId = null
52-
})
53-
}
54-
55-
const cleanup = () => {
56-
if (rafId !== null) {
57-
cancelAnimationFrame(rafId)
58-
rafId = null
59-
}
60-
document.body.style.cursor = ''
61-
document.body.style.userSelect = ''
62-
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId)
63-
document.removeEventListener('pointermove', onPointerMove)
64-
document.removeEventListener('pointerup', endDrag)
65-
document.removeEventListener('pointercancel', endDrag)
66-
window.removeEventListener('blur', endDrag)
67-
cleanupRef.current = null
68-
}
69-
70-
function endDrag() {
71-
cleanup()
72-
const raw = document.documentElement.style.getPropertyValue('--panel-width')
73-
const finalWidth = Number.parseFloat(raw)
74-
if (!Number.isNaN(finalWidth)) setPanelWidth(finalWidth)
75-
setIsResizing(false)
76-
}
77-
78-
cleanupRef.current = cleanup
79-
document.addEventListener('pointermove', onPointerMove)
80-
document.addEventListener('pointerup', endDrag)
81-
document.addEventListener('pointercancel', endDrag)
82-
window.addEventListener('blur', endDrag)
83-
},
84-
[setPanelWidth, setIsResizing]
85-
)
86-
87-
useEffect(() => () => cleanupRef.current?.(), [])
8833

89-
return { handlePointerDown }
34+
return useDragResize({
35+
cursor: 'ew-resize',
36+
compute: computePanelWidth,
37+
apply: applyPanelWidth,
38+
commit: setPanelWidth,
39+
})
9040
}
Lines changed: 40 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,50 @@
1-
import { useCallback, useEffect, useRef } from 'react'
1+
import { useCallback, useRef } from 'react'
2+
import { useDragResize } from '@/hooks/use-drag-resize'
23
import { OUTPUT_PANEL_WIDTH, TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
34
import { useTerminalStore } from '@/stores/terminal'
45

6+
/**
7+
* Applies the output panel width per frame. The `--output-panel-width` CSS
8+
* variable alone sizes the output panel and its sibling logs column, so no
9+
* React work happens during the drag.
10+
*/
11+
function applyOutputPanelWidth(width: number): void {
12+
document.documentElement.style.setProperty('--output-panel-width', `${width}px`)
13+
}
14+
515
/**
616
* Handles the terminal output panel drag-resize with zero React renders
7-
* during the drag.
17+
* during the drag. The terminal rect is captured once on drag start (its
18+
* size cannot change mid-drag, and this keeps forced layout reads off the
19+
* per-move path). The final width is committed to the store (one re-render
20+
* + one localStorage write) when the drag ends.
821
*
9-
* Mirrors the sidebar resize architecture (`use-sidebar-resize.ts`):
10-
*
11-
* pointerdown → capture the pointer on the handle (so move/up keep arriving
12-
* even when the cursor leaves the window)
13-
* pointermove → write to --output-panel-width inside a requestAnimationFrame
14-
* callback (the CSS variable alone sizes the logs column via
15-
* `calc(100% - var(--output-panel-width))`)
16-
* pointerup → cancel any pending RAF, tear down, persist the final width
17-
* to Zustand once (one re-render + one localStorage write)
18-
*
19-
* The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so
20-
* an interrupted gesture can never leave the drag listeners or body cursor
21-
* stuck. A single-flight guard prevents stacking listeners across rapid
22-
* presses, and an unmount cleanup tears down a drag still in flight.
22+
* @returns Pointer-down handler for the resize handle
2323
*/
2424
export function useOutputPanelResize() {
2525
const setOutputPanelWidth = useTerminalStore((s) => s.setOutputPanelWidth)
26-
const cleanupRef = useRef<(() => void) | null>(null)
27-
28-
const handlePointerDown = useCallback(
29-
(e: React.PointerEvent<HTMLElement>) => {
30-
if (cleanupRef.current) return
31-
32-
const terminalEl = document.querySelector('[aria-label="Terminal"]')
33-
if (!terminalEl) return
34-
35-
const handle = e.currentTarget
36-
const pointerId = e.pointerId
37-
document.body.style.cursor = 'ew-resize'
38-
document.body.style.userSelect = 'none'
39-
handle.setPointerCapture?.(pointerId)
40-
41-
let rafId: number | null = null
42-
43-
const onPointerMove = (ev: PointerEvent) => {
44-
if (rafId !== null) cancelAnimationFrame(rafId)
45-
rafId = requestAnimationFrame(() => {
46-
const terminalRect = terminalEl.getBoundingClientRect()
47-
const newWidth = terminalRect.right - ev.clientX
48-
const maxWidth = terminalRect.width - TERMINAL_BLOCK_COLUMN_WIDTH
49-
const clamped = Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth))
50-
document.documentElement.style.setProperty('--output-panel-width', `${clamped}px`)
51-
rafId = null
52-
})
53-
}
54-
55-
const cleanup = () => {
56-
if (rafId !== null) {
57-
cancelAnimationFrame(rafId)
58-
rafId = null
59-
}
60-
document.body.style.cursor = ''
61-
document.body.style.userSelect = ''
62-
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId)
63-
document.removeEventListener('pointermove', onPointerMove)
64-
document.removeEventListener('pointerup', endDrag)
65-
document.removeEventListener('pointercancel', endDrag)
66-
window.removeEventListener('blur', endDrag)
67-
cleanupRef.current = null
68-
}
69-
70-
function endDrag() {
71-
cleanup()
72-
const raw = document.documentElement.style.getPropertyValue('--output-panel-width')
73-
const finalWidth = Number.parseFloat(raw)
74-
if (!Number.isNaN(finalWidth)) setOutputPanelWidth(finalWidth)
75-
}
76-
77-
cleanupRef.current = cleanup
78-
document.addEventListener('pointermove', onPointerMove)
79-
document.addEventListener('pointerup', endDrag)
80-
document.addEventListener('pointercancel', endDrag)
81-
window.addEventListener('blur', endDrag)
82-
},
83-
[setOutputPanelWidth]
84-
)
85-
86-
useEffect(() => () => cleanupRef.current?.(), [])
87-
88-
return { handlePointerDown }
26+
const terminalRectRef = useRef<DOMRect | null>(null)
27+
28+
const captureTerminalRect = useCallback(() => {
29+
const terminalEl = document.querySelector('[aria-label="Terminal"]')
30+
if (!terminalEl) return false
31+
terminalRectRef.current = terminalEl.getBoundingClientRect()
32+
return true
33+
}, [])
34+
35+
const computeOutputPanelWidth = useCallback((ev: PointerEvent) => {
36+
const terminalRect = terminalRectRef.current
37+
if (!terminalRect) return null
38+
const newWidth = terminalRect.right - ev.clientX
39+
const maxWidth = terminalRect.width - TERMINAL_BLOCK_COLUMN_WIDTH
40+
return Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth))
41+
}, [])
42+
43+
return useDragResize({
44+
cursor: 'ew-resize',
45+
compute: computeOutputPanelWidth,
46+
apply: applyOutputPanelWidth,
47+
commit: setOutputPanelWidth,
48+
onStart: captureTerminalRect,
49+
})
8950
}
Lines changed: 37 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,48 @@
1-
import { useCallback, useEffect, useRef } from 'react'
21
import { TERMINAL_CONFIG } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils'
3-
import { TERMINAL_HEIGHT } from '@/stores/constants'
2+
import { useDragResize } from '@/hooks/use-drag-resize'
3+
import { CONTENT_WINDOW_GAP, TERMINAL_HEIGHT } from '@/stores/constants'
44
import { useTerminalStore } from '@/stores/terminal'
55

6-
/** Inset gap between the viewport edge and the content window */
7-
const CONTENT_WINDOW_GAP = 8
6+
/** Computes the clamped terminal height for a pointer position */
7+
function computeTerminalHeight(ev: PointerEvent): number {
8+
const maxHeight = Math.max(
9+
TERMINAL_HEIGHT.MIN,
10+
window.innerHeight * TERMINAL_HEIGHT.MAX_PERCENTAGE
11+
)
12+
const newHeight = window.innerHeight - CONTENT_WINDOW_GAP - ev.clientY
13+
return Math.min(Math.max(newHeight, TERMINAL_HEIGHT.MIN), maxHeight)
14+
}
815

916
/**
10-
* Handles terminal drag-resize with (almost) zero React renders during the drag.
11-
*
12-
* Mirrors the sidebar resize architecture (`use-sidebar-resize.ts`):
13-
*
14-
* pointerdown → capture the pointer on the handle (so move/up keep arriving
15-
* even when the cursor leaves the window)
16-
* pointermove → write to --terminal-height inside a requestAnimationFrame
17-
* callback (the CSS variable alone sizes `.terminal-container`).
18-
* The store is committed mid-drag only when the height crosses
19-
* the expanded threshold, so `isExpanded` subscribers (header
20-
* chevron, auto-open logic) still flip live.
21-
* pointerup → cancel any pending RAF, tear down, persist the final height
22-
* to Zustand once (one re-render + one localStorage write)
17+
* Applies the terminal height per frame. The `--terminal-height` CSS
18+
* variable alone sizes `.terminal-container`, so no React work happens on
19+
* ordinary frames. The store is committed mid-drag only when the height
20+
* crosses the expanded threshold, so `isExpanded` subscribers (header
21+
* chevron, auto-open logic) still flip live during the drag.
22+
*/
23+
function applyTerminalHeight(height: number): void {
24+
document.documentElement.style.setProperty('--terminal-height', `${height}px`)
25+
26+
const store = useTerminalStore.getState()
27+
const wasExpanded = store.terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD
28+
const nowExpanded = height > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD
29+
if (wasExpanded !== nowExpanded) store.setTerminalHeight(height)
30+
}
31+
32+
/**
33+
* Handles terminal drag-resize with zero React renders during the drag
34+
* (except at expanded-threshold crossings). The final height is committed
35+
* to the store (one re-render + one localStorage write) when the drag ends.
2336
*
24-
* The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so
25-
* an interrupted gesture can never leave the drag listeners or body cursor
26-
* stuck. A single-flight guard prevents stacking listeners across rapid
27-
* presses, and an unmount cleanup tears down a drag still in flight.
37+
* @returns Pointer-down handler for the resize handle
2838
*/
2939
export function useTerminalResize() {
3040
const setTerminalHeight = useTerminalStore((s) => s.setTerminalHeight)
31-
const setIsResizing = useTerminalStore((s) => s.setIsResizing)
32-
const cleanupRef = useRef<(() => void) | null>(null)
33-
34-
const handlePointerDown = useCallback(
35-
(e: React.PointerEvent<HTMLElement>) => {
36-
if (cleanupRef.current) return
37-
38-
const handle = e.currentTarget
39-
const pointerId = e.pointerId
40-
setIsResizing(true)
41-
document.body.style.cursor = 'ns-resize'
42-
document.body.style.userSelect = 'none'
43-
handle.setPointerCapture?.(pointerId)
44-
45-
let rafId: number | null = null
46-
47-
const onPointerMove = (ev: PointerEvent) => {
48-
if (rafId !== null) cancelAnimationFrame(rafId)
49-
rafId = requestAnimationFrame(() => {
50-
const maxHeight = window.innerHeight * TERMINAL_HEIGHT.MAX_PERCENTAGE
51-
const newHeight = window.innerHeight - CONTENT_WINDOW_GAP - ev.clientY
52-
const clamped = Math.min(Math.max(newHeight, TERMINAL_HEIGHT.MIN), maxHeight)
53-
document.documentElement.style.setProperty('--terminal-height', `${clamped}px`)
54-
55-
const wasExpanded =
56-
useTerminalStore.getState().terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD
57-
const nowExpanded = clamped > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD
58-
if (wasExpanded !== nowExpanded) setTerminalHeight(clamped)
59-
60-
rafId = null
61-
})
62-
}
63-
64-
const cleanup = () => {
65-
if (rafId !== null) {
66-
cancelAnimationFrame(rafId)
67-
rafId = null
68-
}
69-
document.body.style.cursor = ''
70-
document.body.style.userSelect = ''
71-
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId)
72-
document.removeEventListener('pointermove', onPointerMove)
73-
document.removeEventListener('pointerup', endDrag)
74-
document.removeEventListener('pointercancel', endDrag)
75-
window.removeEventListener('blur', endDrag)
76-
cleanupRef.current = null
77-
}
78-
79-
function endDrag() {
80-
cleanup()
81-
const raw = document.documentElement.style.getPropertyValue('--terminal-height')
82-
const finalHeight = Number.parseFloat(raw)
83-
if (!Number.isNaN(finalHeight)) setTerminalHeight(finalHeight)
84-
setIsResizing(false)
85-
}
86-
87-
cleanupRef.current = cleanup
88-
document.addEventListener('pointermove', onPointerMove)
89-
document.addEventListener('pointerup', endDrag)
90-
document.addEventListener('pointercancel', endDrag)
91-
window.addEventListener('blur', endDrag)
92-
},
93-
[setTerminalHeight, setIsResizing]
94-
)
95-
96-
useEffect(() => () => cleanupRef.current?.(), [])
9741

98-
return { handlePointerDown }
42+
return useDragResize({
43+
cursor: 'ns-resize',
44+
compute: computeTerminalHeight,
45+
apply: applyTerminalHeight,
46+
commit: setTerminalHeight,
47+
})
9948
}

0 commit comments

Comments
 (0)