import type { InjectionKey, Ref } from 'vue' import { computed, ref, shallowRef, watch } from 'vue' export interface PanelConfig { id: string defaultSize: number min: number max: number flex: boolean collapseOnMobile: boolean hideOnMobile: boolean } export interface PanelState { id: string config: PanelConfig size: Ref collapsed: Ref /** DOM reference set by Panel.vue for direct style mutation during drag. */ el: Ref } export interface ResizableContext { direction: Ref<'horizontal' | 'vertical'> isMobile: Ref panels: Ref registerPanel: (config: PanelConfig) => PanelState unregisterPanel: (id: string) => void onHandlePointerDown: (panelId: string, event: PointerEvent) => void } export const RESIZABLE_KEY: InjectionKey = Symbol('Resizable') let _panelUid = 0 export function nextPanelId(): string { return `panel-${++_panelUid}` } // ─── Internal drag state ─────────────────────────────────────────────────── interface DragState { panelId: string pointerId: number isHorizontal: boolean isRtl: boolean startPos: number startSizeA: number startSizeB: number currentSizeA: number currentSizeB: number indexA: number indexB: number isFlexA: boolean isFlexB: boolean elA: HTMLElement | null elB: HTMLElement | null } // ─── Provider ────────────────────────────────────────────────────────────── export function useResizableLayoutProvider(options: { directionProp: Ref<'horizontal' | 'vertical'> mobileDirection: 'horizontal' | 'vertical' breakpoint: number containerEl: Ref }) { const { containerEl, breakpoint, mobileDirection } = options const isMobile = ref(false) const panels = shallowRef([]) const direction = computed<'horizontal' | 'vertical'>(() => isMobile.value ? mobileDirection : options.directionProp.value, ) // ── Breakpoint detection ── let ro: ResizeObserver | null = null function observe() { if (!containerEl.value) return ro?.disconnect() ro = new ResizeObserver((entries) => { const entry = entries[0] if (entry) isMobile.value = entry.contentRect.width < breakpoint }) ro.observe(containerEl.value) isMobile.value = containerEl.value.clientWidth < breakpoint } function disconnect() { ro?.disconnect() ro = null } // On mobile switch: apply collapsed state overrides watch(isMobile, (mobile) => { for (const panel of panels.value) { const { config } = panel panel.collapsed.value = mobile ? (config.collapseOnMobile || false) : false } }) // ── Panel registry ── function registerPanel(config: PanelConfig): PanelState { const state: PanelState = { id: config.id, config, size: ref(config.defaultSize), collapsed: ref(isMobile.value ? config.collapseOnMobile : false), el: ref(null), } panels.value = [...panels.value, state] return state } function unregisterPanel(id: string) { panels.value = panels.value.filter(p => p.id !== id) } function getPanelIndex(id: string) { return panels.value.findIndex(p => p.id === id) } // ── Drag ── // Cleanup function for the current drag's document-level listeners. // Called at the start of every pointerdown to clear any previously stuck drag. let dragCleanup: (() => void) | null = null function flushResize(ds: DragState, currentPos: number) { const rawDelta = currentPos - ds.startPos const delta = ds.isRtl ? -rawDelta : rawDelta const panelA = panels.value[ds.indexA] const panelB = panels.value[ds.indexB] const minA = panelA.config.min const maxA = panelA.config.max const minB = panelB.config.min const maxB = panelB.config.max let newA = ds.currentSizeA let newB = ds.currentSizeB if (ds.isFlexB) { newA = Math.max(minA, Math.min(maxA, ds.startSizeA + delta)) } else if (ds.isFlexA) { newB = Math.max(minB, Math.min(maxB, ds.startSizeB - delta)) } else { const proposed = Math.max(minA, Math.min(maxA, ds.startSizeA + delta)) const actualDelta = proposed - ds.startSizeA newB = Math.max(minB, Math.min(maxB, ds.startSizeB - actualDelta)) newA = ds.startSizeA + (ds.startSizeB - newB) } ds.currentSizeA = newA ds.currentSizeB = newB // Direct DOM mutation — bypasses Vue scheduler for zero-overhead drag const dim = ds.isHorizontal ? 'width' : 'height' if (ds.elA && !ds.isFlexA) { ds.elA.style.flex = `0 0 ${newA}px` ds.elA.style[dim] = `${newA}px` } if (ds.elB && !ds.isFlexB) { ds.elB.style.flex = `0 0 ${newB}px` ds.elB.style[dim] = `${newB}px` } } function onHandlePointerDown(panelId: string, event: PointerEvent) { // Always clean up any previously stuck drag before starting a new one dragCleanup?.() const indexA = getPanelIndex(panelId) if (indexA < 0 || indexA >= panels.value.length - 1) return const panelA = panels.value[indexA] const panelB = panels.value[indexA + 1] if (panelA.collapsed.value || panelB.collapsed.value) return event.preventDefault() const isHorizontal = direction.value === 'horizontal' const pos = isHorizontal ? event.clientX : event.clientY const isRtl = isHorizontal && !!containerEl.value && getComputedStyle(containerEl.value).direction === 'rtl' const ds: DragState = { panelId, pointerId: event.pointerId, isHorizontal, isRtl, startPos: pos, startSizeA: panelA.size.value, startSizeB: panelB.size.value, currentSizeA: panelA.size.value, currentSizeB: panelB.size.value, indexA, indexB: indexA + 1, isFlexA: panelA.config.flex, isFlexB: panelB.config.flex, elA: panelA.el.value, elB: panelB.el.value, } containerEl.value?.setAttribute('data-resizing', '') document.body.style.userSelect = 'none' document.body.style.cursor = isHorizontal ? 'ew-resize' : 'ns-resize' function onMove(e: PointerEvent) { if (e.pointerId !== ds.pointerId) return flushResize(ds, ds.isHorizontal ? e.clientX : e.clientY) } function onUp(e: PointerEvent) { if (e.pointerId !== ds.pointerId) return flushResize(ds, ds.isHorizontal ? e.clientX : e.clientY) // Single Vue update at drag end — syncs DOM state back to reactive refs panels.value[ds.indexA].size.value = ds.currentSizeA panels.value[ds.indexB].size.value = ds.currentSizeB cleanup() } function cleanup() { dragCleanup = null containerEl.value?.removeAttribute('data-resizing') document.body.style.userSelect = '' document.body.style.cursor = '' document.removeEventListener('pointermove', onMove) document.removeEventListener('pointerup', onUp) document.removeEventListener('pointercancel', cleanup) } document.addEventListener('pointermove', onMove) document.addEventListener('pointerup', onUp) document.addEventListener('pointercancel', cleanup) dragCleanup = cleanup } const context: ResizableContext = { direction, isMobile, panels, registerPanel, unregisterPanel, onHandlePointerDown, } return { context, observe, disconnect } }