import { batch, createContext, createMemo, createSignal, onCleanup, Show, useContext } from "solid-js" import type { ComponentProps, JSX } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context" type PaneDefault = number | { size: number; visible?: boolean } type LayoutContextValue = { id: string register: (pane: string, options: { min?: number | string; max?: number | string }) => void size: (pane: string) => number visible: (pane: string) => boolean percent: (pane: string) => number next: (pane: string) => string | undefined startDrag: (left: string, right: string | undefined, event: MouseEvent) => void dragging: () => string | undefined } const LayoutContext = createContext(undefined) export interface ResizeableLayoutProps { id: string defaults: Record class?: ComponentProps<"div">["class"] classList?: ComponentProps<"div">["classList"] children: JSX.Element } export interface ResizeablePaneProps { id: string minSize?: number | string maxSize?: number | string class?: ComponentProps<"div">["class"] classList?: ComponentProps<"div">["classList"] children: JSX.Element } export function ResizeableLayout(props: ResizeableLayoutProps) { const local = useLocal() const [meta, setMeta] = createStore>({}) const [dragging, setDragging] = createSignal() let container: HTMLDivElement | undefined local.layout.ensure(props.id, props.defaults) const order = createMemo(() => local.layout.order(props.id)) const visibleOrder = createMemo(() => order().filter((pane) => local.layout.visible(props.id, pane))) const totalVisible = createMemo(() => { const panes = visibleOrder() if (!panes.length) return 0 return panes.reduce((total, pane) => total + local.layout.size(props.id, pane), 0) }) const percent = (pane: string) => { const panes = visibleOrder() if (!panes.length) return 0 const total = totalVisible() if (!total) return 100 / panes.length return (local.layout.size(props.id, pane) / total) * 100 } const nextPane = (pane: string) => { const panes = visibleOrder() const index = panes.indexOf(pane) if (index === -1) return undefined return panes[index + 1] } const minMax = (pane: string) => meta[pane] ?? { min: 5, max: 95 } const pxToPercent = (px: number, total: number) => (px / total) * 100 const boundsForPair = (left: string, right: string, total: number) => { const leftMeta = minMax(left) const rightMeta = minMax(right) const containerWidth = container?.getBoundingClientRect().width ?? 0 let minLeft = leftMeta.min let maxLeft = leftMeta.max let minRight = rightMeta.min let maxRight = rightMeta.max if (containerWidth && leftMeta.minPx !== undefined) minLeft = pxToPercent(leftMeta.minPx, containerWidth) if (containerWidth && leftMeta.maxPx !== undefined) maxLeft = pxToPercent(leftMeta.maxPx, containerWidth) if (containerWidth && rightMeta.minPx !== undefined) minRight = pxToPercent(rightMeta.minPx, containerWidth) if (containerWidth && rightMeta.maxPx !== undefined) maxRight = pxToPercent(rightMeta.maxPx, containerWidth) const finalMinLeft = Math.max(minLeft, total - maxRight) const finalMaxLeft = Math.min(maxLeft, total - minRight) return { min: Math.min(finalMinLeft, finalMaxLeft), max: Math.max(finalMinLeft, finalMaxLeft), } } const setPair = (left: string, right: string, leftSize: number, rightSize: number) => { batch(() => { local.layout.setSize(props.id, left, leftSize) local.layout.setSize(props.id, right, rightSize) }) } const startDrag = (left: string, right: string | undefined, event: MouseEvent) => { if (!right) return if (!container) return const rect = container.getBoundingClientRect() if (!rect.width) return event.preventDefault() const startX = event.clientX const startLeft = local.layout.size(props.id, left) const startRight = local.layout.size(props.id, right) const total = startLeft + startRight const bounds = boundsForPair(left, right, total) const move = (moveEvent: MouseEvent) => { const delta = ((moveEvent.clientX - startX) / rect.width) * 100 const nextLeft = Math.max(bounds.min, Math.min(bounds.max, startLeft + delta)) const nextRight = total - nextLeft setPair(left, right, nextLeft, nextRight) } const stop = () => { setDragging() document.removeEventListener("mousemove", move) document.removeEventListener("mouseup", stop) } setDragging(left) document.addEventListener("mousemove", move) document.addEventListener("mouseup", stop) onCleanup(() => stop()) } const register = (pane: string, options: { min?: number | string; max?: number | string }) => { let min = 5 let max = 95 let minPx: number | undefined let maxPx: number | undefined if (typeof options.min === "string" && options.min.endsWith("px")) { minPx = parseInt(options.min) min = 0 } else if (typeof options.min === "number") { min = options.min } if (typeof options.max === "string" && options.max.endsWith("px")) { maxPx = parseInt(options.max) max = 100 } else if (typeof options.max === "number") { max = options.max } setMeta(pane, () => ({ min, max, minPx, maxPx })) const fallback = props.defaults[pane] local.layout.ensurePane(props.id, pane, fallback ?? { size: min, visible: true }) } const contextValue: LayoutContextValue = { id: props.id, register, size: (pane) => local.layout.size(props.id, pane), visible: (pane) => local.layout.visible(props.id, pane), percent, next: nextPane, startDrag, dragging, } return (
{ container = node ?? undefined }} class={props.class ? `relative flex h-full w-full ${props.class}` : "relative flex h-full w-full"} classList={props.classList} > {props.children}
) } export function ResizeablePane(props: ResizeablePaneProps) { const context = useContext(LayoutContext)! context.register(props.id, { min: props.minSize, max: props.maxSize }) const visible = () => context.visible(props.id) const width = () => context.percent(props.id) const next = () => context.next(props.id) const dragging = () => context.dragging() === props.id return (
{props.children}
context.startDrag(props.id, next(), event)} >
) }