| 1 | import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; |
| 2 | import Animated, { |
| 3 | runOnJS, |
| 4 | useAnimatedStyle, |
| 5 | useSharedValue, |
| 6 | withTiming, |
| 7 | type SharedValue, |
| 8 | } from "react-native-reanimated"; |
| 9 | |
| 10 | import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout"; |
| 11 | import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; |
| 12 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; |
| 13 | import { WorkspacePaneDivider } from "./workspace-pane-divider"; |
| 14 | |
| 15 | /** |
| 16 | * The trailing inspector column: resize divider + animated reveal. |
| 17 | * |
| 18 | * Rendered by AdaptiveWorkspaceLayout as a SIBLING of the navigator so the |
| 19 | * native stack header (and its trailing toolbar items) spans only the content |
| 20 | * pane — the inspector owns its own full-height column, mirroring how each |
| 21 | * column of a UISplitViewController has its own chrome. |
| 22 | * |
| 23 | * Receives the pane layout via props (not the workspace context hook) so this |
| 24 | * module stays import-cycle-free with AdaptiveWorkspaceLayout. |
| 25 | */ |
| 26 | export function WorkspaceInspectorPane(props: { |
| 27 | readonly pathname: string; |
| 28 | readonly renderedInspectorWidth: SharedValue<number>; |
| 29 | /** |
| 30 | * When false the pane animates closed but keeps its content mounted for the |
| 31 | * exit transition (a route that lost focus). `onClosed` fires once the |
| 32 | * close animation settles so the owner can drop the stale content. |
| 33 | */ |
| 34 | readonly active?: boolean; |
| 35 | readonly onClosed?: () => void; |
| 36 | readonly panes: WorkspacePaneLayout; |
| 37 | readonly renderInspector?: () => ReactNode; |
| 38 | readonly setAuxiliaryPaneWidth: (width: number) => void; |
| 39 | }) { |
| 40 | const { panes, setAuxiliaryPaneWidth } = props; |
| 41 | const inspectorWidth = panes.auxiliaryPaneWidth; |
| 42 | const inspectorSupported = props.renderInspector !== undefined && inspectorWidth !== null; |
| 43 | const inspectorVisible = |
| 44 | inspectorSupported && panes.auxiliaryPaneVisible && (props.active ?? true); |
| 45 | const resizeStartWidth = useRef(0); |
| 46 | const [resizing, setResizing] = useState(false); |
| 47 | |
| 48 | // A file-to-file replace remounts the route. Initialize an already-visible |
| 49 | // inspector at its final position so route replacement never replays an |
| 50 | // entering transition. Only visibility and explicit resizing change it. |
| 51 | const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0); |
| 52 | const { renderedInspectorWidth } = props; |
| 53 | // The content keeps its own width so the reveal (outer width) clips a |
| 54 | // fully-laid-out pane instead of reflowing text every frame. When the OPEN |
| 55 | // pane's target width changes (e.g. the sidebar toggles and reserves |
| 56 | // space), animate the content width in lockstep rather than snapping. |
| 57 | const renderedContentWidth = useSharedValue(inspectorWidth ?? 0); |
| 58 | |
| 59 | const onClosed = props.onClosed; |
| 60 | useEffect(() => { |
| 61 | inspectorProgress.value = withTiming( |
| 62 | inspectorVisible ? 1 : 0, |
| 63 | WORKSPACE_PANE_TIMING, |
| 64 | (finished) => { |
| 65 | if (finished === true && !inspectorVisible && onClosed !== undefined) { |
| 66 | runOnJS(onClosed)(); |
| 67 | } |
| 68 | }, |
| 69 | ); |
| 70 | const targetWidth = inspectorVisible ? (inspectorWidth ?? 0) : 0; |
| 71 | renderedInspectorWidth.value = resizing |
| 72 | ? targetWidth |
| 73 | : withTiming(targetWidth, WORKSPACE_PANE_TIMING); |
| 74 | }, [ |
| 75 | inspectorProgress, |
| 76 | inspectorVisible, |
| 77 | inspectorWidth, |
| 78 | onClosed, |
| 79 | renderedInspectorWidth, |
| 80 | resizing, |
| 81 | ]); |
| 82 | |
| 83 | useEffect(() => { |
| 84 | const targetWidth = inspectorWidth ?? 0; |
| 85 | if (!inspectorVisible || resizing) { |
| 86 | // Hidden panes re-measure silently; during a divider drag the content |
| 87 | // tracks the finger directly. |
| 88 | renderedContentWidth.value = targetWidth; |
| 89 | return; |
| 90 | } |
| 91 | renderedContentWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); |
| 92 | }, [inspectorVisible, inspectorWidth, renderedContentWidth, resizing]); |
| 93 | |
| 94 | const inspectorStyle = useAnimatedStyle( |
| 95 | () => ({ |
| 96 | opacity: inspectorProgress.value, |
| 97 | transform: [{ translateX: (1 - inspectorProgress.value) * 24 }], |
| 98 | width: renderedInspectorWidth.value, |
| 99 | }), |
| 100 | [], |
| 101 | ); |
| 102 | const inspectorContentStyle = useAnimatedStyle(() => ({ width: renderedContentWidth.value }), []); |
| 103 | const beginResize = useCallback(() => { |
| 104 | resizeStartWidth.current = inspectorWidth ?? 0; |
| 105 | setResizing(true); |
| 106 | }, [inspectorWidth]); |
| 107 | const resizeBy = useCallback( |
| 108 | (delta: number) => { |
| 109 | setAuxiliaryPaneWidth( |
| 110 | constrainAuxiliaryPaneWidth({ |
| 111 | preferredWidth: resizeStartWidth.current + delta, |
| 112 | availableWidth: panes.contentPaneWidth, |
| 113 | }), |
| 114 | ); |
| 115 | }, |
| 116 | [panes.contentPaneWidth, setAuxiliaryPaneWidth], |
| 117 | ); |
| 118 | const endResize = useCallback(() => { |
| 119 | setResizing(false); |
| 120 | }, []); |
| 121 | |
| 122 | return ( |
| 123 | <> |
| 124 | {inspectorVisible ? ( |
| 125 | <WorkspacePaneDivider |
| 126 | accessibilityLabel="Resize detail pane" |
| 127 | currentWidth={inspectorWidth ?? 0} |
| 128 | resizeDirection={-1} |
| 129 | onResizeStart={beginResize} |
| 130 | onResizeBy={resizeBy} |
| 131 | onResizeEnd={endResize} |
| 132 | /> |
| 133 | ) : null} |
| 134 | {inspectorSupported ? ( |
| 135 | <Animated.View |
| 136 | className="shrink-0 overflow-hidden" |
| 137 | accessibilityElementsHidden={!inspectorVisible} |
| 138 | collapsable={false} |
| 139 | importantForAccessibility={inspectorVisible ? "auto" : "no-hide-descendants"} |
| 140 | pointerEvents={inspectorVisible ? "auto" : "none"} |
| 141 | style={inspectorStyle} |
| 142 | > |
| 143 | <Animated.View className="flex-1" style={inspectorContentStyle}> |
| 144 | <RenderErrorBoundary |
| 145 | resetKeys={[props.pathname]} |
| 146 | renderFallback={(fallback) => ( |
| 147 | <RenderFailureView {...fallback} title="The inspector couldn't be displayed" /> |
| 148 | )} |
| 149 | > |
| 150 | <InspectorRenderer render={props.renderInspector} /> |
| 151 | </RenderErrorBoundary> |
| 152 | </Animated.View> |
| 153 | </Animated.View> |
| 154 | ) : null} |
| 155 | </> |
| 156 | ); |
| 157 | } |
| 158 | |
| 159 | // The render callback must run inside the boundary's child, not while its |
| 160 | // parent constructs the boundary element. |
| 161 | function InspectorRenderer(props: { readonly render?: () => ReactNode }) { |
| 162 | return <>{props.render?.()}</>; |
| 163 | } |