Skip to main content
Glama
ResizablePanel.vue11.4 kB
<template> <div ref="panelRef" :class=" clsx( 'si-panel', `si-panel-${side}`, side === 'right' ? 'z-[15]' : 'z-20', 'dark:text-white pointer-events-auto relative', isTopOrBottom ? 'border-shade-100 bg-white dark:bg-neutral-900' : 'dark:border-neutral-600 border-neutral-300 bg-white dark:bg-neutral-800', { left: 'border-r-2 ', right: 'border-l-2', top: 'border-b shadow-[0_4px_4px_0_rgba(0,0,0,0.15)]', bottom: 'border-t shadow-[0_-4px_4px_0_rgba(0,0,0,0.15)]', }[side], `${side}-0`, !resizing && 'transition-[width]', ) " :style="{ ...(resizeable && { [isTopOrBottom ? 'height' : 'width']: `${displaySize}px`, }), }" > <PanelResizingHandle v-if="resizeable" class="si-panel__resizer" :panelSide="side" :collapsed="collapsed" @resize-start="onResizeStart" @resize-move="onResizeMove" @resize-end="onResizeEnd" @resize-reset="resetSize" @collapse-toggle="collapseToggle" /> <!-- We blank out the contents of the ResizeablePanel while it is collapsing or opening from collapse to prevent messiness with the elements inside --> <div :class=" clsx( 'si-panel__inner absolute w-full h-full flex flex-col', `si-panel__inner-${side}`, (collapsed || panelOpeningFromCollapse) && 'opacity-0', themeClasses('bg-shade-0', 'bg-neutral-800'), ) " > <!-- most uses will just have a single child --> <slot /> <!-- but here we give the option for 2 resizable child sections within --> <div v-if="$slots.subpanel1" ref="subpanel1Ref" :style="{ height: disableSubpanelResizing ? 'auto' : `${displaySubpanelSplitPercent * 100}%`, }" :class="clsx('relative', !subpanelResizing && 'transition-[height]')" > <div class="relative overflow-hidden w-full h-full"> <slot name="subpanel1" /> </div> <PanelResizingHandle v-if=" $slots.subpanel1 && $slots.subpanel2 && !disableSubpanelResizing " :panelSide="isTopOrBottom ? 'left' : 'top'" :class="isTopOrBottom ? 'h-full' : 'w-full'" :collapsed="subpanelCollapsed" @resize-start="onSubpanelResizeStart" @resize-move="onSubpanelResizeMove" @resize-end="onSubpanelResizeEnd" @resize-reset="onSubpanelResizeReset" @collapse-toggle="subpanelCollapseToggle" /> </div> <div v-if="$slots.subpanel2" :style="{ height: disableSubpanelResizing ? 'auto' : `${100 - displaySubpanelSplitPercent * 100}%`, }" :class=" clsx( 'grow relative overflow-hidden', !subpanelResizing && 'transition-[height]', ) " > <slot name="subpanel2" /> </div> </div> </div> </template> <script setup lang="ts"> import { computed, nextTick, onBeforeUnmount, onMounted, PropType, ref, } from "vue"; import * as _ from "lodash-es"; import clsx from "clsx"; import PanelResizingHandle from "./PanelResizingHandle.vue"; import { themeClasses } from "../utils/theme_tools"; // This variable determines how long after uncollapsing the main panel the panel's content should show // We hide the panel content while it is collapsing or uncollapsing to avoid the content inside from displaying in jenky ways // The CSS animation takes ~150ms, so this number should be equal to or less than that number const PANEL_COLLAPSE_HIDE_CONTENT_TIME = 140; const props = defineProps({ rememberSizeKey: { type: String, required: true }, side: { type: String as PropType<"left" | "right" | "top" | "bottom">, required: true, }, hidden: { type: Boolean }, sizeClasses: { type: String, default: "" }, resizeable: { type: Boolean, default: true }, minSizeRatio: { type: Number, default: 0.1 }, minSize: { type: Number, default: 200 }, maxSizeRatio: { type: Number, default: 0.45 }, maxSize: { type: Number }, defaultSize: { type: Number, default: 320 }, disableSubpanelResizing: { type: Boolean }, defaultSubpanelSplit: { type: Number, default: 0.5 }, }); const APP_MINIMUM_WIDTH = 700; // APP_MINIMUM_WIDTH const getWindowWidth = () => { if (window.innerWidth > APP_MINIMUM_WIDTH) return window.innerWidth; else return APP_MINIMUM_WIDTH; }; const isTopOrBottom = computed( () => props.side === "top" || props.side === "bottom", ); const panelRef = ref<HTMLDivElement>(); const currentSize = ref(0); const displaySize = computed(() => { if (collapsed.value) return 0; else return currentSize.value; }); const collapsed = ref(false); const panelOpeningFromCollapse = ref(false); const panelOpeningFromCollapseTimeout = ref<number>(); const setSize = (newSize: number) => { let finalSize = newSize; // TODO: make sure these checks don't conflict with each other if (props.minSize) { if (finalSize < props.minSize) finalSize = props.minSize; } if (props.minSizeRatio) { const limit = (isTopOrBottom.value ? window.innerHeight : getWindowWidth()) * props.minSizeRatio; if (finalSize < limit) finalSize = limit; } if (props.maxSize) { if (finalSize > props.maxSize) finalSize = props.maxSize; } if (props.maxSizeRatio) { const limit = (isTopOrBottom.value ? window.innerHeight : getWindowWidth()) * props.maxSizeRatio; if (finalSize > limit) finalSize = limit; } currentSize.value = finalSize; emit("sizeSet", finalSize); if (finalSize === props.defaultSize) { window.localStorage.removeItem(primarySizeLocalStorageKey.value); } else { window.localStorage.setItem( primarySizeLocalStorageKey.value, `${finalSize}`, ); } }; const emit = defineEmits<{ (e: "sizeSet", width: number): void; }>(); const maximize = () => { if (props.maxSize) { setSize(props.maxSize); } else if (props.maxSizeRatio) { const limit = (isTopOrBottom.value ? window.innerHeight : getWindowWidth()) * props.maxSizeRatio; setSize(limit); } }; const primarySizeLocalStorageKey = computed( () => `${props.rememberSizeKey}-size`, ); const subpanelSplitLocalStorageKey = computed( () => `${props.rememberSizeKey}-split`, ); const resizing = ref(true); const beginResizeValue = ref(0); const onResizeStart = () => { beginResizeValue.value = currentSize.value; resizing.value = true; }; const onResizeMove = (delta: number) => { const adjustedDelta = props.side === "right" || props.side === "bottom" ? delta : -delta; setSize(beginResizeValue.value + adjustedDelta); }; const onResizeEnd = () => { resizing.value = false; }; const resetSize = (useDefaultSize = true) => { if (props.defaultSize && useDefaultSize) { setSize(props.defaultSize); } }; const collapseSet = (collapse: boolean) => { collapsed.value = collapse; if (!collapsed.value) { panelOpeningFromCollapse.value = true; panelOpeningFromCollapseTimeout.value = window.setTimeout(() => { emit("sizeSet", displaySize.value); panelOpeningFromCollapse.value = false; }, PANEL_COLLAPSE_HIDE_CONTENT_TIME); } else { panelOpeningFromCollapseTimeout.value = window.setTimeout(() => { emit("sizeSet", displaySize.value); panelOpeningFromCollapse.value = false; }, PANEL_COLLAPSE_HIDE_CONTENT_TIME); } }; const collapseToggle = () => { collapseSet(!collapsed.value); }; const onWindowResize = () => { // may change the size because min/max ratio of window size may have changed if (props.resizeable) setSize(currentSize.value); else currentSize.value = props.defaultSize; }; const debounceForResize = _.debounce(onWindowResize, 20); const windowResizeObserver = new ResizeObserver(debounceForResize); // subpanel resizing const subpanelSplitPercent = ref(props.defaultSubpanelSplit); const displaySubpanelSplitPercent = computed(() => { if (subpanelCollapsed.value) return 100; else return subpanelSplitPercent.value; }); onMounted(async () => { const storedSplit = window.localStorage.getItem( subpanelSplitLocalStorageKey.value, ); if (storedSplit) subpanelSplitPercent.value = parseFloat(storedSplit); // We have resizing be true for just the first tick to avoid the panels animating into place when the DOM reloads await nextTick(() => { resizing.value = false; }); }); const subpanel1Ref = ref(); const subpanelResizing = ref(false); const subpanelCollapsed = ref(false); const subpanelOpeningFromCollapse = ref(false); const subpanelOpeningFromCollapseTimeout = ref<number>(); const totalAvailableSize = ref(0); const subpanel1SizePx = computed( () => totalAvailableSize.value * subpanelSplitPercent.value, ); let subpanelResizeStartPanel1Size: number; function onSubpanelResizeStart() { subpanelResizing.value = true; const boundingRect = panelRef.value?.getBoundingClientRect(); if (!boundingRect) return; totalAvailableSize.value = isTopOrBottom.value ? boundingRect.width : boundingRect.height; subpanelResizeStartPanel1Size = subpanel1SizePx.value; } function onSubpanelResizeMove(delta: number) { const newPanel1SizePx = subpanelResizeStartPanel1Size - delta; setSubpanelSplit(newPanel1SizePx / totalAvailableSize.value); } function onSubpanelResizeReset() { setSubpanelSplit(props.defaultSubpanelSplit); } function setSubpanelSplit(newSplitPercent: number) { if (newSplitPercent < 0.2) { subpanelSplitPercent.value = 0.2; } else if (newSplitPercent > 0.8) { subpanelSplitPercent.value = 0.8; } else { subpanelSplitPercent.value = newSplitPercent; } if (subpanelSplitPercent.value === props.defaultSubpanelSplit) { window.localStorage.removeItem(subpanelSplitLocalStorageKey.value); } else { window.localStorage.setItem( subpanelSplitLocalStorageKey.value, `${subpanelSplitPercent.value}`, ); } } function onSubpanelResizeEnd() { subpanelResizing.value = false; } function subpanelCollapseSet(collapse: boolean) { subpanelCollapsed.value = collapse; if (!subpanelCollapsed.value) { subpanelOpeningFromCollapse.value = true; subpanelOpeningFromCollapseTimeout.value = window.setTimeout(() => { subpanelOpeningFromCollapse.value = false; }, PANEL_COLLAPSE_HIDE_CONTENT_TIME) as number; } } function subpanelCollapseToggle() { subpanelCollapseSet(!subpanelCollapsed.value); } onMounted(() => { if (props.resizeable) { const storedSize = window.localStorage.getItem( primarySizeLocalStorageKey.value, ); if (storedSize) { setSize(parseInt(storedSize)); } else { setSize(props.defaultSize); } } else { window.localStorage.removeItem(primarySizeLocalStorageKey.value); } windowResizeObserver.observe(document.body); }); onBeforeUnmount(() => { windowResizeObserver.unobserve(document.body); if (panelOpeningFromCollapseTimeout.value) { clearTimeout(panelOpeningFromCollapseTimeout.value); } }); defineExpose({ setSize, maximize, resetSize, maxSize: props.maxSize, collapseToggle, collapseSet, collapsed, subpanelCollapseToggle, subpanelCollapseSet, subpanelCollapsed, }); </script>

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/systeminit/si'

If you have feedback or need assistance with the MCP directory API, please join our Discord server