import "./index.scss"; import { createSettingsStore } from "@olenbetong/appframe-react"; import clsx from "clsx"; import type React from "react"; import { Children, forwardRef, useEffect, useRef, useState } from "react"; const article = globalThis.af?.article?.id ?? globalThis.window?.location.pathname.substring(1).split("/")[0]; const settingsStore = createSettingsStore(`AfSplitContainers.${globalThis.af?.userSession?.login ?? "anonymous"}`); type SplitContainerSettings = { layoutVersion: string | number; fixedSize: number; }; const settingsCache: Record = {}; async function persistState(key: string, layoutVersion: string | number, fixedSize: number | null | undefined) { if (fixedSize != null) { let settings = { layoutVersion, fixedSize, }; settingsCache[key] = settings; await settingsStore.setItem(key, settings); } } async function getSettings(key: string, layoutVersion: string | number): Promise { let settings = settingsCache[key] ?? (await settingsStore.getItem(key)); if (settings && settings.layoutVersion === layoutVersion) { return settings.fixedSize; } return null; } // Load all article grid layouts even if the grid is not mounted yet. // This ways the persisted state can be restored faster after the initial // page load. settingsStore.keys().then((keys) => Promise.allSettled( keys .filter((key) => key.startsWith(article)) .map((key) => settingsStore.getItem(key).then((settings) => { if (settings && !settingsCache[key]) { settingsCache[key] = settings; } }), ), ), ); type DividerProps = { className?: string; direction: "horizontal" | "vertical"; isDragging: boolean; min: number; max: number; size: number; }; const Divider = forwardRef(function Divider(props: DividerProps, ref) { return ( // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- NO!
•••
); }); type SplitRootProps = React.HTMLProps & { className?: string; children: React.ReactNode; direction: "horizontal" | "vertical"; }; function SplitRoot({ className, direction, ...props }: SplitRootProps) { return
; } type SplitPanelProps = { children: React.ReactNode; className?: string; style?: React.CSSProperties; } & ( | { fixed: true; size: number; } | { fixed: false; size?: number; } ); function SplitPanel(props: SplitPanelProps) { let style = { flex: props.fixed ? `0 0 ${Math.floor(props.size)}px` : "1 1 100%", ...props.style, }; return (
{props.children}
); } export type SplitContainerProps = { className?: string; panelClassName?: string; children: React.ReactNode[]; direction?: "horizontal" | "vertical"; fixedPanel?: 1 | 2; defaultFixedSize?: number; min?: number; max?: number; /** * When the fixed size is persisted, changing the layoutVersion will force clients * to reset to the default value. */ layoutVersion?: string | number; /** * If you set an id, the fixed size will be persisted, and restored after page load */ id?: string; }; export function SplitContainer({ className, children, direction = "vertical", fixedPanel = 1, panelClassName, defaultFixedSize = 400, layoutVersion = 1, min = 0, max, id, }: SplitContainerProps) { let [fixedSize, setFixedSize] = useState(defaultFixedSize); let [isDragging, setIsDragging] = useState(false); let dividerRef = useRef(null); if (Children.count(children) !== 2) { throw Error("SplitContainer requires exactly 2 children"); } let containerRef = useRef(null); let [containerSize, setContainerSize] = useState(0); useEffect(() => { if (!containerRef.current) return; let resizeObserver = new ResizeObserver((entries) => { for (let entry of entries) { if (entry.contentRect) { setContainerSize(direction === "horizontal" ? entry.contentRect.width : entry.contentRect.height); } } }); resizeObserver.observe(containerRef.current); return () => resizeObserver.disconnect(); }, [direction]); useEffect(() => { if (id) { let key = `${article}.${id}`; getSettings(key, layoutVersion).then((data: number | null) => { if (data) { // Persisted state exists, and matches the current layout version, // so restore the state. setFixedSize(data); return Promise.resolve(); } }); } }, [id, layoutVersion]); let childrenArray = Children.toArray(children); useEffect(() => { let divider = dividerRef.current; if (divider) { let initialPosition: { clientX: number; clientY: number; size: number } = { clientX: 0, clientY: 0, size: 0, }; let movingPanel: HTMLElement = ( fixedPanel === 1 ? divider.previousElementSibling : divider.nextElementSibling ) as HTMLElement; function clampSize(size: number) { let adjustedContainerSize = containerSize - 10; // make sure divider will not be stuck outside the container let maxSize = Math.min(max ?? adjustedContainerSize, adjustedContainerSize); if (size < min) { size = min; } else if (size > maxSize) { size = maxSize; } return size; } function getNewSize(event: PointerEvent) { let deltaSize = direction === "horizontal" ? event.clientX - initialPosition.clientX : event.clientY - initialPosition.clientY; deltaSize *= fixedPanel === 1 ? -1 : 1; return clampSize(initialPosition.size - deltaSize); } function handleFixedSizeChanged(size: React.SetStateAction) { setFixedSize((current) => { let newValue: number = clampSize(typeof size === "function" ? size(current) : size); if (id) { let key = `${article}.${id}`; persistState(key, layoutVersion, newValue); } return newValue; }); } function handlePointerUp(event: PointerEvent) { let newSize = getNewSize(event); handleFixedSizeChanged(newSize); setIsDragging(false); movingPanel.style.flex = `0 0 ${Math.floor(newSize)}px`; window.removeEventListener("pointermove", handlePointerMove); } function handlePointerMove(event: PointerEvent) { event.preventDefault(); let newSize = getNewSize(event); let divider = dividerRef.current; if (divider) { divider.setAttribute("aria-valuenow", String(newSize)); } movingPanel.style.flex = `0 0 ${newSize}px`; } function handlePointerDown(event: PointerEvent) { setIsDragging(true); let rect = movingPanel.getBoundingClientRect(); let size = direction === "horizontal" ? rect.width : rect.height; initialPosition = { clientX: event.clientX, clientY: event.clientY, size, }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp, { once: true }); } function handleKeyDown(event: KeyboardEvent) { let reduce = true; let shouldHandle = false; if (!event.altKey && !event.ctrlKey && !event.metaKey) { if (direction === "horizontal" && ["ArrowLeft", "ArrowRight"].includes(event.key)) { shouldHandle = true; reduce = (fixedPanel === 1 && event.key === "ArrowLeft") || (fixedPanel === 2 && event.key === "ArrowRight"); } else if (direction === "vertical" && ["ArrowUp", "ArrowDown"].includes(event.key)) { shouldHandle = true; reduce = (fixedPanel === 1 && event.key === "ArrowUp") || (fixedPanel === 2 && event.key === "ArrowDown"); } } if (shouldHandle) { event.preventDefault(); event.stopPropagation(); let delta = event.shiftKey ? 25 : 5; handleFixedSizeChanged((size) => size + (reduce ? -delta : delta)); } } divider.addEventListener("pointerdown", handlePointerDown); divider.addEventListener("keydown", handleKeyDown); return () => { divider?.removeEventListener("pointerdown", handlePointerDown); divider?.removeEventListener("keydown", handleKeyDown); }; } }, [direction, fixedPanel, layoutVersion, id, min, max, containerSize]); return ( {childrenArray[0]} {childrenArray[1]} ); }