mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
perf(workflow): scope resize CSS-var writes to the container, not :root (#5738)
* perf(workflow): scope resize CSS-var writes to the container, not :root Resizing the editor panel, terminal, output panel, and sidebar was still laggy on large workflows even after the drag handles stopped re-rendering React per frame. A CDP trace showed the cost was style recalculation, not JS or layout: ~3.9s of Document::recalcStyle over a 50-move panel drag. Root cause: each drag frame wrote its CSS variable to document.documentElement. On a large document (~42k elements) any inline custom-property write on the <html> root recalculates style for the whole tree — measured at ~77ms per write, independent of what actually reads the variable (an unused var costs the same). Writing the same variable to the element that consumes it recalcs only that subtree (~0.5ms) — a ~150x reduction. useDragResize now writes the variable to a caller-provided target element during the drag and reconciles to :root once on release (so on-demand readers and the pre-hydration script are unchanged): panel -> .panel-container, terminal -> .terminal-container, output panel -> .terminal-container (both consumers inherit it), sidebar -> .sidebar-shell-outer. The terminal's expanded-threshold sync writes store state directly instead of setTerminalHeight so it no longer touches :root mid-drag. Measured (near-empty canvas, 50-move drag): panel style+layout 3891ms -> 56ms, frame p95 91.6ms -> 8.4ms, main-thread blocking 4566ms -> 0ms; terminal recalc 3899ms -> 16ms, blocking 5558ms -> 0ms; sidebar recalc ~77ms/frame -> ~1ms/frame. * perf(workflow): keep float boundary-sync live during scoped resize drags The resize hooks now write their CSS variable to the consuming container element during a drag (not :root), so use-float-boundary-sync's MutationObserver on :root no longer fired mid-drag and open floats (chat, search-replace, variables) only re-clamped on release. Read each boundary dimension from its scoped element with a :root fallback, and observe the container elements as well as :root, so an open float tracks the drag live exactly as before. * fix(workflow): finalize drag on unmount + clamp reads scoped output width - useDragResize: unmounting mid-drag now runs endDrag (commit + drop the scoped override) instead of a bare cleanup, so navigating away can neither lose the resize nor strand an inline override on a surviving target element. - terminal output-panel clamp: read the live --output-panel-width from the terminal element first (where a drag writes its scoped override), then :root, then the store, so a mid-drag terminal/window resize can't clamp against a stale value. * fix(workflow): robust drag finalize — last-applied value, sidebar unmount, clamp skip Addresses three review findings on the scoped-resize change: - useDragResize: track the last applied value and commit that on release; only recompute from the pointer event while the target is still connected. On an unmount the target's rect can be detached, so a layout-reading compute (e.g. the output panel's) would return a degenerate MIN — committing the last shown value avoids persisting the wrong width, and keeps flick-safety on a normal release. - use-sidebar-resize: finalize on unmount (run endDrag, not bare cleanup) so a drag interrupted by unmount persists the width and drops the scoped override. This matters more than for the panel/terminal because .sidebar-shell-outer lives in the workspace chrome and outlives the sidebar, so a stranded override would win over :root. - terminal output-panel clamp: skip while an output-panel drag is active (its scoped inline override is present). That drag's own compute clamps every frame against the live terminal rect, and a store-driven :root write here would be masked by the inline override anyway. * fix(workflow): sidebar flick-safety + clamp reads committed :root width - use-sidebar-resize: compute the clamped width synchronously on each pointer move (storing lastWidth) and defer only the DOM write to rAF, so a fast flick released before the frame runs still commits the final pointer position instead of a stale frame or nothing. - terminal output-panel clamp: when not mid-drag, read the committed --output-panel-width from :root (written synchronously by the store setter) rather than the React store value, which lags a render behind the commit; fall back to the store before any commit. Comment corrected to match.
This commit is contained in:
+10
-10
@@ -13,18 +13,17 @@ function computePanelWidth(ev: PointerEvent): number {
|
||||
return Math.min(Math.max(newWidth, PANEL_WIDTH.MIN), maxWidth)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the panel width per frame. The `--panel-width` CSS variable alone
|
||||
* sizes `.panel-container`, so no React work happens during the drag.
|
||||
*/
|
||||
function applyPanelWidth(width: number): void {
|
||||
document.documentElement.style.setProperty('--panel-width', `${width}px`)
|
||||
/** The `.panel-container` element sizes itself from `--panel-width`. */
|
||||
function getPanelContainer(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>('.panel-container')
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles panel drag-resize with zero React renders during the drag.
|
||||
* The final width is committed to the store (one re-render + one
|
||||
* localStorage write) when the drag ends.
|
||||
* Handles panel drag-resize with zero React renders during the drag. The
|
||||
* `--panel-width` variable is written to `.panel-container` (a scoped style
|
||||
* recalc) rather than `:root` (a whole-document recalc), and the final width
|
||||
* is committed to the store (one re-render + one localStorage write) when the
|
||||
* drag ends.
|
||||
*
|
||||
* @returns Pointer-down handler for the resize handle
|
||||
*/
|
||||
@@ -33,8 +32,9 @@ export function usePanelResize() {
|
||||
|
||||
return useDragResize({
|
||||
cursor: 'ew-resize',
|
||||
cssVar: '--panel-width',
|
||||
getTarget: getPanelContainer,
|
||||
compute: computePanelWidth,
|
||||
apply: applyPanelWidth,
|
||||
commit: setPanelWidth,
|
||||
})
|
||||
}
|
||||
|
||||
+13
-20
@@ -3,32 +3,25 @@ import { useDragResize } from '@/hooks/use-drag-resize'
|
||||
import { OUTPUT_PANEL_WIDTH, TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
|
||||
/**
|
||||
* Applies the output panel width per frame. The `--output-panel-width` CSS
|
||||
* variable alone sizes the output panel and its sibling logs column, so no
|
||||
* React work happens during the drag.
|
||||
*/
|
||||
function applyOutputPanelWidth(width: number): void {
|
||||
document.documentElement.style.setProperty('--output-panel-width', `${width}px`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the terminal output panel drag-resize with zero React renders
|
||||
* during the drag. The terminal element is captured once on drag start and
|
||||
* its rect is re-read per frame (rAF-aligned, before the CSS-var write), so
|
||||
* the clamp stays correct even if the terminal resizes mid-drag. The final
|
||||
* width is committed to the store (one re-render + one localStorage write)
|
||||
* when the drag ends.
|
||||
* during the drag. `--output-panel-width` is written to `.terminal-container`
|
||||
* (which both the output panel and its sibling logs column inherit from) — a
|
||||
* scoped style recalc rather than a whole-document one on `:root`. The
|
||||
* terminal rect is re-read per frame (rAF-aligned, before the write), so the
|
||||
* clamp stays correct even if the terminal resizes mid-drag. The final width
|
||||
* is committed to the store (one re-render + one localStorage write) when the
|
||||
* drag ends.
|
||||
*
|
||||
* @returns Pointer-down handler for the resize handle
|
||||
*/
|
||||
export function useOutputPanelResize() {
|
||||
const setOutputPanelWidth = useTerminalStore((s) => s.setOutputPanelWidth)
|
||||
const terminalElRef = useRef<Element | null>(null)
|
||||
const terminalElRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const captureTerminalElement = useCallback(() => {
|
||||
terminalElRef.current = document.querySelector('[aria-label="Terminal"]')
|
||||
return terminalElRef.current !== null
|
||||
const getTerminalElement = useCallback(() => {
|
||||
terminalElRef.current = document.querySelector<HTMLElement>('.terminal-container')
|
||||
return terminalElRef.current
|
||||
}, [])
|
||||
|
||||
const computeOutputPanelWidth = useCallback((ev: PointerEvent) => {
|
||||
@@ -42,9 +35,9 @@ export function useOutputPanelResize() {
|
||||
|
||||
return useDragResize({
|
||||
cursor: 'ew-resize',
|
||||
cssVar: '--output-panel-width',
|
||||
getTarget: getTerminalElement,
|
||||
compute: computeOutputPanelWidth,
|
||||
apply: applyOutputPanelWidth,
|
||||
commit: setOutputPanelWidth,
|
||||
onStart: captureTerminalElement,
|
||||
})
|
||||
}
|
||||
|
||||
+24
-15
@@ -13,26 +13,33 @@ function computeTerminalHeight(ev: PointerEvent): number {
|
||||
return Math.min(Math.max(newHeight, TERMINAL_HEIGHT.MIN), maxHeight)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the terminal height per frame. The `--terminal-height` CSS
|
||||
* variable alone sizes `.terminal-container`, so no React work happens on
|
||||
* ordinary frames. The store is committed mid-drag only when the height
|
||||
* crosses the expanded threshold, so `isExpanded` subscribers (header
|
||||
* chevron, auto-open logic) still flip live during the drag.
|
||||
*/
|
||||
function applyTerminalHeight(height: number): void {
|
||||
document.documentElement.style.setProperty('--terminal-height', `${height}px`)
|
||||
/** The `.terminal-container` element sizes itself from `--terminal-height`. */
|
||||
function getTerminalContainer(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>('.terminal-container')
|
||||
}
|
||||
|
||||
const store = useTerminalStore.getState()
|
||||
const wasExpanded = store.terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD
|
||||
/**
|
||||
* Updates the store height mid-drag only when it crosses the expanded
|
||||
* threshold, so `isExpanded` subscribers (header chevron, auto-open logic)
|
||||
* still flip live during the drag. Writes store state directly rather than
|
||||
* calling `setTerminalHeight` so it does not also write `--terminal-height` to
|
||||
* `:root` (a whole-document recalc) — the scoped CSS-var write handled by
|
||||
* {@link useDragResize} already drives the visual, and the final value is
|
||||
* persisted through `setTerminalHeight` on release.
|
||||
*/
|
||||
function syncExpandedThreshold(height: number): void {
|
||||
const wasExpanded =
|
||||
useTerminalStore.getState().terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD
|
||||
const nowExpanded = height > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD
|
||||
if (wasExpanded !== nowExpanded) store.setTerminalHeight(height)
|
||||
if (wasExpanded !== nowExpanded) useTerminalStore.setState({ terminalHeight: height })
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles terminal drag-resize with zero React renders during the drag
|
||||
* (except at expanded-threshold crossings). The final height is committed
|
||||
* to the store (one re-render + one localStorage write) when the drag ends.
|
||||
* (except at expanded-threshold crossings). The `--terminal-height` variable
|
||||
* is written to `.terminal-container` (a scoped style recalc) rather than
|
||||
* `:root` (a whole-document recalc), and the final height is committed to the
|
||||
* store (one re-render + one localStorage write) when the drag ends.
|
||||
*
|
||||
* @returns Pointer-down handler for the resize handle
|
||||
*/
|
||||
@@ -41,8 +48,10 @@ export function useTerminalResize() {
|
||||
|
||||
return useDragResize({
|
||||
cursor: 'ns-resize',
|
||||
cssVar: '--terminal-height',
|
||||
getTarget: getTerminalContainer,
|
||||
compute: computeTerminalHeight,
|
||||
apply: applyTerminalHeight,
|
||||
commit: setTerminalHeight,
|
||||
onApply: syncExpandedThreshold,
|
||||
})
|
||||
}
|
||||
|
||||
+13
-6
@@ -1255,10 +1255,15 @@ export const Terminal = memo(function Terminal() {
|
||||
/**
|
||||
* Adjust output panel width on resize.
|
||||
* Closes the output panel if there's not enough space for the minimum width.
|
||||
* The clamp compares against the live `--output-panel-width` CSS variable
|
||||
* (the visual width — during a drag it runs ahead of the store, which only
|
||||
* commits on release) so a resize mid-drag can never stomp the drag with a
|
||||
* stale store value; the store value is the fallback before any write.
|
||||
*
|
||||
* An active output-panel drag owns clamping — its own compute clamps every
|
||||
* frame against the live terminal rect, and its scoped inline override on
|
||||
* `.terminal-container` (present only while that drag runs) would mask a
|
||||
* store-driven `:root` write here anyway — so this skips while it is active.
|
||||
* Otherwise the width is read from the committed `--output-panel-width` on
|
||||
* `:root`, which the store setter writes synchronously and is therefore
|
||||
* fresher than the React store value (a render behind the commit), falling
|
||||
* back to the store value before any commit exists.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const el = terminalRef.current
|
||||
@@ -1267,6 +1272,8 @@ export const Terminal = memo(function Terminal() {
|
||||
const handleResize = () => {
|
||||
if (!selectedEntry) return
|
||||
|
||||
if (el.style.getPropertyValue('--output-panel-width')) return
|
||||
|
||||
const maxWidth = el.getBoundingClientRect().width - TERMINAL_CONFIG.BLOCK_COLUMN_WIDTH_PX
|
||||
|
||||
if (maxWidth < MIN_OUTPUT_PANEL_WIDTH_PX) {
|
||||
@@ -1275,10 +1282,10 @@ export const Terminal = memo(function Terminal() {
|
||||
return
|
||||
}
|
||||
|
||||
const liveWidth = Number.parseFloat(
|
||||
const committed = Number.parseFloat(
|
||||
document.documentElement.style.getPropertyValue('--output-panel-width')
|
||||
)
|
||||
const currentWidth = Number.isNaN(liveWidth) ? outputPanelWidth : liveWidth
|
||||
const currentWidth = Number.isNaN(committed) ? outputPanelWidth : committed
|
||||
if (currentWidth > maxWidth) {
|
||||
setOutputPanelWidth(Math.max(maxWidth, MIN_OUTPUT_PANEL_WIDTH_PX))
|
||||
}
|
||||
|
||||
+36
-16
@@ -12,9 +12,29 @@ interface UseFloatBoundarySyncProps {
|
||||
const CONTENT_WINDOW_GAP = 8
|
||||
|
||||
/**
|
||||
* Hook to synchronize floats position with layout boundary changes.
|
||||
* Keeps the float within bounds when sidebar, panel, or terminal resize.
|
||||
* Uses requestAnimationFrame for smooth real-time updates
|
||||
* Layout dimensions a float must stay clear of. During a resize drag the live
|
||||
* value is an inline override on the consuming element (a scoped style recalc);
|
||||
* at rest it lives on `:root` (committed via the store / pre-hydration script).
|
||||
* Each entry pairs the element the drag writes to with its variable so the
|
||||
* float tracks the drag live and re-clamps at rest.
|
||||
*/
|
||||
const BOUNDARY_DIMENSIONS = [
|
||||
{ selector: '.sidebar-shell-outer', cssVar: '--sidebar-width' },
|
||||
{ selector: '.panel-container', cssVar: '--panel-width' },
|
||||
{ selector: '.terminal-container', cssVar: '--terminal-height' },
|
||||
] as const
|
||||
|
||||
/** Reads a boundary dimension, preferring the drag's scoped inline override. */
|
||||
function readBoundaryDimension(selector: string, cssVar: string): number {
|
||||
const inline = document.querySelector<HTMLElement>(selector)?.style.getPropertyValue(cssVar)
|
||||
const value = inline || getComputedStyle(document.documentElement).getPropertyValue(cssVar)
|
||||
return Number.parseInt(value || '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to synchronize a float's position with layout boundary changes.
|
||||
* Keeps the float within bounds when the sidebar, panel, or terminal resize.
|
||||
* Uses requestAnimationFrame for smooth real-time updates.
|
||||
*/
|
||||
export function useFloatBoundarySync({
|
||||
isOpen,
|
||||
@@ -30,15 +50,9 @@ export function useFloatBoundarySync({
|
||||
positionRef.current = position
|
||||
|
||||
const checkAndUpdatePosition = useCallback(() => {
|
||||
const sidebarWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
|
||||
)
|
||||
const panelWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
|
||||
)
|
||||
const terminalHeight = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
|
||||
)
|
||||
const sidebarWidth = readBoundaryDimension('.sidebar-shell-outer', '--sidebar-width')
|
||||
const panelWidth = readBoundaryDimension('.panel-container', '--panel-width')
|
||||
const terminalHeight = readBoundaryDimension('.terminal-container', '--terminal-height')
|
||||
|
||||
const prev = previousDimensionsRef.current
|
||||
if (
|
||||
@@ -83,11 +97,17 @@ export function useFloatBoundarySync({
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
/**
|
||||
* Watch both `:root` (at-rest commits, the pre-hydration script) and each
|
||||
* container the resize hooks write to mid-drag, so the float re-clamps live
|
||||
* throughout a drag rather than only on release.
|
||||
*/
|
||||
const observer = new MutationObserver(handleResize)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
})
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
|
||||
for (const { selector } of BOUNDARY_DIMENSIONS) {
|
||||
const el = document.querySelector(selector)
|
||||
if (el) observer.observe(el, { attributes: true, attributeFilter: ['style'] })
|
||||
}
|
||||
|
||||
checkAndUpdatePosition()
|
||||
|
||||
|
||||
+28
-16
@@ -12,30 +12,39 @@ import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
* add `is-resizing` class directly to the DOM (no React
|
||||
* round-trip, so the CSS width transition is suppressed from the
|
||||
* very first frame)
|
||||
* pointermove → write to --sidebar-width inside a requestAnimationFrame
|
||||
* callback (aligns work with the browser paint cycle)
|
||||
* pointermove → write --sidebar-width to `.sidebar-shell-outer` (the element
|
||||
* that sizes the rail) inside a requestAnimationFrame callback.
|
||||
* Scoping the variable to that subtree keeps the style recalc
|
||||
* local; writing it to `:root` instead forces a whole-document
|
||||
* recalc (~150x slower on a large canvas).
|
||||
* pointerup → cancel any pending RAF, tear down, persist final width to
|
||||
* Zustand once (one React re-render to save to localStorage)
|
||||
* Zustand once (writes the authoritative `:root` value for
|
||||
* on-demand readers), then drop the scoped override
|
||||
*
|
||||
* The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so an
|
||||
* interrupted gesture (release outside the window, alt-tab, context menu, the OS
|
||||
* stealing focus) can never leave the `is-resizing` / `sidebar-resizing` classes
|
||||
* stuck — which would otherwise freeze the sidebar at a tiny width with the
|
||||
* collapse transition permanently disabled. A single-flight guard prevents
|
||||
* stacking listeners across rapid presses, and an unmount cleanup tears down a
|
||||
* drag still in flight when the sidebar unmounts (e.g. route change).
|
||||
* stacking listeners across rapid presses, and unmounting mid-drag finalizes it
|
||||
* the same way a release does — persisting the last width and dropping the
|
||||
* scoped override — which matters because `.sidebar-shell-outer` lives in the
|
||||
* workspace chrome and outlives the sidebar, so a stranded override would
|
||||
* otherwise win over the committed `:root` value.
|
||||
*/
|
||||
export function useSidebarResize() {
|
||||
const setSidebarWidth = useSidebarStore((s) => s.setSidebarWidth)
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
const teardownRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLElement>) => {
|
||||
if (cleanupRef.current) return
|
||||
if (teardownRef.current) return
|
||||
|
||||
const handle = e.currentTarget
|
||||
const pointerId = e.pointerId
|
||||
const sidebar = document.querySelector<HTMLElement>('.sidebar-container')
|
||||
const shell = document.querySelector<HTMLElement>('.sidebar-shell-outer')
|
||||
const target = shell ?? document.documentElement
|
||||
sidebar?.classList.add('is-resizing')
|
||||
document.documentElement.classList.add('sidebar-resizing')
|
||||
document.body.style.cursor = 'ew-resize'
|
||||
@@ -43,13 +52,15 @@ export function useSidebarResize() {
|
||||
handle.setPointerCapture?.(pointerId)
|
||||
|
||||
let rafId: number | null = null
|
||||
let lastWidth: number | null = null
|
||||
|
||||
const onPointerMove = (ev: PointerEvent) => {
|
||||
const max = Math.max(SIDEBAR_WIDTH.MIN, window.innerWidth * SIDEBAR_WIDTH.MAX_PERCENTAGE)
|
||||
const clamped = Math.min(Math.max(ev.clientX, SIDEBAR_WIDTH.MIN), max)
|
||||
lastWidth = clamped
|
||||
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const max = Math.max(SIDEBAR_WIDTH.MIN, window.innerWidth * SIDEBAR_WIDTH.MAX_PERCENTAGE)
|
||||
const clamped = Math.min(Math.max(ev.clientX, SIDEBAR_WIDTH.MIN), max)
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${clamped}px`)
|
||||
target.style.setProperty('--sidebar-width', `${clamped}px`)
|
||||
rafId = null
|
||||
})
|
||||
}
|
||||
@@ -68,17 +79,18 @@ export function useSidebarResize() {
|
||||
document.removeEventListener('pointerup', endDrag)
|
||||
document.removeEventListener('pointercancel', endDrag)
|
||||
window.removeEventListener('blur', endDrag)
|
||||
cleanupRef.current = null
|
||||
teardownRef.current = null
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
cleanup()
|
||||
const raw = document.documentElement.style.getPropertyValue('--sidebar-width')
|
||||
const finalWidth = Number.parseFloat(raw)
|
||||
if (!Number.isNaN(finalWidth)) setSidebarWidth(finalWidth)
|
||||
if (lastWidth !== null) {
|
||||
setSidebarWidth(lastWidth)
|
||||
if (target !== document.documentElement) target.style.removeProperty('--sidebar-width')
|
||||
}
|
||||
}
|
||||
|
||||
cleanupRef.current = cleanup
|
||||
teardownRef.current = endDrag
|
||||
document.addEventListener('pointermove', onPointerMove)
|
||||
document.addEventListener('pointerup', endDrag)
|
||||
document.addEventListener('pointercancel', endDrag)
|
||||
@@ -87,7 +99,7 @@ export function useSidebarResize() {
|
||||
[setSidebarWidth]
|
||||
)
|
||||
|
||||
useEffect(() => () => cleanupRef.current?.(), [])
|
||||
useEffect(() => () => teardownRef.current?.(), [])
|
||||
|
||||
return { handlePointerDown }
|
||||
}
|
||||
|
||||
@@ -3,27 +3,42 @@ import { useCallback, useEffect, useRef } from 'react'
|
||||
interface UseDragResizeOptions {
|
||||
/** Cursor applied to the document body for the duration of the drag */
|
||||
cursor: 'ew-resize' | 'ns-resize'
|
||||
/**
|
||||
* The CSS custom property this drag drives (e.g. `--panel-width`). During
|
||||
* the drag it is written as `${value}px`.
|
||||
*/
|
||||
cssVar: string
|
||||
/**
|
||||
* Returns the element that consumes {@link cssVar} (or an ancestor of every
|
||||
* consumer). During the drag the variable is written here — a style recalc
|
||||
* scoped to that subtree — instead of on `:root`, where on a large document
|
||||
* every custom-property write recalculates the whole tree (~150x slower).
|
||||
* Captured once on drag start; a `null` return falls back to
|
||||
* `document.documentElement`.
|
||||
*/
|
||||
getTarget: () => HTMLElement | null
|
||||
/**
|
||||
* Maps a pointer position to the clamped target dimension, or `null` to
|
||||
* ignore the move. Runs at most once per animation frame (before `apply`,
|
||||
* ignore the move. Runs at most once per animation frame (before the write,
|
||||
* so a layout read here happens against clean layout) and once more on
|
||||
* release, so it may read layout but must stay cheap.
|
||||
*/
|
||||
compute: (ev: PointerEvent) => number | null
|
||||
/**
|
||||
* Applies per-frame visual feedback (typically a CSS variable write).
|
||||
* Invoked inside requestAnimationFrame, at most once per frame.
|
||||
*/
|
||||
apply: (value: number) => void
|
||||
/**
|
||||
* Persists the final value once when the drag ends. Not called when the
|
||||
* pointer never moved (a plain click on the handle).
|
||||
* Persists the final value once when the drag ends. Should write the
|
||||
* authoritative value to `:root` (typically via the store setter) so
|
||||
* on-demand readers of {@link cssVar} stay correct; the scoped override is
|
||||
* then removed. Not called when the pointer never moved (a plain click).
|
||||
*/
|
||||
commit: (value: number) => void
|
||||
/**
|
||||
* Optional drag-start hook (e.g. capture an anchor element, set a store
|
||||
* flag). Return `false` to abort the drag before any listeners are
|
||||
* attached.
|
||||
* Optional per-frame hook invoked after the variable is written (e.g. the
|
||||
* terminal's expanded-threshold store sync). Runs inside the rAF callback.
|
||||
*/
|
||||
onApply?: (value: number) => void
|
||||
/**
|
||||
* Optional drag-start hook (e.g. set a resize class). Return `false` to
|
||||
* abort the drag before any listeners are attached.
|
||||
*/
|
||||
onStart?: () => boolean | undefined
|
||||
/** Optional drag-end hook, invoked after teardown and commit */
|
||||
@@ -37,25 +52,31 @@ interface UseDragResizeOptions {
|
||||
*
|
||||
* pointerdown → capture the pointer on the handle (so move/up keep arriving
|
||||
* even when the cursor leaves the window or crosses an iframe)
|
||||
* and capture the scoped target element (see {@link
|
||||
* UseDragResizeOptions.getTarget})
|
||||
* pointermove → remember the latest pointer event and schedule a
|
||||
* requestAnimationFrame callback that `compute`s the clamped
|
||||
* value from it and `apply`s it, so both any layout read and
|
||||
* the DOM write align with the browser paint cycle
|
||||
* pointerup → tear down, `compute` the final value from the latest
|
||||
* pointer event, `apply` and `commit` it once — deriving the
|
||||
* final value from the event (rather than reading state back
|
||||
* out of the DOM) means a fast single-frame flick is never
|
||||
* lost to a cancelled RAF
|
||||
* value from it and writes `cssVar` to the target element, so
|
||||
* both any layout read and the DOM write align with the browser
|
||||
* paint cycle
|
||||
* pointerup → tear down, `compute` the final value from the latest pointer
|
||||
* event, write it, `commit` it once, then drop the scoped
|
||||
* override so the committed `:root` value takes over — deriving
|
||||
* the final value from the event (rather than reading state back
|
||||
* out of the DOM) means a fast single-frame flick is never lost
|
||||
* to a cancelled RAF
|
||||
*
|
||||
* The drag is torn down by `pointerup`/`pointercancel` of the captured
|
||||
* pointer (other pointers are ignored, so a second touch cannot kill the
|
||||
* gesture) or window `blur`, so an interrupted gesture can never leave the
|
||||
* listeners or body cursor stuck. A single-flight guard prevents stacking
|
||||
* listeners across rapid presses, and an unmount cleanup tears down a drag
|
||||
* still in flight.
|
||||
* The drag is torn down by `pointerup`/`pointercancel` of the captured pointer
|
||||
* (other pointers are ignored, so a second touch cannot kill the gesture) or
|
||||
* window `blur`, so an interrupted gesture can never leave the listeners or
|
||||
* body cursor stuck. A single-flight guard prevents stacking listeners across
|
||||
* rapid presses, and unmounting mid-drag finalizes it the same way a release
|
||||
* does — committing the last value and dropping the scoped override — so
|
||||
* navigating away can neither lose the resize nor strand the override on a
|
||||
* surviving target element.
|
||||
*/
|
||||
export function useDragResize(options: UseDragResizeOptions) {
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
const teardownRef = useRef<(() => void) | null>(null)
|
||||
const optionsRef = useRef(options)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -63,17 +84,26 @@ export function useDragResize(options: UseDragResizeOptions) {
|
||||
}, [options])
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
|
||||
if (cleanupRef.current) return
|
||||
if (teardownRef.current) return
|
||||
if (optionsRef.current.onStart?.() === false) return
|
||||
|
||||
const handle = e.currentTarget
|
||||
const pointerId = e.pointerId
|
||||
const { cssVar } = optionsRef.current
|
||||
const target = optionsRef.current.getTarget() ?? document.documentElement
|
||||
document.body.style.cursor = optionsRef.current.cursor
|
||||
document.body.style.userSelect = 'none'
|
||||
handle.setPointerCapture?.(pointerId)
|
||||
|
||||
let rafId: number | null = null
|
||||
let lastEvent: PointerEvent | null = null
|
||||
let lastApplied: number | null = null
|
||||
|
||||
const applyValue = (value: number) => {
|
||||
target.style.setProperty(cssVar, `${value}px`)
|
||||
lastApplied = value
|
||||
optionsRef.current.onApply?.(value)
|
||||
}
|
||||
|
||||
const onPointerMove = (ev: PointerEvent) => {
|
||||
if (ev.pointerId !== pointerId) return
|
||||
@@ -82,7 +112,7 @@ export function useDragResize(options: UseDragResizeOptions) {
|
||||
rafId = null
|
||||
if (lastEvent === null) return
|
||||
const value = optionsRef.current.compute(lastEvent)
|
||||
if (value !== null) optionsRef.current.apply(value)
|
||||
if (value !== null) applyValue(value)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -98,17 +128,23 @@ export function useDragResize(options: UseDragResizeOptions) {
|
||||
document.removeEventListener('pointerup', onPointerEnd)
|
||||
document.removeEventListener('pointercancel', onPointerEnd)
|
||||
window.removeEventListener('blur', endDrag)
|
||||
cleanupRef.current = null
|
||||
teardownRef.current = null
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
cleanup()
|
||||
if (lastEvent !== null) {
|
||||
// Recompute the final value from the last pointer position for an exact
|
||||
// finish (and flick-safety when no frame ran) — but only while the target
|
||||
// is still attached. On an unmount its rect can be detached, so a
|
||||
// layout-reading compute would return a degenerate value; there, commit
|
||||
// the last value actually shown to the user instead.
|
||||
if (lastEvent !== null && target.isConnected) {
|
||||
const value = optionsRef.current.compute(lastEvent)
|
||||
if (value !== null) {
|
||||
optionsRef.current.apply(value)
|
||||
optionsRef.current.commit(value)
|
||||
}
|
||||
if (value !== null) applyValue(value)
|
||||
}
|
||||
if (lastApplied !== null) {
|
||||
optionsRef.current.commit(lastApplied)
|
||||
if (target !== document.documentElement) target.style.removeProperty(cssVar)
|
||||
}
|
||||
optionsRef.current.onEnd?.()
|
||||
}
|
||||
@@ -118,14 +154,14 @@ export function useDragResize(options: UseDragResizeOptions) {
|
||||
endDrag()
|
||||
}
|
||||
|
||||
cleanupRef.current = cleanup
|
||||
teardownRef.current = endDrag
|
||||
document.addEventListener('pointermove', onPointerMove)
|
||||
document.addEventListener('pointerup', onPointerEnd)
|
||||
document.addEventListener('pointercancel', onPointerEnd)
|
||||
window.addEventListener('blur', endDrag)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => cleanupRef.current?.(), [])
|
||||
useEffect(() => () => teardownRef.current?.(), [])
|
||||
|
||||
return { handlePointerDown }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user