From 1b98f6a7162dc3835ffa6cb3d16924606eb5fece Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 23 Aug 2026 22:57:36 +0100 Subject: [PATCH] Add Adaptive Splitter Component and Update Styles - Introduced a new AdaptiveSplitter component to enhance layout flexibility in the dashboard, allowing for dynamic resizing of panels. - Updated App.css to include styles for the new adaptive splitter, ensuring consistent appearance and behavior across different orientations. - Refactored DashboardSidebarSplitter to utilize the AdaptiveSplitter, improving sidebar management and responsiveness during resizing. - Adjusted transition properties and sizes for smoother user experience when interacting with the splitter components. --- assets/stylesheets/App.css | 303 ++++-- .../Dashboard/common/AdaptiveSplitter.jsx | 981 ++++++++++++++++++ .../common/DashboardSidebarSplitter.jsx | 265 +---- 3 files changed, 1275 insertions(+), 274 deletions(-) create mode 100644 src/components/Dashboard/common/AdaptiveSplitter.jsx diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index e5bb2c97..662ecce5 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -37,6 +37,7 @@ .ant-badge, .ant-select-dropdown, .ant-splitter, +.adaptive-splitter, .ant-steps-item-title, .ant-steps-icon, .ant-color-picker-presets-label, @@ -528,11 +529,13 @@ body { width: 35%; } -.farmcontrol-splitter > .ant-splitter-bar { +.farmcontrol-splitter > .ant-splitter-bar, +.farmcontrol-splitter > .adaptive-splitter-bar { margin: 8px; } -.farmcontrol-splitter.large > .ant-splitter-bar { +.farmcontrol-splitter.large > .ant-splitter-bar, +.farmcontrol-splitter.large > .adaptive-splitter-bar { margin: 11px; opacity: 0.6; } @@ -938,41 +941,18 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton { } .sidebar-scroll .simplebar-track.simplebar-vertical { - right: -1px; - width: 0px !important; - transition: - width 0.15s ease-in-out, - right 0.15s ease-in-out; -} - -.sidebar-scroll .simplebar-track.simplebar-vertical { - border-radius: 0 0 6.5px 0; -} - -.sidebar-scroll.dark .simplebar-track.simplebar-vertical { - background-color: #141414; - border-inline-end: 1px solid rgba(253, 253, 253, 0.12); - border-bottom: 1px solid rgba(253, 253, 253, 0.12); -} - -.sidebar-scroll.light .simplebar-track.simplebar-vertical { - background-color: #ffffff; - border-inline-end: 1px solid rgba(5, 5, 5, 0.06); - border-bottom: 1px solid rgba(5, 5, 5, 0.06); -} - -.sidebar-scroll.simplebar-scrolling .simplebar-track.simplebar-vertical { - width: 12px !important; - right: -13px; + top: 8px; + right: calc(-1 * var(--scrollbox-vertical-right-padding) - 0.5px); + width: 8px !important; } .sidebar-scroll .simplebar-track.simplebar-vertical .simplebar-scrollbar:before { - top: 4px; - bottom: 4px; - left: 4px; - right: 4px; + top: 2px; + bottom: 2px; + left: 2px; + right: 2px; } .ant-color-picker @@ -1096,29 +1076,229 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton { margin-left: 4px; } -.farmcontrol-splitter .ant-splitter-panel { +.farmcontrol-splitter .ant-splitter-panel, +.farmcontrol-splitter .adaptive-splitter-panel { overflow: visible; } -.farmcontrol-splitter.ant-splitter-horizontal .ant-splitter-panel { +.farmcontrol-splitter.ant-splitter-horizontal .ant-splitter-panel, +.farmcontrol-splitter.adaptive-splitter-horizontal .adaptive-splitter-panel { width: 100%; min-width: 0; } -.farmcontrol-splitter.ant-splitter-horizontal.is-size-transitioning - .ant-splitter-panel { - transition: - --dashboard-splitter-sidebar-size 0.3s cubic-bezier(0.2, 0, 0, 1) 0s, - flex-basis 0.3s cubic-bezier(0.2, 0, 0, 1) 0s, - width 0.3s cubic-bezier(0.2, 0, 0, 1) 0s, - min-width 0.3s cubic-bezier(0.2, 0, 0, 1) 0s, - max-width 0.3s cubic-bezier(0.2, 0, 0, 1) 0s; +.adaptive-splitter { + display: flex; + width: 100%; + height: 100%; + min-height: 0; + align-items: stretch; + position: relative; } -@property --dashboard-splitter-sidebar-size { - syntax: ''; - inherits: true; - initial-value: 80px; +.adaptive-splitter-vertical { + flex-direction: column; +} + +.adaptive-splitter-panel { + min-width: 0; + min-height: 0; +} + +.adaptive-splitter-bar { + flex: none; + position: relative; + z-index: 2; + user-select: none; + touch-action: none; +} + +.adaptive-splitter-horizontal > .adaptive-splitter-bar { + width: 0; +} + +.adaptive-splitter-vertical > .adaptive-splitter-bar { + height: 0; +} + +.adaptive-splitter-bar-dragger { + position: absolute; + z-index: 1; + background: transparent; +} + +.adaptive-splitter-horizontal + > .adaptive-splitter-bar + .adaptive-splitter-bar-dragger { + top: 0; + bottom: 0; + left: 50%; + width: 6px; + transform: translateX(-50%); + cursor: col-resize; +} + +.adaptive-splitter-vertical + > .adaptive-splitter-bar + .adaptive-splitter-bar-dragger { + left: 0; + right: 0; + top: 50%; + height: 6px; + transform: translateY(-50%); + cursor: row-resize; +} + +.adaptive-splitter-bar-dragger::before { + content: ''; + position: absolute; + inset: 0; + margin: auto; +} + +.adaptive-splitter-horizontal + > .adaptive-splitter-bar + .adaptive-splitter-bar-dragger::before { + width: 2px; + height: 100%; + background: var(--ant-color-split, rgba(5, 5, 5, 0.06)); +} + +.adaptive-splitter-vertical + > .adaptive-splitter-bar + .adaptive-splitter-bar-dragger::before { + height: 2px; + width: 100%; + background: var(--ant-color-split, rgba(5, 5, 5, 0.06)); +} + +.adaptive-splitter-bar-dragger::after { + content: ''; + position: absolute; + inset: 0; + margin: auto; + background: var(--ant-color-text-quaternary, rgba(0, 0, 0, 0.25)); + opacity: 0.35; +} + +.adaptive-splitter-horizontal + > .adaptive-splitter-bar + .adaptive-splitter-bar-dragger::after { + width: 2px; + height: 20px; +} + +.adaptive-splitter-vertical + > .adaptive-splitter-bar + .adaptive-splitter-bar-dragger::after { + height: 2px; + width: 20px; +} + +.adaptive-splitter-bar.is-active .adaptive-splitter-bar-dragger::before, +.adaptive-splitter-bar-dragger:hover::before { + background: var(--ant-color-primary-border-hover, #4096ff); +} + +.adaptive-splitter-bar-collapse { + position: absolute; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--ant-color-text-secondary, rgba(0, 0, 0, 0.45)); + cursor: pointer; +} + +.adaptive-splitter-horizontal + > .adaptive-splitter-bar + .adaptive-splitter-bar-collapse { + top: 50%; + transform: translateY(-50%); +} + +.adaptive-splitter-horizontal + > .adaptive-splitter-bar + .adaptive-splitter-bar-collapse-start { + left: -18px; +} + +.adaptive-splitter-horizontal + > .adaptive-splitter-bar + .adaptive-splitter-bar-collapse-end { + right: -18px; +} + +.adaptive-splitter-bar-preview { + position: absolute; + z-index: 3; + pointer-events: none; + background: var(--ant-color-primary, #1677ff); + opacity: 0.45; +} + +.adaptive-splitter-horizontal + > .adaptive-splitter-bar + .adaptive-splitter-bar-preview { + top: 0; + bottom: 0; + width: 2px; +} + +.adaptive-splitter-vertical + > .adaptive-splitter-bar + .adaptive-splitter-bar-preview { + left: 0; + right: 0; + height: 2px; +} + +.adaptive-splitter.is-dragging { + user-select: none; +} + +.adaptive-splitter.is-dragging.adaptive-splitter-horizontal, +.adaptive-splitter.is-dragging.adaptive-splitter-horizontal * { + cursor: col-resize !important; +} + +.adaptive-splitter.is-dragging.adaptive-splitter-vertical, +.adaptive-splitter.is-dragging.adaptive-splitter-vertical * { + cursor: row-resize !important; +} + +.adaptive-splitter-mask { + position: absolute; + inset: 0; + z-index: 10; + pointer-events: none; +} + +.adaptive-splitter.is-ready:not(.is-dragging) > .adaptive-splitter-panel, +.adaptive-splitter.is-size-transitioning > .adaptive-splitter-panel, +.farmcontrol-splitter.is-size-transitioning > .ant-splitter-panel, +.farmcontrol-splitter.is-size-transitioning > .adaptive-splitter-panel { + transition: + flex-basis var(--adaptive-splitter-size-transition, 0.3s) + cubic-bezier(0.2, 0, 0, 1), + width var(--adaptive-splitter-size-transition, 0.3s) + cubic-bezier(0.2, 0, 0, 1), + min-width var(--adaptive-splitter-size-transition, 0.3s) + cubic-bezier(0.2, 0, 0, 1), + max-width var(--adaptive-splitter-size-transition, 0.3s) + cubic-bezier(0.2, 0, 0, 1), + height var(--adaptive-splitter-size-transition, 0.3s) + cubic-bezier(0.2, 0, 0, 1), + min-height var(--adaptive-splitter-size-transition, 0.3s) + cubic-bezier(0.2, 0, 0, 1), + max-height var(--adaptive-splitter-size-transition, 0.3s) + cubic-bezier(0.2, 0, 0, 1); } .farmcontrol-splitter @@ -1643,41 +1823,34 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton { stroke-width: 8px; } -.dashboard-splitter.farmcontrol-splitter > .ant-splitter-bar { +.dashboard-splitter.farmcontrol-splitter > .ant-splitter-bar, +.dashboard-splitter.farmcontrol-splitter > .adaptive-splitter-bar { margin: 0; } -.dashboard-splitter.farmcontrol-splitter > .ant-splitter-panel { +.dashboard-splitter.farmcontrol-splitter > .ant-splitter-panel, +.dashboard-splitter.farmcontrol-splitter > .adaptive-splitter-panel { padding: 0; } .dashboard-splitter.farmcontrol-splitter > .ant-splitter-bar - .ant-splitter-bar-dragger::before { + .ant-splitter-bar-dragger::before, +.dashboard-splitter.farmcontrol-splitter + > .adaptive-splitter-bar + .adaptive-splitter-bar-dragger::before { background: transparent; } .dashboard-splitter.farmcontrol-splitter > .ant-splitter-bar - .ant-splitter-bar-dragger::after { + .ant-splitter-bar-dragger::after, +.dashboard-splitter.farmcontrol-splitter + > .adaptive-splitter-bar + .adaptive-splitter-bar-dragger::after { content: none; } -.dashboard-splitter.farmcontrol-splitter - .ant-splitter-panel.dashboard-splitter-content-panel { - flex-grow: 1 !important; -} - -.dashboard-splitter.farmcontrol-splitter - .ant-splitter-panel.dashboard-splitter-sidebar-panel.is-collapsed { - flex-grow: 0 !important; - flex-shrink: 0 !important; - flex-basis: var(--dashboard-splitter-sidebar-size) !important; - width: var(--dashboard-splitter-sidebar-size) !important; - min-width: var(--dashboard-splitter-sidebar-size) !important; - max-width: var(--dashboard-splitter-sidebar-size) !important; -} - .object-display-tag .elipsis-text-wrapper.ant-typography code.elipsis-text-measure, diff --git a/src/components/Dashboard/common/AdaptiveSplitter.jsx b/src/components/Dashboard/common/AdaptiveSplitter.jsx new file mode 100644 index 00000000..2e505d6a --- /dev/null +++ b/src/components/Dashboard/common/AdaptiveSplitter.jsx @@ -0,0 +1,981 @@ +import { + Children, + isValidElement, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' +import PropTypes from 'prop-types' +import { + DownOutlined, + LeftOutlined, + RightOutlined, + UpOutlined +} from '@ant-design/icons' + +const SIZE_TRANSITION_MS = 300 +const DEFAULT_EXPAND_DRAG_PX = 10 +const KEYBOARD_STEP_PX = 8 + +const sizeType = PropTypes.oneOfType([PropTypes.number, PropTypes.string]) + +const AdaptiveSplitterPanel = () => null +AdaptiveSplitterPanel.displayName = 'AdaptiveSplitterPanel' +AdaptiveSplitterPanel.isAdaptiveSplitterPanel = true +AdaptiveSplitterPanel.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + style: PropTypes.object, + size: sizeType, + defaultSize: sizeType, + min: sizeType, + max: sizeType, + resizable: PropTypes.bool, + collapsible: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.shape({ + start: PropTypes.bool, + end: PropTypes.bool + }) + ]), + collapsed: PropTypes.bool, + collapsedSize: PropTypes.number, + collapseThreshold: PropTypes.number, + expandDragPx: PropTypes.number, + expandedSize: PropTypes.number, + onCollapse: PropTypes.func +} + +const parseSizeToPx = (size, containerSize) => { + if (size == null || size === '') return null + if (typeof size === 'number') { + return Number.isFinite(size) ? size : null + } + const value = String(size).trim() + if (value.endsWith('%')) { + const pct = Number.parseFloat(value) + return Number.isFinite(pct) ? (pct / 100) * containerSize : null + } + const px = Number.parseFloat(value) + return Number.isFinite(px) ? px : null +} + +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)) + +const getPanelItems = (children) => + Children.toArray(children) + .filter( + (child) => + isValidElement(child) && + (child.type === AdaptiveSplitterPanel || + child.type?.isAdaptiveSplitterPanel) + ) + .map((child) => ({ + ...child.props, + key: child.key + })) + +const getCollapsibleSides = (collapsible) => { + if (!collapsible) return { start: false, end: false } + if (collapsible === true) return { start: true, end: true } + return { + start: !!collapsible.start, + end: !!collapsible.end + } +} + +const getBarEdge = (sizes, barIndex) => { + if (barIndex < 0) return 0 + let edge = 0 + for (let i = 0; i <= barIndex; i += 1) { + edge += sizes[i] || 0 + } + return edge +} + +const SplitBar = ({ + active, + resizable, + isVertical, + lazy, + lazyOffset, + ariaNow, + ariaMin, + ariaMax, + startCollapsible, + endCollapsible, + onPointerDown, + onKeyDown, + onCollapseStart, + onCollapseEnd +}) => { + const startIcon = isVertical ? : + const endIcon = isVertical ? : + + return ( +
+ {lazy && active && ( +
+ )} +
+ {startCollapsible && ( + + )} + {endCollapsible && ( + + )} +
+ ) +} + +SplitBar.propTypes = { + active: PropTypes.bool, + resizable: PropTypes.bool, + isVertical: PropTypes.bool, + lazy: PropTypes.bool, + lazyOffset: PropTypes.number, + ariaNow: PropTypes.number, + ariaMin: PropTypes.number, + ariaMax: PropTypes.number, + startCollapsible: PropTypes.bool, + endCollapsible: PropTypes.bool, + onPointerDown: PropTypes.func, + onKeyDown: PropTypes.func, + onCollapseStart: PropTypes.func, + onCollapseEnd: PropTypes.func +} + +const AdaptiveSplitter = ({ + children, + className = '', + style, + layout, + orientation, + vertical = false, + lazy = false, + onResizeStart, + onResize, + onResizeEnd, + onCollapse, + sizeTransitionMs = SIZE_TRANSITION_MS +}) => { + const isVertical = + orientation === 'vertical' || layout === 'vertical' || vertical + const items = useMemo(() => getPanelItems(children), [children]) + + const containerRef = useRef(null) + const [containerSize, setContainerSize] = useState(0) + const [pxSizes, setPxSizes] = useState([]) + const [dragging, setDragging] = useState(false) + const [activeBar, setActiveBar] = useState(null) + const [sizeTransitioning, setSizeTransitioning] = useState(false) + const [ready, setReady] = useState(false) + const [lazyOffset, setLazyOffset] = useState(0) + + const pxSizesRef = useRef(pxSizes) + pxSizesRef.current = pxSizes + const itemsRef = useRef(items) + itemsRef.current = items + const containerSizeRef = useRef(containerSize) + containerSizeRef.current = containerSize + const dragRef = useRef(null) + const lastExpandedRef = useRef([]) + const sizeTransitionTimeoutRef = useRef(null) + const skipNextTransitionRef = useRef(true) + const lazyOffsetRef = useRef(0) + const pendingAnimateSizesRef = useRef(null) + const animateRafRef = useRef([]) + + const getItemMin = useCallback((item, fallback = 0) => { + const parsed = parseSizeToPx(item?.min, containerSizeRef.current) + return parsed ?? fallback + }, []) + + const getItemMax = useCallback((item, fallback = Infinity) => { + const parsed = parseSizeToPx(item?.max, containerSizeRef.current) + return parsed ?? fallback + }, []) + + const rememberExpandedSize = useCallback((size, index, item) => { + if (!item || item.collapsed) return + if (item.collapseThreshold != null && size < item.collapseThreshold) return + lastExpandedRef.current[index] = size + }, []) + + const resolveExpandedSize = useCallback( + (index, item) => { + if (item?.expandedSize != null) return item.expandedSize + if (lastExpandedRef.current[index] != null) { + return lastExpandedRef.current[index] + } + const fromDefault = parseSizeToPx( + item?.defaultSize, + containerSizeRef.current + ) + if (fromDefault != null) return fromDefault + const max = getItemMax(item) + if (Number.isFinite(max)) return max + return item?.collapsedSize ?? 0 + }, + [getItemMax] + ) + + const applyBarResize = useCallback( + (sizes, barIndex, nextStartSize) => { + const currentItems = itemsRef.current + const startItem = currentItems[barIndex] + const endItem = currentItems[barIndex + 1] + if (!startItem || !endItem) return sizes + + const next = sizes.slice() + const startMin = getItemMin(startItem) + const startMax = getItemMax(startItem) + const endMin = getItemMin(endItem) + const endMax = getItemMax(endItem) + const oldStart = next[barIndex] + const oldEnd = next[barIndex + 1] + const pairTotal = oldStart + oldEnd + + let startSize = clamp(nextStartSize, startMin, startMax) + let endSize = pairTotal - startSize + + if (endSize < endMin) { + endSize = endMin + startSize = pairTotal - endMin + } else if (endSize > endMax) { + endSize = endMax + startSize = pairTotal - endMax + } + + startSize = clamp(startSize, startMin, startMax) + next[barIndex] = startSize + next[barIndex + 1] = pairTotal - startSize + return next + }, + [getItemMax, getItemMin] + ) + + const emitCollapse = useCallback( + (index, collapsed, sizes) => { + itemsRef.current[index]?.onCollapse?.(collapsed) + onCollapse?.( + itemsRef.current.map((panel, panelIndex) => + panelIndex === index ? collapsed : !!panel.collapsed + ), + sizes + ) + }, + [onCollapse] + ) + + const remapDragOrigin = useCallback((pointerPos, edge) => { + const drag = dragRef.current + if (!drag) return + drag.originOffset = pointerPos - edge + drag.lastPointerPos = pointerPos + }, []) + + const endDragSession = useCallback(() => { + const drag = dragRef.current + if (drag?.captureTarget?.hasPointerCapture?.(drag.pointerId)) { + drag.captureTarget.releasePointerCapture(drag.pointerId) + } + dragRef.current = null + lazyOffsetRef.current = 0 + setLazyOffset(0) + setDragging(false) + setActiveBar(null) + }, []) + + const cancelPendingAnimate = useCallback(() => { + animateRafRef.current.forEach((id) => cancelAnimationFrame(id)) + animateRafRef.current = [] + pendingAnimateSizesRef.current = null + }, []) + + const startSizeTransition = useCallback(() => { + setSizeTransitioning(true) + if (sizeTransitionTimeoutRef.current) { + clearTimeout(sizeTransitionTimeoutRef.current) + } + sizeTransitionTimeoutRef.current = setTimeout(() => { + setSizeTransitioning(false) + sizeTransitionTimeoutRef.current = null + }, sizeTransitionMs) + }, [sizeTransitionMs]) + + const scheduleAnimatedSizes = useCallback( + (nextSizes) => { + startSizeTransition() + cancelPendingAnimate() + pendingAnimateSizesRef.current = nextSizes + const ids = [] + ids.push( + requestAnimationFrame(() => { + ids.push( + requestAnimationFrame(() => { + animateRafRef.current = [] + const pending = pendingAnimateSizesRef.current + pendingAnimateSizesRef.current = null + if (pending) setPxSizes(pending) + }) + ) + }) + ) + animateRafRef.current = ids + }, + [cancelPendingAnimate, startSizeTransition] + ) + + const measureContainer = useCallback(() => { + const node = containerRef.current + if (!node) return + const next = isVertical ? node.clientHeight : node.clientWidth + if (next !== containerSizeRef.current) { + setContainerSize(next) + } + }, [isVertical]) + + useLayoutEffect(() => { + measureContainer() + const node = containerRef.current + if (!node || typeof ResizeObserver === 'undefined') return undefined + const observer = new ResizeObserver(() => measureContainer()) + observer.observe(node) + return () => observer.disconnect() + }, [measureContainer]) + + useEffect(() => { + const id = requestAnimationFrame(() => setReady(true)) + return () => cancelAnimationFrame(id) + }, []) + + const deriveSizes = useCallback(() => { + if (!containerSize || !items.length) return [] + + const parsed = items.map((item) => { + if (item.collapsed && item.collapsedSize != null && item.size == null) { + return item.collapsedSize + } + return parseSizeToPx(item.size ?? item.defaultSize, containerSize) + }) + + const knownSum = parsed.reduce( + (sum, size) => sum + (size == null ? 0 : size), + 0 + ) + const unknownCount = parsed.filter((size) => size == null).length + const leftover = Math.max(0, containerSize - knownSum) + const unknownShare = unknownCount ? leftover / unknownCount : 0 + + return parsed.map((size, index) => { + const item = items[index] + const next = size == null ? unknownShare : size + const min = getItemMin(item) + const max = getItemMax(item) + const clamped = clamp(next, min, Number.isFinite(max) ? max : next) + rememberExpandedSize(clamped, index, item) + return clamped + }) + }, [containerSize, getItemMax, getItemMin, items, rememberExpandedSize]) + + useLayoutEffect(() => { + const nextSizes = deriveSizes() + if (!nextSizes.length) return + + const drag = dragRef.current + const prev = pxSizesRef.current + const sameLength = prev.length === nextSizes.length + const changed = + !sameLength || nextSizes.some((size, index) => size !== prev[index]) + + if (drag && sameLength && changed && drag.lastPointerPos != null) { + remapDragOrigin(drag.lastPointerPos, getBarEdge(nextSizes, drag.barIndex)) + skipNextTransitionRef.current = true + } + + const shouldAnimate = + changed && + !drag && + sameLength && + !skipNextTransitionRef.current && + prev.length + + skipNextTransitionRef.current = false + + if (shouldAnimate) { + scheduleAnimatedSizes(nextSizes) + return + } + + if (changed) { + cancelPendingAnimate() + setPxSizes(nextSizes) + } + }, [ + cancelPendingAnimate, + deriveSizes, + remapDragOrigin, + scheduleAnimatedSizes + ]) + + useEffect( + () => () => { + if (sizeTransitionTimeoutRef.current) { + clearTimeout(sizeTransitionTimeoutRef.current) + } + animateRafRef.current.forEach((id) => cancelAnimationFrame(id)) + }, + [] + ) + + const getPointerPos = useCallback( + (event) => { + const node = containerRef.current + if (!node) return 0 + const rect = node.getBoundingClientRect() + return isVertical ? event.clientY - rect.top : event.clientX - rect.left + }, + [isVertical] + ) + + const commitSizes = useCallback( + (nextSizes, { notify = true, end = false } = {}) => { + cancelPendingAnimate() + setPxSizes(nextSizes) + nextSizes.forEach((size, index) => + rememberExpandedSize(size, index, itemsRef.current[index]) + ) + if (!notify) return + if (end) onResizeEnd?.(nextSizes) + else onResize?.(nextSizes) + }, + [cancelPendingAnimate, onResize, onResizeEnd, rememberExpandedSize] + ) + + const resolveDragSize = useCallback( + (barIndex, pointerPos, originOffset, currentSizes) => { + const item = itemsRef.current[barIndex] + const drag = dragRef.current + const collapsed = + drag?.barIndex === barIndex ? drag.collapsed : !!item?.collapsed + const rawEdge = pointerPos - originOffset + const startSize = rawEdge - getBarEdge(currentSizes, barIndex - 1) + + const collapsedSize = item?.collapsedSize ?? getItemMin(item) + const expandDragPx = item?.expandDragPx ?? DEFAULT_EXPAND_DRAG_PX + const collapseThreshold = item?.collapseThreshold + + if (collapsed) { + const preview = Math.max(collapsedSize, startSize) + if (preview - collapsedSize > expandDragPx) { + return { + expand: true, + collapse: false, + startSize: preview + } + } + return { + expand: false, + collapse: false, + startSize: preview + } + } + + if (drag?.blockCollapse) { + if (startSize <= collapsedSize + expandDragPx) { + return { + expand: false, + collapse: true, + startSize: collapsedSize + } + } + if (collapseThreshold != null && startSize >= collapseThreshold) { + drag.blockCollapse = false + } + return { + expand: false, + collapse: false, + startSize: Math.max(startSize, collapsedSize) + } + } + + if (collapseThreshold != null && startSize < collapseThreshold) { + return { + expand: false, + collapse: true, + startSize: collapsedSize + } + } + + return { + expand: false, + collapse: false, + startSize + } + }, + [getItemMin] + ) + + const handlePointerMove = useCallback( + (event) => { + const drag = dragRef.current + if (!drag || event.pointerId !== drag.pointerId) return + + const pointerPos = getPointerPos(event) + drag.lastPointerPos = pointerPos + const currentSizes = pxSizesRef.current + const resolved = resolveDragSize( + drag.barIndex, + pointerPos, + drag.originOffset, + currentSizes + ) + + if (lazy) { + const startEdge = getBarEdge(drag.startSizes, drag.barIndex) + const nextOffset = + getBarEdge( + applyBarResize(drag.startSizes, drag.barIndex, resolved.startSize), + drag.barIndex + ) - startEdge + lazyOffsetRef.current = nextOffset + setLazyOffset(nextOffset) + return + } + + const nextSizes = applyBarResize( + currentSizes, + drag.barIndex, + resolved.startSize + ) + + if (resolved.expand) { + drag.collapsed = false + drag.blockCollapse = true + skipNextTransitionRef.current = true + remapDragOrigin(pointerPos, getBarEdge(nextSizes, drag.barIndex)) + emitCollapse(drag.barIndex, false, nextSizes) + commitSizes(nextSizes) + return + } + + if (resolved.collapse) { + const barIndex = drag.barIndex + endDragSession() + emitCollapse(barIndex, true, nextSizes) + onResizeEnd?.(nextSizes) + return + } + + commitSizes(nextSizes) + }, + [ + applyBarResize, + commitSizes, + emitCollapse, + getPointerPos, + lazy, + endDragSession, + onResizeEnd, + remapDragOrigin, + resolveDragSize + ] + ) + + const stopDragging = useCallback( + (event) => { + const drag = dragRef.current + if (!drag || (event && event.pointerId !== drag.pointerId)) return + + let nextSizes = pxSizesRef.current.slice() + + if (lazy) { + const pointerPos = event ? getPointerPos(event) : drag.lastPointerPos + const resolved = resolveDragSize( + drag.barIndex, + pointerPos, + drag.originOffset, + drag.startSizes + ) + nextSizes = applyBarResize( + drag.startSizes, + drag.barIndex, + resolved.startSize + ) + if (resolved.expand) { + drag.collapsed = false + drag.blockCollapse = true + emitCollapse(drag.barIndex, false, nextSizes) + } + if (resolved.collapse) { + drag.collapsed = true + drag.blockCollapse = false + emitCollapse(drag.barIndex, true, nextSizes) + } + } + + const item = itemsRef.current[drag.barIndex] + const collapsed = drag.collapsed ?? item?.collapsed + if (collapsed) { + const collapsedSize = item.collapsedSize ?? getItemMin(item) + if (nextSizes[drag.barIndex] !== collapsedSize) { + nextSizes = applyBarResize(nextSizes, drag.barIndex, collapsedSize) + } + } + + endDragSession() + skipNextTransitionRef.current = true + commitSizes(nextSizes, { end: true }) + }, + [ + applyBarResize, + commitSizes, + emitCollapse, + endDragSession, + getItemMin, + getPointerPos, + lazy, + resolveDragSize + ] + ) + + const startDragging = useCallback( + (barIndex, event) => { + const item = itemsRef.current[barIndex] + const nextItem = itemsRef.current[barIndex + 1] + if ( + !pxSizesRef.current.length || + item?.resizable === false || + nextItem?.resizable === false + ) { + return + } + + event.preventDefault() + event.stopPropagation() + event.currentTarget.setPointerCapture?.(event.pointerId) + + const pointerPos = getPointerPos(event) + const startSizes = pxSizesRef.current.slice() + + dragRef.current = { + barIndex, + pointerId: event.pointerId, + captureTarget: event.currentTarget, + originOffset: pointerPos - getBarEdge(startSizes, barIndex), + lastPointerPos: pointerPos, + startSizes, + collapsed: !!item.collapsed, + blockCollapse: false + } + skipNextTransitionRef.current = true + setSizeTransitioning(false) + setDragging(true) + setActiveBar(barIndex) + onResizeStart?.(startSizes) + }, + [getPointerPos, onResizeStart] + ) + + useEffect(() => { + if (!dragging) return undefined + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', stopDragging) + window.addEventListener('pointercancel', stopDragging) + return () => { + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', stopDragging) + window.removeEventListener('pointercancel', stopDragging) + } + }, [dragging, handlePointerMove, stopDragging]) + + const collapseViaControl = useCallback( + (barIndex, side) => { + const itemIndex = side === 'start' ? barIndex : barIndex + 1 + const item = itemsRef.current[itemIndex] + if (!item) return + + const collapsedSize = item.collapsedSize ?? getItemMin(item) + const current = pxSizesRef.current[itemIndex] + const alreadyCollapsed = + item.collapsed || + (item.collapseThreshold != null + ? current < item.collapseThreshold + : current <= collapsedSize + 1) + const restored = resolveExpandedSize(itemIndex, item) + let nextStartSize + + if (side === 'start') { + nextStartSize = alreadyCollapsed ? restored : collapsedSize + } else { + const neighbor = pxSizesRef.current[barIndex] + const nextPanelSize = alreadyCollapsed ? restored : collapsedSize + nextStartSize = neighbor + (current - nextPanelSize) + } + + if (!alreadyCollapsed) { + rememberExpandedSize(current, itemIndex, item) + } + + const nextSizes = applyBarResize( + pxSizesRef.current, + barIndex, + nextStartSize + ) + emitCollapse(itemIndex, !alreadyCollapsed, nextSizes) + }, + [ + applyBarResize, + emitCollapse, + getItemMin, + rememberExpandedSize, + resolveExpandedSize + ] + ) + + const handleBarKeyDown = useCallback( + (barIndex, event) => { + const backward = isVertical + ? event.key === 'ArrowUp' + : event.key === 'ArrowLeft' + const forward = isVertical + ? event.key === 'ArrowDown' + : event.key === 'ArrowRight' + if (!backward && !forward) return + + event.preventDefault() + const delta = forward ? KEYBOARD_STEP_PX : -KEYBOARD_STEP_PX + const item = itemsRef.current[barIndex] + const proposed = pxSizesRef.current[barIndex] + delta + const collapsedSize = item?.collapsedSize ?? getItemMin(item) + const expandDragPx = item?.expandDragPx ?? DEFAULT_EXPAND_DRAG_PX + + if ( + item?.collapseThreshold != null && + proposed < item.collapseThreshold && + !item.collapsed + ) { + rememberExpandedSize(pxSizesRef.current[barIndex], barIndex, item) + const collapsedSizes = applyBarResize( + pxSizesRef.current, + barIndex, + collapsedSize + ) + emitCollapse(barIndex, true, collapsedSizes) + commitSizes(collapsedSizes, { end: true }) + return + } + + if (item?.collapsed && proposed - collapsedSize > expandDragPx) { + const expandedSizes = applyBarResize( + pxSizesRef.current, + barIndex, + resolveExpandedSize(barIndex, item) + ) + emitCollapse(barIndex, false, expandedSizes) + commitSizes(expandedSizes, { end: true }) + return + } + + commitSizes(applyBarResize(pxSizesRef.current, barIndex, proposed), { + end: true + }) + }, + [ + applyBarResize, + commitSizes, + emitCollapse, + getItemMin, + isVertical, + rememberExpandedSize, + resolveExpandedSize + ] + ) + + const orientationClass = isVertical ? 'vertical' : 'horizontal' + const rootClassName = [ + 'adaptive-splitter', + 'ant-splitter', + `adaptive-splitter-${orientationClass}`, + `ant-splitter-${orientationClass}`, + ready ? 'is-ready' : '', + sizeTransitioning ? 'is-size-transitioning' : '', + dragging ? 'is-dragging' : '', + className + ] + .filter(Boolean) + .join(' ') + + return ( +
+ {items.flatMap((item, index) => { + const size = pxSizes[index] ?? 0 + const isFill = + item.size == null && + item.defaultSize == null && + !item.collapsed && + index === items.length - 1 + const minPx = getItemMin(item) + const maxPx = getItemMax(item) + const panelStyle = isFill + ? { + flex: '1 1 auto', + minWidth: isVertical ? 0 : minPx, + minHeight: isVertical ? minPx : 0, + overflow: 'auto', + ...item.style + } + : { + flex: `0 0 ${size}px`, + [isVertical ? 'height' : 'width']: size, + minWidth: isVertical ? undefined : minPx, + maxWidth: + isVertical || !Number.isFinite(maxPx) ? undefined : maxPx, + minHeight: isVertical ? minPx : undefined, + maxHeight: + !isVertical || !Number.isFinite(maxPx) ? undefined : maxPx, + overflow: 'auto', + ...item.style + } + + const collapsible = getCollapsibleSides(item.collapsible) + const nextCollapsible = getCollapsibleSides( + items[index + 1]?.collapsible + ) + const showBar = index < items.length - 1 + const barResizable = + item.resizable !== false && items[index + 1]?.resizable !== false + + const nodes = [ +
+ {item.children} +
+ ] + + if (showBar) { + nodes.push( + startDragging(index, event)} + onKeyDown={(event) => handleBarKeyDown(index, event)} + onCollapseStart={() => collapseViaControl(index, 'start')} + onCollapseEnd={() => collapseViaControl(index, 'end')} + /> + ) + } + + return nodes + })} + {dragging && ( +
+ )} +
+ ) +} + +AdaptiveSplitter.Panel = AdaptiveSplitterPanel +AdaptiveSplitter.SIZE_TRANSITION_MS = SIZE_TRANSITION_MS + +AdaptiveSplitter.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + style: PropTypes.object, + layout: PropTypes.oneOf(['horizontal', 'vertical']), + orientation: PropTypes.oneOf(['horizontal', 'vertical']), + vertical: PropTypes.bool, + lazy: PropTypes.bool, + onResizeStart: PropTypes.func, + onResize: PropTypes.func, + onResizeEnd: PropTypes.func, + onCollapse: PropTypes.func, + sizeTransitionMs: PropTypes.number +} + +export default AdaptiveSplitter diff --git a/src/components/Dashboard/common/DashboardSidebarSplitter.jsx b/src/components/Dashboard/common/DashboardSidebarSplitter.jsx index 502c9f65..8dae4aa6 100644 --- a/src/components/Dashboard/common/DashboardSidebarSplitter.jsx +++ b/src/components/Dashboard/common/DashboardSidebarSplitter.jsx @@ -3,22 +3,21 @@ import { isValidElement, useCallback, useContext, - useEffect, useRef, useState } from 'react' import PropTypes from 'prop-types' -import { Layout, Splitter } from 'antd' +import { Layout } from 'antd' import { useMediaQuery } from 'react-responsive' import { ElectronContext } from '../context/ElectronContext' +import AdaptiveSplitter from './AdaptiveSplitter' -const COLLAPSE_BELOW_PX = 150 +const COLLAPSE_BELOW_PX = 200 const DEFAULT_SIDEBAR_PX = 300 const MAX_SIDEBAR_PX = 400 const COLLAPSED_WIDTH_PX = 80 const ELECTRON_COLLAPSED_WIDTH_PX = 55 const EXPAND_DRAG_PX = 10 -const SIZE_TRANSITION_MS = 300 const COLLAPSE_STORAGE_KEY = 'DashboardSidebar_collapseState' const SIZE_STORAGE_KEY = 'DashboardSidebar_width' @@ -56,7 +55,7 @@ const DashboardSidebarSplitter = ({ sidebar, children }) => { ) const [sidebarSize, setSidebarSize] = useState(() => { if (readStoredBoolean(COLLAPSE_STORAGE_KEY, false)) { - return isElectron ? ELECTRON_COLLAPSED_WIDTH_PX : COLLAPSED_WIDTH_PX + return collapsedWidth } return clampExpandedSize( readStoredNumber(SIZE_STORAGE_KEY, DEFAULT_SIDEBAR_PX) @@ -68,118 +67,6 @@ const DashboardSidebarSplitter = ({ sidebar, children }) => { const lastExpandedSizeRef = useRef( clampExpandedSize(readStoredNumber(SIZE_STORAGE_KEY, DEFAULT_SIDEBAR_PX)) ) - const sidebarSizeRef = useRef(sidebarSize) - sidebarSizeRef.current = sidebarSize - const collapseSizeRafRef = useRef([]) - const [sizeTransitioning, setSizeTransitioning] = useState(false) - const sizeTransitionTimeoutRef = useRef(null) - const [resizeLocked, setResizeLocked] = useState(false) - const resizeLockedRef = useRef(false) - const suppressGestureRef = useRef(false) - const lockUntilPointerUpRef = useRef(false) - const lockTimeoutRef = useRef(null) - const pointerUnlockRef = useRef(null) - const ignoreResizeEndRef = useRef(false) - - const setResizeLockedState = useCallback((locked) => { - resizeLockedRef.current = locked - setResizeLocked(locked) - }, []) - - const clearPointerUnlock = useCallback(() => { - if (!pointerUnlockRef.current) return - window.removeEventListener('pointerup', pointerUnlockRef.current) - window.removeEventListener('pointercancel', pointerUnlockRef.current) - pointerUnlockRef.current = null - }, []) - - const attachPointerUnlock = useCallback(() => { - if (pointerUnlockRef.current) return - const handler = () => { - clearPointerUnlock() - suppressGestureRef.current = false - if (lockUntilPointerUpRef.current) { - lockUntilPointerUpRef.current = false - setResizeLockedState(false) - } - } - pointerUnlockRef.current = handler - window.addEventListener('pointerup', handler) - window.addEventListener('pointercancel', handler) - }, [clearPointerUnlock, setResizeLockedState]) - - const startSizeTransition = useCallback(() => { - setSizeTransitioning(true) - if (sizeTransitionTimeoutRef.current) { - clearTimeout(sizeTransitionTimeoutRef.current) - } - sizeTransitionTimeoutRef.current = setTimeout(() => { - setSizeTransitioning(false) - sizeTransitionTimeoutRef.current = null - }, SIZE_TRANSITION_MS) - }, []) - - const cancelCollapseSizeAnimation = useCallback(() => { - collapseSizeRafRef.current.forEach((id) => cancelAnimationFrame(id)) - collapseSizeRafRef.current = [] - }, []) - - const animateCollapsedSize = useCallback(() => { - cancelCollapseSizeAnimation() - const ids = [] - ids.push( - requestAnimationFrame(() => { - ids.push( - requestAnimationFrame(() => { - collapseSizeRafRef.current = [] - setSidebarSize(collapsedWidth) - }) - ) - }) - ) - collapseSizeRafRef.current = ids - }, [cancelCollapseSizeAnimation, collapsedWidth]) - - const lockResizeUntilPointerUp = useCallback(() => { - ignoreResizeEndRef.current = true - suppressGestureRef.current = true - lockUntilPointerUpRef.current = true - setResizeLockedState(true) - attachPointerUnlock() - }, [attachPointerUnlock, setResizeLockedState]) - - const lockResizeFor = useCallback( - (ms) => { - ignoreResizeEndRef.current = true - suppressGestureRef.current = true - setResizeLockedState(true) - attachPointerUnlock() - if (lockTimeoutRef.current) { - clearTimeout(lockTimeoutRef.current) - } - lockTimeoutRef.current = setTimeout(() => { - lockTimeoutRef.current = null - if (!lockUntilPointerUpRef.current) { - setResizeLockedState(false) - } - }, ms) - }, - [attachPointerUnlock, setResizeLockedState] - ) - - useEffect( - () => () => { - if (sizeTransitionTimeoutRef.current) { - clearTimeout(sizeTransitionTimeoutRef.current) - } - if (lockTimeoutRef.current) { - clearTimeout(lockTimeoutRef.current) - } - cancelCollapseSizeAnimation() - clearPointerUnlock() - }, - [cancelCollapseSizeAnimation, clearPointerUnlock] - ) const persistCollapsed = (nextCollapsed) => { sessionStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(nextCollapsed)) @@ -192,113 +79,73 @@ const DashboardSidebarSplitter = ({ sidebar, children }) => { const collapseSidebar = useCallback( (expandedSize) => { if (expandedSize >= COLLAPSE_BELOW_PX) { - lastExpandedSizeRef.current = expandedSize - persistSize(expandedSize) + lastExpandedSizeRef.current = clampExpandedSize(expandedSize) + persistSize(lastExpandedSizeRef.current) } collapsedRef.current = true persistCollapsed(true) - startSizeTransition() - setSidebarSize(sidebarSizeRef.current) setCollapsed(true) - animateCollapsedSize() + setSidebarSize(collapsedWidth) }, - [animateCollapsedSize, startSizeTransition] + [collapsedWidth] ) const expandSidebar = useCallback(() => { - cancelCollapseSizeAnimation() collapsedRef.current = false setCollapsed(false) persistCollapsed(false) - startSizeTransition() - setSidebarSize(DEFAULT_SIDEBAR_PX) - persistSize(DEFAULT_SIDEBAR_PX) - lastExpandedSizeRef.current = DEFAULT_SIDEBAR_PX - }, [cancelCollapseSizeAnimation, startSizeTransition]) + const nextSize = clampExpandedSize(lastExpandedSizeRef.current) + lastExpandedSizeRef.current = nextSize + setSidebarSize(nextSize) + persistSize(nextSize) + }, []) const handleSidebarCollapse = useCallback( (nextCollapsed) => { + if (nextCollapsed === collapsedRef.current) return if (nextCollapsed) { - collapseSidebar(sidebarSize) + collapseSidebar(lastExpandedSizeRef.current) } else { expandSidebar() } - lockResizeFor(SIZE_TRANSITION_MS) }, - [collapseSidebar, expandSidebar, lockResizeFor, sidebarSize] + [collapseSidebar, expandSidebar] ) - const handleResizeStart = useCallback(() => { - if (!resizeLockedRef.current) { - ignoreResizeEndRef.current = false + const handleSplitterCollapse = useCallback( + (nextCollapsed) => { + if (nextCollapsed === collapsedRef.current) return + if (nextCollapsed) { + collapseSidebar(lastExpandedSizeRef.current) + return + } + collapsedRef.current = false + persistCollapsed(false) + setCollapsed(false) + }, + [collapseSidebar] + ) + + const handleResize = useCallback((sizes) => { + const size = sizes[0] + setSidebarSize(size) + if (!collapsedRef.current && size >= COLLAPSE_BELOW_PX) { + lastExpandedSizeRef.current = clampExpandedSize(size) } }, []) - const handleResize = useCallback( - (sizes) => { - if (resizeLockedRef.current || suppressGestureRef.current) { - if (collapsedRef.current) { - setSidebarSize(collapsedWidth) - } - return - } - - const size = sizes[0] - - if (collapsedRef.current) { - if (size - collapsedWidth > EXPAND_DRAG_PX) { - expandSidebar() - lockResizeUntilPointerUp() - return - } - setSidebarSize(Math.max(collapsedWidth, size)) - return - } - - if (size < COLLAPSE_BELOW_PX) { - collapseSidebar(lastExpandedSizeRef.current) - lockResizeUntilPointerUp() - return - } - - setSidebarSize(size) - lastExpandedSizeRef.current = clampExpandedSize(size) - }, - [collapseSidebar, collapsedWidth, expandSidebar, lockResizeUntilPointerUp] - ) - const handleResizeEnd = useCallback( (sizes) => { - if ( - resizeLockedRef.current || - suppressGestureRef.current || - ignoreResizeEndRef.current - ) { - ignoreResizeEndRef.current = false - if (collapsedRef.current) { - startSizeTransition() - setSidebarSize(collapsedWidth) - } - return - } - if (collapsedRef.current) { - if (sizes[0] - collapsedWidth > EXPAND_DRAG_PX) { - expandSidebar() - lockResizeFor(SIZE_TRANSITION_MS) - } else { - startSizeTransition() - setSidebarSize(collapsedWidth) - } + setSidebarSize(collapsedWidth) return } - const next = clampExpandedSize(sizes[0]) lastExpandedSizeRef.current = next setSidebarSize(next) persistSize(next) }, - [collapsedWidth, expandSidebar, lockResizeFor, startSizeTransition] + [collapsedWidth] ) const sidebarNode = isValidElement(sidebar) @@ -320,38 +167,38 @@ const DashboardSidebarSplitter = ({ sidebar, children }) => { return ( - - {sidebarNode} - - + + {children} - - + + ) }